mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Integrated into release/v3.8.18
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -199,6 +199,7 @@ pr_reviews*.json
|
||||
#hidden local data directories (never commit)
|
||||
.local-data/
|
||||
.data-dev/
|
||||
/.junie/
|
||||
|
||||
# internal setup prompts with personal credentials — never commit
|
||||
CODEX-SETUP-PROMPT.md
|
||||
|
||||
@@ -149,6 +149,7 @@ export const ERROR_RULES: ErrorRule[] = [
|
||||
},
|
||||
{ id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" },
|
||||
{ id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" },
|
||||
{ id: "high_demand", text: "high demand", backoff: true, reason: "model_capacity" },
|
||||
{ id: "status_401", status: 401, cooldownMs: 0, reason: "auth_error" },
|
||||
{ id: "status_402", status: 402, cooldownMs: 0, reason: "quota_exhausted" },
|
||||
{ id: "status_403", status: 403, cooldownMs: 0, reason: "quota_exhausted" },
|
||||
|
||||
@@ -67,7 +67,6 @@ import { wasRefreshTokenRotated } from "@omniroute/open-sse/services/refreshSeri
|
||||
import {
|
||||
recordKeyFailure,
|
||||
recordKeySuccess,
|
||||
getInvalidKeyCount,
|
||||
trackConnectionExtraKeys,
|
||||
connectionHasExtraKeys,
|
||||
type KeyHealth,
|
||||
@@ -89,6 +88,7 @@ import {
|
||||
saveRequestUsage,
|
||||
trackPendingRequest,
|
||||
updatePendingRequest,
|
||||
finalizePendingRequest,
|
||||
appendRequestLog,
|
||||
saveCallLog,
|
||||
} from "@/lib/usageDb";
|
||||
@@ -291,6 +291,7 @@ function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
|
||||
}
|
||||
|
||||
import { estimateSizeFast, isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
import { finalizeMostRecentPendingRequest } from "@/lib/usage/usageHistory.ts";
|
||||
|
||||
const MAX_LOG_BODY_CHARS = 8 * 1024; // 8KB cap for logged request/response bodies
|
||||
/**
|
||||
@@ -1999,14 +2000,17 @@ export async function handleChatCore({
|
||||
: body;
|
||||
|
||||
// Track pending requests before slower optional enrichment (settings, logging,
|
||||
// compression) so active-request callers can observe the provider payload even
|
||||
// when upstream never returns response headers.
|
||||
trackPendingRequest(model, provider, connectionId, true, {
|
||||
// compression) so internal usage/runtime counters stay accurate even when
|
||||
// upstream never returns response headers.
|
||||
// Use credentials.connectionId as a fallback so that requests without an
|
||||
// explicit session-level connectionId still register in the pendingRequests map.
|
||||
const pendingConnId = connectionId || credentials?.connectionId || null;
|
||||
const pendingRequestId = trackPendingRequest(model, provider, pendingConnId, true, {
|
||||
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
clientRequest: clientRawRequest?.body ?? body,
|
||||
providerRequest: initialProviderRequest,
|
||||
stage: "registered",
|
||||
});
|
||||
}) || generateRequestId();
|
||||
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
@@ -2106,10 +2110,12 @@ export async function handleChatCore({
|
||||
});
|
||||
}
|
||||
|
||||
const callLogId = generateRequestId();
|
||||
const pipelinePayloads = detailedLoggingEnabled ? reqLogger?.getPipelinePayloads?.() : null;
|
||||
|
||||
if (pipelinePayloads) {
|
||||
if (providerRequest !== undefined && !pipelinePayloads.providerRequest) {
|
||||
pipelinePayloads.providerRequest = providerRequest as Record<string, unknown>;
|
||||
}
|
||||
if (providerResponse !== undefined && !pipelinePayloads.providerResponse) {
|
||||
pipelinePayloads.providerResponse = providerResponse as Record<string, unknown>;
|
||||
}
|
||||
@@ -2127,7 +2133,7 @@ export async function handleChatCore({
|
||||
}
|
||||
|
||||
saveCallLog({
|
||||
id: callLogId,
|
||||
id: pendingRequestId,
|
||||
method: "POST",
|
||||
path: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
status,
|
||||
@@ -2260,6 +2266,7 @@ export async function handleChatCore({
|
||||
userAgent: streamUserAgent,
|
||||
streamDefaultMode: apiKeyInfo?.streamDefaultMode,
|
||||
});
|
||||
|
||||
// `settings` is already consolidated once near the top of handleChatCore
|
||||
// (the "fetch once, reuse" const). A second `const settings` here was a
|
||||
// duplicate same-scope declaration that broke the esbuild/tsx transform
|
||||
@@ -2276,6 +2283,11 @@ export async function handleChatCore({
|
||||
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model, {
|
||||
enabled: detailedLoggingEnabled,
|
||||
captureStreamChunks: capturePipelineStreamChunks,
|
||||
// Provide model/provider/connectionId so streamChunks can be attached to the
|
||||
// in-memory pending request record before final call-log persistence.
|
||||
model,
|
||||
provider: provider || undefined,
|
||||
connectionId: connectionId || credentials?.connectionId || undefined,
|
||||
});
|
||||
|
||||
// 0. Log client raw request (before format conversion)
|
||||
@@ -2783,7 +2795,7 @@ export async function handleChatCore({
|
||||
);
|
||||
}
|
||||
}
|
||||
if (config.cavemanOutputMode?.enabled) {
|
||||
if (config.enabled && config.cavemanOutputMode?.enabled) {
|
||||
try {
|
||||
const { applyCavemanOutputMode } = await import("../services/compression/outputMode.ts");
|
||||
const outputModeLanguage =
|
||||
@@ -3652,8 +3664,8 @@ export async function handleChatCore({
|
||||
) {
|
||||
const parsed = allSettings.cliproxyapi_fallback_codes
|
||||
.split(",")
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
.map((s: string) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !Number.isNaN(n));
|
||||
if (parsed.length > 0) fallbackCodes = parsed;
|
||||
}
|
||||
} catch {
|
||||
@@ -5062,7 +5074,6 @@ export async function handleChatCore({
|
||||
|
||||
// Non-streaming response
|
||||
if (!stream) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase();
|
||||
let responseBody;
|
||||
let responsePayloadFormat = targetFormat;
|
||||
@@ -5111,6 +5122,7 @@ export async function handleChatCore({
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload");
|
||||
trackPendingRequest(model, provider, pendingConnId, false);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage);
|
||||
}
|
||||
|
||||
@@ -5137,6 +5149,7 @@ export async function handleChatCore({
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload");
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage);
|
||||
}
|
||||
}
|
||||
@@ -5186,15 +5199,19 @@ export async function handleChatCore({
|
||||
);
|
||||
// Fall through — continue processing with the new responseBody
|
||||
} catch {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
||||
}
|
||||
} else {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
||||
}
|
||||
} catch {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
||||
}
|
||||
} else {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
||||
}
|
||||
}
|
||||
@@ -5439,6 +5456,10 @@ export async function handleChatCore({
|
||||
"GUARDRAIL",
|
||||
`Response blocked by ${postCallGuardrails.guardrail || "guardrail"}: ${guardrailMessage}`
|
||||
);
|
||||
finalizePendingRequest(model, provider, connectionId, {
|
||||
providerResponse: responseBody,
|
||||
clientResponse: translatedResponse,
|
||||
});
|
||||
return createErrorResult(HTTP_STATUS.BAD_REQUEST, guardrailMessage);
|
||||
}
|
||||
|
||||
@@ -5524,6 +5545,10 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
finalizePendingRequest(model, provider, connectionId, {
|
||||
providerResponse: responseBody,
|
||||
clientResponse: translatedResponse,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(translatedResponse), {
|
||||
@@ -5647,17 +5672,20 @@ export async function handleChatCore({
|
||||
await onRequestSuccess();
|
||||
}
|
||||
|
||||
const responseHeaders: Record<string, string> = buildStreamingResponseHeaders(
|
||||
providerResponse.headers,
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
cacheHit: false,
|
||||
latencyMs: 0,
|
||||
usage: null,
|
||||
costUsd: 0,
|
||||
}
|
||||
);
|
||||
const responseHeaders: Record<string, string> = {
|
||||
...buildStreamingResponseHeaders(
|
||||
providerResponse.headers,
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
cacheHit: false,
|
||||
latencyMs: 0,
|
||||
usage: null,
|
||||
costUsd: 0,
|
||||
}
|
||||
),
|
||||
"x-omniroute-request-id": pendingRequestId,
|
||||
};
|
||||
|
||||
// Create transform stream with logger for streaming response
|
||||
let transformStream;
|
||||
@@ -5749,6 +5777,23 @@ export async function handleChatCore({
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
|
||||
// Ensure the completed details cache is populated so the UI's fast-poll
|
||||
// can pick up provider/client response payloads immediately after the
|
||||
// streaming request finishes.
|
||||
try {
|
||||
// Finalize the most recent pending request (streaming corresponds to the last entry)
|
||||
// Use credentials.connectionId as a fallback so finalization matches the
|
||||
// pending request registration (which may have used credentials.connectionId).
|
||||
const finalizedConnId = connectionId || credentials?.connectionId || null;
|
||||
finalizeMostRecentPendingRequest(model, provider, finalizedConnId, {
|
||||
providerResponse: providerPayload ?? streamResponseBody ?? undefined,
|
||||
clientResponse: clientPayload ?? streamResponseBody ?? undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
// Best-effort — don't break the stream completion path if this fails
|
||||
try { console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e)); } catch {}
|
||||
}
|
||||
|
||||
if (apiKeyInfo?.id && streamUsage) {
|
||||
calculateCost(provider, model, streamUsage, { serviceTier: effectiveServiceTier })
|
||||
.then((estimatedCost) => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function handleEmbedding({
|
||||
|
||||
// Set up request logger for pipeline artifact capture
|
||||
const detailedLoggingEnabled = await isDetailedLoggingEnabled();
|
||||
const captureStreamChunks = await getCallLogPipelineCaptureStreamChunks();
|
||||
const captureStreamChunks = getCallLogPipelineCaptureStreamChunks();
|
||||
const reqLogger = await createRequestLogger(
|
||||
provider || "openai",
|
||||
"openai",
|
||||
@@ -86,6 +86,9 @@ export async function handleEmbedding({
|
||||
{
|
||||
enabled: detailedLoggingEnabled,
|
||||
captureStreamChunks,
|
||||
connectionId: connectionId || undefined,
|
||||
model: model || body.model as string,
|
||||
provider: provider || undefined,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import {
|
||||
getAllCircuitBreakerStatuses,
|
||||
getCircuitBreaker,
|
||||
STATE,
|
||||
} from "../../src/shared/utils/circuitBreaker";
|
||||
import {
|
||||
classify429FromError,
|
||||
@@ -692,7 +691,7 @@ function configureProviderBreaker(
|
||||
) {
|
||||
if (!provider) return null;
|
||||
|
||||
const resolvedProfile = { ...getProviderProfile(provider), ...(profile ?? {}) };
|
||||
const resolvedProfile = { ...getProviderProfile(provider), ...profile };
|
||||
// Issue #2100 follow-up: resolve useUpstream429BreakerHints from the
|
||||
// provider profile (stored override) or fall back to per-provider default.
|
||||
// Stored value type is `boolean | undefined` — never `null` after PATCH.
|
||||
@@ -877,10 +876,10 @@ export function parseRetryAfterFromBody(responseBody: unknown): {
|
||||
|
||||
// OpenAI: "Please retry after 20s" in message
|
||||
const msg = String(error.message || body.message || "");
|
||||
const retryMatch = msg.match(/retry\s+after\s+(\d+)\s*s/i);
|
||||
const retryMatch = RegExp(/retry\s+after\s+(\d+)\s*s/i).exec(msg);
|
||||
if (retryMatch) {
|
||||
return {
|
||||
retryAfterMs: parseInt(retryMatch[1], 10) * 1000,
|
||||
retryAfterMs: Number.parseInt(retryMatch[1], 10) * 1000,
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
@@ -902,17 +901,17 @@ export function parseRetryAfterFromBody(responseBody: unknown): {
|
||||
function parseDelayString(value: unknown): number | null {
|
||||
if (!value) return null;
|
||||
const str = String(value).trim();
|
||||
const msMatch = str.match(/^(\d+)\s*ms$/i);
|
||||
if (msMatch) return parseInt(msMatch[1], 10);
|
||||
const secMatch = str.match(/^(\d+)\s*s$/i);
|
||||
if (secMatch) return parseInt(secMatch[1], 10) * 1000;
|
||||
const minMatch = str.match(/^(\d+)\s*m$/i);
|
||||
if (minMatch) return parseInt(minMatch[1], 10) * 60 * 1000;
|
||||
const hrMatch = str.match(/^(\d+)\s*h$/i);
|
||||
if (hrMatch) return parseInt(hrMatch[1], 10) * 3600 * 1000;
|
||||
const msMatch = RegExp(/^(\d+)\s*ms$/i).exec(str);
|
||||
if (msMatch) return Number.parseInt(msMatch[1], 10);
|
||||
const secMatch = RegExp(/^(\d+)\s*s$/i).exec(str);
|
||||
if (secMatch) return Number.parseInt(secMatch[1], 10) * 1000;
|
||||
const minMatch = RegExp(/^(\d+)\s*m$/i).exec(str);
|
||||
if (minMatch) return Number.parseInt(minMatch[1], 10) * 60 * 1000;
|
||||
const hrMatch = RegExp(/^(\d+)\s*h$/i).exec(str);
|
||||
if (hrMatch) return Number.parseInt(hrMatch[1], 10) * 3600 * 1000;
|
||||
// Bare number → seconds
|
||||
const num = parseInt(str, 10);
|
||||
return isNaN(num) ? null : num * 1000;
|
||||
const num = Number.parseInt(str, 10);
|
||||
return Number.isNaN(num) ? null : num * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,14 +924,15 @@ function parseDelayString(value: unknown): number | null {
|
||||
*/
|
||||
export function parseRetryFromErrorText(errorText: unknown): number | null {
|
||||
if (!errorText || typeof errorText !== "string") return null;
|
||||
const msg: string = String(errorText);
|
||||
|
||||
// Issue #2321: Anthropic OAuth occasionally embeds an absolute ISO 8601
|
||||
// timestamp instead of a relative duration (e.g. "Try again at
|
||||
// 2026-05-17T10:00:00Z" or "Please wait until 2026-05-17T10:00:00.000Z").
|
||||
// Convert to a future-duration in milliseconds if it parses.
|
||||
const isoMatch = errorText.match(
|
||||
const isoMatch = RegExp(
|
||||
/\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i
|
||||
);
|
||||
).exec(msg);
|
||||
if (isoMatch) {
|
||||
const parsedTs = Date.parse(isoMatch[1]);
|
||||
if (Number.isFinite(parsedTs)) {
|
||||
@@ -941,15 +941,15 @@ export function parseRetryFromErrorText(errorText: unknown): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
const match = errorText.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
const match = RegExp(/reset after (\d+h)?(\d+m)?(\d+s)?/i).exec(msg);
|
||||
if (match?.[1] || match?.[2] || match?.[3]) return computeDurationMs(match);
|
||||
|
||||
// Variant without "reset after": "will reset after XhYmZs"
|
||||
const altMatch = errorText.match(/will reset after (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
const altMatch = RegExp(/will reset after (\d+h)?(\d+m)?(\d+s)?/i).exec(msg);
|
||||
if (altMatch?.[1] || altMatch?.[2] || altMatch?.[3]) return computeDurationMs(altMatch);
|
||||
|
||||
// Antigravity / Cloud Code phrasing: "Resets in 164h27m24s".
|
||||
const resetsInMatch = errorText.match(/resets? in (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
const resetsInMatch = RegExp(/resets? in (\d+h)?(\d+m)?(\d+s)?/i).exec(msg);
|
||||
if (resetsInMatch?.[1] || resetsInMatch?.[2] || resetsInMatch?.[3]) {
|
||||
return computeDurationMs(resetsInMatch);
|
||||
}
|
||||
@@ -965,9 +965,9 @@ const MAX_PROVIDER_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
function computeDurationMs(match: RegExpMatchArray): number | null {
|
||||
let totalMs = 0;
|
||||
if (match[1]) totalMs += parseInt(match[1], 10) * 3600 * 1000; // hours
|
||||
if (match[2]) totalMs += parseInt(match[2], 10) * 60 * 1000; // minutes
|
||||
if (match[3]) totalMs += parseInt(match[3], 10) * 1000; // seconds
|
||||
if (match[1]) totalMs += Number.parseInt(match[1], 10) * 3600 * 1000; // hours
|
||||
if (match[2]) totalMs += Number.parseInt(match[2], 10) * 60 * 1000; // minutes
|
||||
if (match[3]) totalMs += Number.parseInt(match[3], 10) * 1000; // seconds
|
||||
return totalMs > 0 ? Math.min(totalMs, MAX_PROVIDER_COOLDOWN_MS) : null;
|
||||
}
|
||||
|
||||
@@ -1019,7 +1019,10 @@ export function classifyErrorText(errorText: unknown): RateLimitReasonValue {
|
||||
const configuredRule = matchErrorRuleByText(errorText);
|
||||
if (configuredRule?.reason) return configuredRule.reason;
|
||||
if (lower.includes("rate_limit")) return RateLimitReason.RATE_LIMIT_EXCEEDED;
|
||||
if (lower.includes("resource exhausted")) return RateLimitReason.MODEL_CAPACITY;
|
||||
if (
|
||||
lower.includes("resource exhausted") ||
|
||||
lower.includes("high demand")
|
||||
) return RateLimitReason.MODEL_CAPACITY;
|
||||
if (
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("invalid api key") ||
|
||||
@@ -1092,7 +1095,6 @@ export function classifyError(
|
||||
*/
|
||||
export function getMsUntilTomorrow(): number {
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs);
|
||||
const tomorrow = new Date(nowMs);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
@@ -1219,12 +1221,12 @@ export function checkFallbackError(
|
||||
: recordHeaders["retry-after"] || recordHeaders["Retry-After"];
|
||||
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds) && String(seconds) === String(retryAfter).trim()) {
|
||||
const seconds = Number.parseInt(retryAfter, 10);
|
||||
if (!Number.isNaN(seconds) && String(seconds) === String(retryAfter).trim()) {
|
||||
return Date.now() + seconds * 1000;
|
||||
}
|
||||
const date = new Date(retryAfter);
|
||||
if (!isNaN(date.getTime())) return date.getTime();
|
||||
if (!Number.isNaN(date.getTime())) return date.getTime();
|
||||
}
|
||||
|
||||
// X-RateLimit-Reset
|
||||
@@ -1234,8 +1236,8 @@ export function checkFallbackError(
|
||||
: recordHeaders["x-ratelimit-reset"] || recordHeaders["X-RateLimit-Reset"];
|
||||
|
||||
if (rlReset) {
|
||||
const ts = parseInt(rlReset, 10);
|
||||
if (!isNaN(ts)) {
|
||||
const ts = Number.parseInt(rlReset, 10);
|
||||
if (!Number.isNaN(ts)) {
|
||||
return ts > 10000000000 ? ts : ts * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
|
||||
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
|
||||
// of falling through to the identity fallback ("xiaomi").
|
||||
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
|
||||
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
|
||||
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
|
||||
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
|
||||
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
|
||||
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
|
||||
|
||||
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
|
||||
// and keep backward compatibility when upstream IDs change.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getPendingById } from "@/lib/usage/usageHistory";
|
||||
import { sanitizeErrorMessage } from "./error.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type HeaderInput =
|
||||
@@ -45,10 +48,13 @@ type RequestLoggerOptions = {
|
||||
captureStreamChunks?: boolean;
|
||||
maxStreamChunkBytes?: number;
|
||||
maxStreamChunkItems?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
connectionId?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_STREAM_CHUNK_BYTES = 128 * 1024;
|
||||
const DEFAULT_MAX_STREAM_CHUNK_ITEMS = 512;
|
||||
const DEFAULT_MAX_STREAM_CHUNK_ITEMS = 10_240;
|
||||
const MAX_LOG_STRING_LENGTH = 64 * 1024;
|
||||
export const MAX_LOG_ARRAY_ITEMS = 24;
|
||||
const MAX_LOG_OBJECT_KEYS = 80;
|
||||
@@ -203,31 +209,77 @@ function compactPipelinePayloads(
|
||||
)
|
||||
);
|
||||
if (Object.keys(compactedChunks).length > 0) {
|
||||
result.streamChunks = compactedChunks as RequestPipelinePayloads["streamChunks"];
|
||||
result.streamChunks = compactedChunks;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key as keyof RequestPipelinePayloads] = value as never;
|
||||
result[key as keyof RequestPipelinePayloads] = value;
|
||||
}
|
||||
|
||||
return hasOwnValues(result) ? result : null;
|
||||
}
|
||||
function makeStreamChunkMethods(
|
||||
options: RequestLoggerOptions,
|
||||
captureChunks: boolean
|
||||
) {
|
||||
const streamChunks = createEmptyStreamChunks();
|
||||
const streamChunkBytes = {
|
||||
provider: { value: 0, truncated: false },
|
||||
openai: { value: 0, truncated: false },
|
||||
client: { value: 0, truncated: false },
|
||||
};
|
||||
const maxBytes =
|
||||
Number.isInteger(options.maxStreamChunkBytes) && Number(options.maxStreamChunkBytes) > 0
|
||||
? Number(options.maxStreamChunkBytes)
|
||||
: DEFAULT_MAX_STREAM_CHUNK_BYTES;
|
||||
const maxItems =
|
||||
Number.isInteger(options.maxStreamChunkItems) && Number(options.maxStreamChunkItems) > 0
|
||||
? Number(options.maxStreamChunkItems)
|
||||
: DEFAULT_MAX_STREAM_CHUNK_ITEMS;
|
||||
let pendingPushed = false;
|
||||
|
||||
const push = () => {
|
||||
if (pendingPushed) return;
|
||||
if (!options.connectionId || !options.model) return;
|
||||
pendingPushed = true;
|
||||
try {
|
||||
const pending = getPendingById();
|
||||
for (const entry of pending.values()) {
|
||||
if (entry?.model === options.model && entry.provider === (options.provider || "")) {
|
||||
entry.streamChunks = { ...streamChunks };
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Do not allow logging failures to disrupt request handling
|
||||
try {
|
||||
console.warn("[requestLogger] updatePendingRequestStreamChunks failed:", e);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const append = (
|
||||
arr: string[],
|
||||
bytes: { value: number; truncated: boolean },
|
||||
chunk: string
|
||||
) => {
|
||||
if (!captureChunks) return;
|
||||
push();
|
||||
appendBoundedChunk(arr, bytes, chunk, maxBytes, maxItems);
|
||||
};
|
||||
|
||||
function createNoOpLogger(): RequestLogger {
|
||||
return {
|
||||
sessionPath: null,
|
||||
logClientRawRequest() {},
|
||||
logOpenAIRequest() {},
|
||||
logTargetRequest() {},
|
||||
logProviderResponse() {},
|
||||
appendProviderChunk() {},
|
||||
appendOpenAIChunk() {},
|
||||
logConvertedResponse() {},
|
||||
appendConvertedChunk() {},
|
||||
logError() {},
|
||||
getPipelinePayloads() {
|
||||
return null;
|
||||
streamChunks,
|
||||
streamChunkBytes,
|
||||
appendProviderChunk(chunk: string) {
|
||||
append(streamChunks.provider, streamChunkBytes.provider, chunk);
|
||||
},
|
||||
appendOpenAIChunk(chunk: string) {
|
||||
append(streamChunks.openai, streamChunkBytes.openai, chunk);
|
||||
},
|
||||
appendConvertedChunk(chunk: string) {
|
||||
append(streamChunks.client, streamChunkBytes.client, chunk);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -238,27 +290,30 @@ export async function createRequestLogger(
|
||||
_model?: string,
|
||||
options: RequestLoggerOptions = {}
|
||||
): Promise<RequestLogger> {
|
||||
const captureStreamChunks = options.captureStreamChunks !== false;
|
||||
// Stream chunk capture is always set up — even when the logger is disabled,
|
||||
// so that active requests always have real-time stream data available via
|
||||
// the /api/logs/active endpoint.
|
||||
const chunkMethods = makeStreamChunkMethods(options, captureStreamChunks);
|
||||
|
||||
if (options.enabled === false) {
|
||||
return createNoOpLogger();
|
||||
return {
|
||||
sessionPath: null,
|
||||
logClientRawRequest() {},
|
||||
logOpenAIRequest() {},
|
||||
logTargetRequest() {},
|
||||
logProviderResponse() {},
|
||||
appendProviderChunk: chunkMethods.appendProviderChunk,
|
||||
appendOpenAIChunk: chunkMethods.appendOpenAIChunk,
|
||||
logConvertedResponse() {},
|
||||
appendConvertedChunk: chunkMethods.appendConvertedChunk,
|
||||
logError() {},
|
||||
getPipelinePayloads() { return null; },
|
||||
};
|
||||
}
|
||||
|
||||
const captureStreamChunks = options.captureStreamChunks !== false;
|
||||
const maxStreamChunkBytes =
|
||||
Number.isInteger(options.maxStreamChunkBytes) && Number(options.maxStreamChunkBytes) > 0
|
||||
? Number(options.maxStreamChunkBytes)
|
||||
: DEFAULT_MAX_STREAM_CHUNK_BYTES;
|
||||
const maxStreamChunkItems =
|
||||
Number.isInteger(options.maxStreamChunkItems) && Number(options.maxStreamChunkItems) > 0
|
||||
? Number(options.maxStreamChunkItems)
|
||||
: DEFAULT_MAX_STREAM_CHUNK_ITEMS;
|
||||
const streamChunks = createEmptyStreamChunks();
|
||||
const streamChunkBytes = {
|
||||
provider: { value: 0, truncated: false },
|
||||
openai: { value: 0, truncated: false },
|
||||
client: { value: 0, truncated: false },
|
||||
};
|
||||
const payloads: RequestPipelinePayloads = {
|
||||
...(captureStreamChunks ? { streamChunks } : {}),
|
||||
...(captureStreamChunks ? { streamChunks: chunkMethods.streamChunks } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -299,51 +354,20 @@ export async function createRequestLogger(
|
||||
};
|
||||
},
|
||||
|
||||
appendProviderChunk(chunk) {
|
||||
if (!captureStreamChunks) return;
|
||||
appendBoundedChunk(
|
||||
streamChunks.provider,
|
||||
streamChunkBytes.provider,
|
||||
chunk,
|
||||
maxStreamChunkBytes,
|
||||
maxStreamChunkItems
|
||||
);
|
||||
},
|
||||
|
||||
appendOpenAIChunk(chunk) {
|
||||
if (!captureStreamChunks) return;
|
||||
appendBoundedChunk(
|
||||
streamChunks.openai,
|
||||
streamChunkBytes.openai,
|
||||
chunk,
|
||||
maxStreamChunkBytes,
|
||||
maxStreamChunkItems
|
||||
);
|
||||
},
|
||||
|
||||
appendProviderChunk: chunkMethods.appendProviderChunk,
|
||||
appendOpenAIChunk: chunkMethods.appendOpenAIChunk,
|
||||
logConvertedResponse(body) {
|
||||
payloads.clientResponse = {
|
||||
timestamp: new Date().toISOString(),
|
||||
body: cloneBoundedForLog(body),
|
||||
};
|
||||
},
|
||||
|
||||
appendConvertedChunk(chunk) {
|
||||
if (!captureStreamChunks) return;
|
||||
appendBoundedChunk(
|
||||
streamChunks.client,
|
||||
streamChunkBytes.client,
|
||||
chunk,
|
||||
maxStreamChunkBytes,
|
||||
maxStreamChunkItems
|
||||
);
|
||||
},
|
||||
appendConvertedChunk: chunkMethods.appendConvertedChunk,
|
||||
|
||||
logError(error, requestBody = null) {
|
||||
payloads.error = {
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
|
||||
requestBody: cloneBoundedForLog(requestBody),
|
||||
};
|
||||
},
|
||||
@@ -353,5 +377,3 @@ export async function createRequestLogger(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logError(_provider: string, _entry: unknown) {}
|
||||
|
||||
@@ -938,10 +938,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
allowedToolNames
|
||||
);
|
||||
if (collectedToolCall) {
|
||||
parsed = toChatCompletionChunkWithToolCall(
|
||||
parsed as JsonRecord,
|
||||
collectedToolCall
|
||||
) as typeof parsed;
|
||||
parsed = toChatCompletionChunkWithToolCall(parsed, collectedToolCall);
|
||||
passthroughHasToolCalls = true;
|
||||
} else {
|
||||
delete delta.content;
|
||||
@@ -1269,7 +1266,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
|
||||
// Passthrough mode: normalize and forward
|
||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||
let output;
|
||||
let output: string;
|
||||
let injectedUsage = false;
|
||||
let clientPayload: unknown = null;
|
||||
let failurePayload: StreamFailurePayload | null = null;
|
||||
@@ -2649,6 +2646,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export default createSSEStream
|
||||
|
||||
// Convenience functions for backward compatibility
|
||||
export function createSSETransformStreamWithLogger(
|
||||
targetFormat: string,
|
||||
|
||||
@@ -275,8 +275,8 @@ function buildStreamErrorChunks(
|
||||
],
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: "upstream_error",
|
||||
code: statusCode,
|
||||
type: statusMapping.responses.type,
|
||||
code: statusMapping.responses.code,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -65,11 +65,6 @@ type ProviderMetricSummary = {
|
||||
lastErrorStatus?: number | null;
|
||||
};
|
||||
|
||||
type ActiveRequestSummary = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type ProviderModelSummary = {
|
||||
fullModel: string;
|
||||
alias?: string;
|
||||
@@ -108,8 +103,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const isElectron = useIsElectron();
|
||||
const { openExternal } = useOpenExternal();
|
||||
const t = useTranslations("home");
|
||||
const tc = useTranslations("common");
|
||||
const ts = useTranslations("sidebar");
|
||||
const tp = useTranslations("providers");
|
||||
const [providerConnections, setProviderConnections] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
@@ -117,7 +110,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [providerMetrics, setProviderMetrics] = useState<Record<string, ProviderMetricSummary>>({});
|
||||
const [activeRequests, setActiveRequests] = useState<ActiveRequestSummary[]>([]);
|
||||
const [providerNodes, setProviderNodes] = useState<
|
||||
Array<{ id?: string; prefix?: string; name?: string }>
|
||||
>([]);
|
||||
@@ -126,7 +118,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
// Platform detection and download links for Electron
|
||||
const platform = typeof window !== "undefined" ? window.electronAPI?.platform : undefined;
|
||||
const platform = typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform;
|
||||
const electronDownload = useMemo(() => {
|
||||
const latest = versionInfo?.latest || "";
|
||||
const cleanLatest = latest.replace(/^v/, "");
|
||||
@@ -174,14 +166,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}>({ status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || typeof window === "undefined" || !window.electronAPI) return;
|
||||
if (!isElectron || typeof globalThis.window === "undefined" || !globalThis.window.electronAPI) return;
|
||||
|
||||
// Trigger initial check silently on mount
|
||||
window.electronAPI.checkForUpdates().catch((err: any) => {
|
||||
globalThis.window.electronAPI.checkForUpdates().catch((err: any) => {
|
||||
console.error("[Electron] Check for updates failed:", err);
|
||||
});
|
||||
|
||||
const dispose = window.electronAPI.onUpdateStatus((data: any) => {
|
||||
const dispose = globalThis.window.electronAPI.onUpdateStatus((data: any) => {
|
||||
setElectronUpdateStatus({
|
||||
status: data.status,
|
||||
version: data.version,
|
||||
@@ -232,8 +224,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseUrl(`${window.location.origin}/v1`);
|
||||
if (typeof globalThis.window !== "undefined") {
|
||||
setBaseUrl(`${globalThis.location.origin}/v1`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -289,18 +281,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const currentController = new AbortController();
|
||||
controller = currentController;
|
||||
try {
|
||||
const [activeRes, metricsRes] = await Promise.all([
|
||||
fetch("/api/logs/active", { cache: "no-store", signal: currentController.signal }),
|
||||
fetch("/api/provider-metrics", { cache: "no-store", signal: currentController.signal }),
|
||||
]);
|
||||
|
||||
if (activeRes.ok) {
|
||||
const data = await activeRes.json();
|
||||
if (!cancelled) {
|
||||
setActiveRequests(Array.isArray(data.activeRequests) ? data.activeRequests : []);
|
||||
}
|
||||
}
|
||||
|
||||
const metricsRes = await fetch("/api/provider-metrics", {
|
||||
cache: "no-store",
|
||||
signal: currentController.signal,
|
||||
});
|
||||
if (metricsRes.ok) {
|
||||
const data = await metricsRes.json();
|
||||
if (!cancelled) {
|
||||
@@ -364,8 +348,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
if (h.status !== "invalid" && h.status !== "warning") return false;
|
||||
// extra_N entries: only flag if the index is still within bounds
|
||||
if (keyId.startsWith("extra_")) {
|
||||
const idx = parseInt(keyId.slice(6), 10);
|
||||
if (isNaN(idx) || idx >= extraKeyCount) return false;
|
||||
const idx = Number.parseInt(keyId.slice(6), 10);
|
||||
if (Number.isNaN(idx) || idx >= extraKeyCount) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -378,9 +362,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
for (const [keyId] of unhealthyKeys) {
|
||||
newUnhealthyKeys.add(`${conn.id}:${keyId}`);
|
||||
}
|
||||
if (firstUnhealthyProviderId === null) {
|
||||
firstUnhealthyProviderId = conn.provider;
|
||||
}
|
||||
firstUnhealthyProviderId ??= conn.provider;
|
||||
unhealthyConnections.push(conn.name || conn.id);
|
||||
unhealthyProviderIds.add(conn.provider);
|
||||
}
|
||||
@@ -493,19 +475,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
.filter((provider) => provider.total > 0)
|
||||
.forEach((provider) => addProvider(provider.id, provider.provider.name));
|
||||
Object.keys(providerMetrics).forEach((provider) => addProvider(provider));
|
||||
activeRequests.forEach((request) => addProvider(request.provider));
|
||||
|
||||
return Array.from(byProvider.values());
|
||||
}, [providerStats, providerMetrics, activeRequests, providerNodes]);
|
||||
|
||||
const topologyActiveRequests = useMemo(
|
||||
() =>
|
||||
activeRequests.map((request) => ({
|
||||
...request,
|
||||
provider: normalizeProviderId(request.provider),
|
||||
})),
|
||||
[activeRequests]
|
||||
);
|
||||
}, [providerStats, providerMetrics, providerNodes]);
|
||||
|
||||
const { lastProvider, errorProvider } = useMemo(() => {
|
||||
let recentProvider = "";
|
||||
@@ -768,19 +740,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
useEffect(() => {
|
||||
if (updatePhase !== "done") return;
|
||||
const timer = setTimeout(() => {
|
||||
window.location.reload();
|
||||
globalThis.window.location.reload();
|
||||
}, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [updatePhase]);
|
||||
|
||||
const stepIcons: Record<string, string> = {
|
||||
install: "download",
|
||||
rebuild: "build",
|
||||
restart: "restart_alt",
|
||||
complete: "check_circle",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const stepLabels: Record<string, string> = {
|
||||
install: "Install Package",
|
||||
rebuild: "Rebuild Native Modules",
|
||||
@@ -901,7 +864,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
setUpdating(false);
|
||||
setUpdatePhase("idle");
|
||||
setUpdateSteps([]);
|
||||
if (updatePhase === "done") window.location.reload();
|
||||
if (updatePhase === "done") globalThis.window.location.reload();
|
||||
}}
|
||||
>
|
||||
{updatePhase === "done" ? "Reload Now" : "Close"}
|
||||
@@ -964,7 +927,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{electronUpdateStatus.status === "available" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.electronAPI?.downloadUpdate()}
|
||||
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
||||
className="font-semibold"
|
||||
>
|
||||
Download Update
|
||||
@@ -983,7 +946,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{electronUpdateStatus.status === "downloaded" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.electronAPI?.installUpdate()}
|
||||
onClick={() => globalThis.window.electronAPI?.installUpdate()}
|
||||
className="font-semibold animate-pulse"
|
||||
>
|
||||
Restart & Install
|
||||
@@ -996,7 +959,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setElectronUpdateStatus({ status: "checking" });
|
||||
window.electronAPI?.checkForUpdates().catch((err: any) => {
|
||||
globalThis.window.electronAPI?.checkForUpdates().catch((err: any) => {
|
||||
setElectronUpdateStatus({ status: "error", message: err.message });
|
||||
});
|
||||
}}
|
||||
@@ -1215,7 +1178,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
</div>
|
||||
<ProviderTopology
|
||||
providers={topologyProviders}
|
||||
activeRequests={topologyActiveRequests}
|
||||
activeRequests={[]}
|
||||
lastProvider={lastProvider}
|
||||
errorProvider={errorProvider}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ConfirmModal, RequestLoggerV2 } from "@/shared/components";
|
||||
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
|
||||
import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const TIME_RANGES = [
|
||||
@@ -21,6 +20,7 @@ export default function LogsPage() {
|
||||
const [cleanHistoryStatus, setCleanHistoryStatus] = useState<string | null>(null);
|
||||
const [requestLogKey, setRequestLogKey] = useState(0);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const requestLoggerRef = useRef<any>(null);
|
||||
const t = useTranslations("logs");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +33,9 @@ export default function LogsPage() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// initial id from URL (synchronously on client) so child can open on mount
|
||||
const initialId = typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null;
|
||||
|
||||
async function handleExport(hours: number) {
|
||||
setExporting(true);
|
||||
setShowExport(false);
|
||||
@@ -87,8 +90,8 @@ export default function LogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="h-full flex flex-col gap-6 overflow-hidden">
|
||||
<div className="flex-shrink-0 flex items-center justify-between gap-4 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("requestLogs")}</h2>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -179,14 +182,13 @@ export default function LogsPage() {
|
||||
</div>
|
||||
|
||||
{cleanHistoryStatus && (
|
||||
<div className="rounded-lg border border-[var(--border,#333)] bg-[var(--card-bg,#1e1e2e)] px-4 py-3 text-sm text-[var(--text-secondary,#aaa)]">
|
||||
<div className="flex-shrink-0 rounded-lg border border-[var(--border,#333)] bg-[var(--card-bg,#1e1e2e)] px-4 py-3 text-sm text-[var(--text-secondary,#aaa)]">
|
||||
{cleanHistoryStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<ActiveRequestsPanel />
|
||||
<RequestLoggerV2 key={requestLogKey} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<RequestLoggerV2 key={requestLogKey} ref={requestLoggerRef} initialSelectedId={initialId} />
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
104
src/app/api/logs/[id]/route.ts
Normal file
104
src/app/api/logs/[id]/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getCallLogById } from "@/lib/usageDb";
|
||||
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = await requireManagementAuth(req);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const id = url.pathname.split("/").pop();
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
// Prefer in-flight active pending requests first to avoid races where
|
||||
// an entry moves to completed between the call-logs list and detail fetch.
|
||||
try {
|
||||
const pendingRequestDetail = getPendingById().get(id);
|
||||
if (pendingRequestDetail) {
|
||||
const pipelinePayloads: any = {
|
||||
clientRequest: pendingRequestDetail.clientRequest ?? null,
|
||||
providerRequest: pendingRequestDetail.providerRequest ?? null,
|
||||
providerResponse: pendingRequestDetail.providerResponse ?? null,
|
||||
clientResponse: pendingRequestDetail.clientResponse ?? null,
|
||||
streamChunks: pendingRequestDetail.streamChunks ?? null,
|
||||
};
|
||||
|
||||
const activeEntry = {
|
||||
id: pendingRequestDetail.id,
|
||||
timestamp: new Date(pendingRequestDetail.startedAt).toISOString(),
|
||||
method: "",
|
||||
path: pendingRequestDetail.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: pendingRequestDetail.model,
|
||||
provider: pendingRequestDetail.provider,
|
||||
connectionId: pendingRequestDetail.connectionId,
|
||||
duration: Date.now() - pendingRequestDetail.startedAt,
|
||||
detailState: "in-flight",
|
||||
active: true,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
|
||||
return NextResponse.json(activeEntry);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("/api/logs/[id] - failed to read active pending detail:", e);
|
||||
}
|
||||
|
||||
// Next, try persistent call log by id
|
||||
let persistedRequest = await getCallLogById(id);
|
||||
|
||||
// If persistent call log doesn't have payloads, try the in-memory completedDetails cache
|
||||
if (!persistedRequest?.pipelinePayloads || Object.keys(persistedRequest.pipelinePayloads).length === 0) {
|
||||
try {
|
||||
const completed = getCompletedDetails();
|
||||
const inMem = completed.get(id);
|
||||
if (inMem) {
|
||||
const pipelinePayloads: any = {
|
||||
clientRequest: inMem.clientRequest ?? null,
|
||||
providerRequest: inMem.providerRequest ?? null,
|
||||
providerResponse: inMem.providerResponse ?? null,
|
||||
clientResponse: inMem.clientResponse ?? null,
|
||||
streamChunks: inMem.streamChunks ?? null,
|
||||
};
|
||||
|
||||
const minimal = {
|
||||
id: inMem.id,
|
||||
timestamp: new Date(inMem.startedAt).toISOString(),
|
||||
path: inMem.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: inMem.model,
|
||||
provider: inMem.provider,
|
||||
connectionId: inMem.connectionId,
|
||||
duration: Date.now() - inMem.startedAt,
|
||||
detailState: "in-memory",
|
||||
active: false,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
|
||||
// Merge with persistent entry if available, preferring persisted fields
|
||||
persistedRequest = persistedRequest
|
||||
? {
|
||||
...persistedRequest,
|
||||
pipelinePayloads: persistedRequest.pipelinePayloads || pipelinePayloads,
|
||||
hasPipelineDetails: persistedRequest.hasPipelineDetails || true,
|
||||
}
|
||||
: minimal;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("/api/logs/[id] - failed to read in-memory completed detail:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(persistedRequest);
|
||||
} catch (err) {
|
||||
console.error("[API ERROR] /api/logs/[id] failed:", err);
|
||||
return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
import { getPendingRequests, clearPendingRequests } from "@/lib/usage/usageHistory";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const pending = getPendingRequests();
|
||||
const connections = await getProviderConnections();
|
||||
const connectionNames = new Map(
|
||||
connections.map((connection: any) => [
|
||||
connection.id,
|
||||
connection.displayName || connection.name || connection.email || connection.id,
|
||||
])
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const activeRequests = Object.entries(pending.details || {})
|
||||
.flatMap(([connectionId, models]) =>
|
||||
Object.entries(models).map(([modelKey, detail]) => ({
|
||||
modelKey,
|
||||
model: detail.model,
|
||||
provider: detail.provider,
|
||||
connectionId,
|
||||
account:
|
||||
connectionNames.get(connectionId) || detail.connectionId || connectionId || "unknown",
|
||||
startedAt: detail.startedAt,
|
||||
runningTimeMs: Math.max(0, now - detail.startedAt),
|
||||
count: pending.byAccount?.[connectionId]?.[modelKey] || 0,
|
||||
clientEndpoint: detail.clientEndpoint || null,
|
||||
clientRequest: detail.clientRequest ?? null,
|
||||
providerRequest: detail.providerRequest ?? null,
|
||||
providerUrl: detail.providerUrl || null,
|
||||
stage: detail.stage || null,
|
||||
stageUpdatedAt: detail.stageUpdatedAt || null,
|
||||
}))
|
||||
)
|
||||
.filter((requestRow) => requestRow.count > 0)
|
||||
.sort((a, b) => a.startedAt - b.startedAt);
|
||||
|
||||
return NextResponse.json({ activeRequests, count: activeRequests.length });
|
||||
} catch (error) {
|
||||
console.log("Error loading active requests:", error);
|
||||
return NextResponse.json({ error: "Failed to load active requests" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
clearPendingRequests();
|
||||
return NextResponse.json({ success: true, message: "Pending request counts cleared" });
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getCallLogs } from "@/lib/usageDb";
|
||||
import { getPendingById } from "@/lib/usage/usageHistory";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
@@ -20,8 +22,55 @@ export async function GET(request: Request) {
|
||||
if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit"));
|
||||
if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset"));
|
||||
|
||||
const logs = await getCallLogs(filter);
|
||||
return NextResponse.json(logs);
|
||||
const [logs, connections] = await Promise.all([
|
||||
getCallLogs(filter),
|
||||
getProviderConnections(),
|
||||
]);
|
||||
|
||||
const connectionNames = new Map(
|
||||
connections.map((connection: any) => [
|
||||
connection.id,
|
||||
connection.displayName || connection.name || connection.email || connection.id,
|
||||
])
|
||||
);
|
||||
|
||||
// Include active (in-flight) requests from the pending-by-id map
|
||||
// so they appear in the logs grid alongside persisted entries.
|
||||
const now = Date.now();
|
||||
const activeEntries: any[] = [];
|
||||
|
||||
for (const detail of getPendingById().values()) {
|
||||
activeEntries.push({
|
||||
id: detail.id,
|
||||
timestamp: new Date(detail.startedAt).toISOString(),
|
||||
method: "",
|
||||
path: detail.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: detail.model,
|
||||
requestedModel: null,
|
||||
provider: detail.provider,
|
||||
account:
|
||||
connectionNames.get(detail.connectionId || "") ||
|
||||
detail.connectionId ||
|
||||
"unknown",
|
||||
connectionId: detail.connectionId,
|
||||
duration: Math.max(0, now - detail.startedAt),
|
||||
tokens: { in: 0, out: 0 },
|
||||
cacheSource: null,
|
||||
sourceFormat: null,
|
||||
targetFormat: null,
|
||||
apiKeyId: null,
|
||||
apiKeyName: null,
|
||||
comboName: null,
|
||||
error: null,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepend active entries (newest first) ahead of persisted logs
|
||||
activeEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
return NextResponse.json([...activeEntries, ...logs]);
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/usage/call-logs failed:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch call logs" }, { status: 500 });
|
||||
|
||||
@@ -1325,7 +1325,7 @@
|
||||
"settings.update_failed": "Settings Update Failed",
|
||||
"sync.token.created": "Sync Token Created",
|
||||
"sync.token.revoked": "Sync Token Revoked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -7840,7 +7840,9 @@
|
||||
"loadingLogs": "Loading logs...",
|
||||
"noLogs": "No logs yet. Make some API calls to see them here.",
|
||||
"noMatchingLogs": "No logs matching current filters.",
|
||||
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
|
||||
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}.",
|
||||
"loadMore": "Load More",
|
||||
"loadingMore": "Loading more..."
|
||||
},
|
||||
"proxyLogger": {
|
||||
"filterAll": "All",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* filesystem artifacts and are loaded only for explicit detail/export flows.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
|
||||
@@ -181,9 +181,7 @@ function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | n
|
||||
)
|
||||
);
|
||||
if (Object.keys(compacted).length > 0) {
|
||||
protectedPayloads.streamChunks = protectPayloadForLog(
|
||||
compacted
|
||||
) as RequestPipelinePayloads["streamChunks"];
|
||||
protectedPayloads.streamChunks = protectPayloadForLog(compacted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -807,7 +805,7 @@ export async function getCallLogs(filter: any = {}) {
|
||||
} else if (filter.status === "ok") {
|
||||
conditions.push("cl.status >= 200 AND cl.status < 300");
|
||||
} else {
|
||||
const statusCode = parseInt(filter.status, 10);
|
||||
const statusCode = Number.parseInt(filter.status, 10);
|
||||
if (!Number.isNaN(statusCode)) {
|
||||
conditions.push("cl.status = @statusCode");
|
||||
params.statusCode = statusCode;
|
||||
@@ -899,6 +897,7 @@ export async function getCallLogById(id: string) {
|
||||
error: artifactResult.artifact.error ?? entry.error,
|
||||
pipelinePayloads: artifactResult.artifact.pipeline ?? buildLegacyPipelinePayloads(id),
|
||||
hasPipelineDetails: Boolean(artifactResult.artifact.pipeline) || entry.hasPipelineDetails,
|
||||
active: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -922,6 +921,7 @@ export async function getCallLogById(id: string) {
|
||||
...legacyInline,
|
||||
pipelinePayloads: legacyPipeline,
|
||||
hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails,
|
||||
active: false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -938,6 +938,7 @@ export async function getCallLogById(id: string) {
|
||||
error: legacyDisk.error ?? entry.error,
|
||||
pipelinePayloads: legacyPipeline,
|
||||
hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails,
|
||||
active: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -951,6 +952,7 @@ export async function getCallLogById(id: string) {
|
||||
error: entry.error,
|
||||
pipelinePayloads: legacyPipeline,
|
||||
hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails,
|
||||
active: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,13 @@ type PendingRequestMetadata = {
|
||||
clientRequest?: unknown;
|
||||
providerRequest?: unknown;
|
||||
providerUrl?: string | null;
|
||||
providerResponse?: unknown;
|
||||
clientResponse?: unknown;
|
||||
stage?: string | null;
|
||||
stageUpdatedAt?: number | null;
|
||||
};
|
||||
type PendingRequestDetail = {
|
||||
id: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
connectionId: string | null;
|
||||
@@ -36,8 +39,15 @@ type PendingRequestDetail = {
|
||||
clientRequest?: unknown;
|
||||
providerRequest?: unknown;
|
||||
providerUrl?: string | null;
|
||||
providerResponse?: unknown;
|
||||
clientResponse?: unknown;
|
||||
stage?: string | null;
|
||||
stageUpdatedAt?: number | null;
|
||||
streamChunks?: {
|
||||
provider?: string[];
|
||||
openai?: string[];
|
||||
client?: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
@@ -148,6 +158,16 @@ function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingReq
|
||||
protectPayloadForLog(metadata.providerRequest)
|
||||
);
|
||||
}
|
||||
if (metadata.providerResponse !== undefined) {
|
||||
normalized.providerResponse = truncatePendingPreview(
|
||||
protectPayloadForLog(metadata.providerResponse)
|
||||
);
|
||||
}
|
||||
if (metadata.clientResponse !== undefined) {
|
||||
normalized.clientResponse = truncatePendingPreview(
|
||||
protectPayloadForLog(metadata.clientResponse)
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -157,13 +177,19 @@ function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingReq
|
||||
const pendingRequests: {
|
||||
byModel: Record<string, number>;
|
||||
byAccount: Record<string, Record<string, number>>;
|
||||
details: Record<string, Record<string, PendingRequestDetail>>;
|
||||
details: Record<string, Record<string, PendingRequestDetail[]>>;
|
||||
} = {
|
||||
byModel: Object.create(null) as Record<string, number>,
|
||||
byAccount: Object.create(null) as Record<string, Record<string, number>>,
|
||||
details: Object.create(null) as Record<string, Record<string, PendingRequestDetail>>,
|
||||
details: Object.create(null) as Record<string, Record<string, PendingRequestDetail[]>>,
|
||||
};
|
||||
|
||||
/**
|
||||
* O(1) ID → PendingRequestDetail lookup map.
|
||||
* Populated when a detail is created and cleaned up when it is removed/finalized.
|
||||
*/
|
||||
const pendingById = new Map<string, PendingRequestDetail>();
|
||||
|
||||
/** Prototype-pollution denylist — prevents crafted model/provider names from mutating Object.prototype. */
|
||||
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
function isSafeKey(key: string): boolean {
|
||||
@@ -185,7 +211,7 @@ export function trackPendingRequest(
|
||||
const normalizedMetadata = normalizePendingMetadata(metadata);
|
||||
|
||||
// Use hasOwnProperty guard to prevent prototype pollution via crafted keys
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byModel, modelKey)) {
|
||||
if (!Object.hasOwn(pendingRequests.byModel, modelKey)) {
|
||||
pendingRequests.byModel[modelKey] = 0;
|
||||
}
|
||||
pendingRequests.byModel[modelKey] = Math.max(
|
||||
@@ -194,16 +220,16 @@ export function trackPendingRequest(
|
||||
);
|
||||
|
||||
if (connectionId) {
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount, connectionId)) {
|
||||
if (!Object.hasOwn(pendingRequests.byAccount, connectionId)) {
|
||||
pendingRequests.byAccount[connectionId] = Object.create(null) as Record<string, number>;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.details, connectionId)) {
|
||||
if (!Object.hasOwn(pendingRequests.details, connectionId)) {
|
||||
pendingRequests.details[connectionId] = Object.create(null) as Record<
|
||||
string,
|
||||
PendingRequestDetail
|
||||
PendingRequestDetail[]
|
||||
>;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount[connectionId], modelKey)) {
|
||||
if (!Object.hasOwn(pendingRequests.byAccount[connectionId], modelKey)) {
|
||||
pendingRequests.byAccount[connectionId][modelKey] = 0;
|
||||
}
|
||||
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
|
||||
@@ -214,24 +240,29 @@ export function trackPendingRequest(
|
||||
const nextCount = pendingRequests.byAccount[connectionId][modelKey];
|
||||
if (started && nextCount > 0) {
|
||||
if (!pendingRequests.details[connectionId][modelKey]) {
|
||||
pendingRequests.details[connectionId][modelKey] = {
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
startedAt: Date.now(),
|
||||
...normalizedMetadata,
|
||||
};
|
||||
} else {
|
||||
const merged = {
|
||||
...pendingRequests.details[connectionId][modelKey],
|
||||
...normalizedMetadata,
|
||||
};
|
||||
pendingRequests.details[connectionId][modelKey] = merged;
|
||||
pendingRequests.details[connectionId][modelKey] = [];
|
||||
}
|
||||
} else if (!started && nextCount === 0) {
|
||||
delete pendingRequests.details[connectionId][modelKey];
|
||||
if (Object.keys(pendingRequests.details[connectionId]).length === 0) {
|
||||
delete pendingRequests.details[connectionId];
|
||||
const newDetail = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
startedAt: Date.now(),
|
||||
...normalizedMetadata,
|
||||
};
|
||||
pendingRequests.details[connectionId][modelKey].push(newDetail);
|
||||
pendingById.set(newDetail.id, newDetail);
|
||||
return newDetail.id;
|
||||
} else if (!started && nextCount >= 0) {
|
||||
if (pendingRequests.details[connectionId]?.[modelKey]?.length) {
|
||||
const removed = pendingRequests.details[connectionId][modelKey].shift();
|
||||
if (removed) pendingById.delete(removed.id);
|
||||
}
|
||||
if (!pendingRequests.details[connectionId]?.[modelKey]?.length) {
|
||||
delete pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (Object.keys(pendingRequests.details[connectionId] || {}).length === 0) {
|
||||
delete pendingRequests.details[connectionId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,20 +277,180 @@ export function updatePendingRequest(
|
||||
if (!connectionId) return;
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
if (!isSafeKey(modelKey)) return;
|
||||
const existing = pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (!existing) return;
|
||||
const merged = { ...existing, ...normalizePendingMetadata(metadata) };
|
||||
pendingRequests.details[connectionId][modelKey] = merged;
|
||||
const details = pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (!details?.length) return;
|
||||
const lastIdx = details.length - 1;
|
||||
details[lastIdx] = { ...details[lastIdx], ...normalizePendingMetadata(metadata) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the first (oldest) pending request detail and then remove it.
|
||||
* Unlike updatePendingRequest which targets the last entry, this is designed
|
||||
* for the non-streaming completion path where the oldest entry must be finalized
|
||||
* before trackPendingRequest(false) removes it from the FIFO queue.
|
||||
*/
|
||||
/**
|
||||
* Completed details cache — keeps finalized response data available for the
|
||||
* active endpoint's fast-poll for a brief window after the pending detail is
|
||||
* removed, so the frontend can show providerResponse/clientResponse before
|
||||
* the persisted call log is fetched.
|
||||
*/
|
||||
const completedDetails = new Map<string, PendingRequestDetail>();
|
||||
|
||||
export function finalizePendingRequest(
|
||||
model: string,
|
||||
provider: string,
|
||||
connectionId: string | null,
|
||||
metadata: PendingRequestMetadata
|
||||
) {
|
||||
if (!connectionId) return;
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
if (!isSafeKey(modelKey)) return;
|
||||
const details = pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (!details?.length) return;
|
||||
const updated = { ...details[0], ...normalizePendingMetadata(metadata) };
|
||||
completedDetails.set(updated.id, updated);
|
||||
setTimeout(() => completedDetails.delete(updated.id), 5000);
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the most recent (last) pending request for the given model/provider/connection.
|
||||
* This is used for streaming requests where the active stream corresponds to the last
|
||||
* entry in the FIFO for the connection/model key. It removes that specific entry and
|
||||
* moves it to completedDetails so the UI fast-poll can pick it up.
|
||||
*/
|
||||
export function finalizeMostRecentPendingRequest(
|
||||
model: string,
|
||||
provider: string,
|
||||
connectionId: string | null,
|
||||
metadata: PendingRequestMetadata
|
||||
) {
|
||||
if (!connectionId) return;
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
if (!isSafeKey(modelKey)) return;
|
||||
const details = pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (!details?.length) return;
|
||||
const lastIdx = details.length - 1;
|
||||
const updated = { ...details[lastIdx], ...normalizePendingMetadata(metadata) };
|
||||
// Move to completed cache
|
||||
completedDetails.set(updated.id, updated);
|
||||
|
||||
// If provider/client responses are missing, attempt to enrich the completed
|
||||
// detail from persisted call_log artifacts (best-effort, non-blocking).
|
||||
(async () => {
|
||||
try {
|
||||
const missingProvider = updated.providerResponse === undefined || updated.providerResponse === null;
|
||||
const missingClient = updated.clientResponse === undefined || updated.clientResponse === null;
|
||||
if ((missingProvider || missingClient) && connectionId) {
|
||||
const db = getDbInstance();
|
||||
const sinceIso = new Date(Date.now() - 30_000).toISOString();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT artifact_relpath FROM call_logs WHERE connection_id = ? AND model = ? AND timestamp >= ? ORDER BY timestamp DESC LIMIT 5`
|
||||
)
|
||||
.all(connectionId, model, sinceIso) as Array<{ artifact_relpath: string | null }>;
|
||||
for (const row of rows) {
|
||||
if (!row.artifact_relpath) continue;
|
||||
const { readCallArtifact } = await import("./callLogArtifacts");
|
||||
const art = readCallArtifact(row.artifact_relpath);
|
||||
if (art.state !== "ready" || !art.artifact) continue;
|
||||
const pipeline = art.artifact.pipeline as any | undefined;
|
||||
// Prefer pipeline payloads over responseBody for structured data
|
||||
if (missingProvider && pipeline?.providerResponse) {
|
||||
updated.providerResponse = pipeline.providerResponse;
|
||||
}
|
||||
if (missingClient && pipeline?.clientResponse) {
|
||||
updated.clientResponse = pipeline.clientResponse;
|
||||
}
|
||||
if ((missingProvider && art.artifact.responseBody) || (missingClient && art.artifact.responseBody)) {
|
||||
// use responseBody as a fallback for both
|
||||
if (missingProvider) updated.providerResponse = art.artifact.responseBody;
|
||||
if (missingClient) updated.clientResponse = art.artifact.responseBody;
|
||||
}
|
||||
if (updated.providerResponse || updated.clientResponse) {
|
||||
// write-back to completed cache and stop searching
|
||||
completedDetails.set(updated.id, updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
try { console.warn("[usageHistory] failed to enrich completed detail from artifacts:", (e && (e.message || e))); } catch {}
|
||||
}
|
||||
})();
|
||||
|
||||
setTimeout(() => completedDetails.delete(updated.id), 5000);
|
||||
|
||||
// Remove the specific pending detail
|
||||
details.splice(lastIdx, 1);
|
||||
pendingById.delete(updated.id);
|
||||
|
||||
// Decrement counters (mirror trackPendingRequest(false) behaviour)
|
||||
if (Object.hasOwn(pendingRequests.byModel, modelKey)) {
|
||||
pendingRequests.byModel[modelKey] = Math.max(0, pendingRequests.byModel[modelKey] - 1);
|
||||
if (pendingRequests.byModel[modelKey] === 0) delete pendingRequests.byModel[modelKey];
|
||||
}
|
||||
if (connectionId && Object.hasOwn(pendingRequests.byAccount, connectionId)) {
|
||||
if (Object.hasOwn(pendingRequests.byAccount[connectionId], modelKey)) {
|
||||
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
|
||||
0,
|
||||
pendingRequests.byAccount[connectionId][modelKey] - 1
|
||||
);
|
||||
if (pendingRequests.byAccount[connectionId][modelKey] === 0) {
|
||||
delete pendingRequests.byAccount[connectionId][modelKey];
|
||||
}
|
||||
}
|
||||
// Clean up details map if empty
|
||||
if (
|
||||
!pendingRequests.details[connectionId] ||
|
||||
Object.keys(pendingRequests.details[connectionId]).length === 0
|
||||
) {
|
||||
delete pendingRequests.details[connectionId];
|
||||
}
|
||||
if (
|
||||
!pendingRequests.byAccount[connectionId] ||
|
||||
Object.keys(pendingRequests.byAccount[connectionId]).length === 0
|
||||
) {
|
||||
delete pendingRequests.byAccount[connectionId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompletedDetails(): Map<string, PendingRequestDetail> {
|
||||
return completedDetails;
|
||||
}
|
||||
|
||||
export function updatePendingRequestStreamChunks(
|
||||
model: string,
|
||||
provider: string,
|
||||
connectionId: string | null,
|
||||
streamChunks: {
|
||||
provider?: string[];
|
||||
openai?: string[];
|
||||
client?: string[];
|
||||
} | null
|
||||
) {
|
||||
if (!connectionId) return;
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
if (!isSafeKey(modelKey)) return;
|
||||
const details = pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (!details?.length) return;
|
||||
details[0].streamChunks = streamChunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pending requests state (for usageStats).
|
||||
* @returns {{ byModel: Object, byAccount: Object }}
|
||||
*/
|
||||
export function getPendingRequests() {
|
||||
export function getPendingRequests(): { byModel: object; byAccount: object } {
|
||||
return pendingRequests;
|
||||
}
|
||||
|
||||
export function getPendingById(): Map<string, PendingRequestDetail> {
|
||||
return pendingById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending request counts.
|
||||
* Used for admin reset when counts leak due to uncaught timeouts or process-level errors.
|
||||
@@ -269,8 +460,9 @@ export function clearPendingRequests() {
|
||||
pendingRequests.byAccount = Object.create(null) as Record<string, Record<string, number>>;
|
||||
pendingRequests.details = Object.create(null) as Record<
|
||||
string,
|
||||
Record<string, PendingRequestDetail>
|
||||
Record<string, PendingRequestDetail[]>
|
||||
>;
|
||||
pendingById.clear();
|
||||
}
|
||||
|
||||
// ──────────────── getUsageDb Shim (backward compat) ────────────────
|
||||
|
||||
@@ -21,6 +21,8 @@ import "./usage/migrations";
|
||||
export {
|
||||
trackPendingRequest,
|
||||
updatePendingRequest,
|
||||
updatePendingRequestStreamChunks,
|
||||
finalizePendingRequest,
|
||||
getUsageDb,
|
||||
saveRequestUsage,
|
||||
getUsageHistory,
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { maskAccount } from "@/shared/utils/formatting";
|
||||
import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
|
||||
type ActiveRequestRow = {
|
||||
model: string;
|
||||
provider: string;
|
||||
account: string;
|
||||
startedAt: number;
|
||||
runningTimeMs: number;
|
||||
count: number;
|
||||
clientEndpoint?: string | null;
|
||||
clientRequest?: unknown;
|
||||
providerRequest?: unknown;
|
||||
providerUrl?: string | null;
|
||||
stage?: string | null;
|
||||
stageUpdatedAt?: number | null;
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
registered: "activeStageRegistered",
|
||||
payload_prepared: "activeStagePayloadPrepared",
|
||||
waiting_account_slot: "activeStageWaitingAccountSlot",
|
||||
waiting_rate_limit: "activeStageWaitingRateLimit",
|
||||
rate_limit_slot_acquired: "activeStageRateLimitSlotAcquired",
|
||||
sending_to_provider: "activeStageSendingToProvider",
|
||||
provider_response_started: "activeStageProviderResponseStarted",
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes <= 0) return `${seconds}s`;
|
||||
if (minutes < 60) return `${minutes}m ${seconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
export default function ActiveRequestsPanel() {
|
||||
const t = useTranslations("logs");
|
||||
const { emailsVisible } = useEmailPrivacyStore();
|
||||
const [rows, setRows] = useState<ActiveRequestRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRow, setSelectedRow] = useState<ActiveRequestRow | null>(null);
|
||||
const [providerNodes, setProviderNodes] = useState<
|
||||
Array<{ id?: string; prefix?: string; name?: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/provider-nodes")
|
||||
.then((r) => (r.ok ? r.json() : { nodes: [] }))
|
||||
.then((d) => setProviderNodes(d.nodes || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
// Skip polling when tab is not visible to save resources
|
||||
if (document.visibilityState !== "visible") return;
|
||||
try {
|
||||
const res = await fetch("/api/logs/active", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setRows(Array.isArray(data.activeRequests) ? data.activeRequests : []);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load active requests:", error);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
const interval = setInterval(load, 15_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(t("confirmClearActiveRequests") || "Clear all active requests?")) return;
|
||||
try {
|
||||
const res = await fetch("/api/logs/active", { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setRows([]);
|
||||
setSelectedRow(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to clear active requests:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedAccountLabel = selectedRow ? maskAccount(selectedRow.account, emailsVisible) : "";
|
||||
const formatStage = (stage?: string | null): string => {
|
||||
if (!stage) return t("activeStageUnknown");
|
||||
const key = STAGE_LABELS[stage];
|
||||
if (key) return t(key);
|
||||
return stage.replace(/_/g, " ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-main">
|
||||
{t("runningRequests")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">{t("runningRequestsDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-300">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{loading ? t("loading") : t("activeCount", { count: rows.length })}
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1 text-xs font-medium text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300"
|
||||
>
|
||||
{t("clearAll") || "Clear All"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-sidebar/40 text-left text-xs uppercase tracking-wide text-text-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">{t("model")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("provider")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("account")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("elapsed")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("activeStage")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("count")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("payloads")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const accountLabel = maskAccount(row.account, emailsVisible);
|
||||
return (
|
||||
<tr
|
||||
key={`${row.account}:${row.provider}:${row.model}:${row.startedAt}`}
|
||||
className="border-t border-border/60"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-text-main">{row.model}</td>
|
||||
<td className="px-4 py-3 text-text-muted">
|
||||
{getProviderDisplayLabel(row.provider, providerNodes) || row.provider}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-muted" title={accountLabel}>
|
||||
{accountLabel}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-main">{formatDuration(row.runningTimeMs)}</td>
|
||||
<td className="px-4 py-3 text-text-muted">{formatStage(row.stage)}</td>
|
||||
<td className="px-4 py-3 text-text-main">{row.count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRow(row)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-sidebar/40"
|
||||
>
|
||||
{t("viewPayloads")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedRow && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4 py-6 backdrop-blur-sm">
|
||||
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-text-main">
|
||||
{getProviderDisplayLabel(selectedRow.provider, providerNodes) ||
|
||||
selectedRow.provider}{" "}
|
||||
/ {selectedRow.model}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("runningRequestDetailMeta", {
|
||||
account: selectedAccountLabel,
|
||||
elapsed: formatDuration(selectedRow.runningTimeMs),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{t("activeStage")}: {formatStage(selectedRow.stage)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRow(null)}
|
||||
className="rounded-full border border-border p-2 text-text-muted transition-colors hover:bg-sidebar/40 hover:text-text-main"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 overflow-y-auto px-5 py-5 md:grid-cols-2">
|
||||
<section className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="mb-3">
|
||||
<h5 className="text-sm font-semibold text-text-main">{t("clientPayload")}</h5>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{selectedRow.clientEndpoint || t("notAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/70 bg-bg p-3 text-xs text-text-muted">
|
||||
{JSON.stringify(selectedRow.clientRequest || {}, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="mb-3">
|
||||
<h5 className="text-sm font-semibold text-text-main">{t("upstreamPayload")}</h5>
|
||||
<p className="mt-1 break-all text-xs text-text-muted">
|
||||
{selectedRow.providerUrl || formatStage(selectedRow.stage)}
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/70 bg-bg p-3 text-xs text-text-muted">
|
||||
{JSON.stringify(selectedRow.providerRequest || {}, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
PROVIDER_COLORS,
|
||||
getHttpStatusStyle as getStatusStyle,
|
||||
@@ -11,8 +10,9 @@ import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/f
|
||||
|
||||
// ─── Payload Code Block ─────────────────────────────────────────────────────
|
||||
|
||||
function PayloadSection({ title, json, onCopy }) {
|
||||
function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const success = await onCopy();
|
||||
@@ -25,7 +25,18 @@ function PayloadSection({ title, json, onCopy }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">{title}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">{title}</h3>
|
||||
{collapsible && (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={open ? `Collapse ${title}` : `Expand ${title}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{open ? "expand_less" : "expand_more"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
@@ -37,9 +48,86 @@ function PayloadSection({ title, json, onCopy }) {
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
{open && (
|
||||
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
{json}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stream section + Detail Modal ───────────────────────────────────────────────────────────
|
||||
|
||||
function StreamSection({ title, json, onCopy }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [autoscroll, setAutoscroll] = useState(() => {
|
||||
try {
|
||||
const v = localStorage.getItem("pref:stream:autoscroll");
|
||||
return v == null ? true : v === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const success = await onCopy();
|
||||
if (success !== false) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// scroll on next animation frame to avoid layout thrash
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
} catch {}
|
||||
});
|
||||
}, [json, autoscroll]);
|
||||
|
||||
const toggleAutoscroll = () => {
|
||||
const next = !autoscroll;
|
||||
setAutoscroll(next);
|
||||
try {
|
||||
localStorage.setItem("pref:stream:autoscroll", next ? "1" : "0");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleAutoscroll}
|
||||
title={autoscroll ? "Autoscroll: on" : "Autoscroll: off"}
|
||||
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${autoscroll ? "text-primary" : ""}`}
|
||||
aria-pressed={autoscroll}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">vertical_align_bottom</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={`Copy ${title}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copied ? "check" : "content_copy"}</span>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words"
|
||||
>
|
||||
{json}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +144,8 @@ export default function RequestLoggerDetail({
|
||||
emailsVisible = false,
|
||||
onClose,
|
||||
onCopy,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
@@ -79,6 +169,7 @@ export default function RequestLoggerDetail({
|
||||
const hasStatusDiscrepancy = providerStatus && providerStatus !== log.status;
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (iso == null) return "\u2014";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return (
|
||||
@@ -122,13 +213,11 @@ export default function RequestLoggerDetail({
|
||||
if (!debugEnabled || !detail?.pipelinePayloads?.streamChunks) return null;
|
||||
let chunks: StreamChunks = detail.pipelinePayloads.streamChunks;
|
||||
|
||||
// If stored as a JSON string, try to parse it so we can render joined raw chunks
|
||||
if (typeof chunks === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(chunks);
|
||||
chunks = parsed;
|
||||
} catch {
|
||||
// Keep as string and return raw text (don't JSON-stringify)
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -155,8 +244,8 @@ export default function RequestLoggerDetail({
|
||||
? "Detailed payload artifact could not be parsed."
|
||||
: null;
|
||||
const tokenStats = {
|
||||
totalIn: detail?.tokens?.in ?? log.tokens?.in ?? 0,
|
||||
totalOut: detail?.tokens?.out ?? log.tokens?.out ?? 0,
|
||||
totalIn: detail?.tokens?.in ?? log.tokens?.in ?? null,
|
||||
totalOut: detail?.tokens?.out ?? log.tokens?.out ?? null,
|
||||
cacheRead: detail?.tokens?.cacheRead ?? log.tokens?.cacheRead,
|
||||
cacheWrite: detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite,
|
||||
reasoning: detail?.tokens?.reasoning ?? log.tokens?.reasoning,
|
||||
@@ -173,7 +262,6 @@ export default function RequestLoggerDetail({
|
||||
? "bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border-emerald-500/30"
|
||||
: "bg-sky-500/20 text-sky-700 dark:text-sky-300 border-sky-500/30";
|
||||
const accountLabel = maskAccount(detail?.account || log.account, emailsVisible);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[5vh]"
|
||||
@@ -192,18 +280,28 @@ export default function RequestLoggerDetail({
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
{log.active ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-bold bg-amber-500/20 text-amber-600 dark:text-amber-400 border border-amber-500/30">
|
||||
<span className="inline-block h-3 w-3 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
</span>
|
||||
) : log.status === 0 ? (
|
||||
<span className="inline-block px-2.5 py-1 rounded text-xs font-bold bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30">
|
||||
Completed
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
)}
|
||||
{hasStatusDiscrepancy && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-bg-subtle border border-border text-text-muted">
|
||||
Upstream: {providerStatus}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-bold text-lg">{log.method}</span>
|
||||
{log.method && <span className="font-bold text-lg">{log.method}</span>}
|
||||
</div>
|
||||
{hasStatusDiscrepancy && (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium mt-0.5">
|
||||
@@ -212,15 +310,29 @@ export default function RequestLoggerDetail({
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-muted font-mono text-sm self-center ml-2">{log.path}</span>
|
||||
{log.id && (
|
||||
<span className="text-[10px] text-text-muted/50 font-mono self-center ml-2 px-1.5 py-0.5 rounded bg-bg-subtle border border-border/40 select-all">
|
||||
{log.id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/dashboard/analytics?tab=route-trace&id=${encodeURIComponent(log.id)}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={!onPrevious}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Previous request"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">alt_route</span>
|
||||
Route Trace
|
||||
</Link>
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Next request"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
@@ -233,151 +345,176 @@ export default function RequestLoggerDetail({
|
||||
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
{/* Metadata Grid */}
|
||||
<div
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border"
|
||||
data-testid="request-log-metadata-grid"
|
||||
>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Completed Time
|
||||
{log.active ? (
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Started At</div>
|
||||
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Duration
|
||||
<div className="min-w-[100px] flex-1">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Duration</div>
|
||||
<div className="text-sm font-medium">{formatDuration(log.duration)}</div>
|
||||
</div>
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Model</div>
|
||||
<div className="text-sm font-medium text-primary font-mono">{log.model}</div>
|
||||
</div>
|
||||
<div className="min-w-[120px] flex-1">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Provider</div>
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
|
||||
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
|
||||
>
|
||||
{providerColor.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-[120px] flex-1">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Account</div>
|
||||
<div className="text-sm font-medium">{accountLabel}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium">{formatDuration(log.duration)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Input</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-input">
|
||||
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
|
||||
Total In: {tokenStats.totalIn.toLocaleString()}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs font-bold">
|
||||
Cache Read: {formatTokenValue(tokenStats.cacheRead)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs font-bold">
|
||||
Cache Write: {formatTokenValue(tokenStats.cacheWrite)}
|
||||
</span>
|
||||
{tokenStats.compressed != null &&
|
||||
tokenStats.compressed > 0 &&
|
||||
(() => {
|
||||
) : (
|
||||
<div
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border"
|
||||
data-testid="request-log-metadata-grid"
|
||||
>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Completed Time
|
||||
</div>
|
||||
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Duration
|
||||
</div>
|
||||
<div className="text-sm font-medium">{formatDuration(log.duration)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Input</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-input">
|
||||
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
|
||||
Total In: {formatTokenValue(tokenStats.totalIn)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs font-bold">
|
||||
Cache Read: {formatTokenValue(tokenStats.cacheRead)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs font-bold">
|
||||
Cache Write: {formatTokenValue(tokenStats.cacheWrite)}
|
||||
</span>
|
||||
{tokenStats.compressed != null && tokenStats.compressed > 0 && (() => {
|
||||
const fromTokens = tokenStats.totalIn + tokenStats.compressed;
|
||||
const pct = Math.round((tokenStats.compressed / fromTokens) * 100);
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded bg-purple-500/20 text-purple-700 dark:text-purple-300 text-xs font-bold">
|
||||
Compressed: {fromTokens.toLocaleString()} →{" "}
|
||||
{tokenStats.totalIn.toLocaleString()} (-{pct}%)
|
||||
Compressed: {fromTokens.toLocaleString()} \u2192 {tokenStats.totalIn.toLocaleString()} (-{pct}%)
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Output
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Output
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-output">
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
|
||||
Total Out: {formatTokenValue(tokenStats.totalOut)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-violet-500/20 text-violet-700 dark:text-violet-400 text-xs font-bold">
|
||||
Reasoning: {formatTokenValue(tokenStats.reasoning)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-output">
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
|
||||
Total Out: {tokenStats.totalOut.toLocaleString()}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-violet-500/20 text-violet-700 dark:text-violet-400 text-xs font-bold">
|
||||
Reasoning: {formatTokenValue(tokenStats.reasoning)}
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Model</div>
|
||||
<div className="text-sm font-medium text-primary font-mono">{log.model}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Requested Model
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-medium font-mono ${
|
||||
(detail?.requestedModel || log.requestedModel) &&
|
||||
(detail?.requestedModel || log.requestedModel) !== log.model
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{detail?.requestedModel || log.requestedModel || "\u2014"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Provider
|
||||
</div>
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
|
||||
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
|
||||
>
|
||||
{providerColor.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Model</div>
|
||||
<div className="text-sm font-medium text-primary font-mono">{log.model}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Requested Model
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Req Protocol
|
||||
</div>
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
|
||||
style={{ backgroundColor: protocol.bg, color: protocol.text }}
|
||||
>
|
||||
{protocol.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-medium font-mono ${
|
||||
(detail?.requestedModel || log.requestedModel) &&
|
||||
(detail?.requestedModel || log.requestedModel) !== log.model
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{detail?.requestedModel || log.requestedModel || "—"}
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Cache Source
|
||||
</div>
|
||||
<span
|
||||
className={`inline-block px-2.5 py-1 rounded text-[10px] font-bold border ${cacheSourceClassName}`}
|
||||
>
|
||||
{cacheSourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Provider
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Account
|
||||
</div>
|
||||
<div className="text-sm font-medium">{accountLabel}</div>
|
||||
</div>
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
|
||||
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
|
||||
>
|
||||
{providerColor.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Req Protocol
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
API Key
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
title={
|
||||
detail?.apiKeyName ||
|
||||
detail?.apiKeyId ||
|
||||
log.apiKeyName ||
|
||||
log.apiKeyId ||
|
||||
"No API key"
|
||||
}
|
||||
>
|
||||
{formatApiKeyLabel(
|
||||
detail?.apiKeyName || log.apiKeyName,
|
||||
detail?.apiKeyId || log.apiKeyId
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
|
||||
style={{ backgroundColor: protocol.bg, color: protocol.text }}
|
||||
>
|
||||
{protocol.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Cache Source
|
||||
</div>
|
||||
<span
|
||||
className={`inline-block px-2.5 py-1 rounded text-[10px] font-bold border ${cacheSourceClassName}`}
|
||||
>
|
||||
{cacheSourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Account
|
||||
</div>
|
||||
<div className="text-sm font-medium" title={accountLabel}>
|
||||
{accountLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
API Key
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
title={
|
||||
detail?.apiKeyName ||
|
||||
detail?.apiKeyId ||
|
||||
log.apiKeyName ||
|
||||
log.apiKeyId ||
|
||||
"No API key"
|
||||
}
|
||||
>
|
||||
{formatApiKeyLabel(
|
||||
detail?.apiKeyName || log.apiKeyName,
|
||||
detail?.apiKeyId || log.apiKeyId
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
|
||||
{detail?.comboName || log.comboName ? (
|
||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
|
||||
{detail?.comboName || log.comboName}
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted">\u2014</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
|
||||
{detail?.comboName || log.comboName ? (
|
||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
|
||||
{detail?.comboName || log.comboName}
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{(detail?.error || log.error) && (
|
||||
@@ -406,6 +543,14 @@ export default function RequestLoggerDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{streamChunksText && (
|
||||
<StreamSection
|
||||
title="Event Stream (Debug)"
|
||||
json={streamChunksText}
|
||||
onCopy={() => onCopy(streamChunksText)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{payloadSections.length > 0 &&
|
||||
payloadSections.map((section) => (
|
||||
<PayloadSection
|
||||
@@ -416,14 +561,6 @@ export default function RequestLoggerDetail({
|
||||
/>
|
||||
))}
|
||||
|
||||
{streamChunksText && (
|
||||
<PayloadSection
|
||||
title="Event Stream (Debug)"
|
||||
json={streamChunksText}
|
||||
onCopy={() => onCopy(streamChunksText)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{payloadSections.length === 0 && responseJson && (
|
||||
<PayloadSection
|
||||
title="Response Payload (Legacy)"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ export default function DashboardLayout({ children }) {
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const isElectron = useIsElectron();
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (typeof globalThis.window === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
|
||||
} catch {
|
||||
@@ -27,7 +27,9 @@ export default function DashboardLayout({ children }) {
|
||||
});
|
||||
|
||||
const isMacElectron =
|
||||
isElectron && typeof window !== "undefined" && window.electronAPI?.platform === "darwin";
|
||||
isElectron &&
|
||||
typeof globalThis.window !== "undefined" &&
|
||||
globalThis.electronAPI?.platform === "darwin";
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
@@ -98,9 +100,11 @@ export default function DashboardLayout({ children }) {
|
||||
/>
|
||||
{!isE2EMode && <MaintenanceBanner />}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar p-4 sm:p-6 lg:p-10">
|
||||
<div className="max-w-7xl mx-auto w-full">
|
||||
<div className="max-w-7xl mx-auto w-full h-full min-h-0 flex flex-col">
|
||||
<Breadcrumbs />
|
||||
{children}
|
||||
<div className="flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
153
tests/integration/active-request-completion.test.ts
Normal file
153
tests/integration/active-request-completion.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
const BASE_URL = process.env.OMNIROUTE_URL || "http://localhost:20128";
|
||||
const MODEL = process.env.TEST_GEMINI_MODEL || "default";
|
||||
|
||||
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
|
||||
|
||||
// Simple SSE reader (compatible with streamed chat completions)
|
||||
async function readSSEStream(response: Response, onChunk?: (chunk: string) => void) {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let buffer = "";
|
||||
let fullContent = "";
|
||||
let finishReason = "unknown";
|
||||
let totalTokens = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
const choice = ((parsed?.choices ?? []) as Array<Record<string, unknown>>)[0];
|
||||
if (choice) {
|
||||
const delta = choice.delta as Record<string, unknown> | undefined;
|
||||
if (delta?.content) {
|
||||
let chunk = delta.content as string;
|
||||
fullContent += chunk;
|
||||
onChunk?.(chunk);
|
||||
}
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason as string;
|
||||
}
|
||||
const usage = parsed.usage as Record<string, number> | undefined;
|
||||
if (usage) {
|
||||
totalTokens =
|
||||
usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fullContent, finishReason, totalTokens };
|
||||
}
|
||||
|
||||
test("live request returns streamChunks", { skip }, async () => {
|
||||
console.log("[TEST] BASE_URL=", BASE_URL, "OMNIROUTE_URL=", process.env.OMNIROUTE_URL, "API_KEY set=", !!API_KEY);
|
||||
|
||||
const messages = [
|
||||
{ role: "system", content: "Execute the user prompt and provide a detailed explanation." },
|
||||
{ role: "user", content: "Ackermann(4,3) step by step" },
|
||||
];
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 60_000);
|
||||
|
||||
try {
|
||||
const completionsResponse = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ model: MODEL, messages, stream: true, max_tokens: 512 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
completionsResponse.status,
|
||||
200,
|
||||
`expected 200 from chat/completions, got ${completionsResponse.status}`
|
||||
);
|
||||
|
||||
const requestId = completionsResponse.headers.get("x-omniroute-request-id");
|
||||
assert.ok(requestId, "expected x-omniroute-request-id header in response");
|
||||
|
||||
let streamFinished = false;
|
||||
|
||||
let streamPromise = readSSEStream(completionsResponse).finally(() => {
|
||||
streamFinished = true;
|
||||
});
|
||||
|
||||
const postPollStart = Date.now();
|
||||
const postPollTimeout = 60_000;
|
||||
|
||||
let sawLogChunksWhileStreaming = false;
|
||||
|
||||
while (Date.now() - postPollStart < postPollTimeout) {
|
||||
try {
|
||||
const activeRequestResponse = await fetch(
|
||||
`${BASE_URL}/api/logs/${encodeURIComponent(requestId!)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
if (activeRequestResponse.ok) {
|
||||
let activeRequest = await activeRequestResponse.json();
|
||||
if (activeRequest.active &&
|
||||
Array.isArray(activeRequest.pipelinePayloads.streamChunks.provider) &&
|
||||
activeRequest.pipelinePayloads.streamChunks.provider.length > 0
|
||||
) {
|
||||
console.log("Stream chunks:", activeRequest.pipelinePayloads.streamChunks.provider);
|
||||
sawLogChunksWhileStreaming = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (streamFinished) {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
|
||||
assert.ok(sawLogChunksWhileStreaming, "streamChunks never appeared while request was active");
|
||||
|
||||
await streamPromise;
|
||||
const logDetailResponse = await fetch(
|
||||
`${BASE_URL}/api/logs/${encodeURIComponent(requestId!)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
assert.ok(logDetailResponse.ok, `failed to fetch log detail: ${logDetailResponse.status}`);
|
||||
let finishedRequest = await logDetailResponse.json();
|
||||
|
||||
assert.equal(finishedRequest.id, requestId, "log detail id should match request id");
|
||||
assert.equal(finishedRequest.active, false, "request should be marked as inactive after completion");
|
||||
|
||||
assert.ok(Array.isArray(finishedRequest.pipelinePayloads.streamChunks.provider) );
|
||||
assert.ok(Array.isArray(finishedRequest.pipelinePayloads.streamChunks.client));
|
||||
|
||||
assert.ok(finishedRequest.pipelinePayloads.streamChunks.provider.length > 0);
|
||||
assert.ok(finishedRequest.pipelinePayloads.streamChunks.client.length > 0);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
@@ -973,7 +973,7 @@ test("chatCore integration: modular compression records analytics row best-effor
|
||||
}
|
||||
});
|
||||
|
||||
test("chatCore integration: caveman output mode records analytics and receipts without prompt compression", async () => {
|
||||
test("chatCore integration: caveman output mode skipped when compression is globally disabled", async () => {
|
||||
const provider = "openai";
|
||||
const model = "gpt-4";
|
||||
|
||||
@@ -1032,22 +1032,78 @@ test("chatCore integration: caveman output mode records analytics and receipts w
|
||||
});
|
||||
|
||||
assert.ok(result.success, "Request should succeed");
|
||||
assert.match(capturedBody.messages[0].content, /Caveman Output Mode/);
|
||||
assert.equal(
|
||||
capturedBody.messages[0].role,
|
||||
"user",
|
||||
"No system message should be injected when compression is disabled"
|
||||
);
|
||||
assert.doesNotMatch(capturedBody.messages[0].content ?? "", /Caveman Output Mode/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
let summary = compressionAnalyticsDb.getCompressionAnalyticsSummary();
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 100 &&
|
||||
(summary.totalRequests === 0 || summary.realUsage.requestsWithReceipts === 0);
|
||||
attempt += 1
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
summary = compressionAnalyticsDb.getCompressionAnalyticsSummary();
|
||||
test("chatCore integration: caveman output mode injected when both compression and output mode are enabled", async () => {
|
||||
const provider = "openai";
|
||||
const model = "gpt-4";
|
||||
|
||||
await compressionDb.updateCompressionSettings({
|
||||
enabled: true,
|
||||
defaultMode: "off",
|
||||
autoTriggerTokens: 0,
|
||||
cavemanOutputMode: {
|
||||
enabled: true,
|
||||
intensity: "full",
|
||||
autoClarity: true,
|
||||
},
|
||||
});
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider,
|
||||
apiKey: "test-key",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
let capturedBody: any = null;
|
||||
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
if (init?.body) {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { role: "assistant", content: "ok" } }],
|
||||
usage: { prompt_tokens: 20, completion_tokens: 4, total_tokens: 24 },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleChatCore({
|
||||
body: {
|
||||
model,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Summarize this implementation." }],
|
||||
},
|
||||
modelInfo: { provider, model },
|
||||
credentials: { apiKey: "test-key" },
|
||||
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() },
|
||||
connectionId: connection.id,
|
||||
onCredentialsRefreshed: () => {},
|
||||
onRequestSuccess: () => {},
|
||||
onStreamFailure: () => {},
|
||||
onDisconnect: () => {},
|
||||
userAgent: "test-agent",
|
||||
comboName: null,
|
||||
});
|
||||
|
||||
assert.equal(summary.byMode["output-caveman"].count, 1);
|
||||
assert.equal(summary.realUsage.requestsWithReceipts, 1);
|
||||
assert.equal(summary.realUsage.totalTokens, 24);
|
||||
assert.ok(result.success, "Request should succeed");
|
||||
assert.equal(capturedBody.messages[0].role, "system");
|
||||
assert.match(capturedBody.messages[0].content ?? "", /Caveman Output Mode/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -230,31 +230,26 @@ describe("API Routes — dashboard and tool consumers", () => {
|
||||
const requestLogger = readProjectFile("src/shared/components/RequestLoggerV2.tsx");
|
||||
const proxyLogger = readProjectFile("src/shared/components/ProxyLogger.tsx");
|
||||
const consoleLogger = readProjectFile("src/shared/components/ConsoleLogViewer.tsx");
|
||||
const activeRequests = readProjectFile("src/shared/components/ActiveRequestsPanel.tsx");
|
||||
|
||||
assert.ok(logsPage, "logs page should exist");
|
||||
assert.ok(requestLogger, "RequestLoggerV2 should exist");
|
||||
assert.ok(proxyLogger, "ProxyLogger should exist");
|
||||
assert.ok(consoleLogger, "ConsoleLogViewer should exist");
|
||||
assert.ok(activeRequests, "ActiveRequestsPanel should exist");
|
||||
assert.match(logsPage, /RequestLoggerV2/);
|
||||
assert.match(logsPage, /EmailPrivacyToggle/);
|
||||
assert.match(logsPage, /ProxyLogger/);
|
||||
assert.match(logsPage, /ConsoleLogViewer/);
|
||||
assert.match(logsPage, /ActiveRequestsPanel/);
|
||||
// AuditLogTab removed: audit moved to its own /dashboard/audit page (#2859).
|
||||
assert.match(logsPage, /\/api\/logs\/export/);
|
||||
assert.match(requestLogger, /\/api\/usage\/call-logs/);
|
||||
assert.match(requestLogger, /\/api\/logs\/\$\{/);
|
||||
assert.doesNotMatch(requestLogger, /\/api\/logs\/active/);
|
||||
assert.match(requestLogger, /\/api\/logs\/detail/);
|
||||
assert.match(requestLogger, /useEmailPrivacyStore/);
|
||||
assert.match(requestLogger, /maskAccount\(log\.account, emailsVisible\)/);
|
||||
assert.match(requestLogger, /emailsVisible=\{emailsVisible\}/);
|
||||
assert.match(proxyLogger, /\/api\/usage\/proxy-logs/);
|
||||
assert.match(consoleLogger, /\/api\/logs\/console/);
|
||||
assert.match(activeRequests, /\/api\/logs\/active/);
|
||||
assert.match(activeRequests, /useEmailPrivacyStore/);
|
||||
assert.match(activeRequests, /maskAccount\(row\.account, emailsVisible\)/);
|
||||
assertRouteMethods("src/app/api/logs/active/route.ts", ["GET"]);
|
||||
assertRouteMethods("src/app/api/logs/console/route.ts", ["GET"]);
|
||||
assertRouteMethods("src/app/api/logs/detail/route.ts", ["GET", "POST"]);
|
||||
assertRouteMethods("src/app/api/logs/export/route.ts", ["GET"]);
|
||||
@@ -263,25 +258,17 @@ describe("API Routes — dashboard and tool consumers", () => {
|
||||
assertRouteMethods("src/app/api/usage/call-logs/[id]/route.ts", ["GET"]);
|
||||
});
|
||||
|
||||
it("keeps the active request payload modal on opaque theme surfaces", () => {
|
||||
const activeRequests = readProjectFile("src/shared/components/ActiveRequestsPanel.tsx");
|
||||
it("keeps request log surfaces on opaque theme colors", () => {
|
||||
const requestLogger = readProjectFile("src/shared/components/RequestLoggerV2.tsx");
|
||||
const globals = readProjectFile("src/app/globals.css");
|
||||
|
||||
assert.ok(activeRequests, "ActiveRequestsPanel should exist");
|
||||
assert.ok(requestLogger, "RequestLoggerV2 should exist");
|
||||
assert.ok(globals, "globals.css should exist");
|
||||
assert.match(globals, /--color-card:\s+#ffffff/);
|
||||
assert.match(globals, /--color-card:\s+#161b22/);
|
||||
assert.match(globals, /--color-card:\s+var\(--color-card\)/);
|
||||
assert.match(activeRequests, /rounded-xl border border-border bg-surface/);
|
||||
assert.match(activeRequests, /backdrop-blur-sm/);
|
||||
assert.match(
|
||||
activeRequests,
|
||||
/rounded-2xl[^"]*border border-border[^"]*bg-surface[^"]*shadow-2xl/
|
||||
);
|
||||
assert.doesNotMatch(activeRequests, /bg-card\/70/);
|
||||
assert.doesNotMatch(activeRequests, /rounded-2xl[^"]*bg-card/);
|
||||
assert.doesNotMatch(activeRequests, /shadow-\[/);
|
||||
assert.doesNotMatch(activeRequests, /border-black\/10/);
|
||||
assert.match(requestLogger, /bg-black\/5 dark:bg-black\/20/);
|
||||
assert.doesNotMatch(requestLogger, /\/api\/logs\/active/);
|
||||
});
|
||||
|
||||
it("keeps usage quota wired through A2A and MCP tools", () => {
|
||||
|
||||
788
tests/integration/live-gemini-workload.test.ts
Normal file
788
tests/integration/live-gemini-workload.test.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
const BASE_URL = process.env.OMNIROUTE_URL || "http://localhost:3000";
|
||||
const MODEL = process.env.TEST_GEMINI_MODEL || "default";
|
||||
const NUM_REQUESTS = 25;
|
||||
|
||||
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Test Data Generator
|
||||
// --------------------------------------------------------------------------
|
||||
type Message = { role: string; content: string };
|
||||
|
||||
function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPTS = [
|
||||
"You are a helpful coding assistant. Respond concisely and accurately.",
|
||||
"You are a senior software engineer reviewing code. Be thorough and critical.",
|
||||
"You are a data scientist analyzing complex datasets. Explain your reasoning step by step.",
|
||||
"You are a technical writer creating documentation. Be clear and well-structured.",
|
||||
"You are a DevOps engineer debugging infrastructure issues. Think about root causes.",
|
||||
"You are a security auditor reviewing code for vulnerabilities. Be meticulous.",
|
||||
"You are an AI researcher explaining concepts. Use analogies and examples.",
|
||||
"You are a product manager evaluating technical proposals. Consider trade-offs.",
|
||||
];
|
||||
|
||||
const USER_PROMPTS = [
|
||||
"Write a function that implements a trie data structure with insert, search, and startsWith methods.",
|
||||
"Explain the difference between REST and GraphQL, with pros and cons of each.",
|
||||
"Debug this code and explain what's wrong: function sum(a,b){return a+b} console.log(sum(1,2,3))",
|
||||
"Write a SQL query to find the top 5 most common words in a articles table across all articles.",
|
||||
"Compare and contrast Docker vs Podman for container orchestration.",
|
||||
"Explain how HTTP/2 multiplexing works and why it improves performance.",
|
||||
"Write a Python decorator that caches function results with a TTL.",
|
||||
"What are the trade-offs between microservices and monoliths? When would you choose each?",
|
||||
"Design a rate limiter algorithm. Compare token bucket vs sliding window.",
|
||||
"Explain the CAP theorem and how it applies to distributed databases.",
|
||||
"Write a React custom hook for WebSocket connections with auto-reconnect.",
|
||||
"How does garbage collection work in V8 JavaScript engine?",
|
||||
"Compare SQLite vs PostgreSQL for a production web application.",
|
||||
"Write a bash script that monitors CPU and memory usage and alerts when thresholds are exceeded.",
|
||||
"Explain zero-trust networking principles and how to implement them.",
|
||||
"Write a TypeScript type-safe event emitter class.",
|
||||
"How does TLS 1.3 handshake work? Compare with TLS 1.2.",
|
||||
"Design a URL shortening service. Cover the database schema, API design, and scaling considerations.",
|
||||
"Explain the event loop in Node.js with specific examples of microtasks vs macrotasks.",
|
||||
"Write a regex to validate email addresses and explain the trade-offs.",
|
||||
"Compare Kafka vs RabbitMQ for event-driven architectures.",
|
||||
"How would you implement feature flags in a distributed system?",
|
||||
"Write an implementation of Promise.all() with timeout support.",
|
||||
"Explain the actor model and how it compares to traditional threading.",
|
||||
"Design a caching strategy for a read-heavy API serving 10k requests/second.",
|
||||
];
|
||||
|
||||
const CODE_BLOCKS = [
|
||||
`\`\`\`python
|
||||
def quicksort(arr):
|
||||
if len(arr) <= 1:
|
||||
return arr
|
||||
pivot = arr[len(arr) // 2]
|
||||
left = [x for x in arr if x < pivot]
|
||||
middle = [x for x in arr if x == pivot]
|
||||
right = [x for x in arr if x > pivot]
|
||||
return quicksort(left) + middle + quicksort(right)
|
||||
\`\`\``,
|
||||
`\`\`\`javascript
|
||||
const pipeline = (initial, ...fns) =>
|
||||
fns.reduce((acc, fn) => fn(acc), initial);
|
||||
|
||||
const result = pipeline(
|
||||
5,
|
||||
x => x * 2,
|
||||
x => x + 1,
|
||||
x => x ** 2
|
||||
);
|
||||
\`\`\``,
|
||||
`\`\`\`typescript
|
||||
interface Task<T> {
|
||||
id: string;
|
||||
priority: number;
|
||||
execute: () => Promise<T>;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
class TaskQueue<T> {
|
||||
private queue: Task<T>[] = [];
|
||||
private running = false;
|
||||
|
||||
async enqueue(task: Task<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({
|
||||
...task,
|
||||
execute: () => task.execute().then(resolve).catch(reject),
|
||||
});
|
||||
if (!this.running) this.processNext();
|
||||
});
|
||||
}
|
||||
|
||||
private async processNext(): Promise<void> {
|
||||
this.running = true;
|
||||
const sorted = this.queue.sort((a, b) => b.priority - a.priority);
|
||||
const task = sorted.shift();
|
||||
if (task) {
|
||||
const result = await task.execute();
|
||||
this.processNext();
|
||||
} else {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
`\`\`\`sql
|
||||
WITH word_freq AS (
|
||||
SELECT
|
||||
UNNEST(STRING_TO_ARRAY(LOWER(content), ' ')) AS word,
|
||||
COUNT(*) AS cnt
|
||||
FROM articles
|
||||
WHERE content IS NOT NULL
|
||||
GROUP BY word
|
||||
)
|
||||
SELECT word, cnt
|
||||
FROM word_freq
|
||||
WHERE LENGTH(word) > 3
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 20;
|
||||
\`\`\``,
|
||||
`\`\`\`yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
namespace: production
|
||||
data:
|
||||
database.url: "postgresql://db.internal:5432/app"
|
||||
redis.host: "redis-cluster.internal"
|
||||
redis.port: "6379"
|
||||
log.level: "info"
|
||||
feature.flags: '{"darkMode":true,"betaApi":false,"newDashboard":true}'
|
||||
\`\`\``,
|
||||
];
|
||||
|
||||
const LONG_DOCUMENTS = [
|
||||
`In distributed systems theory, the CAP theorem states that it is impossible for a distributed data store to simultaneously provide more than two out of the following three guarantees: Consistency (every read receives the most recent write or an error), Availability (every request receives a non-error response, without the guarantee that it contains the most recent write), and Partition Tolerance (the system continues to operate despite an arbitrary number of messages being dropped or delayed by the network between nodes). This fundamental trade-off was first articulated by Eric Brewer in 2000 and later formally proven as a theorem by Seth Gilbert and Nancy Lynch in 2002.
|
||||
|
||||
The practical implication of CAP is that when a network partition occurs, system designers must choose between consistency and availability. Traditional ACID-compliant databases like PostgreSQL typically choose consistency (CP), while many NoSQL systems like Cassandra choose availability (AP). However, modern systems increasingly recognize that CAP is not binary — techniques like conflict-free replicated data types (CRDTs), consensus algorithms like Raft, and hybrid approaches allow systems to achieve different trade-offs at different levels.
|
||||
|
||||
In practice, most production systems aim for "PA/EL" (Partition-Aware / Eventually-Literate) or use compensatory transactions to handle inconsistencies. The PACELC extension further refines CAP by noting that even when the network is functioning normally (no partition), there's still a trade-off between latency and consistency. Systems like Amazon DynamoDB and Google Spanner represent different points on this continuum, with Spanner using TrueTime to achieve external consistency at higher latency, while DynamoDB prioritizes availability and partition tolerance with weaker consistency models.`, `Concurrency models represent fundamentally different approaches to managing multiple computations that overlap in time. The traditional threading model, used extensively in languages like Java and C++, relies on shared memory with explicit locking mechanisms (mutexes, semaphores, read-write locks) to coordinate access to shared state. This model is well-understood but notoriously difficult to get right — deadlocks, race conditions, and priority inversion are common bugs that can be extremely subtle.
|
||||
|
||||
The actor model, popularized by Erlang and later adopted by Akka, Dapr, and Orleans, takes a different approach: each actor is an independent computation unit with its own private state, communicating exclusively through asynchronous message passing. Actors can create other actors, send messages, and change their behavior for the next message they receive. This model eliminates shared-state concurrency issues entirely, since actors never share memory — they only share messages. The trade-off is that certain patterns (like distributed transactions) become more complex to implement.
|
||||
|
||||
Software Transactional Memory (STM) offers another paradigm, borrowing from database transactions to manage memory accesses. Clojure's STM, for instance, uses multiversion concurrency control (MVCC) to provide optimistic concurrency — transactions proceed in isolation and are committed atomically, with automatic retry on conflicts. STM can be more ergonomic than explicit locking, but performance overhead and the challenge of handling side effects within transactions remain concerns.
|
||||
|
||||
The structured concurrency model, recently gaining traction through Kotlin coroutines, Java virtual threads, and Swift's async/await, organizes concurrent operations into a hierarchy where each operation's lifetime is scoped to its enclosing block. This ensures proper cleanup, simplifies error propagation, and prevents resource leaks. Go's goroutines follow a similar philosophy with channels providing CSP-style communication (Communicating Sequential Processes), where processes communicate by sending values through typed channels rather than through shared memory.
|
||||
|
||||
For I/O-bound workloads, event-driven models (Node.js, Python asyncio, C# async/await) use an event loop to multiplex concurrent operations onto a single thread, avoiding context-switching overhead. This works well when the workload is primarily waiting on I/O, but CPU-bound work can block the event loop and degrade responsiveness. The modern trend is toward hybrid approaches that combine the scalability of event-driven models with the flexibility of thread pools for CPU-intensive work.`,
|
||||
|
||||
`Event sourcing is an architectural pattern where state changes are stored as an immutable sequence of events, rather than as the current state. Instead of updating a row in a database when a user changes their email address, you append an "EmailChanged" event to an event store. The current state is derived by replaying all events for that entity. This fundamental shift in thinking has profound implications for system design, traceability, and temporal queries.
|
||||
|
||||
The primary advantages of event sourcing include: complete audit trail (every state change is recorded with full context), temporal querying (you can reconstruct state at any point in time), event-driven architecture fit (events can be published to downstream consumers naturally), and the ability to create multiple different views (projections) from the same event stream. Command Query Responsibility Segregation (CQRS) is a natural companion pattern, where write operations use the event store and read operations use pre-computed projections optimized for specific query patterns.
|
||||
|
||||
However, event sourcing introduces significant complexity: event schema evolution must be carefully managed (events are permanent), the event store becomes a critical piece of infrastructure requiring careful backup and disaster recovery, and read models can be eventually consistent with the write model, potentially serving stale data. Common implementation patterns using PostgreSQL as an event store include the "outbox pattern" with transactional outbox tables, and using JSONB columns for flexible event payloads while maintaining indexed metadata columns for efficient querying.
|
||||
|
||||
Tools like Kafka (for event streaming), Debezium (for change data capture), and Axon Framework (for Java-based CQRS/ES) provide infrastructure to implement these patterns. The decision to adopt event sourcing should be driven by concrete requirements for audit trails, temporal queries, or complex event-driven workflows — it adds significant complexity that may not be justified for simpler CRUD applications.`,
|
||||
];
|
||||
|
||||
const AGENTIC_TASKS = [
|
||||
"I need to build a microservice that processes webhook events. Let's start with the requirements. First, what should I consider when designing the webhook ingestion endpoint?",
|
||||
"Let me show you what I have so far for the task queue system. Actually, first let me reconsider the requirements — we need to handle priorities and timeouts.",
|
||||
"I'm working on refactoring the authentication module. Looking at the current code, I see we're using JWT with refresh tokens. But we also need API key auth for service-to-service communication.",
|
||||
"Before we write any code, let me think about the data model. We have users, organizations, projects, and teams. Users can belong to multiple organizations and each project belongs to one organization.",
|
||||
"Let me walk through the deployment pipeline. We build with Docker, push to GHCR, deploy to Kubernetes. But we're seeing issues with rolling updates — sometimes the old pods serve requests after the new ones are ready.",
|
||||
"I just realized there's a security concern. We're exposing internal service names in error messages returned to the client. Let me check all the error handling paths.",
|
||||
"OK I've been thinking about the caching strategy more. We need multi-layer caching: Redis for API responses, CDN for static assets, and browser caching for images. But invalidation is tricky — what happens when a user updates their avatar?",
|
||||
"Let me trace through the payment flow end to end. User submits payment → we create a pending transaction → call Stripe → handle webhook → update order status → send confirmation email. Each step has failure modes.",
|
||||
];
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Generator helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function genSystemMessage(): Message {
|
||||
return { role: "system", content: pick(SYSTEM_PROMPTS) };
|
||||
}
|
||||
|
||||
function genUserMessage(): Message {
|
||||
return { role: "user", content: pick(USER_PROMPTS) };
|
||||
}
|
||||
|
||||
function genCodeReviewMessage(): Message {
|
||||
return {
|
||||
role: "user",
|
||||
content: `Review this code and suggest improvements:\n${pick(CODE_BLOCKS)}\n\nFocus on: performance, readability, edge cases.`,
|
||||
};
|
||||
}
|
||||
|
||||
function genLongDocMessage(): Message {
|
||||
return {
|
||||
role: "user",
|
||||
content: `Please summarize the following text and extract 5 key insights:\n\n${pick(LONG_DOCUMENTS)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function genAgenticTaskMessage(): Message {
|
||||
return { role: "user", content: pick(AGENTIC_TASKS) };
|
||||
}
|
||||
|
||||
function genMultiTurnConversation(turns: number): Message[] {
|
||||
const messages: Message[] = [];
|
||||
|
||||
if (Math.random() > 0.3) {
|
||||
messages.push({ role: "system", content: pick(SYSTEM_PROMPTS) });
|
||||
}
|
||||
|
||||
const topics = [
|
||||
"building a real-time chat application with WebSockets",
|
||||
"designing a distributed task queue",
|
||||
"implementing OAuth 2.0 from scratch",
|
||||
"optimizing database query performance at scale",
|
||||
"creating a monitoring and alerting system",
|
||||
"building a feature flag system with gradual rollout",
|
||||
"designing an API gateway with rate limiting",
|
||||
"implementing event-driven microservices",
|
||||
];
|
||||
|
||||
const topic = pick(topics);
|
||||
|
||||
const assistantReplies = [
|
||||
`Great question about ${topic}. Let me break this down systematically and consider the key design decisions involved. First, we need to understand the core requirements and constraints. The main challenges here are around scalability, reliability, and maintainability.\n\nLet me start with the architecture: I'd recommend a layered approach. At the foundation, we need a solid data model. Then we build the business logic layer, followed by the API layer. For ${topic}, we should consider using established patterns like the repository pattern for data access and the strategy pattern for algorithm selection.\n\nHere's a concrete example of how I'd structure this:\n\n1. First, define clear interfaces and contracts\n2. Implement the core logic with dependency injection\n3. Add observability (metrics, logging, tracing)\n4. Write comprehensive tests\n5. Add performance optimizations iteratively`,
|
||||
`Building on what we discussed about ${topic}, I want to dive deeper into the implementation details. There are several important design patterns that apply here.\n\nThe key insight is that we need to separate concerns properly. Let me illustrate with a specific scenario:\n\nConsider how data flows through the system. We receive input, process it through a pipeline, and produce output. Each stage of the pipeline should be independently testable and replaceable.\n\nSome common pitfalls to avoid:\n- Tight coupling between components\n- Neglecting error handling and edge cases\n- Premature optimization without profiling\n- Ignoring security implications\n\nInstead, focus on:\n- Clean interfaces between modules\n- Comprehensive error handling with meaningful messages\n- Performance baselines before optimization\n- Security review as part of the design process`,
|
||||
`Let me reconsider my approach to ${topic}. After thinking about it more carefully, I realize there are some important nuances I should address.\n\nThe initial approach I suggested works for small to medium scale, but for production systems we need to consider:\n\n1. **Resilience**: What happens when dependencies fail? Circuit breakers, retries with exponential backoff, graceful degradation.\n2. **Observability**: How do we know the system is working correctly? Distributed tracing, structured logging, metrics with dashboards.\n3. **Operational complexity**: How do we deploy, monitor, and debug this in production?\n\nLet me revise the architecture to address these concerns...`,
|
||||
];
|
||||
|
||||
const followUps = [
|
||||
`That's helpful. Now let me think about the specific implementation. How would you handle error cases where ${topic} encounters a failure mid-operation? Should we use compensating transactions or rollback?`,
|
||||
`I see. Building on that, what about monitoring and observability for ${topic}? What metrics should we track and what alerting thresholds make sense?`,
|
||||
`Interesting points. Going deeper — how would we test ${topic}? Integration tests? E2E tests? Property-based testing? What's the testing strategy?`,
|
||||
`That makes sense. Now considering the deployment aspect — how would we roll out ${topic} incrementally? Feature flags? Blue-green deployment? Canary releases?`,
|
||||
`One more thing — for ${topic}, how do we handle data consistency across services? Eventual consistency? Saga pattern? Two-phase commit?`,
|
||||
`Let me think about the security implications of ${topic}. What are the threat models we should consider? Authentication, authorization, data encryption, audit logging?`,
|
||||
`Good. Now about performance — what's the bottleneck in ${topic}? Database queries? Network calls? CPU-bound computation? How do we profile and optimize?`,
|
||||
];
|
||||
|
||||
messages.push({ role: "user", content: `I need help with ${topic}. Can you walk me through the design and implementation?` });
|
||||
|
||||
const numTurns = Math.min(turns, 6);
|
||||
|
||||
for (let i = 0; i < numTurns; i++) {
|
||||
messages.push({ role: "assistant", content: pick(assistantReplies) });
|
||||
messages.push({ role: "user", content: pick(followUps) });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function genCodeConversation(): Message[] {
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: "You are a senior developer reviewing production code. Be thorough." },
|
||||
{ role: "user", content: `I have this code that's causing performance issues in production. Can you help me optimize it?\n\n${pick(CODE_BLOCKS)}\n\nThe function is called about 10,000 times per second and we're seeing GC pressure.` },
|
||||
{ role: "assistant", content: "Let me analyze the code for performance issues. I can see several opportunities for optimization:\n\n1. **Allocation pressure**: The code creates intermediate objects on every call. We should reuse buffers or use object pooling.\n2. **Algorithmic complexity**: The current approach is O(n²) but we can make it O(n log n) with a better data structure.\n3. **GC overhead**: Frequent allocations trigger GC pauses.\n\nLet me provide an optimized version that addresses these issues." },
|
||||
{ role: "user", content: "Thanks, that helped a lot. Now, I also need to add error handling. What patterns do you recommend for this kind of batch processing? Should I use the Result pattern, exceptions, or something else?" },
|
||||
{ role: "assistant", content: "For batch processing, I recommend a combination approach:\n\nUse a Result/Either type for expected failures (validation errors, not-found cases) and exceptions for unexpected failures (network errors, bugs). This gives you typed error handling for business logic while keeping unexpected errors visible.\n\nHere's a pattern I use:\n\n```typescript\ntype Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };\n\nasync function processBatch(items: Item[]): Promise<Result<Processed[], BatchError>> {\n const results: Processed[] = [];\n for (const item of items) {\n try {\n const processed = await processItem(item);\n results.push(processed);\n } catch (err) {\n return { ok: false, error: new BatchError(item.id, err) };\n }\n }\n return { ok: true, value: results };\n}\n```\n\nThis way callers can pattern-match on the result without try/catch everywhere." },
|
||||
{ role: "user", content: "That pattern looks clean. One more question — how should I test the batch processor? I want good coverage but the tests are slow because of the async operations." },
|
||||
];
|
||||
return messages;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Request variants
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
buildMessages: () => Message[];
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// 1-5: Basic single-turn requests with system prompts
|
||||
{ name: "basic coding question", buildMessages: () => [genSystemMessage(), genUserMessage()] },
|
||||
{ name: "code review request", buildMessages: () => [genSystemMessage(), genCodeReviewMessage()] },
|
||||
{ name: "long document analysis", buildMessages: () => [genSystemMessage(), genLongDocMessage()] },
|
||||
{ name: "direct question no system", buildMessages: () => [genUserMessage()] },
|
||||
{ name: "agentic planning task", buildMessages: () => [genSystemMessage(), genAgenticTaskMessage()] },
|
||||
|
||||
// 6-10: Multi-turn agentic conversations (different depths)
|
||||
{ name: "agentic 3-turn conversation", buildMessages: () => genMultiTurnConversation(3) },
|
||||
{ name: "agentic 5-turn conversation", buildMessages: () => genMultiTurnConversation(5) },
|
||||
{ name: "agentic 2-turn conversation", buildMessages: () => genMultiTurnConversation(2) },
|
||||
{ name: "agentic 4-turn conversation", buildMessages: () => genMultiTurnConversation(4) },
|
||||
{ name: "agentic 6-turn conversation", buildMessages: () => genMultiTurnConversation(6) },
|
||||
|
||||
// 11-15: Code-heavy conversations
|
||||
{ name: "code conversation with review", buildMessages: () => genCodeConversation() },
|
||||
{ name: "code review with long doc", buildMessages: () => [genSystemMessage(), genLongDocMessage(), { role: "user", content: `Now let's apply this to actual code. Optimize this:\n${pick(CODE_BLOCKS)}` }] },
|
||||
{ name: "multi-part coding task", buildMessages: () => [
|
||||
genSystemMessage(),
|
||||
{ role: "user", content: `First, let me understand the problem. I need to build a webhook processor.\n\n${pick(CODE_BLOCKS)}\n\nActually, let me also consider the monitoring aspects after the initial implementation.` },
|
||||
{ role: "assistant", content: "Let me outline the architecture for a robust webhook processor. The key components are:\n\n1. **Ingestion endpoint**: Validates signatures, deduplicates, queues\n2. **Processing pipeline**: Routes to handlers based on event type\n3. **Retry logic**: Exponential backoff with dead letter queue\n4. **Monitoring**: Metrics for throughput, latency, error rates\n\nHere's a detailed design..." },
|
||||
{ role: "user", content: "Great breakdown. Now let's focus on the retry logic specifically. I need it to handle different error types differently: 4xx should not retry, 5xx should retry up to 3 times, and network errors should retry up to 5 times with shorter intervals." },
|
||||
]},
|
||||
{ name: "debugging session simulation", buildMessages: () => [
|
||||
genSystemMessage(),
|
||||
{ role: "user", content: `I'm seeing this error in production and I can't figure out the root cause:\n\n\`\`\`\nError: EMFILE: too many open files\n at FSReqCallback.open (node:fs:249)\n at Object.openSync (node:fs:466)\n at Object.readFileSync (node:fs:355)\n\`\`\`\n\nThis happens intermittently under load. The service processes file uploads.` },
|
||||
{ role: "assistant", content: "The EMFILE error occurs when a process exceeds the file descriptor limit. Let me help you debug this systematically:\n\n1. **Check the current limit**: `ulimit -n` shows the soft limit\n2. **Find descriptor leaks**: Use `lsof -p <PID>` to list open files\n3. **Common causes**:\n - Streams not properly closed after processing\n - Database connections not returned to pool\n - File handles held by garbage collector\n\nMost likely you're opening file streams in your upload handler without properly closing them in all code paths (especially error paths)." },
|
||||
{ role: "user", content: "You're right, I found it! We were using `fs.readFileSync` in a middleware but not catching errors — on validation failures the file handle was leaking. What's the best pattern to prevent this?" },
|
||||
{ role: "assistant", content: "Great find! Here's the recommended pattern:\n\n```typescript\nimport { open, readFile, unlink } from 'fs/promises';\n\nasync function processUpload(path: string) {\n let file;\n try {\n file = await open(path, 'r');\n const data = await readFile(file);\n // validate and process\n } finally {\n await file?.close().catch(() => {});\n await unlink(path).catch(() => {}); // cleanup temp file\n }\n}\n```\n\nKey principles:\n- Always use `finally` for cleanup\n- Prefer async file operations\n- Use the file handle API (open → use → close) rather than one-shot methods for long-lived operations\n- Add file descriptor monitoring in production" },
|
||||
{ role: "user", content: "Perfect, that's very clear. Now let me also think about monitoring — what metrics should we expose around file descriptors to catch this early next time?" },
|
||||
]},
|
||||
{ name: "architecture discussion with trade-offs", buildMessages: () => [
|
||||
genSystemMessage(),
|
||||
genLongDocMessage(),
|
||||
{ role: "assistant", content: "Based on the document, I can extract the following key insights about distributed systems design...\n\n1. **CAP theorem trade-offs** need careful evaluation based on your specific use case\n2. **Consensus algorithms** like Raft provide strong consistency but at latency cost\n3. **Event-driven architectures** offer scalability but require careful handling of eventual consistency\n4. **CQRS + Event Sourcing** gives flexibility but adds operational complexity\n5. **Monitoring and observability** are critical in distributed systems" },
|
||||
{ role: "user", content: "Given these insights, how would you architect a payment processing system that needs both strong consistency (for balances) and high availability (for the API)? This seems like the classic CAP dilemma applied to fintech." },
|
||||
]},
|
||||
|
||||
// 16-20: Content-type variants
|
||||
{ name: "JSON-heavy structured data prompt", buildMessages: () => [genSystemMessage(), {
|
||||
role: "user",
|
||||
content: `Here's a JSON payload from our API. Transform it into a different structure and explain your changes:\n\n${JSON.stringify({
|
||||
event: "order.created",
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
orderId: `ord_${randomInt(100000, 999999)}`,
|
||||
customer: { id: `cus_${randomInt(10000, 99999)}`, tier: ["basic", "premium", "enterprise"][randomInt(0, 2)] },
|
||||
items: Array.from({ length: randomInt(3, 8) }, (_, i) => ({
|
||||
sku: `SKU-${String(i + 1).padStart(4, "0")}`,
|
||||
name: pick(["Widget Pro", "Deluxe Gadget", "Basic Tool", "Premium Service", "Add-on Pack"]),
|
||||
quantity: randomInt(1, 5),
|
||||
price: parseFloat((Math.random() * 200 + 5).toFixed(2)),
|
||||
})),
|
||||
total: 0,
|
||||
shipping: { method: pick(["standard", "express", "overnight"]), address: { street: "123 Main St", city: "San Francisco", state: "CA", zip: "94105" } },
|
||||
payment: { method: pick(["card", "wallet", "invoice"]), status: "pending" },
|
||||
},
|
||||
}, null, 2)}`,
|
||||
}]},
|
||||
{ name: "markdown formatting prompt", buildMessages: () => [genSystemMessage(), {
|
||||
role: "user",
|
||||
content: `# Architecture Review Request
|
||||
|
||||
## Project Overview
|
||||
We are building a **real-time collaboration platform** similar to Google Docs but for code editing.
|
||||
|
||||
## Requirements
|
||||
1. Real-time sync using **CRDTs** (not OT)
|
||||
2. **Multi-cursor** support with presence awareness
|
||||
3. **Offline-first** with conflict resolution
|
||||
4. **Plugin system** for extensions
|
||||
|
||||
## Questions
|
||||
1. What's the best CRDT implementation for this use case?
|
||||
2. How do we handle **large documents** (100k+ lines)?
|
||||
3. What's the **performance budget** for sync operations?
|
||||
|
||||
## Current Stack
|
||||
| Component | Technology | Status |
|
||||
|-----------|------------|--------|
|
||||
| Frontend | React + Monaco Editor | ✅ POC done |
|
||||
| Sync Layer | Yjs | ✅ POC done |
|
||||
| Backend | Node.js + WebSockets | ⚠️ In progress |
|
||||
| Storage | PostgreSQL | 🎯 Planned |
|
||||
| Auth | OAuth 2.0 + JWT | ✅ Done |
|
||||
|
||||
Please provide a detailed analysis with code examples.`,
|
||||
}]},
|
||||
{ name: "multi-part instructions with constraints", buildMessages: () => [genSystemMessage(), {
|
||||
role: "user",
|
||||
content: `I need you to solve this problem with specific constraints:
|
||||
|
||||
PROBLEM: Design a rate-limited API proxy that:
|
||||
1. Routes requests to different backends based on path prefix
|
||||
2. Enforces per-tenant rate limits (100 req/s per tenant)
|
||||
3. Caches responses with configurable TTL
|
||||
4. Logs all requests with timing data
|
||||
|
||||
CONSTRAINTS:
|
||||
- Must handle 50k req/s peak
|
||||
- P99 latency must stay under 50ms
|
||||
- No single point of failure
|
||||
- Must support gradual rollout
|
||||
|
||||
Please provide:
|
||||
1. Architecture diagram (ASCII art)
|
||||
2. Data structures
|
||||
3. Key algorithms
|
||||
4. Trade-offs`,
|
||||
}]},
|
||||
{ name: "debugging with stack trace", buildMessages: () => [genSystemMessage(), {
|
||||
role: "user",
|
||||
content: `I'm getting this error in production. Help me debug it:\n\n\`\`\`\nTypeError: Cannot read properties of undefined (reading 'map')\n at transformResponse (/app/src/services/transformer.ts:142:23)\n at processResponse (/app/src/middleware/response.ts:89:14)\n at processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async handleRequest (/app/src/router/index.ts:234:18)\n at async Server.handle (/app/src/server.ts:56:22)\n\`\`\`\n\nThe relevant code at line 142:\n\`\`\`typescript\nconst transformed = response.data.items.map(item => ({\n id: item.id,\n name: item.attributes.name,\n price: item.attributes.price,\n}));\n\`\`\`\n\nI think the issue is that \`response.data.items\` is sometimes undefined. What's the best way to handle this? Should I validate the response shape, use optional chaining, or something else?`,
|
||||
}]},
|
||||
{ name: "comparative analysis request", buildMessages: () => [genSystemMessage(), {
|
||||
role: "user",
|
||||
content: `Compare these three approaches to handling async operations in JavaScript/TypeScript. For each, provide:\n1. A concrete code example\n2. Error handling strategy\n3. Performance characteristics\n4. When to use it\n\n**Approach 1: Callbacks**\n**Approach 2: Promises with async/await**\n**Approach 3: Observables (RxJS)**\n\nI'm specifically interested in:\n- How cancellation works in each\n- Memory implications for long-lived operations\n- Composability (combining multiple async operations)`,
|
||||
}]},
|
||||
|
||||
// 21-25: Edge cases and long context
|
||||
{ name: "very long single user message", buildMessages: () => [genSystemMessage(), {
|
||||
role: "user",
|
||||
content: `I need a comprehensive analysis of the following topics. Please address each one in detail:\n\n${LONG_DOCUMENTS.join("\n\n---\n\n")}\n\nFor each section above, please:\n1. Summarize the key points\n2. Identify any logical gaps or missing information\n3. Suggest practical applications\n4. List follow-up questions that would deepen understanding`,
|
||||
}]},
|
||||
{ name: "system prompt + multi-part code task", buildMessages: () => [
|
||||
{ role: "system", content: "You are a strict code reviewer. Always point out potential bugs, security issues, and performance problems. Use specific line references." },
|
||||
{ role: "user", content: `Review this React component for production readiness:\n\n${pick(CODE_BLOCKS)}\n\n${pick(CODE_BLOCKS)}` },
|
||||
]},
|
||||
{ name: "conversation with long assistant responses", buildMessages: () => [
|
||||
genSystemMessage(),
|
||||
{ role: "user", content: "Explain how modern CPU architectures handle branch prediction and speculative execution, including the security implications." },
|
||||
{ role: "assistant", content: `Modern CPU branch prediction is a fascinating and complex topic with significant security implications.
|
||||
|
||||
**How Branch Prediction Works**
|
||||
|
||||
Modern processors use deep pipelines (14-20 stages in modern x86 CPUs). To keep these pipelines full, they need to predict which way branches will go before the condition is actually evaluated. Processors maintain several structures:
|
||||
|
||||
1. **Branch Target Buffer (BTB)**: Caches the target address of recently executed branches. When a branch instruction is decoded, the BTB predicts the target.
|
||||
|
||||
2. **Branch History Table (BHT) / Pattern History Table (PHT)**: Tracks the recent outcomes of branches (taken/not taken) using 2-bit saturating counters. A branch that was taken twice in a row is likely to be taken again.
|
||||
|
||||
3. **Return Stack Buffer (RSB)**: A hardware stack that predicts return addresses for CALL/RET instructions. Since returns are indirect (the address depends on the call site), the RSB provides much better accuracy than the BTB for returns.
|
||||
|
||||
4. **Tournament Predictors**: Modern CPUs combine multiple prediction strategies (e.g., local predictor per branch, global predictor that considers branch history) and select the best performer. The Intel Pentium M's "Hybrid Predictor" and later "Perceptron Predictors" in some designs are examples.
|
||||
|
||||
**Speculative Execution**
|
||||
|
||||
Once a prediction is made, the CPU speculatively executes instructions along the predicted path before the branch condition is resolved. This includes:
|
||||
- Computing addresses and loading data into cache
|
||||
- Executing arithmetic operations
|
||||
- Even writing to architectural state (though these are retired only if the prediction was correct)
|
||||
|
||||
If the prediction was wrong, the CPU must:
|
||||
1. Flush the pipeline (discard all speculatively executed instructions)
|
||||
2. Restore the architectural state to the checkpoint before the branch
|
||||
3. Fetch from the correct path
|
||||
|
||||
The performance penalty for a misprediction is roughly 10-20 cycles on modern CPUs — hence the massive investment in accurate prediction.
|
||||
|
||||
**Security Implications: Spectre and Meltdown**
|
||||
|
||||
The problem is that speculative execution leaves side-effect traces in microarchitectural state, particularly the cache. Here's how Spectre Variant 1 (Bounds Check Bypass) works:
|
||||
|
||||
\`\`\`c
|
||||
if (x < array1->length) {
|
||||
// Speculatively executed even when x is out of bounds
|
||||
value = array2[array1[x] * 4096]; // Cache-timing side channel
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
By training the branch predictor to predict the bounds check will pass, an attacker can cause speculative access to arbitrary memory. The value read affects which cache line of array2 is loaded, which can be detected through timing measurements.
|
||||
|
||||
Mitigations include:
|
||||
- **LFENCE serialization**: Insert serializing instructions after bounds checks (heavy overhead)
|
||||
- **Retpolines**: Replace indirect branches with returns to confuse the BTB
|
||||
- **Indirect Branch Restricted Speculation (IBRS)**: Hardware-level mitigation in newer CPUs
|
||||
- **Single Thread Indirect Branch Predictor (STIBP)**: Prevents cross-hyperthread predictor interference
|
||||
|
||||
Meltdown (CVE-2018-3615) is a related but distinct attack where the CPU speculatively executes instructions after a fault (not a branch misprediction), allowing access to kernel memory from user space. This affects primarily Intel and some ARM cores.
|
||||
|
||||
**Modern Defenses**
|
||||
|
||||
Starting with Intel's "Security First" architecture (Ice Lake and later), hardware mitigations include:
|
||||
- **Predictor tagging by privilege level**: Branch predictors are tagged with the CPL, preventing user-mode training from affecting kernel predictions
|
||||
- **Hardware-enforced bounds checking**: Some instructions can be marked to prevent speculation past them
|
||||
- **Mitigation-less performance**: Newer generations have reduced the performance impact of software mitigations from 30%+ to 2-5%`,
|
||||
},
|
||||
{ role: "user", content: `That's a great overview. Now, zooming in on one specific aspect: how do Return Stack Buffer (RSB) and CALL/RET prediction work at the microarchitectural level? Specifically, how does the CPU handle:
|
||||
|
||||
1. Deeply nested function calls (e.g., recursive functions with 100+ levels)
|
||||
2. Indirect function calls through function pointers
|
||||
3. Return-oriented programming (ROP) exploits and how CET/Shadow Stack mitigates them
|
||||
|
||||
Also, can you explain the "RSB underflow" issue when switching between privilege levels?`,
|
||||
},
|
||||
{ role: "assistant", content: `Excellent questions. Let me dive deep into each area.
|
||||
|
||||
**RSB Microarchitecture**
|
||||
|
||||
The Return Stack Buffer is a specialized predictor implemented as a circular buffer (typically 16-48 entries in modern CPUs). Each CALL instruction pushes the return address onto this hardware stack. Each RET instruction pops the top entry and uses it as the predicted target.
|
||||
|
||||
The key insight is that the RSB is a true stack — it naturally handles nested calls correctly, unlike the BTB which would confuse RET instructions that can have many different targets depending on context.
|
||||
|
||||
1. **Deeply Nested Calls (100+ levels)**:
|
||||
|
||||
When the nesting depth exceeds the RSB size:
|
||||
- The bottom entries are simply overwritten (circular buffer behavior)
|
||||
- On return beyond the RSB depth, the CPU experiences an **RSB underflow** — there's no valid prediction available
|
||||
- The fallback mechanism varies by microarchitecture:
|
||||
- Intel Sandy Bridge and earlier: falls back to BTB prediction for RETs (poor accuracy)
|
||||
- Intel Haswell and later: uses a "return predictor" that's actually a secondary BTB trained on RET behavior
|
||||
- AMD Zen: uses a hybrid approach with a deeper RSB-like structure
|
||||
|
||||
The practical impact: deeply recursive functions (like tree traversals) may have significantly worse performance due to RSB misses on the returns from deep levels.
|
||||
|
||||
2. **Indirect Calls Through Function Pointers**:
|
||||
|
||||
Modern CPUs handle indirect calls and returns quite differently:
|
||||
- **Indirect calls** (CALL [rax]): predicted using the BTB, which stores mapping from the indirect call instruction address to the recently used target(s). Modern BTBs use tagged entries and can track multiple targets for a single instruction (through the Indirect Branch Predictor - IBP).
|
||||
- **Indirect jumps** (JMP [rax]): same BTB mechanism
|
||||
- **RET instructions**: predicted via RSB exclusively
|
||||
|
||||
The Indirect Branch Predictor (IBP) in recent Intel cores uses a structure similar to the BTB but dedicated to indirect branches, with entries tagged by the branch history to provide context-sensitive prediction.
|
||||
|
||||
3. **Return-Oriented Programming and CET**:
|
||||
|
||||
ROP works precisely because of how RSB prediction functions:
|
||||
- The attacker finds small instruction sequences ("gadgets") ending in RET in existing code
|
||||
- By overflowing the stack or corrupting the return address, the attacker chains these gadgets
|
||||
- The CPU's RSB is poisoned by the corrupted stack — the RSB stores the *intended* return address from the original CALL, but the actual stack contains the attacker-controlled return address
|
||||
- After the misprediction is detected and corrected, the speculative execution has already happened
|
||||
|
||||
**Control-flow Enforcement Technology (CET)**:
|
||||
|
||||
Intel CET provides two complementary protections:
|
||||
|
||||
a) **Shadow Stack (SHSTK)**: A separate, hardware-protected stack that shadows the return addresses pushed by CALL instructions. On RET, the CPU compares the return address from the regular stack with the shadow stack — any mismatch triggers a hardware exception. This makes ROP attacks nearly impossible because:
|
||||
- The shadow stack is writable only through CALL instructions (not regular memory writes)
|
||||
- Each RET verifies against the shadow stack
|
||||
- The shadow stack is protected from regular load/store instructions
|
||||
|
||||
b) **Indirect Branch Tracking (IBT)**: For indirect CALL and JMP instructions, the target must begin with an ENDBRANCH instruction (encoded as 0xF3 0x0F 0x1E 0xFA on x86). This prevents JOP/COP (Jump/Call-Oriented Programming) attacks.
|
||||
|
||||
**RSB Underflow Between Privilege Levels**:
|
||||
|
||||
When switching from a higher privilege level (kernel) to a lower one (user):
|
||||
1. The CPU executes a SYSRET or IRET instruction
|
||||
2. The RSB had entries from kernel-mode CALL instructions
|
||||
3. These entries predict return addresses *within the kernel*
|
||||
4. In user mode, if the user process attempts multiple returns (perhaps through a deep call chain leading to RSB underflow), the RSB may predict kernel addresses — speculatively executing kernel code from user space!
|
||||
|
||||
The mitigation (kernel RSB stuffing) involves executing 32 fake CALL instructions after transitioning to user mode, pushing and popping to overwrite the RSB entries with harmless addresses. This is enabled by:
|
||||
- Linux kernel CONFIG_RETPOLINE and CONFIG_RSB stuffing
|
||||
- RETBLEED mitigations (CVE-2022-29900, CVE-2022-29901) which showed that even RSB entries from different prediction contexts could be poisoned`,
|
||||
},
|
||||
{ role: "user", content: "Fascinating. One last question on this topic — how do AMD and ARM implementations compare to Intel's here? I'm particularly interested in ARM's PAC (Pointer Authentication) and how it differs from CET's shadow stack approach. Also, are there any known bypasses for shadow stacks or PAC in practice?" },
|
||||
]},
|
||||
{ name: "mixed content types in conversation", buildMessages: () => [
|
||||
genSystemMessage(),
|
||||
{ role: "user", content: `I need to process this data and then answer questions about it. First, here's the data:\n\n## System Configuration\n${JSON.stringify({
|
||||
environment: "production",
|
||||
region: pick(["us-east-1", "eu-west-1", "ap-southeast-1"]),
|
||||
services: [
|
||||
{ name: "api-gateway", replicas: 3, memory: "512Mi", cpu: "250m" },
|
||||
{ name: "user-service", replicas: 5, memory: "1Gi", cpu: "500m" },
|
||||
{ name: "payment-service", replicas: 2, memory: "2Gi", cpu: "1" },
|
||||
{ name: "notification-service", replicas: 2, memory: "256Mi", cpu: "100m" },
|
||||
],
|
||||
databases: [
|
||||
{ name: "postgres-primary", type: "PostgreSQL", version: "16", storage: "500Gi" },
|
||||
{ name: "redis-cache", type: "Redis", version: "7", storage: "50Gi" },
|
||||
{ name: "clickhouse-analytics", type: "ClickHouse", version: "24", storage: "2Ti" },
|
||||
],
|
||||
monitoring: {
|
||||
prometheus: { retention: "30d", storage: "200Gi" },
|
||||
grafana: { plugins: ["prometheus", "elasticsearch", "cloudwatch"] },
|
||||
alertmanager: { slack: true, pagerduty: true, email: true },
|
||||
},
|
||||
}, null, 2)}\n\nHere's a SQL query that aggregates the deployment data:\n\n${pick(CODE_BLOCKS)}`,
|
||||
},
|
||||
{ role: "assistant", content: "I've analyzed your deployment configuration. Here are my observations:\n\n**Resource Allocation Analysis:**\n- Your API gateway has reasonable resource allocation for typical traffic patterns\n- The payment service has significant CPU allocation which suggests CPU-bound processing\n- Notification service appears under-provisioned if you handle high throughput\n\n**Potential Issues:**\n1. Single PostgreSQL primary is a single point of failure — consider read replicas\n2. No connection pooling configuration visible — pgbouncer recommended\n3. ClickHouse storage seems high — verify your data retention policies\n\n**Recommendations:**\n1. Add pod disruption budgets for production services\n2. Configure horizontal pod autoscaling with appropriate metrics\n3. Consider separating batch workloads onto spot instances" },
|
||||
{ role: "user", content: `Great analysis. Now, let me give you some additional context and ask specific questions:\n\n1. We're seeing increased latency in the payment service during peak hours. The CPU usage goes to 80%+ and response times spike from 200ms to 2s. What should we investigate first?\n\n2. For the notification service, we're planning to scale from 2 to 10 replicas ahead of a Black Friday event. Besides increasing replicas, what else should we prepare?\n\n3. Looking at the SQL query in the config — that's actually a migration script that's running slowly. It takes 45 minutes on the primary. How would you optimize it without significant refactoring?`,
|
||||
},
|
||||
{ role: "assistant", content: "Let me address each question:\n\n**1. Payment Service Latency**\n\nThe 2s spike at 80% CPU suggests CPU contention rather than a bottleneck elsewhere. Here's my troubleshooting plan:\n\nFirst, profile to find the hot path:\n```\nalias curl_node='curl -s -X GET http://localhost:9464/metrics'\n# Look at event loop lag, GC duration, CPU profile\nnode --prof app.js # Run with profiling\nnode --prof-process isolate-*.log > profile.txt\n```\n\nCommon causes in payment processing:\n- Cryptography operations (signature verification?) — consider caching\n- JSON serialization of large objects — use streaming or schema-based serialization\n- Synchronous database calls in hot path — verify connection pool settings\n\n**2. Black Friday Scaling for Notifications**\n\nScaling to 10 replicas is good, but also:\n- **Pre-warm connection pools**: Databases, Redis, and downstream APIs\n- **Queue-based architecture**: Ensure notifications are buffered through a queue (SQS, RabbitMQ) rather than direct push — this absorbs spikes\n- **Rate limiting downstream**: Email providers (SendGrid, SES) have API limits — implement client-side rate limiting\n- **Degrade gracefully**: Consider priority levels for notifications (transactional > promotional)\n- **Load test**: Run at 2x expected load a week before\n\n**3. Slow Migration Query**\n\n45 minutes is problematic — it holds schema locks and blocks other operations. Options without major refactoring:\n\na) **Batch processing**:\n```sql\n-- Instead of one massive UPDATE\nDO $$\nDECLARE\n batch_size CONSTANT INTEGER := 10000;\nBEGIN\n FOR i IN 0..(SELECT CEIL(COUNT(*)/batch_size) FROM target_table) LOOP\n UPDATE target_table\n SET processed = true\n WHERE ctid IN (\n SELECT ctid FROM target_table\n WHERE processed IS false\n LIMIT batch_size\n );\n COMMIT; -- Release locks periodically\n PERFORM pg_sleep(0.1); -- Allow other queries through\n END LOOP;\nEND $$;\n```\n\nb) **Maintenance window with full_table vacuum**: If downtime is acceptable, drop indexes temporarily, run the migration, rebuild indexes concurrently\n\nc) **pg_repack**: Online operation without locking\n\nAlso check if there's a missing index that the migration query needs — 45 minutes often indicates a sequential scan on a large table." },
|
||||
{ role: "user", content: `The batching approach for the migration seems most practical. Let me implement that. One concern though — the migration also needs to backfill a new column based on data from a JOIN with another table that has 5M rows. Can you show me the updated batching approach that handles the JOIN case?
|
||||
|
||||
Also, for the payment service profiling — we found that it's the JSON serialization of the payment response that's causing the issue. The response includes the full transaction history. How would you implement paginated responses for transaction history without breaking existing API clients?`,
|
||||
}]},
|
||||
{ name: "long context with repeated patterns", buildMessages: () => [
|
||||
genSystemMessage(),
|
||||
...(Array.from({ length: 5 }, () => [
|
||||
{ role: "user", content: `Consider this scenario in a microservices architecture: ${pick(AGENTIC_TASKS)}` },
|
||||
{ role: "assistant", content: `Here's my analysis of this scenario. The key considerations are scalability, fault tolerance, and operational complexity. Let me break this down...\n\n1. **Architecture**: We need to consider the overall system design and how components interact\n2. **Data flow**: Understanding how data moves through the system is critical\n3. **Failure modes**: Each component can fail in different ways\n4. **Monitoring**: We need observability at every layer\n\nThe recommended approach depends on your specific constraints around latency, throughput, and consistency requirements.` },
|
||||
])).flat(),
|
||||
{ role: "user", content: "Now, given all the scenarios above, what common patterns do you see? Are there any cross-cutting concerns that appear in multiple scenarios?" },
|
||||
]},
|
||||
];
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// SSE Stream Reader
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
interface StreamResult {
|
||||
fullContent: string;
|
||||
finishReason: string;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
async function readSSEStream(response: Response): Promise<StreamResult> {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullContent = "";
|
||||
let finishReason = "unknown";
|
||||
let totalTokens = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
const choice = ((parsed?.choices ?? []) as Array<Record<string, unknown>>)[0];
|
||||
if (choice) {
|
||||
const delta = choice.delta as Record<string, unknown> | undefined;
|
||||
if (delta?.content) fullContent += delta.content as string;
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason as string;
|
||||
}
|
||||
const usage = parsed.usage as Record<string, number> | undefined;
|
||||
if (usage) {
|
||||
totalTokens =
|
||||
usage.total_tokens ??
|
||||
(usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fullContent, finishReason, totalTokens };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
interface RequestResult {
|
||||
index: number;
|
||||
name: string;
|
||||
status: number;
|
||||
duration: number;
|
||||
totalTokens: number;
|
||||
contentLength: number;
|
||||
messageCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
test("live Gemini workload — 25 varied requests to exercise request UI", { skip }, async (t) => {
|
||||
const results: RequestResult[] = [];
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let totalDuration = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(NUM_REQUESTS, testCases.length); i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
await t.test(`[${String(i + 1).padStart(2, "0")}/${NUM_REQUESTS}] ${tc.name}`, async (t) => {
|
||||
const messages = tc.buildMessages();
|
||||
|
||||
// Timeout: 120s per request (long conversations can be slow)
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const duration = performance.now() - start;
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Start reading the SSE stream concurrently.
|
||||
const streamPromise = readSSEStream(response);
|
||||
|
||||
const streamResult = await streamPromise;
|
||||
|
||||
const totalTokensNum = streamResult.totalTokens;
|
||||
const content = streamResult.fullContent;
|
||||
const finishReason = streamResult.finishReason;
|
||||
|
||||
const result: RequestResult = {
|
||||
index: i + 1,
|
||||
name: tc.name,
|
||||
status: response.status,
|
||||
duration: Math.round(duration),
|
||||
totalTokens: totalTokensNum || 0,
|
||||
contentLength: content.length,
|
||||
messageCount: messages.length,
|
||||
};
|
||||
|
||||
const msPerToken = totalTokensNum > 0 ? (duration / totalTokensNum).toFixed(1) : "?";
|
||||
|
||||
console.log(
|
||||
` [${String(i + 1).padStart(2, " ")}] ${tc.name.padEnd(45)} ` +
|
||||
`HTTP ${response.status} | ` +
|
||||
`${Math.round(duration).toString().padStart(5)}ms | ` +
|
||||
`${String(messages.length).padStart(2)} msgs | ` +
|
||||
`${String(totalTokensNum).padStart(5)} tok | ` +
|
||||
`${msPerToken.padStart(4)} ms/tok | ` +
|
||||
`finish: ${finishReason} | ` +
|
||||
`response: ${content.length} chars`
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
assert.ok(content.length > 0, "response should have content");
|
||||
assert.ok(finishReason === "stop" || finishReason === "length", `expected stop/length finish, got ${finishReason}`);
|
||||
passed++;
|
||||
} else {
|
||||
const errorBody = await response.text().catch(() => "unknown");
|
||||
assert.fail(`HTTP ${response.status}: ${errorBody}`);
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
totalDuration += duration;
|
||||
totalTokens += totalTokensNum;
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.log(` [${String(i + 1).padStart(2, " ")}] ${tc.name.padEnd(45)} FAILED: ${errorMessage}`);
|
||||
results.push({
|
||||
index: i + 1,
|
||||
name: tc.name,
|
||||
status: 0,
|
||||
duration: Math.round(performance.now() - start),
|
||||
totalTokens: 0,
|
||||
contentLength: 0,
|
||||
messageCount: messages.length,
|
||||
error: errorMessage,
|
||||
});
|
||||
failed++;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
const total = passed + failed;
|
||||
const avgDuration = total > 0 ? Math.round(totalDuration / total) : 0;
|
||||
const avgTokens = total > 0 ? Math.round(totalTokens / total) : 0;
|
||||
|
||||
console.log("");
|
||||
console.log("=".repeat(90));
|
||||
console.log(" LIVE GEMINI WORKLOAD SUMMARY");
|
||||
console.log("=".repeat(90));
|
||||
console.log(` Model: ${MODEL}`);
|
||||
console.log(` Endpoint: ${BASE_URL}/v1/chat/completions`);
|
||||
console.log(` Total: ${total} requests (${passed} passed, ${failed} failed)`);
|
||||
console.log(` Avg duration: ${avgDuration}ms`);
|
||||
console.log(` Avg tokens: ${avgTokens}`);
|
||||
console.log(` Total time: ${Math.round(totalDuration / 1000)}s`);
|
||||
console.log(` Total tokens: ${totalTokens}`);
|
||||
console.log("");
|
||||
console.log(" Request breakdown:");
|
||||
console.log(" ---------------");
|
||||
|
||||
for (const r of results) {
|
||||
const status = r.error ? "❌" : r.status === 200 ? "✓" : "⚠";
|
||||
console.log(
|
||||
` ${status} [#${String(r.index).padStart(2, " ")}] ${r.name.padEnd(42)} ` +
|
||||
`${String(r.duration).padStart(6)}ms ` +
|
||||
`${String(r.totalTokens).padStart(5)} tok ` +
|
||||
`${String(r.messageCount).padStart(2)} msgs ` +
|
||||
`${r.contentLength} chars` +
|
||||
(r.error ? ` — ${r.error}` : "")
|
||||
);
|
||||
}
|
||||
console.log("=".repeat(90));
|
||||
});
|
||||
});
|
||||
188
tests/integration/llama-cpp-provider.test.ts
Normal file
188
tests/integration/llama-cpp-provider.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-llamacpp-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-llamacpp-secret";
|
||||
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
|
||||
const { initTranslators } = await import("../../open-sse/translator/index.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
|
||||
const { getCircuitBreaker, resetAllCircuitBreakers } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
|
||||
|
||||
type FetchCall = {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, any> | null;
|
||||
};
|
||||
|
||||
function toPlainHeaders(headers: HeadersInit | undefined | null) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequest({
|
||||
url = "http://localhost/v1/chat/completions",
|
||||
body,
|
||||
headers = {},
|
||||
}: {
|
||||
url?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
} = {}) {
|
||||
return new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildLlamaResponse(text: string, model: string) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-llamacpp",
|
||||
object: "chat.completion",
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: text },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
clearInflight();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
await initTranslators();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
|
||||
clearInflight();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
test("llama-cpp provider: routes request to custom baseUrl with no auth header", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "llama-cpp",
|
||||
authType: "apikey",
|
||||
name: "llama-cpp-primary",
|
||||
apiKey: null,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: { baseUrl: "http://localhost:10965/v1" },
|
||||
});
|
||||
|
||||
const fetchCalls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
method: init.method || "GET",
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildLlamaResponse("Why did the programmer go broke? Because he used up all his cache!", "unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "llamacpp/unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Tell me a joke." }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1, "should make exactly one upstream call");
|
||||
|
||||
const upstream = fetchCalls[0];
|
||||
assert.match(upstream.url, /^http:\/\/localhost:10965\/v1\/chat\/completions$/);
|
||||
assert.equal(upstream.headers.Authorization, undefined, "no auth header for local provider");
|
||||
assert.equal(upstream.body.messages[0].content, "Tell me a joke.");
|
||||
assert.equal(upstream.body.model, "unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M");
|
||||
assert.equal(json.choices[0].message.content, "Why did the programmer go broke? Because he used up all his cache!");
|
||||
});
|
||||
|
||||
test("llama-cpp provider: alias matching works via model catalog prefix", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "llama-cpp",
|
||||
authType: "apikey",
|
||||
name: "llama-cpp-secondary",
|
||||
apiKey: null,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: { baseUrl: "http://localhost:10965/v1" },
|
||||
});
|
||||
|
||||
const fetchCalls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({ url: String(url), method: init.method, headers: toPlainHeaders(init.headers), body: init.body ? JSON.parse(String(init.body)) : null });
|
||||
return buildLlamaResponse("42", "unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "llamacpp/unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "What is the answer?" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = (await response.json()) as any;
|
||||
assert.equal(response.status, 200, `expected 200, got ${response.status}: ${JSON.stringify(json)}`);
|
||||
assert.equal(json.choices[0].message.content, "42");
|
||||
});
|
||||
|
||||
test("llama-cpp provider: returns 400 when no connection exists", async () => {
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "llamacpp/unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const json = (await response.json()) as any;
|
||||
assert.match(json.error.message, /No credentials for provider/);
|
||||
});
|
||||
27
tests/manual/api.http
Normal file
27
tests/manual/api.http
Normal file
@@ -0,0 +1,27 @@
|
||||
###
|
||||
# @name Log with 502 failure, missing provider request and provider response in UI
|
||||
GET {{omniroute-address}}/api/logs/1780945510189-ajcf19
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
|
||||
###
|
||||
# @name List all models
|
||||
GET {{omniroute-address}}/models
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @name Send a standard OpenAI chat request to a model that exist but the endpoint points to a IP that is refusing connection
|
||||
POST {{omniroute-address}}/v1/chat/completions
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
|
||||
{
|
||||
"model": "llamacpp/gemma4-ping:latest",
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Tell me a joke."
|
||||
}
|
||||
]
|
||||
}
|
||||
###
|
||||
@@ -17,7 +17,7 @@ Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "mistral/mistral-embed",
|
||||
"model": "llama-embeddings/default",
|
||||
"input": "The food was delicious and the waiter was very attentive."
|
||||
}
|
||||
|
||||
|
||||
42
tests/manual/llama.http
Normal file
42
tests/manual/llama.http
Normal file
@@ -0,0 +1,42 @@
|
||||
###
|
||||
# @name List model directly from llama-cpp provider
|
||||
GET http://localhost:10965/v1/models
|
||||
|
||||
###
|
||||
# @name List all models
|
||||
GET {{omniroute-address}}/models
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @name Send a standard OpenAI chat request directly to llama-cpp provider
|
||||
POST http://localhost:10965/v1/chat/completions
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
|
||||
{
|
||||
"model": "unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M",
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Tell me a joke."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @name Send a standard OpenAI chat request to this llama-cpp provider
|
||||
POST {{omniroute-address}}/v1/chat/completions
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
|
||||
{
|
||||
"model": "llamacpp/unsloth/gemma-4-26B-A4B-it-GGUF:UD-IQ2_M",
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Tell me a joke."
|
||||
}
|
||||
]
|
||||
}
|
||||
###
|
||||
660
tests/unit/active-request-stream-chunks-lifecycle.test.ts
Normal file
660
tests/unit/active-request-stream-chunks-lifecycle.test.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-active-stream-lifecycle-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Helper: Simulates /api/logs/[id] API route logic ──────────────────────
|
||||
//
|
||||
// The production route uses 3-tier lookup:
|
||||
// 1. DB (getCallLogById)
|
||||
// 2. completedDetails in-memory cache (getCompletedDetails)
|
||||
// 3. pendingById in-memory active requests (getPendingById)
|
||||
//
|
||||
// Each tier constructs the API response shape including pipelinePayloads.
|
||||
|
||||
function buildApiResponseFromPending(id: string): Record<string, unknown> | null {
|
||||
const active = usageHistory.getPendingById().get(id);
|
||||
if (!active) return null;
|
||||
|
||||
const pipelinePayloads: Record<string, unknown> = {
|
||||
clientRequest: active.clientRequest ?? null,
|
||||
providerRequest: active.providerRequest ?? null,
|
||||
providerResponse: active.providerResponse ?? null,
|
||||
clientResponse: active.clientResponse ?? null,
|
||||
streamChunks: active.streamChunks ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: active.id,
|
||||
timestamp: new Date(active.startedAt).toISOString(),
|
||||
method: "",
|
||||
path: active.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: active.model,
|
||||
provider: active.provider,
|
||||
connectionId: active.connectionId,
|
||||
duration: Date.now() - active.startedAt,
|
||||
detailState: "in-flight",
|
||||
active: true,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiResponseFromCompleted(id: string): Record<string, unknown> | null {
|
||||
const completed = usageHistory.getCompletedDetails();
|
||||
const inMem = completed.get(id);
|
||||
if (!inMem) return null;
|
||||
|
||||
const pipelinePayloads: Record<string, unknown> = {
|
||||
clientRequest: inMem.clientRequest ?? null,
|
||||
providerRequest: inMem.providerRequest ?? null,
|
||||
providerResponse: inMem.providerResponse ?? null,
|
||||
clientResponse: inMem.clientResponse ?? null,
|
||||
streamChunks: inMem.streamChunks ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: inMem.id,
|
||||
timestamp: new Date(inMem.startedAt).toISOString(),
|
||||
path: inMem.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: inMem.model,
|
||||
provider: inMem.provider,
|
||||
connectionId: inMem.connectionId,
|
||||
duration: Date.now() - inMem.startedAt,
|
||||
detailState: "in-memory",
|
||||
active: false,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helper: Simulates frontend streamChunksText IIFE ──────────────────────
|
||||
function computeStreamChunksText(
|
||||
debugEnabled: boolean,
|
||||
pipelinePayloads: Record<string, unknown> | null | undefined
|
||||
): string | null {
|
||||
if (!debugEnabled || !pipelinePayloads?.streamChunks) return null;
|
||||
let chunks: unknown = pipelinePayloads.streamChunks;
|
||||
|
||||
if (typeof chunks === "string") {
|
||||
try {
|
||||
chunks = JSON.parse(chunks);
|
||||
} catch {
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks && typeof chunks === "object") {
|
||||
try {
|
||||
return Object.entries(chunks as Record<string, unknown>)
|
||||
.map(([stage, arr]) => {
|
||||
const joined = Array.isArray(arr) ? (arr as string[]).join("") : String(arr);
|
||||
return `--- ${stage} ---\n${joined}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
} catch {
|
||||
return JSON.stringify(chunks, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Helper: Simulates frontend openDetail state merge ─────────────────────
|
||||
function mergeDetailData(
|
||||
prev: Record<string, unknown> | null,
|
||||
data: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const dataHasPipeline =
|
||||
data?.pipelinePayloads &&
|
||||
Object.keys(data.pipelinePayloads || {}).length > 0;
|
||||
return {
|
||||
...prev,
|
||||
...data,
|
||||
pipelinePayloads: dataHasPipeline
|
||||
? data.pipelinePayloads
|
||||
: prev?.pipelinePayloads,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test: Full lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
test("streamChunks survive the full lifecycle: in-flight → completed → persisted", async () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-lifecycle-1";
|
||||
|
||||
// ── Phase 1: Track a pending request ──
|
||||
const requestId = usageHistory.trackPendingRequest(
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
true,
|
||||
{
|
||||
clientRequest: { messages: [{ role: "user", content: "hello" }] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
clientEndpoint: "/v1/chat/completions",
|
||||
}
|
||||
);
|
||||
|
||||
assert.ok(requestId, "trackPendingRequest should return a request ID");
|
||||
assert.ok(
|
||||
usageHistory.getPendingById().has(requestId),
|
||||
"request ID should be in pendingById immediately"
|
||||
);
|
||||
|
||||
// ── Phase 2: Simulate streaming — chunks arrive gradually ──
|
||||
// Round 1: provider chunks
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"type":"message_start"}\n\n'],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
// Verify via pendingById (what the API reads)
|
||||
const apiResponse1 = buildApiResponseFromPending(requestId);
|
||||
assert.ok(apiResponse1, "API should find in-flight request");
|
||||
assert.ok(apiResponse1!.pipelinePayloads, "should have pipelinePayloads");
|
||||
assert.ok(
|
||||
(apiResponse1!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should be present in API response"
|
||||
);
|
||||
|
||||
// Simulate frontend streamChunksText
|
||||
const text1 = computeStreamChunksText(
|
||||
true,
|
||||
apiResponse1!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text1, "streamChunksText should be non-null with debugEnabled");
|
||||
assert.ok(
|
||||
text1!.includes("message_start"),
|
||||
"streamChunksText should contain the chunk content"
|
||||
);
|
||||
|
||||
// Verify frontend state merge
|
||||
const initialDetail = mergeDetailData(null, apiResponse1 as unknown as Record<string, unknown>);
|
||||
assert.ok(
|
||||
(initialDetail.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should survive the openDetail state merge"
|
||||
);
|
||||
const text1after = computeStreamChunksText(
|
||||
true,
|
||||
initialDetail.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text1after, "streamChunksText should work after state merge");
|
||||
assert.ok(text1after!.includes("message_start"), "content preserved after state merge");
|
||||
|
||||
// Round 2: openai chunks arrive (simulating proxy relay)
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"type":"message_start"}\n\n'],
|
||||
openai: ['data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'],
|
||||
client: [],
|
||||
});
|
||||
|
||||
// Round 3: client (converted) chunk arrives
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"type":"message_start"}\n\n'],
|
||||
openai: ['data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'],
|
||||
client: ['data: {"content":"Hello"}\n\n'],
|
||||
});
|
||||
|
||||
// Verify API sees the latest (shows all stages now)
|
||||
const apiResponse2 = buildApiResponseFromPending(requestId);
|
||||
const streamChunks2 = (apiResponse2!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
assert.equal(streamChunks2.provider.length, 1, "provider chunks: 1");
|
||||
assert.equal(streamChunks2.openai.length, 1, "openai chunks: 1");
|
||||
assert.equal(streamChunks2.client.length, 1, "client chunks: 1");
|
||||
|
||||
// Verify frontend text includes all 3 stages
|
||||
const text2 = computeStreamChunksText(
|
||||
true,
|
||||
apiResponse2!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text2!.includes("--- provider ---"), "should include provider stage");
|
||||
assert.ok(text2!.includes("--- openai ---"), "should include openai stage");
|
||||
assert.ok(text2!.includes("--- client ---"), "should include client stage");
|
||||
|
||||
// Verify debugEnabled=false hides the stream
|
||||
const textHidden = computeStreamChunksText(
|
||||
false,
|
||||
apiResponse2!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.equal(textHidden, null, "streamChunksText should be null when debugEnabled=false");
|
||||
|
||||
// Verify null pipelinePayloads hides the stream
|
||||
const textNoPayload = computeStreamChunksText(true, null);
|
||||
assert.equal(textNoPayload, null, "streamChunksText should be null without pipelinePayloads");
|
||||
|
||||
const textNoChunks = computeStreamChunksText(true, {});
|
||||
assert.equal(textNoChunks, null, "streamChunksText should be null without streamChunks key");
|
||||
|
||||
// ── Phase 3: Simulate request completion ──
|
||||
usageHistory.finalizeMostRecentPendingRequest(model, provider, connectionId, {
|
||||
status: 200,
|
||||
model,
|
||||
provider,
|
||||
clientResponse: { choices: [{ message: { content: "Hello" } }] },
|
||||
});
|
||||
|
||||
// The request should be in completedDetails now
|
||||
assert.ok(
|
||||
!usageHistory.getPendingById().has(requestId),
|
||||
"request should be removed from pendingById after finalization"
|
||||
);
|
||||
|
||||
const completedResponse = buildApiResponseFromCompleted(requestId);
|
||||
assert.ok(completedResponse, "API should find request in completedDetails");
|
||||
assert.ok(
|
||||
(completedResponse!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should be in completedDetails"
|
||||
);
|
||||
|
||||
// Verify frontend can render from completed response
|
||||
const completedText = computeStreamChunksText(
|
||||
true,
|
||||
completedResponse!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(completedText, "streamChunksText should work from completed data");
|
||||
assert.ok(completedText!.includes("Hello"), "content preserved after completion");
|
||||
|
||||
// ── Phase 4: Persist to DB (simulates saveCallLog) ──
|
||||
await callLogs.saveCallLog({
|
||||
id: requestId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model,
|
||||
requestedModel: model,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: 5000,
|
||||
tokens: { in: 10, out: 20 },
|
||||
requestBody: { messages: [{ role: "user", content: "hello" }] },
|
||||
responseBody: { choices: [{ message: { content: "Hello" } }] },
|
||||
error: null,
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
comboName: null,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
tokensCompressed: null,
|
||||
cacheSource: "upstream",
|
||||
apiKeyId: null,
|
||||
apiKeyName: null,
|
||||
noLog: false,
|
||||
pipelinePayloads: completedResponse!.pipelinePayloads as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// Verify DB has the entry
|
||||
const dbEntry = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry, "should find persisted call log by the same ID");
|
||||
assert.ok(
|
||||
dbEntry.pipelinePayloads,
|
||||
"persisted entry should have pipelinePayloads"
|
||||
);
|
||||
|
||||
// The pipelinePayloads in the DB may have been compacted/truncated.
|
||||
// streamChunks may or may not be there depending on captureStreamChunks,
|
||||
// but the ID must match so the API can find it.
|
||||
assert.equal(
|
||||
(dbEntry as Record<string, unknown>).id,
|
||||
requestId,
|
||||
"DB entry ID should match the original request ID"
|
||||
);
|
||||
});
|
||||
|
||||
test("streamChunksText renders progressive updates correctly", () => {
|
||||
// Simulate the scenario where streamChunks grows between polls
|
||||
|
||||
// Poll 1: first chunk arrives
|
||||
const payload1 = {
|
||||
streamChunks: { provider: ['data: {"content":"A"}\n\n'], openai: [], client: [] },
|
||||
};
|
||||
const text1 = computeStreamChunksText(true, payload1);
|
||||
assert.ok(text1, "poll 1 should produce text");
|
||||
assert.ok(text1!.includes("A"), "poll 1 should show chunk A");
|
||||
|
||||
// Poll 2: second chunk appended
|
||||
const payload2 = {
|
||||
streamChunks: {
|
||||
provider: ['data: {"content":"A"}\n\n', 'data: {"content":"B"}\n\n'],
|
||||
openai: [],
|
||||
client: [],
|
||||
},
|
||||
};
|
||||
const text2 = computeStreamChunksText(true, payload2);
|
||||
assert.ok(text2!.includes("A"), "poll 2 should still show chunk A");
|
||||
assert.ok(text2!.includes("B"), "poll 2 should show newly arrived chunk B");
|
||||
|
||||
// The joined text should be the concatenation
|
||||
assert.ok(
|
||||
text2!.includes('data: {"content":"A"}\n\ndata: {"content":"B"}'),
|
||||
"poll 2 joined text should contain both chunks"
|
||||
);
|
||||
});
|
||||
|
||||
test("pooling effect state merge preserves streamChunks across updates", () => {
|
||||
// Simulate the polling useEffect state merge:
|
||||
// setDetailData(prev => ({...prev, ...data, pipelinePayloads: data?.pipelinePayloads || prev?.pipelinePayloads}))
|
||||
|
||||
let detailData: Record<string, unknown> | null = null;
|
||||
|
||||
// openDetail initial fetch
|
||||
const initialFetch = {
|
||||
pipelinePayloads: {
|
||||
streamChunks: { provider: ['data: {"a":1}\n\n'] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
},
|
||||
active: true,
|
||||
detailState: "in-flight",
|
||||
};
|
||||
detailData = mergeDetailData(null, initialFetch);
|
||||
assert.ok(
|
||||
(detailData!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"initial merge should preserve streamChunks"
|
||||
);
|
||||
|
||||
// Poll response adds more chunks
|
||||
const pollResponse = {
|
||||
pipelinePayloads: {
|
||||
streamChunks: {
|
||||
provider: ['data: {"a":1}\n\n', 'data: {"b":2}\n\n'],
|
||||
},
|
||||
providerRequest: { model: "gpt-4" },
|
||||
},
|
||||
active: true,
|
||||
detailState: "in-flight",
|
||||
};
|
||||
detailData = mergeDetailData(detailData, pollResponse);
|
||||
|
||||
const mergedChunks = (detailData!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
assert.equal(mergedChunks.provider.length, 2, "merged should have 2 chunks");
|
||||
assert.equal(
|
||||
mergedChunks.provider[1],
|
||||
'data: {"b":2}\n\n',
|
||||
"merged should include new chunk"
|
||||
);
|
||||
|
||||
// Simulate: polling response loses pipelinePayloads (null)
|
||||
// This should NOT overwrite the existing streamChunks
|
||||
const nullPayloadResponse = {
|
||||
pipelinePayloads: null,
|
||||
active: true,
|
||||
detailState: "in-flight",
|
||||
};
|
||||
const afterNullPayload = mergeDetailData(detailData, nullPayloadResponse);
|
||||
assert.ok(
|
||||
(afterNullPayload.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should survive null pipelinePayloads update"
|
||||
);
|
||||
|
||||
// Simulate: polling response has pipelinePayloads but NO streamChunks
|
||||
// This SHOULD overwrite (|| semantics) — but the production condition is: when
|
||||
// data?.pipelinePayloads is truthy, it replaces prev. If captureStreamChunks was false,
|
||||
// the data won't have streamChunks. This is expected behavior — the frontend doesn't
|
||||
// try to retain old streamChunks when new data explicitly lacks them.
|
||||
const noChunksPayloadResponse = {
|
||||
pipelinePayloads: {
|
||||
providerResponse: { status: 200 },
|
||||
// no streamChunks key
|
||||
},
|
||||
active: false,
|
||||
detailState: "ready",
|
||||
};
|
||||
const afterNoChunks = mergeDetailData(detailData, noChunksPayloadResponse);
|
||||
const payloadAfter = afterNoChunks.pipelinePayloads as Record<string, unknown>;
|
||||
assert.equal(
|
||||
payloadAfter.streamChunks,
|
||||
undefined,
|
||||
"streamChunks is lost when new pipelinePayloads lacks it and is truthy"
|
||||
);
|
||||
// This demonstrates the || semantics: data.pipelinePayloads is truthy ({providerResponse: {...}})
|
||||
// so it replaces prev.pipelinePayloads even though it lacks streamChunks.
|
||||
// The Event Stream section will not render after this update.
|
||||
// However, in practice this only happens after request completion when the polling
|
||||
// transitions to the persisted artifact which may have truncated streamChunks.
|
||||
// By that point the Event Stream section is no longer needed (streaming is done).
|
||||
});
|
||||
|
||||
test("pendingById references are live: push mutates the shared arrays visible to API", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-ref-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
// Simulate push() — store initial empty arrays
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: [],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
// Get the pending detail reference
|
||||
const detailFromPending = usageHistory.getPendingById().get(requestId);
|
||||
assert.ok(detailFromPending, "detail should be in pendingById");
|
||||
assert.ok(detailFromPending!.streamChunks, "streamChunks should be set to wrapper object");
|
||||
|
||||
// Simulate appendBoundedChunk — pushes to the shared array
|
||||
detailFromPending!.streamChunks!.provider.push('data: {"chunk":"live"}');
|
||||
|
||||
// Re-read from pendingById — should see the mutation
|
||||
const detailReRead = usageHistory.getPendingById().get(requestId);
|
||||
assert.equal(
|
||||
detailReRead!.streamChunks!.provider.length,
|
||||
1,
|
||||
"mutation should be visible through pendingById"
|
||||
);
|
||||
assert.equal(
|
||||
detailReRead!.streamChunks!.provider[0],
|
||||
'data: {"chunk":"live"}',
|
||||
"mutated content should be visible through pendingById"
|
||||
);
|
||||
|
||||
// Verify via simulated API response
|
||||
const apiResp = buildApiResponseFromPending(requestId);
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
assert.equal(apiChunks.provider.length, 1, "API should see the live mutation");
|
||||
});
|
||||
|
||||
test("no connectionId in request logger does not break anything", async () => {
|
||||
const { createRequestLogger } = await import(
|
||||
"../../open-sse/utils/requestLogger.ts"
|
||||
);
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-noop-1", true);
|
||||
|
||||
// Logger without connectionId/model — push() bails, no crash
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"a":1}');
|
||||
logger.appendOpenAIChunk('data: {"b":2}');
|
||||
logger.appendConvertedChunk('data: {"c":3}');
|
||||
|
||||
// The pending detail should NOT have streamChunks (no connectionId match)
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["conn-noop-1"]?.["gpt-4 (openai)"]?.[0];
|
||||
assert.equal(detail.streamChunks, undefined, "streamChunks should not be set");
|
||||
|
||||
// Simulated API should return null for streamChunks
|
||||
const apiResp = buildApiResponseFromPending(
|
||||
// We need the actual ID. trackPendingRequest returns it now.
|
||||
(() => {
|
||||
usageHistory.clearPendingRequests();
|
||||
const id = usageHistory.trackPendingRequest("gpt-4", "openai", "conn-noop-2", true);
|
||||
return id!;
|
||||
})()
|
||||
);
|
||||
// This request had no streaming, so streamChunks is null
|
||||
if (apiResp) {
|
||||
const noChunks = computeStreamChunksText(
|
||||
true,
|
||||
apiResp.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.equal(noChunks, null, "no streamChunks means no event stream");
|
||||
}
|
||||
});
|
||||
|
||||
test("createRequestLogger and trackPendingRequest with matching model propagate streamChunks", async () => {
|
||||
// The fix: chatCore.ts now passes `model` (not `effectiveModel`) to
|
||||
// createRequestLogger, so the modelKey matches what trackPendingRequest uses.
|
||||
|
||||
const { createRequestLogger } = await import(
|
||||
"../../open-sse/utils/requestLogger.ts"
|
||||
);
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-match-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(
|
||||
model, provider, connectionId, true,
|
||||
{ clientRequest: { messages: [] } }
|
||||
);
|
||||
|
||||
// Use matching model (the fix)
|
||||
const logger = await createRequestLogger("openai", "openai", model, {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"chunk":"hello"}');
|
||||
logger.appendOpenAIChunk('data: {"choices":[{"delta":{"content":"hi"}}]}');
|
||||
logger.appendConvertedChunk('data: {"content":"world"}');
|
||||
|
||||
const detail = usageHistory.getPendingById().get(requestId);
|
||||
assert.ok(detail, "pending detail should exist");
|
||||
assert.ok(detail!.streamChunks, "streamChunks should be set");
|
||||
assert.equal(detail!.streamChunks!.provider.length, 1);
|
||||
assert.equal(detail!.streamChunks!.openai.length, 1);
|
||||
assert.equal(detail!.streamChunks!.client.length, 1);
|
||||
|
||||
const apiResp = buildApiResponseFromPending(requestId!);
|
||||
assert.ok(apiResp);
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>).streamChunks as Record<string, string[]>;
|
||||
assert.equal(apiChunks?.provider[0], 'data: {"chunk":"hello"}');
|
||||
});
|
||||
|
||||
test("streamChunks in completedDetails survives 5-second TTL window", async () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-ttl-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [] },
|
||||
});
|
||||
|
||||
// Simulate streaming then completion
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"chunk":"final"}\n\n'],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
usageHistory.finalizeMostRecentPendingRequest(model, provider, connectionId, {
|
||||
status: 200,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
|
||||
// Should be in completedDetails
|
||||
assert.ok(
|
||||
usageHistory.getCompletedDetails().has(requestId),
|
||||
"should be in completedDetails after finalization"
|
||||
);
|
||||
|
||||
const completedResp = buildApiResponseFromCompleted(requestId);
|
||||
assert.ok(completedResp, "API response should be available from completedDetails");
|
||||
assert.ok(
|
||||
(completedResp!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should be in completedDetails"
|
||||
);
|
||||
|
||||
// The completedDetails TTL is 5000ms. We verify by saving to DB first, then
|
||||
// waiting for the TTL to expire, then verifying the data is gone from
|
||||
// completedDetails but still accessible from the DB (via getCallLogById).
|
||||
// Save to DB
|
||||
await callLogs.saveCallLog({
|
||||
id: requestId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model,
|
||||
requestedModel: model,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: 3000,
|
||||
tokens: { in: 5, out: 10 },
|
||||
requestBody: {},
|
||||
responseBody: {},
|
||||
error: null,
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
comboName: null,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
tokensCompressed: null,
|
||||
cacheSource: "upstream",
|
||||
apiKeyId: null,
|
||||
apiKeyName: null,
|
||||
noLog: false,
|
||||
pipelinePayloads: completedResp!.pipelinePayloads as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// Verify DB has it
|
||||
const dbEntry1 = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry1, "DB should have the entry after saveCallLog");
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise((r) => setTimeout(r, 5100));
|
||||
|
||||
// completedDetails should be cleared
|
||||
assert.ok(
|
||||
!usageHistory.getCompletedDetails().has(requestId),
|
||||
"completedDetails should have been cleared after TTL"
|
||||
);
|
||||
|
||||
// But DB should still have it
|
||||
const dbEntry2 = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry2, "DB should still have the entry after TTL expiry");
|
||||
assert.equal(
|
||||
(dbEntry2 as Record<string, unknown>).id,
|
||||
requestId,
|
||||
"DB entry ID should match the original request ID"
|
||||
);
|
||||
});
|
||||
@@ -80,36 +80,7 @@ test("BodyTimeoutError from withBodyTimeout does not leave timer leaks", async (
|
||||
assert.ok(true, "all timers cleaned up successfully");
|
||||
});
|
||||
|
||||
// ── 4. DELETE /api/logs/active endpoint ──────────────────────────────────
|
||||
|
||||
test("DELETE /api/logs/active route requires management authentication", () => {
|
||||
const content = fs.readFileSync("src/app/api/logs/active/route.ts", "utf8");
|
||||
|
||||
assert.ok(
|
||||
content.includes('from "@/lib/api/requireManagementAuth"'),
|
||||
"should import requireManagementAuth"
|
||||
);
|
||||
assert.ok(content.includes("export async function DELETE"), "should export DELETE handler");
|
||||
assert.ok(
|
||||
content.includes("await requireManagementAuth(request)"),
|
||||
"DELETE should check management auth"
|
||||
);
|
||||
});
|
||||
|
||||
test("DELETE /api/logs/active calls clearPendingRequests", () => {
|
||||
const content = fs.readFileSync("src/app/api/logs/active/route.ts", "utf8");
|
||||
|
||||
assert.ok(
|
||||
content.includes("clearPendingRequests"),
|
||||
"DELETE handler should call clearPendingRequests"
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("Pending request counts cleared"),
|
||||
"DELETE handler should return success message"
|
||||
);
|
||||
});
|
||||
|
||||
// ── 5. clearPendingRequests + trackPendingRequest integration ────────────
|
||||
// ── 4. clearPendingRequests + trackPendingRequest integration ────────────
|
||||
|
||||
test("trackPendingRequest followed by clearPendingRequests zeroes all counts", async () => {
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
|
||||
@@ -91,6 +91,17 @@ describe("Caveman output mode", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("skips injection when disabled", () => {
|
||||
const result = applyCavemanOutputMode(
|
||||
{ messages: [{ role: "user", content: "Tell me a joke." }] },
|
||||
{ enabled: false, intensity: "full", autoClarity: true }
|
||||
);
|
||||
assert.equal(result.applied, false);
|
||||
assert.equal(result.skippedReason, "disabled");
|
||||
assert.equal(result.body.messages?.[0]?.role, "user");
|
||||
assert.equal(result.body.messages?.[0]?.content, "Tell me a joke.");
|
||||
});
|
||||
|
||||
it("builds intensity-specific instructions", () => {
|
||||
assert.match(
|
||||
buildCavemanOutputInstruction({ enabled: true, intensity: "ultra", autoClarity: true }),
|
||||
|
||||
322
tests/unit/provider-request-failure-pipeline.test.ts
Normal file
322
tests/unit/provider-request-failure-pipeline.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
// @ts-nocheck
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provreq-fail-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { invalidateCacheControlSettingsCache } =
|
||||
await import("../../src/lib/cacheControlSettings.ts");
|
||||
const { clearCache } = await import("../../src/lib/semanticCache.ts");
|
||||
const { clearIdempotency } = await import("../../src/lib/idempotencyLayer.ts");
|
||||
const { getPendingRequests, clearPendingRequests } =
|
||||
await import("../../src/lib/usage/usageHistory.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const {
|
||||
resetAll: resetAccountSemaphores,
|
||||
} = await import("../../open-sse/services/accountSemaphore.ts");
|
||||
const { clearModelLock } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLogs.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const { resetPayloadRulesConfigForTests } =
|
||||
await import("../../open-sse/services/payloadRules.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function noopLog() {
|
||||
return { debug() {}, info() {}, warn() {}, error() {} };
|
||||
}
|
||||
|
||||
async function waitFor(fn, timeoutMs = 1500) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await fn();
|
||||
if (result) return result;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForAsyncSideEffects() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
async function getLatestCallLog() {
|
||||
const rows = await getCallLogs({ limit: 5 });
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
return getCallLogById(rows[0].id);
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
resetPayloadRulesConfigForTests();
|
||||
invalidateCacheControlSettingsCache();
|
||||
clearCache();
|
||||
clearIdempotency();
|
||||
clearInflight();
|
||||
clearModelLock();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.before(async () => {
|
||||
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearPendingRequests();
|
||||
resetAccountSemaphores();
|
||||
await waitForAsyncSideEffects();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearPendingRequests();
|
||||
resetAccountSemaphores();
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("network failure persisted call log includes providerRequest in pipeline payloads", async () => {
|
||||
const body = {
|
||||
model: "gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Connection refused");
|
||||
};
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(body),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
} as any);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502);
|
||||
|
||||
await waitForAsyncSideEffects();
|
||||
|
||||
const detail = await waitFor(getLatestCallLog);
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads when call_log_pipeline_enabled is true");
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerRequest,
|
||||
"providerRequest must be present in pipeline payloads even on network failure"
|
||||
);
|
||||
const providerReqBody =
|
||||
detail.pipelinePayloads.providerRequest.body ??
|
||||
detail.pipelinePayloads.providerRequest;
|
||||
assert.equal(
|
||||
providerReqBody.model,
|
||||
"gpt-4o-mini",
|
||||
"providerRequest should contain the translated model"
|
||||
);
|
||||
const messages =
|
||||
providerReqBody.messages ??
|
||||
(Array.isArray(providerReqBody) ? providerReqBody : null);
|
||||
if (messages) {
|
||||
assert.equal(messages[0]?.content, "hello");
|
||||
}
|
||||
assert.equal(
|
||||
detail.pipelinePayloads.providerResponse ?? null,
|
||||
null,
|
||||
"providerResponse should be null/absent on network failure (no response received)"
|
||||
);
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.error,
|
||||
"pipeline payloads should include the error details"
|
||||
);
|
||||
});
|
||||
|
||||
test("network timeout persisted call log includes providerRequest in pipeline payloads", async () => {
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const executor = getExecutor("openai");
|
||||
const originalGetTimeoutMs = executor.getTimeoutMs?.bind(executor);
|
||||
executor.getTimeoutMs = () => 200;
|
||||
|
||||
const body = {
|
||||
model: "gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "timeout test" }],
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Promise(() => {}); // never resolve
|
||||
};
|
||||
|
||||
try {
|
||||
const invocation = handleChatCore({
|
||||
body: structuredClone(body),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
} as any);
|
||||
|
||||
const result = await invocation;
|
||||
await waitForAsyncSideEffects();
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(
|
||||
result.status === 504,
|
||||
`expected 504 timeout, got ${result.status}`
|
||||
);
|
||||
|
||||
const detail = await waitFor(getLatestCallLog);
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads");
|
||||
|
||||
assert.ok(
|
||||
detail.pipelinePayloads?.providerRequest,
|
||||
"providerRequest must be present in pipeline payloads on timeout"
|
||||
);
|
||||
const providerReqBody =
|
||||
detail.pipelinePayloads?.providerRequest?.body ??
|
||||
detail.pipelinePayloads?.providerRequest;
|
||||
assert.equal(
|
||||
providerReqBody?.model,
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
} finally {
|
||||
if (originalGetTimeoutMs) executor.getTimeoutMs = originalGetTimeoutMs;
|
||||
}
|
||||
});
|
||||
|
||||
test("provider error response (HTTP 502) includes both providerRequest and providerResponse in pipeline", async () => {
|
||||
const body = {
|
||||
model: "gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "trigger 502" }],
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: { message: "Upstream provider error", type: "server_error" },
|
||||
}),
|
||||
{
|
||||
status: 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(body),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
} as any);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502);
|
||||
|
||||
await waitForAsyncSideEffects();
|
||||
|
||||
const detail = await waitFor(getLatestCallLog);
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads");
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerRequest,
|
||||
"providerRequest must be present on HTTP error response"
|
||||
);
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerResponse,
|
||||
"providerResponse must be present on HTTP error response (upstream responded)"
|
||||
);
|
||||
assert.equal(
|
||||
detail.pipelinePayloads.providerResponse.status,
|
||||
502,
|
||||
"providerResponse status should reflect the upstream error"
|
||||
);
|
||||
});
|
||||
|
||||
test("successful response includes both providerRequest and providerResponse in pipeline", async () => {
|
||||
const body = {
|
||||
model: "gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-ok",
|
||||
object: "chat.completion",
|
||||
model: "gpt-4o-mini",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "world" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(body),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
} as any);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
|
||||
await waitForAsyncSideEffects();
|
||||
|
||||
const detail = await waitFor(getLatestCallLog);
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads");
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerRequest,
|
||||
"providerRequest must be present on success"
|
||||
);
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerResponse,
|
||||
"providerResponse must be present on success"
|
||||
);
|
||||
});
|
||||
@@ -110,6 +110,27 @@ test("classifyErrorText: handles various patterns", () => {
|
||||
assert.equal(classifyErrorText("random error"), RateLimitReason.UNKNOWN);
|
||||
});
|
||||
|
||||
test("classifyErrorText: Gemini 503 high demand maps to MODEL_CAPACITY", () => {
|
||||
const geminiMsg =
|
||||
"[503]: This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.";
|
||||
assert.equal(classifyErrorText(geminiMsg), RateLimitReason.MODEL_CAPACITY);
|
||||
});
|
||||
|
||||
test("classifyError: 503 with Gemini high demand message returns MODEL_CAPACITY", () => {
|
||||
const geminiMsg =
|
||||
"[503]: This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.";
|
||||
assert.equal(classifyError(503, geminiMsg), RateLimitReason.MODEL_CAPACITY);
|
||||
});
|
||||
|
||||
test("checkFallbackError: 503 with Gemini high demand returns MODEL_CAPACITY reason", () => {
|
||||
const geminiMsg =
|
||||
"[503]: This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.";
|
||||
const result = checkFallbackError(503, geminiMsg);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.MODEL_CAPACITY);
|
||||
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
|
||||
});
|
||||
|
||||
// ─── Per-Model Lockout Tests ────────────────────────────────────────────────
|
||||
|
||||
test("lockModel + isModelLocked: locks specific model", () => {
|
||||
|
||||
488
tests/unit/request-logger-endpoints.test.ts
Normal file
488
tests/unit/request-logger-endpoints.test.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-reqlogger-ep-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Active log endpoint removal ─────────────────────────────────────────
|
||||
|
||||
test("/api/logs/active route is removed", () => {
|
||||
assert.equal(
|
||||
fs.existsSync("src/app/api/logs/active/route.ts"),
|
||||
false,
|
||||
"active logs must not expose an in-memory timing-sensitive endpoint"
|
||||
);
|
||||
});
|
||||
|
||||
test("RequestLoggerV2 detail fetch uses /api/logs/[id] and not /api/logs/active", () => {
|
||||
const content = fs.readFileSync("src/shared/components/RequestLoggerV2.tsx", "utf8");
|
||||
assert.match(content, /fetch\(`\/api\/logs\/\$\{[^}]+\.id\}`,\s*\{\s*cache:\s*"no-store"/);
|
||||
assert.doesNotMatch(content, /fetch\(["'`]\/api\/logs\/active/);
|
||||
assert.match(content, /detailState:\s*"pending"/);
|
||||
});
|
||||
|
||||
// ─── usageHistory module behaviour ───────────────────────────────────────
|
||||
|
||||
test("trackPendingRequest creates a detail entry", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "hi" }] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
providerUrl: "https://api.openai.com/v1/chat/completions",
|
||||
});
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["conn-1"]?.["gpt-4 (openai)"]?.[0];
|
||||
assert.ok(detail, "should create detail entry");
|
||||
assert.equal(detail.model, "gpt-4");
|
||||
assert.equal(detail.provider, "openai");
|
||||
assert.equal(detail.connectionId, "conn-1");
|
||||
assert.ok(detail.id, "should have an id");
|
||||
assert.ok(detail.startedAt > 0, "should have startedAt timestamp");
|
||||
assert.ok(detail.clientRequest, "should preserve clientRequest");
|
||||
assert.equal(detail.clientRequest.messages[0].content, "hi");
|
||||
});
|
||||
|
||||
test("trackPendingRequest decrements and removes detail on finish", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true);
|
||||
assert.equal(usageHistory.getPendingRequests().details["conn-1"]?.["gpt-4 (openai)"]?.length, 1);
|
||||
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", false);
|
||||
const after = usageHistory.getPendingRequests();
|
||||
assert.equal(after.details["conn-1"]?.["gpt-4 (openai)"]?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test("trackPendingRequest does not go negative", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", false);
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", false);
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
assert.equal(pending.byModel["gpt-4 (openai)"], 0);
|
||||
});
|
||||
|
||||
test("updatePendingRequestStreamChunks stores stream chunks in the detail", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true);
|
||||
|
||||
const chunks = { provider: ["data: {\"a\":1}"], openai: [], client: [] };
|
||||
usageHistory.updatePendingRequestStreamChunks("gpt-4", "openai", "conn-1", chunks);
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["conn-1"]?.["gpt-4 (openai)"]?.[0];
|
||||
assert.ok(detail.streamChunks, "streamChunks should be set");
|
||||
assert.equal(detail.streamChunks.provider.length, 1);
|
||||
assert.equal(detail.streamChunks.provider[0], "data: {\"a\":1}");
|
||||
});
|
||||
|
||||
test("updatePendingRequestStreamChunks stores empty streamChunks object (not null)", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true);
|
||||
|
||||
// Call with empty arrays (as pushStreamChunks does before data flows)
|
||||
const empty = { provider: [], openai: [], client: [] };
|
||||
usageHistory.updatePendingRequestStreamChunks("gpt-4", "openai", "conn-1", empty);
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["conn-1"]?.["gpt-4 (openai)"]?.[0];
|
||||
assert.ok(detail.streamChunks, "streamChunks should be set even when empty");
|
||||
assert.deepEqual(detail.streamChunks, { provider: [], openai: [], client: [] });
|
||||
|
||||
// Verify the reference is live: mutations to the original object are visible
|
||||
empty.provider.push("data: hello");
|
||||
assert.equal(detail.streamChunks.provider.length, 1);
|
||||
assert.equal(detail.streamChunks.provider[0], "data: hello");
|
||||
});
|
||||
|
||||
test("clearPendingRequests resets all counts and details", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("m1", "p1", "c1", true);
|
||||
usageHistory.trackPendingRequest("m2", "p2", "c2", true);
|
||||
assert.equal(Object.keys(usageHistory.getPendingRequests().byModel).length, 2);
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
assert.equal(Object.keys(pending.byModel).length, 0);
|
||||
assert.equal(Object.keys(pending.byAccount).length, 0);
|
||||
assert.equal(Object.keys(pending.details).length, 0);
|
||||
});
|
||||
|
||||
// ─── Pending request data remains available for internal usage stats ─────
|
||||
|
||||
test("pending request detail shape remains available internally", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("claude-3-opus", "anthropic", "conn-2", true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "hello" }] },
|
||||
providerRequest: { model: "claude-3-opus" },
|
||||
providerUrl: "https://api.anthropic.com/v1/messages",
|
||||
});
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const entries = Object.entries(pending.details).flatMap(([connectionId, models]) =>
|
||||
Object.entries(models).flatMap(([modelKey, details]) =>
|
||||
details.map((detail) => ({
|
||||
id: detail.id,
|
||||
model: detail.model,
|
||||
provider: detail.provider,
|
||||
connectionId,
|
||||
startedAt: detail.startedAt,
|
||||
clientRequest: detail.clientRequest ?? null,
|
||||
providerRequest: detail.providerRequest ?? null,
|
||||
providerUrl: detail.providerUrl ?? null,
|
||||
streamChunks: detail.streamChunks ?? null,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(entries.length, 1);
|
||||
const row = entries[0];
|
||||
assert.ok(row.id);
|
||||
assert.equal(row.model, "claude-3-opus");
|
||||
assert.equal(row.provider, "anthropic");
|
||||
assert.equal(row.connectionId, "conn-2");
|
||||
assert.ok(row.startedAt > 0);
|
||||
assert.ok(row.clientRequest, "clientRequest should be present");
|
||||
assert.ok(row.providerRequest, "providerRequest should be present");
|
||||
assert.ok(row.providerUrl, "providerUrl should be present");
|
||||
assert.equal(row.streamChunks, null, "streamChunks should be null initially");
|
||||
});
|
||||
|
||||
// ─── /api/usage/call-logs/[id] route structure ────────────────────────────
|
||||
|
||||
test("GET /api/usage/call-logs/[id] route exists and uses auth", () => {
|
||||
const content = fs.readFileSync("src/app/api/usage/call-logs/[id]/route.ts", "utf8");
|
||||
assert.ok(content.includes("export async function GET"), "should export GET handler");
|
||||
assert.ok(content.includes("requireManagementAuth"), "should check auth");
|
||||
assert.ok(content.includes("getCallLogById"), "should import getCallLogById");
|
||||
});
|
||||
|
||||
test("getCallLogById returns null for unknown id", async () => {
|
||||
const log = await callLogs.getCallLogById("nonexistent-id-12345");
|
||||
assert.equal(log, null);
|
||||
});
|
||||
|
||||
// ─── Merged view data shape ────────────────────────────────────────────
|
||||
|
||||
test("normalized active row has expected fields for the grid view", () => {
|
||||
const rawRow = {
|
||||
id: "test-id-1",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
account: "conn-1",
|
||||
startedAt: Date.now() - 5000,
|
||||
runningTimeMs: 5000,
|
||||
stage: "streaming",
|
||||
stageUpdatedAt: Date.now(),
|
||||
clientRequest: { messages: [] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
providerUrl: "https://api.openai.com/v1",
|
||||
streamChunks: null,
|
||||
};
|
||||
|
||||
const normalized = {
|
||||
active: true,
|
||||
id: rawRow.id,
|
||||
model: rawRow.model,
|
||||
provider: rawRow.provider,
|
||||
account: rawRow.account,
|
||||
timestamp: new Date(rawRow.startedAt).toISOString(),
|
||||
duration: Math.max(0, Date.now() - rawRow.startedAt),
|
||||
status: 0,
|
||||
sourceFormat: null,
|
||||
tokens: null,
|
||||
comboName: null,
|
||||
apiKeyName: null,
|
||||
apiKeyId: null,
|
||||
cacheSource: null,
|
||||
requestedModel: null,
|
||||
stage: rawRow.stage,
|
||||
stageUpdatedAt: rawRow.stageUpdatedAt,
|
||||
_activeRow: rawRow,
|
||||
};
|
||||
|
||||
assert.equal(normalized.active, true);
|
||||
assert.equal(normalized.id, "test-id-1");
|
||||
assert.equal(normalized.model, "gpt-4");
|
||||
assert.equal(normalized.provider, "openai");
|
||||
assert.equal(normalized.status, 0);
|
||||
assert.ok(normalized.duration >= 5000);
|
||||
assert.ok(normalized.duration < 60000);
|
||||
assert.equal(normalized.tokens, null);
|
||||
assert.equal(normalized.cacheSource, null);
|
||||
assert.equal(normalized.stage, "streaming");
|
||||
});
|
||||
|
||||
// ─── requestLoggerSignature module ────────────────────────────────────────
|
||||
|
||||
const sigMod = await import("../../src/shared/components/requestLoggerSignature.ts");
|
||||
|
||||
test("resolveInitialVisibility returns true when document is undefined (SSR)", () => {
|
||||
assert.equal(sigMod.resolveInitialVisibility(), true);
|
||||
});
|
||||
|
||||
test("shouldAutoRefresh returns true when recording and on first page", () => {
|
||||
assert.equal(sigMod.shouldAutoRefresh(true, 50, 50), true);
|
||||
assert.equal(sigMod.shouldAutoRefresh(true, 25, 50), true);
|
||||
});
|
||||
|
||||
test("shouldAutoRefresh returns false when not recording or past first page", () => {
|
||||
assert.equal(sigMod.shouldAutoRefresh(false, 50, 50), false);
|
||||
assert.equal(sigMod.shouldAutoRefresh(true, 100, 50), false);
|
||||
assert.equal(sigMod.shouldAutoRefresh(false, 100, 50), false);
|
||||
});
|
||||
|
||||
test("computeLogsSignature produces different signatures for different data", () => {
|
||||
const a = sigMod.computeLogsSignature([
|
||||
{ id: "1", status: 200, duration: 100, tokens: { out: 50 } },
|
||||
]);
|
||||
const b = sigMod.computeLogsSignature([
|
||||
{ id: "1", status: 200, duration: 150, tokens: { out: 50 } },
|
||||
]);
|
||||
assert.notEqual(a, b, "different duration should change signature");
|
||||
});
|
||||
|
||||
test("computeLogsSignature returns empty string for non-array input", () => {
|
||||
assert.equal(sigMod.computeLogsSignature(null), "");
|
||||
assert.equal(sigMod.computeLogsSignature(undefined), "");
|
||||
assert.equal(sigMod.computeLogsSignature({}), "");
|
||||
});
|
||||
|
||||
// ─── Duration live computation ───────────────────────────────────────────
|
||||
|
||||
test("duration is computed from startedAt and grows over time", () => {
|
||||
const startedAt = Date.now() - 3000;
|
||||
const duration = Math.max(0, Date.now() - startedAt);
|
||||
assert.ok(duration >= 3000, "duration should be at least 3s");
|
||||
assert.ok(duration < 60000, "duration should be within reason");
|
||||
});
|
||||
|
||||
// ─── Navigation logic ────────────────────────────────────────────────────
|
||||
|
||||
test("handlePrev at first item closes modal (no wrap-around)", () => {
|
||||
const items = [
|
||||
{ id: "a", active: false },
|
||||
{ id: "b", active: false },
|
||||
{ id: "c", active: false },
|
||||
];
|
||||
const selectedId = "a";
|
||||
const idx = items.findIndex((l) => l.id === selectedId);
|
||||
assert.equal(idx, 0);
|
||||
|
||||
// handlePrev should NOT wrap to last item
|
||||
if (idx > 0) {
|
||||
assert.equal(items[idx - 1].id, "should not reach");
|
||||
} else {
|
||||
// closes modal
|
||||
assert.ok(true, "prev at first item closes modal");
|
||||
}
|
||||
});
|
||||
|
||||
test("handlePrev navigates backward when not at first item", () => {
|
||||
const items = [
|
||||
{ id: "a", active: false },
|
||||
{ id: "b", active: false },
|
||||
{ id: "c", active: false },
|
||||
];
|
||||
const selectedId = "b";
|
||||
const idx = items.findIndex((l) => l.id === selectedId);
|
||||
assert.equal(idx, 1);
|
||||
|
||||
if (idx > 0) {
|
||||
assert.equal(items[idx - 1].id, "a", "should navigate to previous item");
|
||||
}
|
||||
});
|
||||
|
||||
test("handleNext at last item closes modal (no wrap-around)", () => {
|
||||
const items = [
|
||||
{ id: "a", active: false },
|
||||
{ id: "b", active: false },
|
||||
];
|
||||
const selectedId = "b";
|
||||
const idx = items.findIndex((l) => l.id === selectedId);
|
||||
assert.equal(idx, items.length - 1);
|
||||
|
||||
// handleNext at last item should close
|
||||
if (idx < items.length - 1) {
|
||||
assert.equal(items[idx + 1].id, "should not reach");
|
||||
} else {
|
||||
assert.ok(true, "next at last item closes modal");
|
||||
}
|
||||
});
|
||||
|
||||
test("handleNext navigates forward when not at last item", () => {
|
||||
const items = [
|
||||
{ id: "a", active: false },
|
||||
{ id: "b", active: false },
|
||||
];
|
||||
const selectedId = "a";
|
||||
const idx = items.findIndex((l) => l.id === selectedId);
|
||||
|
||||
if (idx < items.length - 1) {
|
||||
assert.equal(items[idx + 1].id, "b", "should navigate to next item");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Detail endpoint polling ─────────────────────────────────────────────
|
||||
|
||||
test("deep-linked missing request id is kept pending for /api/logs/[id] polling", () => {
|
||||
const content = fs.readFileSync("src/shared/components/RequestLoggerV2.tsx", "utf8");
|
||||
assert.match(content, /pendingLookup:\s*true/);
|
||||
// A deep-linked id that 404s while still active is kept pending...
|
||||
assert.match(content, /setDetailData\(\{\s*detailState:\s*"pending"\s*\}\)/);
|
||||
// ...and the detail-polling effect re-runs on that pending state.
|
||||
assert.match(content, /detailData\?\.detailState/);
|
||||
assert.doesNotMatch(content, /activeRequests|completedRows|completedRow/);
|
||||
});
|
||||
|
||||
// ─── End-to-end streamChunks integration ──────────────────────────────────
|
||||
|
||||
test("createRequestLogger with connectionId/model/provider populates streamChunks on pending request", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
// track request so the pending detail entry exists
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-1", true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "hi" }] },
|
||||
});
|
||||
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
connectionId: "test-conn-1",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
// append a provider chunk — this should call pushStreamChunks() → updatePendingRequestStreamChunks()
|
||||
logger.appendProviderChunk('data: {"content":"hello"}');
|
||||
logger.appendProviderChunk('data: {"content":" world"}');
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["test-conn-1"]?.["gpt-4 (openai)"]?.[0];
|
||||
|
||||
assert.ok(detail, "pending request detail should exist");
|
||||
assert.ok(detail.streamChunks, "streamChunks should be non-null after appendProviderChunk");
|
||||
assert.ok(Array.isArray(detail.streamChunks.provider), "provider array should exist");
|
||||
assert.equal(detail.streamChunks.provider.length, 2, "should have 2 provider chunks");
|
||||
assert.equal(detail.streamChunks.provider[0], 'data: {"content":"hello"}');
|
||||
assert.equal(detail.streamChunks.provider[1], 'data: {"content":" world"}');
|
||||
assert.deepEqual(detail.streamChunks.openai, []);
|
||||
assert.deepEqual(detail.streamChunks.client, []);
|
||||
});
|
||||
|
||||
test("createRequestLogger without connectionId does not populate streamChunks", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-2", true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "hi" }] },
|
||||
});
|
||||
|
||||
// create logger WITHOUT connectionId/model/provider — pushStreamChunks will bail
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
// intentionally omit connectionId, model, provider
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"content":"hello"}');
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["test-conn-2"]?.["gpt-4 (openai)"]?.[0];
|
||||
|
||||
assert.ok(detail, "pending request detail should exist");
|
||||
assert.equal(detail.streamChunks, undefined,
|
||||
"streamChunks should be undefined when connectionId not provided to createRequestLogger"
|
||||
);
|
||||
});
|
||||
|
||||
test("createRequestLogger appendOpenAIChunk and appendConvertedChunk also populate streamChunks", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
usageHistory.trackPendingRequest("claude-3", "anthropic", "test-conn-3", true);
|
||||
|
||||
const logger = await createRequestLogger("anthropic", "openai", "claude-3", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
connectionId: "test-conn-3",
|
||||
model: "claude-3",
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
logger.appendOpenAIChunk('data: {"role":"assistant","content":"hi"}');
|
||||
logger.appendConvertedChunk('data: {"content":"there"}');
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["test-conn-3"]?.["claude-3 (anthropic)"]?.[0];
|
||||
|
||||
assert.ok(detail?.streamChunks, "streamChunks should be set");
|
||||
assert.equal(detail.streamChunks.openai.length, 1);
|
||||
assert.equal(detail.streamChunks.openai[0], 'data: {"role":"assistant","content":"hi"}');
|
||||
assert.equal(detail.streamChunks.client.length, 1);
|
||||
assert.equal(detail.streamChunks.client[0], 'data: {"content":"there"}');
|
||||
});
|
||||
|
||||
test("createRequestLogger captures stream chunks even when enabled: false", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-4", true);
|
||||
|
||||
// Logger is disabled (enabled: false) — previously this would return a
|
||||
// no-op logger that didn't capture any chunks. Now stream chunks are
|
||||
// always captured regardless of the enabled flag.
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: false,
|
||||
captureStreamChunks: true,
|
||||
connectionId: "test-conn-4",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"content":"hello"}');
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["test-conn-4"]?.["gpt-4 (openai)"]?.[0];
|
||||
|
||||
assert.ok(detail?.streamChunks, "streamChunks should be set even when logger is disabled");
|
||||
assert.equal(detail.streamChunks.provider.length, 1);
|
||||
assert.equal(detail.streamChunks.provider[0], 'data: {"content":"hello"}');
|
||||
|
||||
// But getPipelinePayloads should return null when disabled
|
||||
assert.equal(logger.getPipelinePayloads(), null, "pipeline payloads should be null when disabled");
|
||||
});
|
||||
|
||||
test("createRequestLogger disabled logger other methods are no-ops", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: false,
|
||||
captureStreamChunks: false,
|
||||
});
|
||||
|
||||
// These should not throw
|
||||
logger.logClientRawRequest("/endpoint", { foo: "bar" });
|
||||
logger.logOpenAIRequest({ model: "gpt-4" });
|
||||
logger.logTargetRequest("https://api.openai.com", {}, { model: "gpt-4" });
|
||||
logger.logProviderResponse(200, "OK", {}, {});
|
||||
logger.logConvertedResponse({ choices: [] });
|
||||
logger.logError(new Error("test"));
|
||||
logger.appendProviderChunk("test");
|
||||
logger.appendOpenAIChunk("test");
|
||||
logger.appendConvertedChunk("test");
|
||||
|
||||
assert.equal(logger.getPipelinePayloads(), null);
|
||||
});
|
||||
@@ -54,7 +54,36 @@ test("createDisconnectAwareStream converts upstream errors into SSE error chunks
|
||||
|
||||
assert.match(text, /"finish_reason":"error"/);
|
||||
assert.match(text, /"message":"provider exploded"/);
|
||||
assert.match(text, /"code":429/);
|
||||
assert.match(text, /"code":"rate_limit_exceeded"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("createDisconnectAwareStream: Gemini 503 high-demand error becomes SSE error chunk with message preserved", async () => {
|
||||
const geminiMsg =
|
||||
"[503]: This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.";
|
||||
const upstreamError = Object.assign(new Error(geminiMsg), { statusCode: 503 });
|
||||
const transformStream = {
|
||||
readable: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(upstreamError);
|
||||
},
|
||||
}),
|
||||
writable: {
|
||||
getWriter() {
|
||||
return {
|
||||
abort() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stream = createDisconnectAwareStream(transformStream, createStreamController());
|
||||
const text = await readStreamText(stream);
|
||||
|
||||
assert.match(text, /"finish_reason":"error"/);
|
||||
assert.match(text, /"message":"\[503\]: This model is currently experiencing high demand/);
|
||||
assert.match(text, /"type":"server_error"/);
|
||||
assert.match(text, /"code":"server_error"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
|
||||
@@ -469,7 +469,8 @@ test("pending request metadata stores sanitized payload previews and clears afte
|
||||
});
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["conn-preview"]["gpt-test (openai)"];
|
||||
const detailArr = pending.details["conn-preview"]["gpt-test (openai)"];
|
||||
const detail = detailArr[0];
|
||||
const clientRequestPreview = detail.clientRequest as Record<string, unknown>;
|
||||
const providerRequestPreview = detail.providerRequest as Record<string, unknown>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user