From ebdc1c214d87c4d8088e65a983c1b353e0777b8a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 16 Feb 2026 01:35:57 -0300 Subject: [PATCH] refactor: extract CliStatusBadge component and refactor CLI tool cards - Create shared CliStatusBadge component with support for configured, not_configured, not_installed, other, and unknown statuses - Replace inline status badge markup in ClaudeToolCard and ClineToolCard with the new reusable component - Add colored status dot indicator alongside badge text - Support batch status fallback so badges render even when cards are collapsed - Refactor model/provider selection logic in tool card configuration --- .../cli-tools/components/ClaudeToolCard.js | 20 +- .../cli-tools/components/CliStatusBadge.js | 52 ++ .../cli-tools/components/ClineToolCard.js | 28 +- .../cli-tools/components/CodexToolCard.js | 20 +- .../cli-tools/components/DefaultToolCard.js | 60 +- .../cli-tools/components/DroidToolCard.js | 20 +- .../cli-tools/components/KiloToolCard.js | 27 +- .../cli-tools/components/OpenClawToolCard.js | 20 +- .../dashboard/endpoint/EndpointPageClient.js | 49 +- .../translator/TranslatorPageClient.js | 2 +- .../translator/components/ChatTesterMode.js | 570 +++++++++--------- .../translator/components/LiveMonitorMode.js | 40 +- .../translator/components/PlaygroundMode.js | 20 +- .../translator/components/TestBenchMode.js | 126 ++-- .../translator/hooks/useAvailableModels.js | 66 ++ .../translator/hooks/useProviderOptions.js | 79 +++ src/app/api/sync/cloud/route.js | 88 ++- src/lib/usageAnalytics.js | 6 +- src/shared/components/UsageAnalytics.js | 1 + 19 files changed, 715 insertions(+), 579 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.js create mode 100644 src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.js create mode 100644 src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.js diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index edc188d015..d0fe30b791 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import CliStatusBadge from "./CliStatusBadge"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -273,21 +274,10 @@ export default function ClaudeToolCard({

{tool.name}

- {effectiveConfigStatus === "configured" && ( - - Connected - - )} - {effectiveConfigStatus === "not_configured" && ( - - Not configured - - )} - {effectiveConfigStatus === "other" && ( - - Other - - )} +

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.js b/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.js new file mode 100644 index 0000000000..b1e3701779 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.js @@ -0,0 +1,52 @@ +"use client"; + +/** + * Shared status badge for CLI tool cards. + * Shows the effective config/installation status using batch data, + * so badges are visible even when cards are collapsed. + */ +export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) { + // Determine badge from effectiveConfigStatus or batchStatus + const status = effectiveConfigStatus || batchStatus?.configStatus || null; + + if (!status) return null; + + const badges = { + configured: { + dotClass: "bg-green-500", + badgeClass: "bg-green-500/10 text-green-600 dark:text-green-400", + text: "Configured", + }, + not_configured: { + dotClass: "bg-yellow-500", + badgeClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + text: "Not configured", + }, + not_installed: { + dotClass: "bg-zinc-400 dark:bg-zinc-500", + badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400", + text: "Not installed", + }, + other: { + dotClass: "bg-blue-500", + badgeClass: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + text: "Custom", + }, + unknown: { + dotClass: "bg-zinc-400 dark:bg-zinc-500", + badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400", + text: "Unknown", + }, + }; + + const badge = badges[status] || badges.unknown; + + return ( + + + {badge.text} + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js index 7a9173faeb..1b42fad273 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import CliStatusBadge from "./CliStatusBadge"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -206,28 +207,6 @@ export default function ClineToolCard({ setShowManualConfigModal(false); }; - const renderStatusBadge = () => { - if (!cliReady) return null; - const badges = { - configured: { - class: "bg-green-500/10 text-green-600 dark:text-green-400", - text: "Connected", - }, - not_configured: { - class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", - text: "Not configured", - }, - other: { class: "bg-blue-500/10 text-blue-600 dark:text-blue-400", text: "Custom config" }, - }; - const badge = badges[effectiveConfigStatus]; - if (!badge) return null; - return ( - - {badge.text} - - ); - }; - return (
@@ -254,7 +233,10 @@ export default function ClineToolCard({

{tool.name}

- {renderStatusBadge()} +

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 7243d8d8eb..22c62ccf0b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import CliStatusBadge from "./CliStatusBadge"; export default function CodexToolCard({ tool, @@ -333,21 +334,10 @@ wire_api = "responses"

{tool.name}

- {effectiveConfigStatus === "configured" && ( - - Connected - - )} - {effectiveConfigStatus === "not_configured" && ( - - Not configured - - )} - {effectiveConfigStatus === "other" && ( - - Other - - )} +

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index e4cb49fb9e..2cfab8a7d5 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -13,6 +13,7 @@ export default function DefaultToolCard({ apiKeys, activeProviders = [], cloudEnabled = false, + batchStatus, }) { const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); @@ -483,23 +484,48 @@ export default function DefaultToolCard({

{tool.name}

- {runtimeStatus && !runtimeStatus.error && ( - - {runtimeStatus.reason === "not_required" - ? "Guide" - : runtimeStatus.installed && runtimeStatus.runnable - ? "Detected" - : "Not ready"} - - )} + {(() => { + // Use runtime status if available (after expanding), otherwise use batch status + const rs = runtimeStatus; + const bs = batchStatus; + const isGuide = rs?.reason === "not_required" || tool.configType === "guide"; + const isDetected = rs ? rs.installed && rs.runnable : bs?.installed && bs?.runnable; + const isInstalled = rs ? rs.installed : bs?.installed; + + if (isGuide) { + return ( + + + Guide + + ); + } + if (isDetected) { + return ( + + + Detected + + ); + } + if (isInstalled === false && (rs || bs)) { + return ( + + + Not installed + + ); + } + if (isInstalled && !isDetected && (rs || bs)) { + return ( + + + Not ready + + ); + } + return null; + })()}

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 62146697a6..43bf08d006 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import CliStatusBadge from "./CliStatusBadge"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -266,21 +267,10 @@ export default function DroidToolCard({

{tool.name}

- {effectiveConfigStatus === "configured" && ( - - Connected - - )} - {effectiveConfigStatus === "not_configured" && ( - - Not configured - - )} - {effectiveConfigStatus === "other" && ( - - Other - - )} +

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js index c99a6e7697..608cb73ebf 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import CliStatusBadge from "./CliStatusBadge"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -192,27 +193,6 @@ export default function KiloToolCard({ setShowManualConfigModal(false); }; - const renderStatusBadge = () => { - if (!cliReady) return null; - const badges = { - configured: { - class: "bg-green-500/10 text-green-600 dark:text-green-400", - text: "Connected", - }, - not_configured: { - class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", - text: "Not configured", - }, - }; - const badge = badges[effectiveConfigStatus]; - if (!badge) return null; - return ( - - {badge.text} - - ); - }; - return (
@@ -239,7 +219,10 @@ export default function KiloToolCard({

{tool.name}

- {renderStatusBadge()} +

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 56f4c53660..eb713e708b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import CliStatusBadge from "./CliStatusBadge"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -270,21 +271,10 @@ export default function OpenClawToolCard({

{tool.name}

- {effectiveConfigStatus === "configured" && ( - - Connected - - )} - {effectiveConfigStatus === "not_configured" && ( - - Not configured - - )} - {effectiveConfigStatus === "other" && ( - - Other - - )} +

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index b288ec4b1e..7db7952f39 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -176,38 +176,33 @@ export default function APIPageClient({ machineId }) { setModalSuccess(false); setSyncStep("syncing"); try { - const { ok, data } = await postCloudAction("enable"); + const { ok, status, data } = await postCloudAction("enable"); if (ok) { setSyncStep("verifying"); // Brief delay so user sees the verifying step await new Promise((r) => setTimeout(r, 600)); - if (data.verified) { - setCloudEnabled(true); - setSyncStep("done"); - setModalSuccess(true); - setCloudSyncing(false); - dispatchCloudChange(); + // Sync succeeded — mark as enabled regardless of verify result + setCloudEnabled(true); + setSyncStep("done"); + setModalSuccess(true); + setCloudSyncing(false); + dispatchCloudChange(); - // Show success in modal for a moment, then close - await new Promise((r) => setTimeout(r, 1200)); - setShowCloudModal(false); - setModalSuccess(false); + // Show success in modal for a moment, then close + await new Promise((r) => setTimeout(r, 1200)); + setShowCloudModal(false); + setModalSuccess(false); + + if (data.verified) { setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" }); } else { - setCloudEnabled(true); - setSyncStep("done"); - setModalSuccess(true); - setCloudSyncing(false); - dispatchCloudChange(); - - await new Promise((r) => setTimeout(r, 1200)); - setShowCloudModal(false); - setModalSuccess(false); setCloudStatus({ type: "warning", - message: data.verifyError || "Connected but verification pending", + message: data.verifyError + ? `Connected — verification pending: ${data.verifyError}` + : "Connected — verification pending", }); } @@ -218,10 +213,18 @@ export default function APIPageClient({ machineId }) { // Reload settings to ensure fresh state await loadCloudSettings(); } else { - setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" }); + // Sync failed — provide a helpful error message + let errorMessage = data.error || "Failed to enable cloud"; + if (status === 502 || status === 408) { + errorMessage = + "Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud)."; + } + setCloudStatus({ type: "error", message: errorMessage }); + setShowCloudModal(false); } } catch (error) { - setCloudStatus({ type: "error", message: error.message }); + setCloudStatus({ type: "error", message: error.message || "Connection failed" }); + setShowCloudModal(false); } finally { setCloudSyncing(false); setSyncStep(""); diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js index 0a505bc4c1..3c32c7982f 100644 --- a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js +++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js @@ -27,7 +27,7 @@ export default function TranslatorPageClient() { Translator Playground

- Debug, test, and visualize API format translations + Debug, test, and visualize how OmniRoute translates API requests between providers

diff --git a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js index 3d9c7b698f..bd1233be4a 100644 --- a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js +++ b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js @@ -3,11 +3,8 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, Select, Badge } from "@/shared/components"; import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates"; -import { - AI_PROVIDERS, - OPENAI_COMPATIBLE_PREFIX, - ANTHROPIC_COMPATIBLE_PREFIX, -} from "@/shared/constants/providers"; +import { useProviderOptions } from "../hooks/useProviderOptions"; +import { useAvailableModels } from "../hooks/useAvailableModels"; import dynamic from "next/dynamic"; const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); @@ -16,20 +13,18 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); * Chat Tester Mode: * - Left: Chat interface (send messages as a specific client format) * - Right: Pipeline visualization showing each translation step + * + * How it works: + * 1. You type a message and select a "Client Format" (how the request is structured) + * 2. The message is built into a request body matching the client format + * 3. OmniRoute detects the format, translates it through the pipeline, and sends to the provider + * 4. Each pipeline step is shown on the right: Client → Detect → OpenAI → Provider → Response */ -const DEFAULT_MODELS = { - openai: "gpt-4o", - claude: "claude-sonnet-4-20250514", - gemini: "gemini-2.5-flash", - "openai-responses": "gpt-4o", -}; export default function ChatTesterMode() { - const [provider, setProvider] = useState("openai"); - const [providerOptions, setProviderOptions] = useState([]); + const { provider, setProvider, providerOptions } = useProviderOptions("openai"); + const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels(); const [clientFormat, setClientFormat] = useState("openai"); - const [model, setModel] = useState(DEFAULT_MODELS.openai); - const [availableModels, setAvailableModels] = useState([]); const [message, setMessage] = useState(""); const [sending, setSending] = useState(false); const [chatHistory, setChatHistory] = useState([]); @@ -37,79 +32,11 @@ export default function ChatTesterMode() { const [expandedStep, setExpandedStep] = useState(null); const messagesEndRef = useRef(null); - // Update default model when client format changes + // Pick a smart default model when format changes or models finish loading useEffect(() => { - setModel(DEFAULT_MODELS[clientFormat] || "gpt-4o"); - }, [clientFormat]); - - // Load available models - useEffect(() => { - const fetchModels = async () => { - try { - const res = await fetch("/api/v1/models"); - const data = await res.json(); - const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b)); - setAvailableModels(models); - } catch { - setAvailableModels([]); - } - }; - fetchModels(); - }, []); - - // Load providers - useEffect(() => { - const fetchProviders = async () => { - try { - const [connRes, nodesRes] = await Promise.all([ - fetch("/api/providers"), - fetch("/api/provider-nodes"), - ]); - const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]); - const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n])); - const activeProviders = new Set( - (connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider) - ); - const options = [...activeProviders] - .map((pid) => { - const info = AI_PROVIDERS[pid]; - const node = nodeMap.get(pid); - let label = info?.name || node?.name || pid; - if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX)) - label = node?.name || "OpenAI Compatible"; - if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) - label = node?.name || "Anthropic Compatible"; - return { value: pid, label }; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - - const nextOptions = - options.length > 0 - ? options - : Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name })); - setProviderOptions(nextOptions); - if (nextOptions.length > 0) { - setProvider((current) => - nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value - ); - } - } catch { - const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({ - value: id, - label: info.name, - })); - setProviderOptions(fallbackOptions); - if (fallbackOptions.length > 0) { - setProvider((current) => - fallbackOptions.some((opt) => opt.value === current) - ? current - : fallbackOptions[0].value - ); - } - } - }; - fetchProviders(); - }, []); + const picked = pickModelForFormat(clientFormat); + if (picked) setModel(picked); + }, [clientFormat, pickModelForFormat, setModel]); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -170,6 +97,7 @@ export default function ChatTesterMode() { steps.push({ id: 1, name: "Client Request", + description: "The request body as your client would send it", format: clientFormat, content: JSON.stringify(clientRequest, null, 2), status: "done", @@ -187,6 +115,7 @@ export default function ChatTesterMode() { steps.push({ id: 2, name: "Format Detected", + description: "OmniRoute auto-detects the API format from the request structure", format: detectedFormat, content: JSON.stringify( { detectedFormat, clientFormat, match: detectedFormat === clientFormat }, @@ -212,6 +141,7 @@ export default function ChatTesterMode() { steps.push({ id: 3, name: "OpenAI Intermediate", + description: "All formats are first normalized to OpenAI format (the universal bridge)", format: "openai", content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2), status: toOpenaiData.success ? "done" : "error", @@ -234,12 +164,13 @@ export default function ChatTesterMode() { steps.push({ id: 4, name: "Provider Format", + description: `OpenAI format is translated to the provider's native format`, format: targetFmt, content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2), status: providerTargetData.success ? "done" : "error", }); - // Step 5: Send to provider (use the OpenAI intermediate since the proxy handles translation) + // Step 5: Send to provider const sendRes = await fetch("/api/translator/send", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -251,6 +182,7 @@ export default function ChatTesterMode() { steps.push({ id: 5, name: "Provider Response", + description: "The raw response from the provider API", format: targetFmt, content: JSON.stringify(errData, null, 2), status: "error", @@ -274,6 +206,7 @@ export default function ChatTesterMode() { steps.push({ id: 5, name: "Provider Response", + description: "The raw SSE stream from the provider API", format: targetFmt, content: fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""), @@ -291,6 +224,7 @@ export default function ChatTesterMode() { steps.push({ id: steps.length + 1, name: "Error", + description: "An unexpected error occurred", format: "error", content: JSON.stringify({ error: err.message }, null, 2), status: "error", @@ -305,229 +239,269 @@ export default function ChatTesterMode() { }; return ( -
- {/* Left: Chat Interface */} -
- {/* Controls */} - -
-
-
- - setProvider(e.target.value)} - options={providerOptions} - /> -
-
-
- -
- setModel(e.target.value)} - list="model-suggestions" - placeholder="e.g. gpt-4o, claude-sonnet-4-20250514" - className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" - /> - - {availableModels.map((m) => ( - -
-
-
-
- - {/* Chat Messages */} - -
- {chatHistory.length === 0 && ( -
- chat -

Send a message to see the translation pipeline

-
- )} - {chatHistory.map((msg, i) => ( -
-
-

- {msg.role === "user" - ? `You (${FORMAT_META[clientFormat]?.label})` - : "Assistant"} -

-

{msg.content}

-
-
- ))} -
-
- - {/* Input */} -
-
- setMessage(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()} - placeholder="Type a message..." - className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" - disabled={sending} - /> - -
-
- +
+ {/* Info Banner */} +
+ + info + +
+

Pipeline Debugger

+

+ Send messages as a specific client format and see how each step of the translation + pipeline works. The right panel shows the full flow:{" "} + + Client Request → Format Detection → OpenAI Intermediate → Provider Format → Response + + . Click any step to inspect the data at that stage. +

+
- {/* Right: Pipeline Visualization */} -
- -
-
- - account_tree - -

Translation Pipeline

-
-

Click on any step to inspect the data

-
-
- - {!pipeline ? ( +
+ {/* Left: Chat Interface */} +
+ {/* Controls */} -
- - account_tree - -

Send a message to see the pipeline

+
+
+
+ + setProvider(e.target.value)} + options={providerOptions} + /> +
+
+
+ +
+ setModel(e.target.value)} + list="model-suggestions" + placeholder="Select or type a model name..." + className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" + /> + + {availableModels.map((m) => ( + +
+
- ) : ( -
- {pipeline.map((step, i) => { - const meta = FORMAT_META[step.format] || { - label: step.format, - color: "gray", - icon: "code", - }; - const isExpanded = expandedStep === step.id; - return ( -
- {/* Connector line */} - {i > 0 && ( -
-
-
- )} - - +
+ {chatHistory.length === 0 && ( +
+ + chat + +

+ Send a message to see the translation pipeline +

+

+ Your message will be formatted as a{" "} + {FORMAT_META[clientFormat]?.label} request, translated through + the pipeline, and sent to the selected provider. +

+
+ )} + {chatHistory.map((msg, i) => ( +
+
-
+
+ ))} +
+
- {/* Step info */} -
-

{step.name}

-
+ {/* Input */} +
+
+ setMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()} + placeholder="Type a message..." + className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" + disabled={sending} + /> + +
+
+ +
- {/* Format badge */} - - {meta.label} - + {/* Right: Pipeline Visualization */} +
+ +
+
+ + account_tree + +

Translation Pipeline

+
+

+ Click on any step to inspect the data at that stage +

+
+
- {/* Expand icon */} - - {isExpanded ? "expand_less" : "expand_more"} - - + {!pipeline ? ( + +
+ + account_tree + +

Pipeline visualization

+

+ Send a message to see how your request flows through detection → translation → + provider call. +

+
+
+ ) : ( +
+ {pipeline.map((step, i) => { + const meta = FORMAT_META[step.format] || { + label: step.format, + color: "gray", + icon: "code", + }; + const isExpanded = expandedStep === step.id; - {/* Expanded content */} - {isExpanded && ( -
-
- -
+ return ( +
+ {/* Connector line */} + {i > 0 && ( +
+
)} - -
- ); - })} -
- )} + + + + + {/* Expanded content */} + {isExpanded && ( +
+
+ +
+
+ )} +
+
+ ); + })} +
+ )} +
); diff --git a/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js b/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js index 69d66bdad0..076bb525b8 100644 --- a/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js +++ b/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js @@ -49,6 +49,23 @@ export default function LiveMonitorMode() { return (
+ {/* Info Banner */} +
+ + info + +
+

Real-Time Translation Activity

+

+ Shows translation events as API calls flow through OmniRoute. Events come from the + in-memory buffer (resets on restart). Use{" "} + Chat Tester,{" "} + Test Bench, or external API calls to + generate events. +

+
+
+ {/* Stats Cards */}
@@ -99,11 +116,26 @@ export default function LiveMonitorMode() { monitoring

No translations yet

-

- Translations will appear here as requests flow through the proxy. +

+ Translation events appear here as requests flow through OmniRoute. Use any of these + methods to generate events:

-

- Make API calls to your OmniRoute endpoints to see live translation data. +

+ + Chat Tester tab + + + Test Bench tab + + + External API calls + + + IDE/CLI integrations + +
+

+ Note: Events are stored in-memory and reset when the server restarts.

) : ( diff --git a/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js b/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js index a2a9c2ba11..179e63dde8 100644 --- a/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js +++ b/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js @@ -65,12 +65,6 @@ export default function PlaygroundMode() { step: "direct", sourceFormat, targetFormat, - provider: - targetFormat === "claude" - ? "anthropic" - : targetFormat === "gemini" - ? "google" - : "openai", body: parsed, }), }); @@ -114,6 +108,20 @@ export default function PlaygroundMode() { return (
+ {/* Info Banner */} +
+ + info + +
+

Format Converter

+

+ Paste or type a JSON request body. The translator will auto-detect the source format and + convert it to the target format. Use this to debug how OmniRoute translates requests + between formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API). +

+
+
{/* Format Controls Bar */}
diff --git a/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js b/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js index 9e1faccd5a..ab0c317b46 100644 --- a/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js +++ b/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js @@ -2,16 +2,18 @@ import { useState, useEffect } from "react"; import { Card, Button, Select, Badge } from "@/shared/components"; -import { EXAMPLE_TEMPLATES, FORMAT_META } from "../exampleTemplates"; -import { - AI_PROVIDERS, - OPENAI_COMPATIBLE_PREFIX, - ANTHROPIC_COMPATIBLE_PREFIX, -} from "@/shared/constants/providers"; +import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates"; +import { useProviderOptions } from "../hooks/useProviderOptions"; +import { useAvailableModels } from "../hooks/useAvailableModels"; /** * Test Bench Mode: * Run translation + send scenarios between providers to validate compatibility. + * + * How it works: + * Predefined scenarios (Simple Chat, Tool Calling, etc.) are loaded from example templates, + * translated from the source format to the target provider, and sent to the provider API. + * Results show pass/fail, latency, and chunk count, with a compatibility percentage. */ const SCENARIOS = [ @@ -23,92 +25,18 @@ const SCENARIOS = [ { id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" }, ]; -const DEFAULT_MODELS = { - openai: "gpt-4o", - claude: "claude-sonnet-4-20250514", - gemini: "gemini-2.5-flash", -}; - export default function TestBenchMode() { const [sourceFormat, setSourceFormat] = useState("claude"); - const [provider, setProvider] = useState("openai"); - const [providerOptions, setProviderOptions] = useState([]); - const [model, setModel] = useState(DEFAULT_MODELS.claude); - const [availableModels, setAvailableModels] = useState([]); + const { provider, setProvider, providerOptions } = useProviderOptions("openai"); + const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels(); const [results, setResults] = useState({}); const [runningAll, setRunningAll] = useState(false); - // Update default model when source format changes + // Pick a smart default model when source format changes or models finish loading useEffect(() => { - setModel(DEFAULT_MODELS[sourceFormat] || "gpt-4o"); - }, [sourceFormat]); - - // Load available models - useEffect(() => { - const fetchModels = async () => { - try { - const res = await fetch("/api/v1/models"); - const data = await res.json(); - const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b)); - setAvailableModels(models); - } catch { - setAvailableModels([]); - } - }; - fetchModels(); - }, []); - - useEffect(() => { - const fetchProviders = async () => { - try { - const [connRes, nodesRes] = await Promise.all([ - fetch("/api/providers"), - fetch("/api/provider-nodes"), - ]); - const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]); - const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n])); - const activeProviders = new Set( - (connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider) - ); - const options = [...activeProviders] - .map((pid) => { - const info = AI_PROVIDERS[pid]; - const node = nodeMap.get(pid); - let label = info?.name || node?.name || pid; - if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX)) - label = node?.name || "OpenAI Compatible"; - if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) - label = node?.name || "Anthropic Compatible"; - return { value: pid, label }; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - const nextOptions = - options.length > 0 - ? options - : Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name })); - setProviderOptions(nextOptions); - if (nextOptions.length > 0) { - setProvider((current) => - nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value - ); - } - } catch { - const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({ - value: id, - label: info.name, - })); - setProviderOptions(fallbackOptions); - if (fallbackOptions.length > 0) { - setProvider((current) => - fallbackOptions.some((opt) => opt.value === current) - ? current - : fallbackOptions[0].value - ); - } - } - }; - fetchProviders(); - }, []); + const picked = pickModelForFormat(sourceFormat); + if (picked) setModel(picked); + }, [sourceFormat, pickModelForFormat, setModel]); const runScenario = async (scenario) => { setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } })); @@ -213,6 +141,22 @@ export default function TestBenchMode() { return (
+ {/* Info Banner */} +
+ + info + +
+

Compatibility Tester

+

+ Run predefined scenarios (Simple Chat, Tool Calling, etc.) to verify translation and + provider compatibility. Select a source format and target provider, then run all tests + to see a compatibility percentage. Use this to find which features work across + providers. +

+
+
+ {/* Controls */}
@@ -227,11 +171,9 @@ export default function TestBenchMode() { setSourceFormat(e.target.value); setResults({}); }} - options={[ - { value: "openai", label: "OpenAI" }, - { value: "claude", label: "Claude" }, - { value: "gemini", label: "Gemini" }, - ]} + options={FORMAT_OPTIONS.filter((o) => + ["openai", "claude", "gemini", "openai-responses"].includes(o.value) + )} />
@@ -271,7 +213,7 @@ export default function TestBenchMode() { value={model} onChange={(e) => setModel(e.target.value)} list="testbench-model-suggestions" - placeholder="e.g. gpt-4o, claude-sonnet-4-20250514" + placeholder="Select or type a model name..." className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" /> diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.js b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.js new file mode 100644 index 0000000000..20891d215f --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.js @@ -0,0 +1,66 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +/** + * Prefix-based format→model matching, used to pick a smart default + * model from the available models list when the user changes format. + */ +const FORMAT_MODEL_PREFIXES = { + openai: ["gpt-", "o1-", "o3-", "o4-"], + "openai-responses": ["gpt-", "o1-", "o3-", "o4-"], + claude: ["claude-"], + gemini: ["gemini-"], +}; + +/** + * Hook to fetch available models and provide smart default selection. + * + * @returns {{ + * model: string, + * setModel: Function, + * availableModels: string[], + * loading: boolean, + * pickModelForFormat: (format: string) => string + * }} + */ +export function useAvailableModels() { + const [model, setModel] = useState(""); + const [availableModels, setAvailableModels] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchModels = async () => { + try { + const res = await fetch("/api/v1/models"); + const data = await res.json(); + const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b)); + setAvailableModels(models); + } catch { + setAvailableModels([]); + } finally { + setLoading(false); + } + }; + fetchModels(); + }, []); + + /** + * Pick the best model for a given format from the available models. + * Returns the first model matching the format prefixes, or the first available model. + */ + const pickModelForFormat = useCallback( + (format) => { + if (availableModels.length === 0) return ""; + const prefixes = FORMAT_MODEL_PREFIXES[format] || []; + for (const prefix of prefixes) { + const match = availableModels.find((m) => m.startsWith(prefix)); + if (match) return match; + } + return availableModels[0]; + }, + [availableModels] + ); + + return { model, setModel, availableModels, loading, pickModelForFormat }; +} diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.js b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.js new file mode 100644 index 0000000000..a96a56b194 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.js @@ -0,0 +1,79 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + AI_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; + +/** + * Hook to fetch and manage provider options for the Translator tools. + * Fetches active providers from the API and builds a sorted list of options. + * Falls back to the static AI_PROVIDERS list if the API is unreachable. + * + * @param {string} [initialProvider="openai"] - Initial provider value + * @returns {{ provider: string, setProvider: Function, providerOptions: Array<{value: string, label: string}>, loading: boolean }} + */ +export function useProviderOptions(initialProvider = "openai") { + const [provider, setProvider] = useState(initialProvider); + const [providerOptions, setProviderOptions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchProviders = async () => { + try { + const [connRes, nodesRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/provider-nodes"), + ]); + const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]); + const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n])); + const activeProviders = new Set( + (connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider) + ); + const options = [...activeProviders] + .map((pid) => { + const info = AI_PROVIDERS[pid]; + const node = nodeMap.get(pid); + let label = info?.name || node?.name || pid; + if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX)) + label = node?.name || "OpenAI Compatible"; + if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + label = node?.name || "Anthropic Compatible"; + return { value: pid, label }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + const nextOptions = + options.length > 0 + ? options + : Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name })); + setProviderOptions(nextOptions); + if (nextOptions.length > 0) { + setProvider((current) => + nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value + ); + } + } catch { + const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({ + value: id, + label: info.name, + })); + setProviderOptions(fallbackOptions); + if (fallbackOptions.length > 0) { + setProvider((current) => + fallbackOptions.some((opt) => opt.value === current) + ? current + : fallbackOptions[0].value + ); + } + } finally { + setLoading(false); + } + }; + fetchProviders(); + }, []); + + return { provider, setProvider, providerOptions, loading }; +} diff --git a/src/app/api/sync/cloud/route.js b/src/app/api/sync/cloud/route.js index 3e47a0543e..426e1ab5ca 100644 --- a/src/app/api/sync/cloud/route.js +++ b/src/app/api/sync/cloud/route.js @@ -66,15 +66,22 @@ export async function POST(request) { const machineId = await getConsistentMachineId(); switch (action) { - case "enable": - await updateSettings({ cloudEnabled: true }); - // Auto create key if none exists + case "enable": { + // Auto create key if none exists (before sync, so it's included in sync data) const keys = await getApiKeys(); let createdKey = null; if (keys.length === 0) { createdKey = await createApiKey("Default Key", machineId); } - return syncAndVerify(machineId, createdKey?.key, keys); + // Sync first — only enable if sync succeeds + const enableResult = await syncAndVerify(machineId, createdKey?.key, keys); + const enableBody = await enableResult.clone().json().catch(() => ({})); + // Only persist cloudEnabled if sync succeeded (body.success exists) + if (enableBody.success) { + await updateSettings({ cloudEnabled: true }); + } + return enableResult; + } case "sync": { const syncResult = await syncToCloud(machineId); if (syncResult.error) { @@ -95,16 +102,19 @@ export async function POST(request) { } /** - * Sync and verify connection with ping + * Sync and verify connection with ping (retry on verify) */ async function syncAndVerify(machineId, createdKey, existingKeys) { // Step 1: Sync data to cloud const syncResult = await syncToCloud(machineId, createdKey); if (syncResult.error) { - return NextResponse.json(syncResult, { status: 502 }); + return NextResponse.json( + { error: `Cloud sync failed: ${syncResult.error}` }, + { status: 502 } + ); } - // Step 2: Verify connection by pinging the cloud + // Step 2: Verify connection by pinging the cloud (with retry) const apiKey = createdKey || existingKeys[0]?.key; if (!apiKey) { return NextResponse.json({ @@ -114,34 +124,48 @@ async function syncAndVerify(machineId, createdKey, existingKeys) { }); } - try { - const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - }); + // Retry verify up to 2 times with a delay (cloud may need a moment after sync) + const MAX_VERIFY_ATTEMPTS = 2; + const VERIFY_RETRY_DELAY_MS = 1500; + let lastVerifyError = null; - if (pingResponse.ok) { - return NextResponse.json({ - ...syncResult, - verified: true, - }); - } else { - return NextResponse.json({ - ...syncResult, - verified: false, - verifyError: `Ping failed: ${pingResponse.status}`, - }); + for (let attempt = 1; attempt <= MAX_VERIFY_ATTEMPTS; attempt++) { + try { + const pingResponse = await fetchWithTimeout( + `${CLOUD_URL}/${machineId}/v1/verify`, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }, + 5000 + ); + + if (pingResponse.ok) { + return NextResponse.json({ + ...syncResult, + verified: true, + }); + } + lastVerifyError = `Ping failed: ${pingResponse.status}`; + } catch (error) { + lastVerifyError = error?.name === "AbortError" ? "Verify timeout" : error.message; + } + + // Wait before retry (except on last attempt) + if (attempt < MAX_VERIFY_ATTEMPTS) { + await new Promise((r) => setTimeout(r, VERIFY_RETRY_DELAY_MS)); } - } catch (error) { - return NextResponse.json({ - ...syncResult, - verified: false, - verifyError: error.message, - }); } + + // Sync succeeded but verify failed — still return success with warning + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: lastVerifyError || "Verification failed after retries", + }); } /** diff --git a/src/lib/usageAnalytics.js b/src/lib/usageAnalytics.js index 2507a843d8..14d745b840 100644 --- a/src/lib/usageAnalytics.js +++ b/src/lib/usageAnalytics.js @@ -9,7 +9,7 @@ import { calculateCost } from "@/lib/usageDb.js"; /** * Compute date range boundaries - * @param {string} range - "7d" | "30d" | "90d" | "ytd" | "all" + * @param {string} range - "1d" | "7d" | "30d" | "90d" | "ytd" | "all" * @returns {{ start: Date, end: Date }} */ function getDateRange(range) { @@ -17,6 +17,10 @@ function getDateRange(range) { let start; switch (range) { + case "1d": + start = new Date(end); + start.setDate(start.getDate() - 1); + break; case "7d": start = new Date(end); start.setDate(start.getDate() - 7); diff --git a/src/shared/components/UsageAnalytics.js b/src/shared/components/UsageAnalytics.js index 47f1bd11c4..bceb6f1067 100644 --- a/src/shared/components/UsageAnalytics.js +++ b/src/shared/components/UsageAnalytics.js @@ -47,6 +47,7 @@ export default function UsageAnalytics() { }, [fetchAnalytics]); const ranges = [ + { value: "1d", label: "1D" }, { value: "7d", label: "7D" }, { value: "30d", label: "30D" }, { value: "90d", label: "90D" },