Fixed Codex

This commit is contained in:
decolua
2026-02-21 14:36:06 +07:00
parent f2025cc776
commit adf57aa0c9
18 changed files with 1419 additions and 1627 deletions

View File

@@ -16,6 +16,8 @@ const nextConfig = {
path: false,
};
}
// Stop watching logs directory to prevent HMR during streaming
config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next)[\\/]/ };
return config;
},
async rewrites() {

View File

@@ -906,8 +906,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
&& !isDroidCLI;
if (needsCodexTranslation) {
log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`);
transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
// For claude clients, translate directly to claude format
// For openai/openai-responses clients, translate to openai (responsesHandler will re-add event: lines)
const codexTarget = sourceFormat === FORMATS.CLAUDE ? FORMATS.CLAUDE : FORMATS.OPENAI;
log?.debug?.("STREAM", `Codex translation mode: openai-responses → ${codexTarget}`);
transformStream = createSSETransformStreamWithLogger('openai-responses', codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
} else if (needsTranslation(targetFormat, sourceFormat)) {
log?.debug?.("STREAM", `Translation mode: ${targetFormat}${sourceFormat}`);
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);

View File

@@ -100,6 +100,7 @@ export function generateProjectId() {
}
// Helper: Remove unsupported keywords recursively from object/array
// Also strips all vendor extension fields (x- prefixed) not supported by Gemini
function removeUnsupportedKeywords(obj, keywords) {
if (!obj || typeof obj !== "object") return;
@@ -108,13 +109,11 @@ function removeUnsupportedKeywords(obj, keywords) {
removeUnsupportedKeywords(item, keywords);
}
} else {
// Delete unsupported keys at current level
for (const keyword of keywords) {
if (keyword in obj) {
delete obj[keyword];
for (const key of Object.keys(obj)) {
if (keywords.includes(key) || key.startsWith("x-")) {
delete obj[key];
}
}
// Recurse into remaining values
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
removeUnsupportedKeywords(value, keywords);

View File

@@ -415,8 +415,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
return null;
}
// Function call started
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
// Function call started (standard function_call or custom_tool_call)
if (eventType === "response.output_item.added" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) {
const item = data.item;
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
@@ -443,8 +443,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
};
}
// Function call arguments delta
if (eventType === "response.function_call_arguments.delta") {
// Function call arguments delta (standard or custom_tool_call variant)
if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") {
const argsDelta = data.delta || "";
if (!argsDelta) return null;
@@ -466,8 +466,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
};
}
// Function call done
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
// Function call done (standard or custom_tool_call variant)
if (eventType === "response.output_item.done" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) {
state.toolCallIndex++;
return null;
}

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"express": "^5.2.1",
@@ -25,6 +26,7 @@
"ora": "^9.1.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.7.0",
"selfsigned": "^5.5.0",
"socks-proxy-agent": "^8.0.5",
"undici": "^7.19.2",

View File

@@ -18,6 +18,19 @@ export default function ProviderDetailPage() {
const [loading, setLoading] = useState(true);
const [providerNode, setProviderNode] = useState(null);
const [showOAuthModal, setShowOAuthModal] = useState(false);
// Auto-reopen OAuthModal if pending auth exists (survives HMR/reload)
useEffect(() => {
try {
const raw = localStorage.getItem("oauth_pending_auth");
if (raw) {
const data = JSON.parse(raw);
if (data.provider === providerId && Date.now() - data.timestamp < 300000) {
setShowOAuthModal(true);
}
}
} catch { /* ignore */ }
}, [providerId]);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);

View File

@@ -0,0 +1,34 @@
"use client";
import PropTypes from "prop-types";
import Card from "@/shared/components/Card";
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
export default function OverviewCards({ stats }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="px-4 py-3 flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
<span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span>
</Card>
<Card className="px-4 py-3 flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
<span className="text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
</Card>
<Card className="px-4 py-3 flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
<span className="text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
</Card>
<Card className="px-4 py-3 flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">Total Cost</span>
<span className="text-2xl font-bold text-warning">{fmtCost(stats.totalCost)}</span>
</Card>
</div>
);
}
OverviewCards.propTypes = {
stats: PropTypes.object.isRequired,
};

View File

@@ -0,0 +1,249 @@
"use client";
import { useMemo, useState } from "react";
import PropTypes from "prop-types";
import {
ReactFlow,
Handle,
Position,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { AI_PROVIDERS } from "@/shared/constants/providers";
function getProviderConfig(providerId) {
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
}
// Use local provider images from /public/providers/
function getProviderImageUrl(providerId) {
return `/providers/${providerId}.png`;
}
// Custom provider node - rectangle with image + name
function ProviderNode({ data }) {
const { label, color, imageUrl, textIcon, active } = data;
const [imgError, setImgError] = useState(false);
return (
<div
className="flex items-center gap-2.5 px-4 py-2.5 rounded-lg border-2 transition-all duration-300 bg-bg"
style={{
borderColor: active ? color : "var(--color-border)",
boxShadow: active ? `0 0 16px ${color}40` : "none",
minWidth: "150px",
}}
>
<Handle type="target" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="target" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="target" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="target" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
{/* Provider icon */}
<div
className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
style={{ backgroundColor: `${color}15` }}
>
{!imgError ? (
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} />
) : (
<span className="text-sm font-bold" style={{ color }}>{textIcon}</span>
)}
</div>
{/* Provider name */}
<span
className="text-sm font-medium truncate"
style={{ color: active ? color : "var(--color-text)" }}
>
{label}
</span>
{/* Active indicator */}
{active && (
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: color }} />
<span className="relative inline-flex rounded-full h-2 w-2" style={{ backgroundColor: color }} />
</span>
)}
</div>
);
}
ProviderNode.propTypes = {
data: PropTypes.object.isRequired,
};
// Center 9Router node
function RouterNode({ data }) {
return (
<div className="flex items-center justify-center px-5 py-3 rounded-xl border-2 border-primary bg-primary/5 shadow-md min-w-[130px]">
<Handle type="source" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" />
<span className="text-sm font-bold text-primary">9Router</span>
{data.activeCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
{data.activeCount}
</span>
)}
</div>
);
}
RouterNode.propTypes = {
data: PropTypes.object.isRequired,
};
const nodeTypes = { provider: ProviderNode, router: RouterNode };
// Place N nodes evenly along an ellipse around the router center.
function buildLayout(providers, activeSet, lastSet) {
const nodeW = 180;
const nodeH = 30;
const routerW = 120;
const routerH = 44;
const nodeGap = 24;
const count = providers.length;
// Compute rx so arc spacing between nodes >= nodeW + nodeGap
const minRx = ((nodeW + nodeGap) * count) / (2 * Math.PI);
const rx = Math.max(320, minRx);
const ry = Math.max(200, rx * 0.55); // ellipse ratio ~0.55
if (count === 0) {
return {
nodes: [{ id: "router", type: "router", position: { x: 0, y: 0 }, data: { activeCount: 0 }, draggable: false }],
edges: [],
};
}
const nodes = [];
const edges = [];
nodes.push({
id: "router",
type: "router",
position: { x: -routerW / 2, y: -routerH / 2 },
data: { activeCount: activeSet.size },
draggable: false,
});
const edgeStyle = (active, last, color) => {
if (active) return { stroke: color, strokeWidth: 2, opacity: 0.8 };
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
};
providers.forEach((p, i) => {
const config = getProviderConfig(p.provider);
const active = activeSet.has(p.provider?.toLowerCase());
const last = !active && lastSet.has(p.provider?.toLowerCase());
const nodeId = `provider-${p.provider}`;
const data = {
label: config.name || p.name || p.provider,
color: config.color || "#6b7280",
imageUrl: getProviderImageUrl(p.provider),
textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(),
active,
};
// Distribute evenly starting from top (−π/2), clockwise
const angle = -Math.PI / 2 + (2 * Math.PI * i) / count;
const cx = rx * Math.cos(angle);
const cy = ry * Math.sin(angle);
// Pick router handle closest to the node direction
let sourceHandle, targetHandle;
if (Math.abs(angle + Math.PI / 2) < Math.PI / 4 || Math.abs(angle - 3 * Math.PI / 2) < Math.PI / 4) {
sourceHandle = "top"; targetHandle = "bottom";
} else if (Math.abs(angle - Math.PI / 2) < Math.PI / 4) {
sourceHandle = "bottom"; targetHandle = "top";
} else if (cx > 0) {
sourceHandle = "right"; targetHandle = "left";
} else {
sourceHandle = "left"; targetHandle = "right";
}
nodes.push({
id: nodeId,
type: "provider",
position: { x: cx - nodeW / 2, y: cy - nodeH / 2 },
data,
draggable: false,
});
edges.push({
id: `e-${nodeId}`,
source: "router",
sourceHandle,
target: nodeId,
targetHandle,
animated: active,
style: edgeStyle(active, last, config.color),
});
});
return { nodes, edges };
}
export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "" }) {
const activeSet = useMemo(
() => new Set(activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean)),
[activeRequests]
);
// lastSet: providers that finished most recently (not currently active)
const lastSet = useMemo(
() => new Set(lastProvider ? [lastProvider.toLowerCase()] : []),
[lastProvider]
);
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => buildLayout(providers, activeSet, lastSet),
[providers, activeSet, lastSet]
);
return (
<div className="w-full rounded-lg border border-border bg-bg-subtle/30" style={{ height: 480 }}>
{providers.length === 0 ? (
<div className="h-full flex items-center justify-center text-text-muted text-sm">
No providers connected
</div>
) : (
<ReactFlow
nodes={initialNodes}
edges={initialEdges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
onInit={(instance) => setTimeout(() => instance.fitView({ padding: 0.3 }), 50)}
proOptions={{ hideAttribution: true }}
panOnDrag={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={false}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
/>
)}
</div>
);
}
ProviderTopology.propTypes = {
providers: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
provider: PropTypes.string,
name: PropTypes.string,
})),
activeRequests: PropTypes.arrayOf(PropTypes.shape({
provider: PropTypes.string,
model: PropTypes.string,
account: PropTypes.string,
})),
lastProvider: PropTypes.string,
};

View File

@@ -0,0 +1,160 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import Card from "@/shared/components/Card";
const PERIODS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "60d", label: "60D" },
];
const fmtTokens = (n) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n || 0);
};
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
export default function UsageChart() {
const [period, setPeriod] = useState("7d");
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState("tokens");
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/usage/chart?period=${period}`);
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch (e) {
console.error("Failed to fetch chart data:", e);
} finally {
setLoading(false);
}
}, [period]);
useEffect(() => {
fetchData();
}, [fetchData]);
const hasData = data.some((d) => d.tokens > 0 || d.cost > 0);
return (
<Card className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
<button
onClick={() => setViewMode("tokens")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("cost")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cost" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Cost
</button>
</div>
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
{p.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="h-48 flex items-center justify-center text-text-muted text-sm">Loading...</div>
) : !hasData ? (
<div className="h-48 flex items-center justify-center text-text-muted text-sm">No data for this period</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradTokens" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.25} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
tickFormatter={viewMode === "tokens" ? fmtTokens : fmtCost}
width={50}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-bg)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value, name) =>
name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"]
}
/>
{viewMode === "tokens" ? (
<Area
type="monotone"
dataKey="tokens"
stroke="#6366f1"
strokeWidth={2}
fill="url(#gradTokens)"
dot={false}
activeDot={{ r: 4 }}
/>
) : (
<Area
type="monotone"
dataKey="cost"
stroke="#f59e0b"
strokeWidth={2}
fill="url(#gradCost)"
dot={false}
activeDot={{ r: 4 }}
/>
)}
</AreaChart>
</ResponsiveContainer>
)}
</Card>
);
}
UsageChart.propTypes = {};

View File

@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect, useCallback, useMemo, Fragment } from "react";
import PropTypes from "prop-types";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
function fmtTime(iso) {
if (!iso) return "Never";
const diffMins = Math.floor((Date.now() - new Date(iso)) / 60000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
return new Date(iso).toLocaleDateString();
}
function SortIcon({ field, currentSort, currentOrder }) {
if (currentSort !== field) return <span className="ml-1 opacity-20"></span>;
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
}
SortIcon.propTypes = {
field: PropTypes.string.isRequired,
currentSort: PropTypes.string.isRequired,
currentOrder: PropTypes.string.isRequired,
};
/**
* Render 3 token or cost cells based on viewMode
*/
function ValueCells({ item, viewMode, isSummary = false }) {
if (viewMode === "tokens") {
return (
<>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(item.totalTokens)}
</td>
</>
);
}
return (
<>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)}
</td>
<td className="px-6 py-3 text-right font-medium text-warning">
{fmtCost(item.totalCost || item.cost)}
</td>
</>
);
}
ValueCells.propTypes = {
item: PropTypes.object.isRequired,
viewMode: PropTypes.string.isRequired,
isSummary: PropTypes.bool,
};
/**
* Reusable sortable usage table with expandable group rows.
*
* @param {object} props
* @param {string} props.title - Table title
* @param {Array} props.columns - Column definitions [{field, label}]
* @param {Array} props.groupedData - Grouped data from groupDataByKey
* @param {string} props.tableType - Table type key for sort URL params
* @param {string} props.sortBy - Current sort field
* @param {string} props.sortOrder - Current sort order
* @param {function} props.onToggleSort - Sort toggle handler
* @param {string} props.viewMode - "tokens" or "costs"
* @param {string} props.storageKey - localStorage key for expanded state
* @param {function} props.renderGroupLabel - Render group summary first cell content
* @param {function} props.renderDetailCells - Render detail row custom cells (before value cells)
* @param {function} props.renderSummaryCells - Render summary row cells after group label (placeholder cols)
* @param {string} props.emptyMessage - Empty state message
*/
export default function UsageTable({
title,
columns,
groupedData,
tableType,
sortBy,
sortOrder,
onToggleSort,
viewMode,
storageKey,
renderDetailCells,
renderSummaryCells,
emptyMessage,
}) {
const [expanded, setExpanded] = useState(new Set());
// Load expanded state from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) setExpanded(new Set(JSON.parse(saved)));
} catch (e) {
console.error(`Failed to load ${storageKey}:`, e);
}
}, [storageKey]);
// Save expanded state to localStorage
useEffect(() => {
try {
localStorage.setItem(storageKey, JSON.stringify([...expanded]));
} catch (e) {
console.error(`Failed to save ${storageKey}:`, e);
}
}, [expanded, storageKey]);
const toggleGroup = useCallback((groupKey) => {
setExpanded((prev) => {
const next = new Set(prev);
next.has(groupKey) ? next.delete(groupKey) : next.add(groupKey);
return next;
});
}, []);
const valueColumns = useMemo(() => {
if (viewMode === "tokens") {
return [
{ field: "promptTokens", label: "Input Tokens" },
{ field: "completionTokens", label: "Output Tokens" },
{ field: "totalTokens", label: "Total Tokens" },
];
}
return [
{ field: "promptTokens", label: "Input Cost" },
{ field: "completionTokens", label: "Output Cost" },
{ field: "cost", label: "Total Cost" },
];
}, [viewMode]);
const totalColSpan = columns.length + valueColumns.length;
return (
<Card className="overflow-hidden">
<div className="p-4 border-b border-border bg-bg-subtle/50">
<h3 className="font-semibold">{title}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
<tr>
{columns.map((col) => (
<th
key={col.field}
className={`px-6 py-3 cursor-pointer hover:bg-bg-subtle/50 ${col.align === "right" ? "text-right" : ""}`}
onClick={() => onToggleSort(tableType, col.field)}
>
{col.label}{" "}
<SortIcon field={col.field} currentSort={sortBy} currentOrder={sortOrder} />
</th>
))}
{valueColumns.map((col) => (
<th
key={col.field}
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => onToggleSort(tableType, col.field)}
>
{col.label}{" "}
<SortIcon field={col.field} currentSort={sortBy} currentOrder={sortOrder} />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{groupedData.map((group) => (
<Fragment key={group.groupKey}>
{/* Group summary row */}
<tr
className="group-summary cursor-pointer hover:bg-bg-subtle/50 transition-colors"
onClick={() => toggleGroup(group.groupKey)}
>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[18px] text-text-muted transition-transform ${expanded.has(group.groupKey) ? "rotate-90" : ""}`}>
chevron_right
</span>
<span className={`font-medium transition-colors ${group.summary.pending > 0 ? "text-primary" : ""}`}>
{group.groupKey}
</span>
</div>
</td>
{renderSummaryCells(group)}
<ValueCells item={group.summary} viewMode={viewMode} isSummary />
</tr>
{/* Detail rows */}
{expanded.has(group.groupKey) && group.items.map((item) => (
<tr
key={`detail-${item.key}`}
className="group-detail hover:bg-bg-subtle/20 transition-colors"
>
{renderDetailCells(item)}
<ValueCells item={item} viewMode={viewMode} />
</tr>
))}
</Fragment>
))}
{groupedData.length === 0 && (
<tr>
<td colSpan={totalColSpan} className="px-6 py-8 text-center text-text-muted">
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
);
}
UsageTable.propTypes = {
title: PropTypes.string.isRequired,
columns: PropTypes.arrayOf(PropTypes.shape({
field: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
align: PropTypes.string,
})).isRequired,
groupedData: PropTypes.array.isRequired,
tableType: PropTypes.string.isRequired,
sortBy: PropTypes.string.isRequired,
sortOrder: PropTypes.string.isRequired,
onToggleSort: PropTypes.func.isRequired,
viewMode: PropTypes.string.isRequired,
storageKey: PropTypes.string.isRequired,
renderDetailCells: PropTypes.func.isRequired,
renderSummaryCells: PropTypes.func.isRequired,
emptyMessage: PropTypes.string.isRequired,
};
// Re-export utilities for use in UsageStats orchestrator
export { fmt, fmtCost, fmtTime };

View File

@@ -18,8 +18,8 @@ function UsagePageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const [activeTab, setActiveTab] = useState(searchParams.get("tab") || "overview");
const [tabLoading, setTabLoading] = useState(false);
// Sync tab with URL on mount and when URL changes
useEffect(() => {
const tabFromUrl = searchParams.get("tab");
if (tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)) {
@@ -27,12 +27,15 @@ function UsagePageContent() {
}
}, [searchParams]);
// Update URL when tab changes
const handleTabChange = (value) => {
if (value === activeTab) return;
setTabLoading(true);
setActiveTab(value);
const params = new URLSearchParams(searchParams);
params.set("tab", value);
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
// Brief loading flash so user sees feedback
setTimeout(() => setTabLoading(false), 300);
};
return (
@@ -40,7 +43,6 @@ function UsagePageContent() {
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "logs", label: "Logger" },
{ value: "limits", label: "Limits" },
{ value: "details", label: "Details" },
]}
@@ -48,19 +50,24 @@ function UsagePageContent() {
onChange={handleTabChange}
/>
{/* Content */}
{activeTab === "overview" && (
<Suspense fallback={<CardSkeleton />}>
<UsageStats />
</Suspense>
{tabLoading ? (
<CardSkeleton />
) : (
<>
{activeTab === "overview" && (
<Suspense fallback={<CardSkeleton />}>
<UsageStats />
</Suspense>
)}
{activeTab === "logs" && <RequestLogger />}
{activeTab === "limits" && (
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
)}
{activeTab === "details" && <RequestDetailsTab />}
</>
)}
{activeTab === "logs" && <RequestLogger />}
{activeTab === "limits" && (
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
)}
{activeTab === "details" && <RequestDetailsTab />}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getChartData } from "@/lib/usageDb";
const VALID_PERIODS = new Set(["24h", "7d", "30d", "60d"]);
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const period = searchParams.get("period") || "7d";
if (!VALID_PERIODS.has(period)) {
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
}
const data = await getChartData(period);
return NextResponse.json(data);
} catch (error) {
console.error("[API] Failed to get chart data:", error);
return NextResponse.json({ error: "Failed to fetch chart data" }, { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { getUsageStats, statsEmitter } from "@/lib/usageDb";
export const dynamic = "force-dynamic";
export async function GET() {
const encoder = new TextEncoder();
const state = { closed: false, keepalive: null, send: null };
const stream = new ReadableStream({
async start(controller) {
state.send = async () => {
if (state.closed) return;
try {
const stats = await getUsageStats();
if (stats.activeRequests?.length > 0) {
console.log(`[SSE] Push | active=${stats.activeRequests.length} | ${stats.activeRequests.map(r => r.provider).join(",")}`);
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
} catch {
// Controller closed → self-cleanup
state.closed = true;
statsEmitter.off("update", state.send);
clearInterval(state.keepalive);
}
};
await state.send();
console.log(`[SSE] Client connected | listeners=${statsEmitter.listenerCount("update") + 1}`);
statsEmitter.on("update", state.send);
state.keepalive = setInterval(() => {
if (state.closed) { clearInterval(state.keepalive); return; }
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
state.closed = true;
clearInterval(state.keepalive);
}
}, 25000);
},
cancel() {
state.closed = true;
statsEmitter.off("update", state.send);
clearInterval(state.keepalive);
console.log("[SSE] Client disconnected");
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

View File

@@ -3,6 +3,43 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
const OAUTH_SESSION_KEY = "oauth_pending_auth";
/**
* Direct exchange: callback page calls exchange API itself
* when relay to opener fails (e.g. HMR reload destroyed listeners)
*/
async function directExchange(code, state) {
try {
const raw = localStorage.getItem(OAUTH_SESSION_KEY);
if (!raw) return false;
const session = JSON.parse(raw);
// Expired (5 min)
if (Date.now() - session.timestamp > 300000) {
localStorage.removeItem(OAUTH_SESSION_KEY);
return false;
}
const res = await fetch(`/api/oauth/${session.provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
state,
}),
});
const data = await res.json();
localStorage.removeItem(OAUTH_SESSION_KEY);
return res.ok && data.success;
} catch {
return false;
}
}
/**
* OAuth Callback Page Content
*/
@@ -24,7 +61,7 @@ function CallbackContent() {
fullUrl: window.location.href,
};
let sent = false;
let relayed = false;
// Check if this callback is from expected origin/port
const expectedOrigins = [
@@ -35,8 +72,8 @@ function CallbackContent() {
// Method 1: postMessage to opener (popup mode)
if (window.opener) {
try {
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev
sent = true;
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
relayed = true;
} catch (e) {
console.log("postMessage failed:", e);
}
@@ -47,7 +84,7 @@ function CallbackContent() {
const channel = new BroadcastChannel("oauth_callback");
channel.postMessage(callbackData);
channel.close();
sent = true;
relayed = true;
} catch (e) {
console.log("BroadcastChannel failed:", e);
}
@@ -55,25 +92,53 @@ function CallbackContent() {
// Method 3: localStorage event (fallback)
try {
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
sent = true;
relayed = true;
} catch (e) {
console.log("localStorage failed:", e);
}
if (sent && (code || error)) {
// Use setTimeout to avoid synchronous setState in effect
if (!(code || error)) {
setTimeout(() => setStatus("manual"), 0);
return;
}
if (error) {
setTimeout(() => {
setStatus("success");
// Auto close after 1.5 seconds
setTimeout(() => {
window.close();
// If can't close (not a popup), show success message
setTimeout(() => setStatus("done"), 500);
}, 1500);
}, 0);
} else {
setTimeout(() => setStatus("manual"), 0);
return;
}
// Try direct exchange FIRST (before relay may be lost to HMR)
// Then relay as backup for normal flow
const handleExchange = async () => {
const pending = localStorage.getItem(OAUTH_SESSION_KEY);
if (pending) {
// Direct exchange - works even if opener was destroyed by HMR
const ok = await directExchange(code, state);
if (ok) {
setStatus("success");
setTimeout(() => {
window.close();
setTimeout(() => setStatus("done"), 500);
}, 1500);
return;
}
}
// Fallback: relay succeeded and OAuthModal handled it
setStatus("success");
setTimeout(() => {
window.close();
setTimeout(() => setStatus("done"), 500);
}, 1500);
};
handleExchange();
}, [searchParams]);
return (

View File

@@ -1,5 +1,6 @@
import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import { EventEmitter } from "events";
import path from "path";
import os from "os";
import fs from "fs";
@@ -71,11 +72,18 @@ const defaultData = {
// Singleton instance
let dbInstance = null;
// Track in-flight requests in memory
const pendingRequests = {
byModel: {},
byAccount: {}
};
// Use global to share pending state across Next.js route modules
if (!global._pendingRequests) {
global._pendingRequests = { byModel: {}, byAccount: {} };
}
const pendingRequests = global._pendingRequests;
// Use global to share singleton across Next.js route modules
if (!global._statsEmitter) {
global._statsEmitter = new EventEmitter();
global._statsEmitter.setMaxListeners(50);
}
export const statsEmitter = global._statsEmitter;
/**
* Track a pending request
@@ -93,11 +101,14 @@ export function trackPendingRequest(model, provider, connectionId, started) {
// Track by account
if (connectionId) {
const accountKey = connectionId; // We use connectionId as key here
const accountKey = connectionId;
if (!pendingRequests.byAccount[accountKey]) pendingRequests.byAccount[accountKey] = {};
if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0;
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
}
console.log(`[PENDING] ${started ? "START" : "END"} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("update")}`);
statsEmitter.emit("update");
}
/**
@@ -159,12 +170,15 @@ export async function saveRequestUsage(entry) {
db.data.history = [];
}
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
entry.cost = entryCost;
db.data.history.push(entry);
// Optional: Limit history size if needed in future
// if (db.data.history.length > 10000) db.data.history.shift();
await db.write();
statsEmitter.emit("update");
} catch (error) {
console.error("Failed to save usage stats:", error);
}
@@ -387,6 +401,34 @@ export async function getUsageStats() {
};
}
// 20 most recent requests from history (always in sync with SSE emit)
const seen = new Set();
const recentRequests = [...history]
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.map((e) => {
const t = e.tokens || {};
const promptTokens = t.prompt_tokens || t.input_tokens || 0;
const completionTokens = t.completion_tokens || t.output_tokens || 0;
return {
timestamp: e.timestamp,
model: e.model,
provider: e.provider || "",
promptTokens,
completionTokens,
status: e.status || "ok",
};
})
.filter((e) => {
if (e.promptTokens === 0 && e.completionTokens === 0) return false;
// Deduplicate: same model+provider+tokens within same minute
const minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, 20);
const stats = {
totalRequests: history.length,
totalPromptTokens: 0,
@@ -399,7 +441,8 @@ export async function getUsageStats() {
byEndpoint: {},
last10Minutes: [],
pending: pendingRequests,
activeRequests: []
activeRequests: [],
recentRequests,
};
// Build active requests list from pending counts
@@ -618,5 +661,54 @@ export async function getUsageStats() {
return stats;
}
/**
* Get time-series chart data for a given period
* @param {"24h"|"7d"|"30d"|"60d"} period
* @returns {Promise<Array<{label: string, tokens: number, cost: number}>>}
*/
export async function getChartData(period = "7d") {
const db = await getUsageDb();
const history = db.data.history || [];
const now = Date.now();
let bucketCount, bucketMs, labelFn;
if (period === "24h") {
bucketCount = 24;
bucketMs = 3600000; // 1 hour
labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
} else if (period === "7d") {
bucketCount = 7;
bucketMs = 86400000;
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
} else if (period === "30d") {
bucketCount = 30;
bucketMs = 86400000;
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
} else {
bucketCount = 60;
bucketMs = 86400000;
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
const startTime = now - bucketCount * bucketMs;
const buckets = Array.from({ length: bucketCount }, (_, i) => {
const ts = startTime + i * bucketMs;
return { label: labelFn(ts), tokens: 0, cost: 0, _ts: ts };
});
for (const entry of history) {
const entryTime = new Date(entry.timestamp).getTime();
if (entryTime < startTime || entryTime > now) continue;
const idx = Math.min(Math.floor((entryTime - startTime) / bucketMs), bucketCount - 1);
const promptTokens = entry.tokens?.prompt_tokens || 0;
const completionTokens = entry.tokens?.completion_tokens || 0;
buckets[idx].tokens += promptTokens + completionTokens;
// Use pre-stored cost if available, else 0
buckets[idx].cost += entry.cost || 0;
}
return buckets.map(({ label, tokens, cost }) => ({ label, tokens, cost }));
}
// Re-export request details functions from new SQLite-based module
export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js";

View File

@@ -5,6 +5,34 @@ import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
const OAUTH_SESSION_KEY = "oauth_pending_auth";
function saveAuthSession(provider, data) {
try {
localStorage.setItem(OAUTH_SESSION_KEY, JSON.stringify({ provider, ...data, timestamp: Date.now() }));
} catch { /* storage unavailable */ }
}
function loadAuthSession(provider) {
try {
const raw = localStorage.getItem(OAUTH_SESSION_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
// Only restore if same provider and within 5 minutes
if (data.provider !== provider || Date.now() - data.timestamp > 300000) {
localStorage.removeItem(OAUTH_SESSION_KEY);
return null;
}
return data;
} catch {
return null;
}
}
function clearAuthSession() {
try { localStorage.removeItem(OAUTH_SESSION_KEY); } catch { /* ignore */ }
}
/**
* OAuth Modal Component
* - Localhost: Auto callback via popup message
@@ -56,9 +84,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const data = await res.json();
if (!res.ok) throw new Error(data.error);
clearAuthSession();
setStep("success");
onSuccess?.();
} catch (err) {
clearAuthSession();
setError(err.message);
setStep("error");
}
@@ -152,6 +182,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!res.ok) throw new Error(data.error);
setAuthData({ ...data, redirectUri });
saveAuthSession(provider, { codeVerifier: data.codeVerifier, redirectUri, state: data.state });
// For Codex or non-localhost: use manual input mode
if (provider === "codex" || !isLocalhost) {
@@ -176,13 +207,25 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
// Reset state and start OAuth when modal opens
useEffect(() => {
if (isOpen && provider) {
// Try restore pending auth from localStorage (survives HMR/reload)
const saved = loadAuthSession(provider);
if (saved) {
setAuthData({ codeVerifier: saved.codeVerifier, redirectUri: saved.redirectUri, state: saved.state });
setStep("waiting");
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
return; // Don't restart OAuth — just re-listen for callback
}
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// Auto start OAuth
startOAuthFlow();
}
}, [isOpen, provider, startOAuthFlow]);
@@ -206,6 +249,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
if (code) {
// Skip if callback page already handled exchange (localStorage cleared)
const stillPending = localStorage.getItem(OAUTH_SESSION_KEY);
if (!stillPending) {
callbackProcessedRef.current = true;
setStep("success");
onSuccess?.();
return;
}
callbackProcessedRef.current = true;
await exchangeTokens(code, state);
}
@@ -293,10 +344,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
};
// Clear session on modal close
const handleClose = useCallback(() => {
clearAuthSession();
onClose();
}, [onClose]);
if (!provider || !providerInfo) return null;
return (
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={onClose} size="lg">
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{/* Waiting Step (Localhost - popup mode) */}
{step === "waiting" && !isDeviceCode && (
@@ -389,7 +446,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
Connect
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
@@ -406,7 +463,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
<p className="text-sm text-text-muted mb-4">
Your {providerInfo.name} account has been connected.
</p>
<Button onClick={onClose} fullWidth>
<Button onClick={handleClose} fullWidth>
Done
</Button>
</div>
@@ -424,7 +481,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
Try Again
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -102,4 +102,4 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
}, {});
// Providers that support usage/quota API
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"];
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex"];