mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
Integrated into release/v3.8.43
This commit is contained in:
@@ -90,6 +90,10 @@ export function createProgressTransform({
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
clearInterval(intervalId);
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 16384 },
|
||||
{ highWaterMark: 16384 }
|
||||
|
||||
@@ -270,7 +270,8 @@ function makeStreamChunkMethods(options: RequestLoggerOptions, captureChunks: bo
|
||||
const append = (arr: string[], bytes: { value: number; truncated: boolean }, chunk: string) => {
|
||||
if (!captureChunks) return;
|
||||
push();
|
||||
appendBoundedChunk(arr, bytes, chunk, maxBytes, maxItems);
|
||||
const ts = new Date().toISOString().slice(11, 23);
|
||||
appendBoundedChunk(arr, bytes, `[${ts}] ${chunk}`, maxBytes, maxItems);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -111,5 +111,9 @@ export function createSseHeartbeatTransform({
|
||||
flush() {
|
||||
stop();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
stop();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FORMATS } from "../translator/formats.ts";
|
||||
|
||||
type StructuredSSEEvent = {
|
||||
index: number;
|
||||
timestamp?: string;
|
||||
event?: string;
|
||||
data: unknown;
|
||||
};
|
||||
@@ -660,6 +661,7 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
|
||||
|
||||
const event: StructuredSSEEvent = {
|
||||
index: events.length + droppedEvents,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: cloneLogPayload(payload),
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export function buildCallLogListRows({
|
||||
apiKeyName: null,
|
||||
comboName: null,
|
||||
error: null,
|
||||
correlationId: detail.correlationId || null,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
@@ -96,6 +97,7 @@ export function buildCallLogListRows({
|
||||
apiKeyName: null,
|
||||
comboName: null,
|
||||
error: detail.error || null,
|
||||
correlationId: detail.correlationId || null,
|
||||
active: false,
|
||||
completed: true,
|
||||
completedAt: completedAt ? new Date(completedAt).toISOString() : null,
|
||||
@@ -104,9 +106,12 @@ export function buildCallLogListRows({
|
||||
}
|
||||
|
||||
return [...activeEntries, ...completedEntries, ...logs].sort((a, b) => {
|
||||
const timestampDelta = rowTimestampMs(b) - rowTimestampMs(a);
|
||||
if (timestampDelta !== 0) return timestampDelta;
|
||||
return rowPriority(a) - rowPriority(b);
|
||||
// Active requests always on top
|
||||
const pa = rowPriority(a);
|
||||
const pb = rowPriority(b);
|
||||
if (pa !== pb) return pa - pb;
|
||||
// Within same priority, newest first
|
||||
return rowTimestampMs(b) - rowTimestampMs(a);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,19 +130,28 @@ export async function GET(request: Request) {
|
||||
if (searchParams.get("apiKey")) filter.apiKey = searchParams.get("apiKey");
|
||||
if (searchParams.get("combo")) filter.combo = searchParams.get("combo");
|
||||
if (searchParams.get("search")) filter.search = searchParams.get("search");
|
||||
if (searchParams.get("correlationId")) filter.correlationId = searchParams.get("correlationId");
|
||||
if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit"));
|
||||
if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset"));
|
||||
|
||||
const [logs, connections] = await Promise.all([getCallLogs(filter), getProviderConnections()]);
|
||||
|
||||
return NextResponse.json(
|
||||
buildCallLogListRows({
|
||||
logs,
|
||||
connections,
|
||||
pendingDetails: getPendingById().values(),
|
||||
completedDetails: getCompletedDetails().values(),
|
||||
})
|
||||
);
|
||||
const rows = buildCallLogListRows({
|
||||
logs,
|
||||
connections,
|
||||
pendingDetails: getPendingById().values(),
|
||||
completedDetails: getCompletedDetails().values(),
|
||||
});
|
||||
|
||||
// When correlationId filter is set, also filter in-memory entries
|
||||
// (active + completed) that don't match — getCallLogs already filters
|
||||
// the DB rows but activeEntries/completedEntries bypass it.
|
||||
if (filter.correlationId) {
|
||||
const cid = filter.correlationId;
|
||||
return NextResponse.json(rows.filter((r: any) => r.correlationId === cid));
|
||||
}
|
||||
|
||||
return NextResponse.json(rows);
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/usage/call-logs failed:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch call logs" }, { status: 500 });
|
||||
|
||||
@@ -353,7 +353,8 @@ export async function checkConnection(conn) {
|
||||
// - transient cooldown state (unavailable) owned by the request path
|
||||
const refreshCapableNeedsReauth =
|
||||
supportsTokenRefresh(conn.provider) &&
|
||||
(!conn.testStatus || conn.testStatus === "active");
|
||||
(!conn.testStatus || conn.testStatus === "active") &&
|
||||
!(conn.apiKey && conn.apiKey.length > 0); // API-key-only connections don't need refresh tokens
|
||||
if (refreshCapableNeedsReauth) {
|
||||
const now = new Date().toISOString();
|
||||
await updateProviderConnection(conn.id, {
|
||||
|
||||
@@ -176,6 +176,8 @@ export default function RequestLoggerDetail({
|
||||
onCopy,
|
||||
onPrevious,
|
||||
onNext,
|
||||
relatedLogs = [],
|
||||
onSelectRelated,
|
||||
}) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
@@ -202,11 +204,12 @@ export default function RequestLoggerDetail({
|
||||
if (iso == null) return "\u2014";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (!Number.isFinite(d.getTime())) return "\u2014";
|
||||
return (
|
||||
d.toLocaleDateString("pt-BR") + ", " + d.toLocaleTimeString("en-US", { hour12: false })
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
return "\u2014";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,32 +242,17 @@ export default function RequestLoggerDetail({
|
||||
: [];
|
||||
const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null;
|
||||
const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null;
|
||||
const streamChunksText = (() => {
|
||||
const streamChunks = (() => {
|
||||
if (!debugEnabled || !detail?.pipelinePayloads?.streamChunks) return null;
|
||||
let chunks: StreamChunks = detail.pipelinePayloads.streamChunks;
|
||||
|
||||
if (typeof chunks === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(chunks);
|
||||
chunks = parsed;
|
||||
chunks = JSON.parse(chunks);
|
||||
} catch {
|
||||
return chunks;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks && typeof chunks === "object") {
|
||||
try {
|
||||
return Object.entries(chunks)
|
||||
.map(([stage, arr]) => {
|
||||
const joined = Array.isArray(arr) ? arr.join("") : String(arr);
|
||||
return `--- ${stage} ---\n${joined}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
} catch {
|
||||
return toPrettyJson(chunks);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks && typeof chunks === "object") return chunks;
|
||||
return null;
|
||||
})();
|
||||
const detailIssue =
|
||||
@@ -346,6 +334,14 @@ export default function RequestLoggerDetail({
|
||||
{log.id}
|
||||
</span>
|
||||
)}
|
||||
{log.correlationId && (
|
||||
<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"
|
||||
title="Correlation ID"
|
||||
>
|
||||
cid: {log.correlationId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -421,7 +417,23 @@ export default function RequestLoggerDetail({
|
||||
>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Completed Time
|
||||
Started At
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{(() => {
|
||||
try {
|
||||
const ts = new Date(log.timestamp).getTime();
|
||||
if (!Number.isFinite(ts)) return "\u2014";
|
||||
return formatDate(new Date(ts - (log.duration || 0)).toISOString());
|
||||
} catch {
|
||||
return "\u2014";
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Ended At
|
||||
</div>
|
||||
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
|
||||
</div>
|
||||
@@ -597,6 +609,62 @@ export default function RequestLoggerDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Requests (same correlation ID) */}
|
||||
{relatedLogs.length > 1 && (
|
||||
<div className="p-4 rounded-xl bg-bg-subtle border border-border">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-2 font-bold">
|
||||
Related Requests ({relatedLogs.length})
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[...relatedLogs]
|
||||
.sort((a, b) => {
|
||||
const aStart = new Date(a.timestamp).getTime() - (a.duration || 0);
|
||||
const bStart = new Date(b.timestamp).getTime() - (b.duration || 0);
|
||||
return aStart - bStart;
|
||||
})
|
||||
.map((r) => {
|
||||
const rStatusStyle = r.active ? null : getStatusStyle(r.status);
|
||||
const isCurrent = r.id === log.id;
|
||||
const startTime = new Date(new Date(r.timestamp).getTime() - (r.duration || 0));
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => !isCurrent && onSelectRelated?.(r)}
|
||||
disabled={isCurrent}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-colors ${
|
||||
isCurrent
|
||||
? "bg-primary/10 border border-primary/30 cursor-default"
|
||||
: "hover:bg-bg-hover cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-[9px] font-bold min-w-[28px] text-center"
|
||||
style={
|
||||
rStatusStyle
|
||||
? { backgroundColor: rStatusStyle.bg, color: rStatusStyle.text }
|
||||
: { backgroundColor: "#374151", color: "#fff" }
|
||||
}
|
||||
>
|
||||
{r.status || "..."}
|
||||
</span>
|
||||
<span className="font-mono text-text-muted">{r.id}</span>
|
||||
<span className="text-text-muted">{r.model}</span>
|
||||
<span className="text-text-muted text-[10px]">
|
||||
{startTime.toLocaleTimeString("en-US", { hour12: false })}
|
||||
</span>
|
||||
<span className="text-text-muted ml-auto">
|
||||
{formatDuration(r.duration)}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-[9px] text-primary font-bold ml-1">current</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailIssue && (
|
||||
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="text-[10px] text-amber-700 dark:text-amber-400 uppercase tracking-wider mb-1 font-bold">
|
||||
@@ -612,14 +680,63 @@ export default function RequestLoggerDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{streamChunksText && (
|
||||
{streamChunks && streamChunks.provider && (
|
||||
<StreamSection
|
||||
title="Event Stream (Debug)"
|
||||
json={streamChunksText}
|
||||
onCopy={() => onCopy(streamChunksText)}
|
||||
title="Provider Event Stream"
|
||||
json={
|
||||
Array.isArray(streamChunks.provider)
|
||||
? streamChunks.provider.join("")
|
||||
: String(streamChunks.provider)
|
||||
}
|
||||
onCopy={() =>
|
||||
onCopy(
|
||||
Array.isArray(streamChunks.provider)
|
||||
? streamChunks.provider.join("")
|
||||
: String(streamChunks.provider)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{streamChunks && streamChunks.client && (
|
||||
<StreamSection
|
||||
title="Client Event Stream"
|
||||
json={
|
||||
Array.isArray(streamChunks.client)
|
||||
? streamChunks.client.join("")
|
||||
: String(streamChunks.client)
|
||||
}
|
||||
onCopy={() =>
|
||||
onCopy(
|
||||
Array.isArray(streamChunks.client)
|
||||
? streamChunks.client.join("")
|
||||
: String(streamChunks.client)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{streamChunks &&
|
||||
streamChunks.openai &&
|
||||
!streamChunks.provider &&
|
||||
!streamChunks.client && (
|
||||
<StreamSection
|
||||
title="Event Stream"
|
||||
json={
|
||||
Array.isArray(streamChunks.openai)
|
||||
? streamChunks.openai.join("")
|
||||
: String(streamChunks.openai)
|
||||
}
|
||||
onCopy={() =>
|
||||
onCopy(
|
||||
Array.isArray(streamChunks.openai)
|
||||
? streamChunks.openai.join("")
|
||||
: String(streamChunks.openai)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{payloadSections.length > 0 &&
|
||||
payloadSections.map((section) => (
|
||||
<PayloadSection
|
||||
|
||||
@@ -139,7 +139,36 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [sortBy, setSortBy] = useState("newest");
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
const [correlationIdFilter, setCorrelationIdFilter] = useState("");
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Column sort toggle: clicking a column header toggles asc/desc
|
||||
const columnSortMap = {
|
||||
status: { desc: "status_desc", asc: "status_asc" },
|
||||
model: { desc: "model_desc", asc: "model_asc" },
|
||||
tokens: { desc: "tokens_desc", asc: "tokens_asc" },
|
||||
tps: { desc: "tps_desc", asc: "tps_asc" },
|
||||
duration: { desc: "duration_desc", asc: "duration_asc" },
|
||||
time: { desc: "newest", asc: "oldest" },
|
||||
};
|
||||
const toggleSort = useCallback((column: string) => {
|
||||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||||
if (!mapping) return;
|
||||
setSortBy((prev) => {
|
||||
if (prev === mapping.desc) return mapping.asc;
|
||||
return mapping.desc;
|
||||
});
|
||||
}, []);
|
||||
const getSortIndicator = useCallback(
|
||||
(column: string) => {
|
||||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||||
if (!mapping) return "";
|
||||
if (sortBy === mapping.desc) return " ↓";
|
||||
if (sortBy === mapping.asc) return " ↑";
|
||||
return "";
|
||||
},
|
||||
[sortBy]
|
||||
);
|
||||
const [detailData, setDetailData] = useState(null);
|
||||
const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false);
|
||||
const [detailLoggingLoading, setDetailLoggingLoading] = useState(false);
|
||||
@@ -222,6 +251,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
if (selectedProvider) params.set("provider", selectedProvider);
|
||||
if (selectedAccount) params.set("account", selectedAccount);
|
||||
if (selectedApiKey) params.set("apiKey", selectedApiKey);
|
||||
if (correlationIdFilter) params.set("correlationId", correlationIdFilter);
|
||||
params.set("limit", String(limit));
|
||||
|
||||
const res = await fetch(`/api/usage/call-logs?${params}`);
|
||||
@@ -251,6 +281,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
selectedAccount,
|
||||
selectedProvider,
|
||||
selectedApiKey,
|
||||
correlationIdFilter,
|
||||
limit,
|
||||
]
|
||||
);
|
||||
@@ -437,6 +468,34 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
return arr;
|
||||
}, [filteredLogs, sortBy]);
|
||||
|
||||
// Group by correlationId: mark retries as children so they render indented
|
||||
// under the first request in each group. Compute group health:
|
||||
// "healed" — at least one failure followed by a success
|
||||
// "failed" — all attempts failed
|
||||
// null — single request or all succeeded
|
||||
const groupedLogs = useMemo(() => {
|
||||
const cidGroups = new Map();
|
||||
for (const log of sortedLogs) {
|
||||
const cid = log.correlationId;
|
||||
if (cid) {
|
||||
if (!cidGroups.has(cid)) cidGroups.set(cid, []);
|
||||
cidGroups.get(cid).push(log);
|
||||
}
|
||||
}
|
||||
return sortedLogs.map((log) => {
|
||||
const cid = log.correlationId;
|
||||
if (!cid) return { ...log, isRetry: false, groupSize: 1, groupStatus: null };
|
||||
const group = cidGroups.get(cid);
|
||||
if (!group || group.length <= 1)
|
||||
return { ...log, isRetry: false, groupSize: 1, groupStatus: null };
|
||||
const isFirst = group[0].id === log.id;
|
||||
const hasFailure = group.some((g) => g.status >= 400 || g.active);
|
||||
const hasSuccess = group.some((g) => g.status >= 200 && g.status < 300);
|
||||
const groupStatus = hasFailure && hasSuccess ? "healed" : hasFailure ? "failed" : null;
|
||||
return { ...log, isRetry: !isFirst, groupSize: group.length, groupStatus };
|
||||
});
|
||||
}, [sortedLogs]);
|
||||
|
||||
// Fetch log detail from the persisted call-log endpoint. If a deep-linked
|
||||
// request is still being finalized, keep the modal open and poll this same
|
||||
// endpoint until the row appears.
|
||||
@@ -598,6 +657,50 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
};
|
||||
}, [selectedLog?.id, detailData?.detailState, selectedLog?.active, fetchLogs]);
|
||||
|
||||
// Poll for related logs (same correlationId) while the detail modal is open.
|
||||
// This ensures newly completed retries appear without closing the modal.
|
||||
useEffect(() => {
|
||||
const cid = selectedLog?.correlationId;
|
||||
if (!selectedLog?.id || !cid) return;
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
try {
|
||||
const res = await fetch(`/api/usage/call-logs?correlationId=${encodeURIComponent(cid)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (cancelled || !res.ok) return;
|
||||
const cidLogs = await res.json();
|
||||
if (!Array.isArray(cidLogs) || cidLogs.length === 0) return;
|
||||
setLogs((prev) => {
|
||||
const ids = new Set(cidLogs.map((l: any) => l.id));
|
||||
let changed = false;
|
||||
const merged = prev.map((l: any) => {
|
||||
const updated = cidLogs.find((c: any) => c.id === l.id);
|
||||
if (updated) {
|
||||
changed = true;
|
||||
return { ...l, ...updated };
|
||||
}
|
||||
return l;
|
||||
});
|
||||
for (const cl of cidLogs) {
|
||||
if (!merged.some((m: any) => m.id === cl.id)) {
|
||||
merged.push(cl);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? merged : prev;
|
||||
});
|
||||
} catch {
|
||||
/* poll failed — non-critical */
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [selectedLog?.id, selectedLog?.correlationId]);
|
||||
|
||||
const currentLogIndex = useMemo(() => {
|
||||
if (!selectedLog) return -1;
|
||||
return sortedLogsForNav.findIndex((l) => l.id === selectedLog.id);
|
||||
@@ -685,13 +788,14 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
);
|
||||
|
||||
// Stats (memoized to avoid re-computation on every render)
|
||||
const { totalCount, okCount, errorCount, comboCount, apiKeyCount } = useMemo(
|
||||
const { totalCount, okCount, errorCount, comboCount, apiKeyCount, runningCount } = useMemo(
|
||||
() => ({
|
||||
totalCount: filteredLogs.length,
|
||||
okCount: filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length,
|
||||
errorCount: filteredLogs.filter((l) => l.status >= 400).length,
|
||||
comboCount: logs.filter((l) => l.comboName).length,
|
||||
apiKeyCount: uniqueApiKeys.length,
|
||||
runningCount: filteredLogs.filter((l) => l.active === true).length,
|
||||
}),
|
||||
[filteredLogs, logs, uniqueApiKeys]
|
||||
);
|
||||
@@ -749,6 +853,20 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Correlation ID Filter */}
|
||||
<div className="min-w-[180px] relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[16px]">
|
||||
tag
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Correlation ID"
|
||||
value={correlationIdFilter}
|
||||
onChange={(e) => setCorrelationIdFilter(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Dropdown */}
|
||||
<select
|
||||
value={selectedProvider}
|
||||
@@ -818,6 +936,11 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
|
||||
{totalCount} {t("total")}
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<span className="px-2 py-1 rounded bg-amber-500/10 text-amber-700 dark:text-amber-400 font-mono">
|
||||
{runningCount} running
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 font-mono">
|
||||
{okCount} {t("ok")}
|
||||
</span>
|
||||
@@ -1000,7 +1123,13 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<thead className={LOG_TABLE_HEAD_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
|
||||
<tr className={LOG_TABLE_ROW_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
|
||||
{visibleColumns.status && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.status")}</th>
|
||||
<th
|
||||
className={`${LOG_TABLE_HEADER_CELL_CLASS} cursor-pointer select-none`}
|
||||
onClick={() => toggleSort("status")}
|
||||
>
|
||||
{t("columns.status")}
|
||||
{getSortIndicator("status")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.cacheSource && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.cacheSource")}</th>
|
||||
@@ -1027,21 +1156,45 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.combo")}</th>
|
||||
)}
|
||||
{visibleColumns.tokens && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.tokens")}</th>
|
||||
<th
|
||||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||||
onClick={() => toggleSort("tokens")}
|
||||
>
|
||||
{t("columns.tokens")}
|
||||
{getSortIndicator("tokens")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.tps && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.tps")}</th>
|
||||
<th
|
||||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||||
onClick={() => toggleSort("tps")}
|
||||
>
|
||||
{t("columns.tps")}
|
||||
{getSortIndicator("tps")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.duration && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.duration")}</th>
|
||||
<th
|
||||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||||
onClick={() => toggleSort("duration")}
|
||||
>
|
||||
{t("columns.duration")}
|
||||
{getSortIndicator("duration")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.time && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.time")}</th>
|
||||
<th
|
||||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||||
onClick={() => toggleSort("time")}
|
||||
>
|
||||
{t("columns.time")}
|
||||
{getSortIndicator("time")}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{sortedLogs.map((log) => {
|
||||
{groupedLogs.map((log) => {
|
||||
const isActive = log.active === true;
|
||||
const statusStyle = isActive ? null : getStatusStyle(log.status);
|
||||
const protocolKey = isActive ? null : log.sourceFormat || log.provider;
|
||||
@@ -1064,7 +1217,10 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<tr
|
||||
key={log.id}
|
||||
onClick={() => openDetail(log)}
|
||||
className={`cursor-pointer hover:bg-sky-500/10 dark:hover:bg-sky-400/10 transition-colors ${isError ? "bg-red-500/5" : ""}`}
|
||||
className={`cursor-pointer hover:bg-sky-500/10 dark:hover:bg-sky-400/10 transition-colors ${isError ? "bg-red-500/5" : ""} ${log.isRetry ? "border-l-2 border-l-amber-500/50" : ""}`}
|
||||
style={
|
||||
log.isRetry ? { backgroundColor: "rgba(245,158,11,0.03)" } : undefined
|
||||
}
|
||||
>
|
||||
{visibleColumns.status && (
|
||||
<td className="px-3 py-2">
|
||||
@@ -1076,11 +1232,41 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<span className="inline-block h-3 w-3 rounded-full border-2 border-amber-500 border-t-transparent animate-spin" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded text-[10px] font-bold min-w-[36px] text-center"
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{log.status || "..."}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded text-[10px] font-bold min-w-[36px] text-center"
|
||||
style={{
|
||||
backgroundColor: statusStyle.bg,
|
||||
color: statusStyle.text,
|
||||
}}
|
||||
>
|
||||
{log.status || "..."}
|
||||
</span>
|
||||
{log.groupStatus === "healed" &&
|
||||
!log.isRetry &&
|
||||
log.status >= 400 && (
|
||||
<span
|
||||
className="text-emerald-500 text-[11px]"
|
||||
title="Recovered by retry"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{log.isRetry && (
|
||||
<button
|
||||
className="inline-flex items-center text-amber-500 hover:text-amber-400 text-[11px] ml-0.5"
|
||||
title="Go to parent request"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const parent = groupedLogs.find(
|
||||
(g) => g.correlationId === log.correlationId && !g.isRetry
|
||||
);
|
||||
if (parent) openDetail(parent);
|
||||
}}
|
||||
>
|
||||
↳
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -1103,7 +1289,49 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
)}
|
||||
{visibleColumns.model && (
|
||||
<td className="px-3 py-2 font-medium text-primary font-mono text-[11px]">
|
||||
{log.model}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{log.model}</span>
|
||||
{log.groupStatus === "healed" && !log.isRetry && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 px-1 py-0 rounded text-[8px] font-bold bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/25"
|
||||
title={`Failed, recovered after ${log.groupSize - 1} retry`}
|
||||
>
|
||||
healed
|
||||
</span>
|
||||
)}
|
||||
{log.groupStatus === "failed" && !log.isRetry && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 px-1 py-0 rounded text-[8px] font-bold bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/25"
|
||||
title={`All ${log.groupSize} attempts failed`}
|
||||
>
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{log.correlationId && !log.isRetry && log.groupSize > 1 && (
|
||||
<div
|
||||
className="text-[9px] text-text-muted font-normal truncate max-w-[120px]"
|
||||
title={log.correlationId}
|
||||
>
|
||||
{log.correlationId.slice(0, 12)}… · {log.groupSize} attempts
|
||||
</div>
|
||||
)}
|
||||
{log.correlationId && !log.isRetry && log.groupSize <= 1 && (
|
||||
<div
|
||||
className="text-[9px] text-text-muted font-normal truncate max-w-[120px]"
|
||||
title={log.correlationId}
|
||||
>
|
||||
{log.correlationId.slice(0, 12)}…
|
||||
</div>
|
||||
)}
|
||||
{log.correlationId && log.isRetry && (
|
||||
<div
|
||||
className="text-[9px] text-amber-500/70 font-normal truncate max-w-[120px]"
|
||||
title={log.correlationId}
|
||||
>
|
||||
{log.correlationId.slice(0, 12)}…
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.requestedModel && (
|
||||
@@ -1303,6 +1531,15 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
onCopy={copyToClipboard}
|
||||
onPrevious={handlePrev}
|
||||
onNext={handleNext}
|
||||
relatedLogs={
|
||||
selectedLog.correlationId
|
||||
? groupedLogs.filter((l) => l.correlationId === selectedLog.correlationId)
|
||||
: []
|
||||
}
|
||||
onSelectRelated={(r) => {
|
||||
closeDetail();
|
||||
openDetail(r);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 MODEL = "default";
|
||||
|
||||
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
|
||||
|
||||
@@ -54,7 +54,14 @@ async function readSSEStream(response: Response, onChunk?: (chunk: string) => vo
|
||||
}
|
||||
|
||||
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);
|
||||
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." },
|
||||
@@ -106,9 +113,10 @@ test("live request returns streamChunks", { skip }, async () => {
|
||||
);
|
||||
if (activeRequestResponse.ok) {
|
||||
let activeRequest = await activeRequestResponse.json();
|
||||
if (activeRequest.active &&
|
||||
Array.isArray(activeRequest.pipelinePayloads.streamChunks.provider) &&
|
||||
activeRequest.pipelinePayloads.streamChunks.provider.length > 0
|
||||
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;
|
||||
@@ -140,9 +148,13 @@ test("live request returns streamChunks", { skip }, async () => {
|
||||
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.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.provider));
|
||||
assert.ok(Array.isArray(finishedRequest.pipelinePayloads.streamChunks.client));
|
||||
|
||||
assert.ok(finishedRequest.pipelinePayloads.streamChunks.provider.length > 0);
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* Uses same env vars as tests/manual/gemini.http:
|
||||
* OMNIROUTE_URL — base URL (default http://localhost:20128)
|
||||
* OMNIROUTE_API_KEY — API key for auth
|
||||
* TEST_GEMINI_MODEL — model override (default gemini/gemma-4-31b-it)
|
||||
* TEST_THINKING_GEMINI_MODEL — thinking model override, skipped if unset
|
||||
*/
|
||||
|
||||
@@ -17,7 +16,7 @@ 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 || "gemini/gemma-4-31b-it";
|
||||
const MODEL = "default";
|
||||
const THINKING_MODEL = process.env.TEST_THINKING_GEMINI_MODEL || "gemini/gemini-2.5-flash";
|
||||
const NUM_HISTORICAL_ROUNDS = 15;
|
||||
|
||||
|
||||
246
tests/integration/live-gemini-nonstream.test.ts
Normal file
246
tests/integration/live-gemini-nonstream.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* tests/integration/live-gemini-nonstream.test.ts
|
||||
*
|
||||
* Non-streaming variant of live-gemini-workload.test.ts.
|
||||
* Reuses the same CASE_BUILDERS payload generators but sends stream: false.
|
||||
* Validates that non-streaming responses return content and complete without errors.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
skip,
|
||||
randomInt,
|
||||
sendAndValidate,
|
||||
CASE_BUILDERS,
|
||||
ensureTestEnvironment,
|
||||
DELAY_BETWEEN_REQUESTS_MS,
|
||||
type Message,
|
||||
} from "./liveGeminiShared.ts";
|
||||
|
||||
test.before(async () => {
|
||||
await ensureTestEnvironment();
|
||||
});
|
||||
|
||||
// ── Non-streaming concurrent load — 5 parallel threads × 5 iterations ──
|
||||
|
||||
test("[00] non-streaming: concurrent load — 5 threads × 2 iterations", { skip }, async () => {
|
||||
const THREAD_COUNT = 5;
|
||||
const SET_COUNT = 2;
|
||||
const TOTAL_REQUESTS = THREAD_COUNT * SET_COUNT;
|
||||
|
||||
console.log(
|
||||
`\n Non-streaming concurrent: ${THREAD_COUNT} threads × ${SET_COUNT} iterations = ${TOTAL_REQUESTS} requests`
|
||||
);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const requestWindows: { cid: string; start: number; end: number }[] = [];
|
||||
let parallelViolation: string | null = null;
|
||||
|
||||
const threadResults = await Promise.allSettled(
|
||||
Array.from({ length: THREAD_COUNT }, (_, threadIdx) =>
|
||||
(async () => {
|
||||
const results: {
|
||||
status: number;
|
||||
duration: number;
|
||||
tokens: number;
|
||||
contentLength: number;
|
||||
correlationId: string;
|
||||
}[] = [];
|
||||
for (let set = 1; set <= SET_COUNT; set++) {
|
||||
if (parallelViolation) break;
|
||||
|
||||
const idx = randomInt(0, CASE_BUILDERS.length - 1);
|
||||
const tc = CASE_BUILDERS[idx];
|
||||
const label = `ns-t${threadIdx + 1}-i${set}: ${tc.name}`;
|
||||
const requestStart = Date.now();
|
||||
const r = await sendAndValidate(label, tc.build, false);
|
||||
const requestEnd = Date.now();
|
||||
|
||||
const cid = r.correlationId;
|
||||
const myWindow = { cid, start: requestStart, end: requestEnd };
|
||||
const siblings = requestWindows.filter((w) => w.cid === cid);
|
||||
for (const s of siblings) {
|
||||
if (myWindow.start < s.end && s.start < myWindow.end) {
|
||||
parallelViolation =
|
||||
`PARALLEL REQUEST DETECTED: cid=${cid.slice(0, 12)}… ` +
|
||||
`window [${new Date(myWindow.start).toISOString().slice(11, 23)}–${new Date(myWindow.end).toISOString().slice(11, 23)}] ` +
|
||||
`overlaps with [${new Date(s.start).toISOString().slice(11, 23)}–${new Date(s.end).toISOString().slice(11, 23)}]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
requestWindows.push(myWindow);
|
||||
results.push({ ...r } as any);
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
||||
const totalDuration = performance.now() - start;
|
||||
|
||||
const fulfilled = threadResults.filter((r) => r.status === "fulfilled") as PromiseFulfilledResult<
|
||||
{
|
||||
status: number;
|
||||
duration: number;
|
||||
tokens: number;
|
||||
contentLength: number;
|
||||
correlationId: string;
|
||||
}[]
|
||||
>[];
|
||||
const rejected = threadResults.filter((r) => r.status === "rejected") as PromiseRejectedResult[];
|
||||
|
||||
const allResults = fulfilled.flatMap((r) => r.value);
|
||||
const totalTokens = allResults.reduce((sum, r) => sum + r.tokens, 0);
|
||||
const avgDuration =
|
||||
allResults.length > 0
|
||||
? Math.round(allResults.reduce((s, r) => s + r.duration, 0) / allResults.length)
|
||||
: 0;
|
||||
|
||||
console.log(
|
||||
`\n Non-streaming concurrent summary: ${fulfilled.length}/${THREAD_COUNT} threads completed | ` +
|
||||
`${allResults.length}/${TOTAL_REQUESTS} requests succeeded | ` +
|
||||
`${Math.round(totalDuration)}ms wall clock | ` +
|
||||
`${avgDuration}ms avg per request | ` +
|
||||
`${totalTokens} total tokens`
|
||||
);
|
||||
|
||||
if (rejected.length > 0) {
|
||||
for (const r of rejected) {
|
||||
const msg = r.reason instanceof Error ? r.message : String(r.reason);
|
||||
console.log(` THREAD FAILED: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parallelViolation) {
|
||||
console.log(`\n !! ${parallelViolation}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
fulfilled.length === THREAD_COUNT,
|
||||
`expected all ${THREAD_COUNT} threads to complete, ${rejected.length} failed`
|
||||
);
|
||||
assert.ok(
|
||||
allResults.length === TOTAL_REQUESTS,
|
||||
`expected ${TOTAL_REQUESTS} total requests, got ${allResults.length}`
|
||||
);
|
||||
assert.ok(!parallelViolation, parallelViolation);
|
||||
|
||||
// Verify all correlation IDs are unique
|
||||
const cids = allResults.map((r) => r.correlationId);
|
||||
const uniqueCids = new Set(cids);
|
||||
assert.equal(
|
||||
uniqueCids.size,
|
||||
cids.length,
|
||||
`expected ${cids.length} unique CIDs, got ${uniqueCids.size}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Non-streaming sequential test ───────────────────────────────────────
|
||||
|
||||
test("[01] non-streaming: sequential — 1 thread × 5 iterations", { skip }, async () => {
|
||||
const SET_COUNT = 5;
|
||||
|
||||
console.log(`\n Non-streaming sequential: 1 thread × ${SET_COUNT} iterations`);
|
||||
|
||||
const start = performance.now();
|
||||
const results: {
|
||||
status: number;
|
||||
duration: number;
|
||||
tokens: number;
|
||||
contentLength: number;
|
||||
correlationId: string;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 1; i <= SET_COUNT; i++) {
|
||||
const idx = randomInt(0, CASE_BUILDERS.length - 1);
|
||||
const tc = CASE_BUILDERS[idx];
|
||||
const label = `ns-i${i}: ${tc.name}`;
|
||||
try {
|
||||
if (i > 1) await new Promise((r) => setTimeout(r, DELAY_BETWEEN_REQUESTS_MS));
|
||||
const r = await sendAndValidate(label, tc.build, false);
|
||||
results.push(r);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(` ${label.padEnd(45)} FAILED: ${msg}`);
|
||||
assert.fail(`iteration ${i} failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = performance.now() - start;
|
||||
const totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
|
||||
const avgDuration =
|
||||
results.length > 0
|
||||
? Math.round(results.reduce((s, r) => s + r.duration, 0) / results.length)
|
||||
: 0;
|
||||
|
||||
console.log(
|
||||
`\n Non-streaming summary: ${results.length}/${SET_COUNT} succeeded | ` +
|
||||
`${Math.round(totalDuration)}ms wall clock | ` +
|
||||
`${avgDuration}ms avg per request | ` +
|
||||
`${totalTokens} total tokens`
|
||||
);
|
||||
|
||||
const cids = results.map((r) => r.correlationId);
|
||||
const uniqueCids = new Set(cids);
|
||||
assert.equal(
|
||||
uniqueCids.size,
|
||||
cids.length,
|
||||
`expected ${cids.length} unique CIDs, got ${uniqueCids.size}`
|
||||
);
|
||||
assert.equal(results.length, SET_COUNT, `expected ${SET_COUNT} results, got ${results.length}`);
|
||||
});
|
||||
|
||||
// ── Non-streaming: all payloads return content ──────────────────────────
|
||||
|
||||
test("[02] non-streaming: all payloads return content", { skip }, async () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (let i = 0; i < CASE_BUILDERS.length; i++) {
|
||||
const tc = CASE_BUILDERS[i];
|
||||
const label = `ns-${String(i + 1).padStart(2, "0")}: ${tc.name}`;
|
||||
try {
|
||||
const r = await sendAndValidate(label, tc.build, false);
|
||||
if (r.contentLength === 0) {
|
||||
failures.push(`${tc.name}: 0 bytes content`);
|
||||
}
|
||||
if (r.status !== 200) {
|
||||
failures.push(`${tc.name}: HTTP ${r.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push(`${tc.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Non-streaming failures (${failures.length}):`);
|
||||
for (const f of failures) console.log(` ${f}`);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
failures.length,
|
||||
0,
|
||||
`${failures.length}/${CASE_BUILDERS.length} non-streaming payloads failed`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Non-streaming: correlation IDs are unique ───────────────────────────
|
||||
|
||||
test("[03] non-streaming: correlation IDs are unique per request", { skip }, async () => {
|
||||
const cids: string[] = [];
|
||||
const count = 5;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tc = CASE_BUILDERS[i % CASE_BUILDERS.length];
|
||||
const r = await sendAndValidate(`ns-cid-${i + 1}: ${tc.name}`, tc.build, false);
|
||||
cids.push(r.correlationId);
|
||||
}
|
||||
|
||||
const unique = new Set(cids);
|
||||
assert.equal(
|
||||
unique.size,
|
||||
count,
|
||||
`expected ${count} unique CIDs, got ${unique.size}: ${cids.join(", ")}`
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
188
tests/integration/live-gemini.test.ts
Normal file
188
tests/integration/live-gemini.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ensureTestEnvironment, MODEL, BASE_URL, API_KEY } from "./liveGeminiShared.ts";
|
||||
|
||||
const DIRECT_MODEL = process.env.TEST_GEMINI_DIRECT_MODEL || "gemini/gemini-2.0-flash";
|
||||
|
||||
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
|
||||
|
||||
test.before(async () => {
|
||||
await ensureTestEnvironment();
|
||||
});
|
||||
|
||||
async function readSSEStream(response: Response): Promise<{
|
||||
fullContent: string;
|
||||
finishReason: string;
|
||||
model: string;
|
||||
totalTokens: number;
|
||||
}> {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullContent = "";
|
||||
let finishReason = "unknown";
|
||||
let model = "";
|
||||
let totalTokens = 0;
|
||||
let chunkCount = 0;
|
||||
let sampleLines: string[] = [];
|
||||
|
||||
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;
|
||||
chunkCount++;
|
||||
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;
|
||||
} else if (delta?.reasoning_content) {
|
||||
fullContent += delta.reasoning_content as string;
|
||||
}
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason as string;
|
||||
}
|
||||
if (!model) {
|
||||
if (parsed.model) {
|
||||
model = parsed.model as string;
|
||||
} else if (choice?.model) {
|
||||
model = choice.model 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
|
||||
}
|
||||
|
||||
if (sampleLines.length < 3) {
|
||||
sampleLines.push(data.slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SSE] total raw chunks: ${chunkCount}, content length: ${fullContent.length}, finish: ${finishReason}, model: ${model}, tokens: ${totalTokens}`
|
||||
);
|
||||
if (sampleLines.length > 0) {
|
||||
console.log(`[SSE] sample lines: ${JSON.stringify(sampleLines)}`);
|
||||
}
|
||||
|
||||
return { fullContent, finishReason, model, totalTokens };
|
||||
}
|
||||
|
||||
test("live Gemini — single hello-world request via combo 'default'", { skip }, async (t) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||
|
||||
try {
|
||||
console.log(`[TEST] Sending request with model=${MODEL} to ${BASE_URL}/v1/chat/completions`);
|
||||
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: [{ role: "user", content: "Say 'Hello world' and nothing else." }],
|
||||
stream: true,
|
||||
max_tokens: 50,
|
||||
temperature: 0,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
console.log(`[TEST] Response status: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "unknown");
|
||||
console.log(`[TEST] Response body: ${body}`);
|
||||
}
|
||||
|
||||
assert.equal(response.status, 200, "Expected HTTP 200 from combo request");
|
||||
|
||||
const { fullContent, finishReason, model, totalTokens } = await readSSEStream(response);
|
||||
|
||||
assert.ok(fullContent.length > 0, "response should have content");
|
||||
assert.ok(
|
||||
finishReason === "stop" || finishReason === "length",
|
||||
`expected stop/length finish, got ${finishReason}`
|
||||
);
|
||||
assert.ok(totalTokens > 0, `should have non-zero token count, got ${totalTokens}`);
|
||||
assert.ok(
|
||||
model.toLowerCase().includes("gemini") || model.toLowerCase().includes("gemma"),
|
||||
`response model "${model}" should be a Gemini/Gemma model`
|
||||
);
|
||||
console.log(
|
||||
`[TEST] OK: model=${model}, finish=${finishReason}, tokens=${totalTokens}, content=${fullContent.length} chars`
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
test("live Gemini — direct model request (skip combo)", { skip }, async (t) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||
|
||||
try {
|
||||
console.log(`[TEST] Direct model request with model=${DIRECT_MODEL}`);
|
||||
const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: DIRECT_MODEL,
|
||||
messages: [{ role: "user", content: "Say 'Hello world' and nothing else." }],
|
||||
stream: true,
|
||||
max_tokens: 50,
|
||||
temperature: 0,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
console.log(`[TEST] Direct response status: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "unknown");
|
||||
console.log(`[TEST] Direct response body: ${body}`);
|
||||
}
|
||||
|
||||
assert.equal(response.status, 200, "Expected HTTP 200 for direct model");
|
||||
|
||||
const { fullContent, finishReason, model, totalTokens } = await readSSEStream(response);
|
||||
|
||||
assert.ok(fullContent.length > 0, "response should have content");
|
||||
assert.ok(
|
||||
finishReason === "stop" || finishReason === "length",
|
||||
`expected stop/length finish, got ${finishReason}`
|
||||
);
|
||||
assert.ok(totalTokens > 0, `should have non-zero token count, got ${totalTokens}`);
|
||||
assert.ok(
|
||||
model.toLowerCase().includes("gemini") || model.toLowerCase().includes("gemma"),
|
||||
`response model "${model}" should be a Gemini/Gemma model`
|
||||
);
|
||||
console.log(`[TEST] Direct OK: model=${model}, finish=${finishReason}, tokens=${totalTokens}`);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
1108
tests/integration/liveGeminiShared.ts
Normal file
1108
tests/integration/liveGeminiShared.ts
Normal file
File diff suppressed because it is too large
Load Diff
173
tests/unit/apikey-connection-health-check.test.ts
Normal file
173
tests/unit/apikey-connection-health-check.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Regression test: API-key-only connections must not be falsely expired.
|
||||
*
|
||||
* Bug: tokenHealthCheck.checkConnection() marked API-key-only connections
|
||||
* (e.g. gemini with just an API key, no OAuth refresh token) as
|
||||
* testStatus="expired" because it expected OAuth refresh tokens for any
|
||||
* provider in the supportsTokenRefresh set.
|
||||
*
|
||||
* Fix: connections that have an apiKey configured are skipped during OAuth
|
||||
* token validation, since they don't require refresh tokens.
|
||||
*/
|
||||
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";
|
||||
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apikey-health-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { checkConnection } = await import("../../src/lib/tokenHealthCheck.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error: any) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("API-key-only gemini connection is NOT marked expired by health check", async () => {
|
||||
await resetStorage();
|
||||
|
||||
// Create a gemini connection with an API key but no refresh token
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "gemini",
|
||||
name: "gemini-apikey-test",
|
||||
apiKey: "AIzaSyTest1234567890abcdefghijklmnop",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
healthCheckInterval: 60,
|
||||
// No refreshToken — this is an API-key-only connection
|
||||
});
|
||||
|
||||
assert.equal(conn.testStatus, "active", "precondition: connection starts as active");
|
||||
|
||||
// Run the health check
|
||||
await checkConnection(conn);
|
||||
|
||||
// Re-read from DB
|
||||
const updated = await providersDb.getProviderConnectionById(conn.id);
|
||||
|
||||
assert.equal(
|
||||
updated?.testStatus,
|
||||
"active",
|
||||
"API-key-only connection should remain active — not be marked expired"
|
||||
);
|
||||
assert.notEqual(
|
||||
updated?.errorCode,
|
||||
"no_refresh_token",
|
||||
"API-key-only connection should not get no_refresh_token error"
|
||||
);
|
||||
});
|
||||
|
||||
test("gemini connection WITHOUT apiKey AND WITHOUT refreshToken IS marked expired", async () => {
|
||||
await resetStorage();
|
||||
|
||||
// Create a gemini OAuth connection that lost its refresh token
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "gemini",
|
||||
name: "gemini-oauth-no-refresh",
|
||||
accessToken: "ya29.expired-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
healthCheckInterval: 60,
|
||||
// No apiKey, no refreshToken — this is a broken OAuth connection
|
||||
});
|
||||
|
||||
assert.equal(conn.testStatus, "active", "precondition: connection starts as active");
|
||||
|
||||
// Run the health check
|
||||
await checkConnection(conn);
|
||||
|
||||
// Re-read from DB
|
||||
const updated = await providersDb.getProviderConnectionById(conn.id);
|
||||
|
||||
assert.equal(
|
||||
updated?.testStatus,
|
||||
"expired",
|
||||
"OAuth connection without refresh token should be marked expired"
|
||||
);
|
||||
assert.equal(
|
||||
updated?.errorCode,
|
||||
"no_refresh_token",
|
||||
"OAuth connection should get no_refresh_token error code"
|
||||
);
|
||||
});
|
||||
|
||||
test("API-key-only antigravity connection is NOT marked expired by health check", async () => {
|
||||
await resetStorage();
|
||||
|
||||
// antigravity also supports token refresh — verify the fix applies to all providers
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
name: "agy-apikey-test",
|
||||
apiKey: "sk-ant-test1234567890",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
healthCheckInterval: 60,
|
||||
});
|
||||
|
||||
await checkConnection(conn);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(conn.id);
|
||||
|
||||
assert.equal(
|
||||
updated?.testStatus,
|
||||
"active",
|
||||
"antigravity API-key-only connection should remain active"
|
||||
);
|
||||
});
|
||||
|
||||
test("connection with both apiKey and refreshToken: refresh path is tried", async () => {
|
||||
await resetStorage();
|
||||
|
||||
// Edge case: connection has both an API key and a refresh token
|
||||
// The health check tries the refresh token path first.
|
||||
// With a stale/invalid refresh token, the connection gets marked expired
|
||||
// even though an API key exists — the refresh path takes precedence.
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "gemini",
|
||||
name: "gemini-dual-auth",
|
||||
apiKey: "AIzaSyTest1234567890abcdefghijklmnop",
|
||||
refreshToken: "1//old-refresh-token",
|
||||
accessToken: "ya29.expired-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
healthCheckInterval: 60,
|
||||
});
|
||||
|
||||
await checkConnection(conn);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(conn.id);
|
||||
|
||||
// The refresh token path is tried first. Since the refresh token is invalid,
|
||||
// the connection gets marked expired. This is expected — the operator should
|
||||
// either remove the stale refresh token or re-authenticate.
|
||||
assert.equal(
|
||||
updated?.testStatus,
|
||||
"expired",
|
||||
"dual-auth connection with stale refresh token should be expired (refresh path takes precedence)"
|
||||
);
|
||||
});
|
||||
130
tests/unit/call-logs-correlation-sort.test.ts
Normal file
130
tests/unit/call-logs-correlation-sort.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Pure-function coverage for buildCallLogListRows (src/app/api/usage/call-logs/route.ts):
|
||||
// - merges active/completed in-memory entries with persisted DB rows
|
||||
// - sorts by priority (active > completed > persisted) then newest-first
|
||||
// - carries correlationId through so the GET handler can filter on it
|
||||
import { buildCallLogListRows } from "../../src/app/api/usage/call-logs/route.ts";
|
||||
|
||||
test("buildCallLogListRows: active requests sort before completed and persisted rows", () => {
|
||||
const now = 1_000_000;
|
||||
const rows = buildCallLogListRows({
|
||||
logs: [
|
||||
{
|
||||
id: "persisted-1",
|
||||
timestamp: new Date(now - 5_000).toISOString(),
|
||||
correlationId: "corr-a",
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
pendingDetails: [
|
||||
{
|
||||
id: "active-1",
|
||||
startedAt: now - 1_000,
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
connectionId: "conn-1",
|
||||
correlationId: "corr-a",
|
||||
},
|
||||
],
|
||||
completedDetails: [
|
||||
{
|
||||
id: "completed-1",
|
||||
startedAt: now - 3_000,
|
||||
completedAt: now - 2_000,
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
connectionId: "conn-2",
|
||||
correlationId: "corr-b",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 3);
|
||||
// active (priority 0) first, then completed (priority 1), then persisted (priority 2)
|
||||
assert.equal(rows[0].id, "active-1");
|
||||
assert.equal(rows[0].active, true);
|
||||
assert.equal(rows[1].id, "completed-1");
|
||||
assert.equal(rows[1].completed, true);
|
||||
assert.equal(rows[2].id, "persisted-1");
|
||||
});
|
||||
|
||||
test("buildCallLogListRows: within the same priority, newest timestamp sorts first", () => {
|
||||
const now = 2_000_000;
|
||||
const rows = buildCallLogListRows({
|
||||
logs: [
|
||||
{ id: "old", timestamp: new Date(now - 10_000).toISOString() },
|
||||
{ id: "new", timestamp: new Date(now - 1_000).toISOString() },
|
||||
],
|
||||
connections: [],
|
||||
pendingDetails: [],
|
||||
completedDetails: [],
|
||||
now,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((r: any) => r.id),
|
||||
["new", "old"]
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCallLogListRows: in-memory entries carry correlationId for downstream filtering", () => {
|
||||
const now = 3_000_000;
|
||||
const rows = buildCallLogListRows({
|
||||
logs: [],
|
||||
connections: [],
|
||||
pendingDetails: [
|
||||
{
|
||||
id: "active-cid",
|
||||
startedAt: now - 500,
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
connectionId: "conn-1",
|
||||
correlationId: "corr-xyz",
|
||||
},
|
||||
],
|
||||
completedDetails: [
|
||||
{
|
||||
id: "completed-no-cid",
|
||||
startedAt: now - 4_000,
|
||||
completedAt: now - 3_000,
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
connectionId: "conn-2",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
const active = rows.find((r: any) => r.id === "active-cid");
|
||||
const completed = rows.find((r: any) => r.id === "completed-no-cid");
|
||||
assert.equal(active?.correlationId, "corr-xyz");
|
||||
assert.equal(completed?.correlationId, null);
|
||||
});
|
||||
|
||||
test("buildCallLogListRows: dedupes completed in-memory entries already persisted to the DB", () => {
|
||||
const now = 4_000_000;
|
||||
const rows = buildCallLogListRows({
|
||||
logs: [{ id: "dup-1", timestamp: new Date(now - 1_000).toISOString() }],
|
||||
connections: [],
|
||||
pendingDetails: [],
|
||||
completedDetails: [
|
||||
{
|
||||
id: "dup-1",
|
||||
startedAt: now - 3_000,
|
||||
completedAt: now - 2_000,
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
connectionId: "conn-1",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, "dup-1");
|
||||
// persisted row wins (no `completed` flag)
|
||||
assert.equal(rows[0].completed, undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user