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
This commit is contained in:
diegosouzapw
2026-02-16 01:35:57 -03:00
parent 467e998650
commit ebdc1c214d
19 changed files with 715 additions and 579 deletions

View File

@@ -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({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{effectiveConfigStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{effectiveConfigStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{effectiveConfigStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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 (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
>
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
{badge.text}
</span>
);
}

View File

@@ -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 (
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
{badge.text}
</span>
);
};
return (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
@@ -254,7 +233,10 @@ export default function ClineToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{renderStatusBadge()}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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"
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{effectiveConfigStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{effectiveConfigStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{effectiveConfigStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{runtimeStatus && !runtimeStatus.error && (
<span
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${
runtimeStatus.reason === "not_required"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: runtimeStatus.installed && runtimeStatus.runnable
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
}`}
>
{runtimeStatus.reason === "not_required"
? "Guide"
: runtimeStatus.installed && runtimeStatus.runnable
? "Detected"
: "Not ready"}
</span>
)}
{(() => {
// 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 (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500" />
Guide
</span>
);
}
if (isDetected) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500" />
Detected
</span>
);
}
if (isInstalled === false && (rs || bs)) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400">
<span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" />
Not installed
</span>
);
}
if (isInstalled && !isDetected && (rs || bs)) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
<span className="size-1.5 rounded-full bg-yellow-500" />
Not ready
</span>
);
}
return null;
})()}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{effectiveConfigStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{effectiveConfigStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{effectiveConfigStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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 (
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
{badge.text}
</span>
);
};
return (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
@@ -239,7 +219,10 @@ export default function KiloToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{renderStatusBadge()}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{effectiveConfigStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{effectiveConfigStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{effectiveConfigStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>

View File

@@ -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("");

View File

@@ -27,7 +27,7 @@ export default function TranslatorPageClient() {
Translator Playground
</h1>
<p className="text-sm text-text-muted mt-1">
Debug, test, and visualize API format translations
Debug, test, and visualize how OmniRoute translates API requests between providers
</p>
</div>
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />

View File

@@ -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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Chat Interface */}
<div className="space-y-4">
{/* Controls */}
<Card>
<div className="p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Client Format
</label>
<Select
value={clientFormat}
onChange={(e) => setClientFormat(e.target.value)}
options={FORMAT_OPTIONS.filter((o) =>
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
)}
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Provider
</label>
<Select
value={provider}
onChange={(e) => setProvider(e.target.value)}
options={providerOptions}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Model
</label>
<div className="relative">
<input
type="text"
value={model}
onChange={(e) => 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"
/>
<datalist id="model-suggestions">
{availableModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
</div>
</Card>
{/* Chat Messages */}
<Card className="min-h-[400px] flex flex-col">
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
{chatHistory.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">chat</span>
<p className="text-sm">Send a message to see the translation pipeline</p>
</div>
)}
{chatHistory.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary/10 text-text-main border border-primary/20"
: "bg-bg-subtle text-text-main border border-border"
}`}
>
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
{msg.role === "user"
? `You (${FORMAT_META[clientFormat]?.label})`
: "Assistant"}
</p>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-border">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => 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}
/>
<Button
icon="send"
onClick={handleSend}
loading={sending}
disabled={!message.trim() || sending}
>
Send
</Button>
</div>
</div>
</Card>
<div className="space-y-4">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Pipeline Debugger</p>
<p>
Send messages as a specific client format and see how each step of the translation
pipeline works. The right panel shows the full flow:{" "}
<strong className="text-text-main">
Client Request Format Detection OpenAI Intermediate Provider Format Response
</strong>
. Click any step to inspect the data at that stage.
</p>
</div>
</div>
{/* Right: Pipeline Visualization */}
<div className="space-y-4">
<Card>
<div className="p-4 space-y-1">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">
account_tree
</span>
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
</div>
<p className="text-xs text-text-muted">Click on any step to inspect the data</p>
</div>
</Card>
{!pipeline ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Chat Interface */}
<div className="space-y-4">
{/* Controls */}
<Card>
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
account_tree
</span>
<p className="text-sm">Send a message to see the pipeline</p>
<div className="p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Client Format
</label>
<Select
value={clientFormat}
onChange={(e) => setClientFormat(e.target.value)}
options={FORMAT_OPTIONS.filter((o) =>
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
)}
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Provider
</label>
<Select
value={provider}
onChange={(e) => setProvider(e.target.value)}
options={providerOptions}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Model
</label>
<div className="relative">
<input
type="text"
value={model}
onChange={(e) => 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"
/>
<datalist id="model-suggestions">
{availableModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
</div>
</Card>
) : (
<div className="space-y-2">
{pipeline.map((step, i) => {
const meta = FORMAT_META[step.format] || {
label: step.format,
color: "gray",
icon: "code",
};
const isExpanded = expandedStep === step.id;
return (
<div key={step.id}>
{/* Connector line */}
{i > 0 && (
<div className="flex justify-center py-1">
<div className="w-px h-4 bg-border" />
</div>
)}
<Card
className={
step.status === "error"
? "border-red-500/30"
: isExpanded
? "border-primary/30"
: ""
}
{/* Chat Messages */}
<Card className="min-h-[400px] flex flex-col">
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
{chatHistory.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
chat
</span>
<p className="text-sm font-medium mb-1">
Send a message to see the translation pipeline
</p>
<p className="text-xs text-center max-w-xs">
Your message will be formatted as a{" "}
<strong>{FORMAT_META[clientFormat]?.label}</strong> request, translated through
the pipeline, and sent to the selected provider.
</p>
</div>
)}
{chatHistory.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary/10 text-text-main border border-primary/20"
: "bg-bg-subtle text-text-main border border-border"
}`}
>
<button
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="w-full p-3 flex items-center gap-3 text-left"
>
{/* Step number */}
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
step.status === "error"
? "bg-red-500/10 text-red-500"
: step.status === "done"
? `bg-${meta.color}-500/10 text-${meta.color}-500`
: "bg-bg-subtle text-text-muted"
}`}
>
{step.status === "error" ? "!" : step.id}
</div>
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
{msg.role === "user"
? `You (${FORMAT_META[clientFormat]?.label})`
: "Assistant"}
</p>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Step info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main">{step.name}</p>
</div>
{/* Input */}
<div className="p-3 border-t border-border">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => 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}
/>
<Button
icon="send"
onClick={handleSend}
loading={sending}
disabled={!message.trim() || sending}
>
Send
</Button>
</div>
</div>
</Card>
</div>
{/* Format badge */}
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
{meta.label}
</Badge>
{/* Right: Pipeline Visualization */}
<div className="space-y-4">
<Card>
<div className="p-4 space-y-1">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">
account_tree
</span>
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
</div>
<p className="text-xs text-text-muted">
Click on any step to inspect the data at that stage
</p>
</div>
</Card>
{/* Expand icon */}
<span className="material-symbols-outlined text-[18px] text-text-muted">
{isExpanded ? "expand_less" : "expand_more"}
</span>
</button>
{!pipeline ? (
<Card>
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
account_tree
</span>
<p className="text-sm font-medium mb-1">Pipeline visualization</p>
<p className="text-xs text-center max-w-xs">
Send a message to see how your request flows through detection translation
provider call.
</p>
</div>
</Card>
) : (
<div className="space-y-2">
{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 && (
<div className="px-3 pb-3">
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="250px"
defaultLanguage="json"
value={step.content}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 11,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
return (
<div key={step.id}>
{/* Connector line */}
{i > 0 && (
<div className="flex justify-center py-1">
<div className="w-px h-4 bg-border" />
</div>
)}
</Card>
</div>
);
})}
</div>
)}
<Card
className={
step.status === "error"
? "border-red-500/30"
: isExpanded
? "border-primary/30"
: ""
}
>
<button
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="w-full p-3 flex items-center gap-3 text-left"
>
{/* Step number */}
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
step.status === "error"
? "bg-red-500/10 text-red-500"
: step.status === "done"
? `bg-${meta.color}-500/10 text-${meta.color}-500`
: "bg-bg-subtle text-text-muted"
}`}
>
{step.status === "error" ? "!" : step.id}
</div>
{/* Step info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main">{step.name}</p>
{step.description && (
<p className="text-[10px] text-text-muted truncate">
{step.description}
</p>
)}
</div>
{/* Format badge */}
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
{meta.label}
</Badge>
{/* Expand icon */}
<span className="material-symbols-outlined text-[18px] text-text-muted">
{isExpanded ? "expand_less" : "expand_more"}
</span>
</button>
{/* Expanded content */}
{isExpanded && (
<div className="px-3 pb-3">
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="250px"
defaultLanguage="json"
value={step.content}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 11,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
</div>
)}
</Card>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);

View File

@@ -49,6 +49,23 @@ export default function LiveMonitorMode() {
return (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Real-Time Translation Activity</p>
<p>
Shows translation events as API calls flow through OmniRoute. Events come from the
in-memory buffer (resets on restart). Use{" "}
<strong className="text-text-main">Chat Tester</strong>,{" "}
<strong className="text-text-main">Test Bench</strong>, or external API calls to
generate events.
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
@@ -99,11 +116,26 @@ export default function LiveMonitorMode() {
monitoring
</span>
<p className="text-sm font-medium mb-1">No translations yet</p>
<p className="text-xs">
Translations will appear here as requests flow through the proxy.
<p className="text-xs text-center max-w-sm">
Translation events appear here as requests flow through OmniRoute. Use any of these
methods to generate events:
</p>
<p className="text-xs mt-2">
Make API calls to your OmniRoute endpoints to see live translation data.
<div className="flex flex-wrap gap-2 mt-3 text-xs">
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Chat Tester tab
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Test Bench tab
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
External API calls
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
IDE/CLI integrations
</span>
</div>
<p className="text-[10px] mt-3 text-text-muted/70">
Note: Events are stored in-memory and reset when the server restarts.
</p>
</div>
) : (

View File

@@ -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 (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Format Converter</p>
<p>
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).
</p>
</div>
</div>
{/* Format Controls Bar */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">

View File

@@ -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 (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
<p>
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.
</p>
</div>
</div>
{/* Controls */}
<Card>
<div className="p-4 flex flex-col gap-4">
@@ -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)
)}
/>
</div>
<div className="flex items-center justify-center px-2">
@@ -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"
/>
<datalist id="testbench-model-suggestions">

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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",
});
}
/**

View File

@@ -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);

View File

@@ -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" },