diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index b3bb9930..1f134f3c 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -43,47 +43,54 @@ export async function handleComboChat({ body, models, handleSingleModel, log }) const modelStr = models[i]; log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`); - const result = await handleSingleModel(body, modelStr); - - // Success (2xx) - return response - if (result.ok) { - log.info("COMBO", `Model ${modelStr} succeeded`); - return result; - } - - // Extract error info from response - let errorText = result.statusText || ""; - let retryAfter = null; try { - const errorBody = await result.clone().json(); - errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - // Ignore JSON parse errors - } + const result = await handleSingleModel(body, modelStr); + + // Success (2xx) - return response + if (result.ok) { + log.info("COMBO", `Model ${modelStr} succeeded`); + return result; + } - // Track earliest retryAfter across all combo models - if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { - earliestRetryAfter = retryAfter; - } + // Extract error info from response + let errorText = result.statusText || ""; + let retryAfter = null; + try { + const errorBody = await result.clone().json(); + errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; + } catch { + // Ignore JSON parse errors + } - // Normalize error text to string (Worker-safe) - if (typeof errorText !== "string") { - try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } - } + // Track earliest retryAfter across all combo models + if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { + earliestRetryAfter = retryAfter; + } - // Check if should fallback to next model - const { shouldFallback } = checkFallbackError(result.status, errorText); - - if (!shouldFallback) { - log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); - return result; - } + // Normalize error text to string (Worker-safe) + if (typeof errorText !== "string") { + try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } + } - // Fallback to next model - lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; - log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + // Check if should fallback to next model + const { shouldFallback } = checkFallbackError(result.status, errorText); + + if (!shouldFallback) { + log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); + return result; + } + + // Fallback to next model + lastError = errorText || String(result.status); + if (!lastStatus) lastStatus = result.status; + log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + } catch (error) { + // Catch unexpected exceptions to ensure fallback continues + lastError = error.message || String(error); + if (!lastStatus) lastStatus = 500; + log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError }); + } } // All models failed diff --git a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js index 6d88f6b1..59925b12 100644 --- a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js +++ b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js @@ -22,8 +22,9 @@ export default function OverviewCards({ stats }) { {fmt(stats.totalCompletionTokens)} - Total Cost - {fmtCost(stats.totalCost)} + Est. Cost + ~{fmtCost(stats.totalCost)} + Estimated, not actual billing ); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js index eaf810da..42b08154 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js @@ -1,11 +1,13 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useCallback, useRef, useEffect } from "react"; import PropTypes from "prop-types"; import { ReactFlow, Handle, Position, + useNodesState, + useEdgesState, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AI_PROVIDERS } from "@/shared/constants/providers"; @@ -206,11 +208,36 @@ export default function ProviderTopology({ providers = [], activeRequests = [], [errorProvider] ); - const { nodes: initialNodes, edges: initialEdges } = useMemo( + // Stable key for providers list — only changes when provider set changes + const providersKey = useMemo( + () => providers.map((p) => p.provider).sort().join(","), + [providers] + ); + + const { nodes: layoutNodes, edges: layoutEdges } = useMemo( () => buildLayout(providers, activeSet, lastSet, errorSet), [providers, activeSet, lastSet, errorSet] ); + const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges); + const rfInstance = useRef(null); + + // Sync nodes/edges when data changes + useEffect(() => { + setNodes(layoutNodes); + setEdges(layoutEdges); + // Re-fit view after update + if (rfInstance.current) { + setTimeout(() => rfInstance.current.fitView({ padding: 0.3 }), 50); + } + }, [layoutNodes, layoutEdges, setNodes, setEdges]); + + const onInit = useCallback((instance) => { + rfInstance.current = instance; + setTimeout(() => instance.fitView({ padding: 0.3 }), 50); + }, []); + return (
{providers.length === 0 ? ( @@ -219,12 +246,15 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
) : ( setTimeout(() => instance.fitView({ padding: 0.3 }), 50)} + onInit={onInit} proOptions={{ hideAttribution: true }} panOnDrag={false} zoomOnScroll={false}