fix(dashboard): surface manual config CTA when Claude CLI auto-detect fails

When OmniRoute is deployed on a remote server, the Claude tool card's CLI
auto-detect runs on the server (where Claude CLI is not installed) and the
warning panel was hiding the "Manual Config" button. Users with Claude
installed on their local machine had no way to copy the settings.json
snippet from the dashboard.

Always surface the "Manual Config" button alongside "How to Install"
in the detection-failure panel. The button opens the same ManualConfigModal
already used in the happy path. The "How to Install" guide stays put for
users intending a local install. Added vitest regression covering both the
button presence and the modal open behavior when detection returns
{ installed: false }.

Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/602
This commit is contained in:
diegosouzapw
2026-06-20 19:15:09 -03:00
parent 3b25297110
commit 48db135701
3 changed files with 205 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(embeddings):** forward output dimensions to Gemini for consistent embedding dims. (thanks @nguyenha935)
- **fix(translator):** sanitize Read tool args from non-Anthropic models to prevent retry loops. (thanks @GodrezJr2)
- **fix(usage):** reuse Gemini CLI project ID for quota checks (avoid re-discovery). (thanks @Delcado19)
- **fix(dashboard):** surface manual config CTA when Claude CLI detection fails (remote deployments). (thanks @anuragg-saxenaa)
- **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari)
- **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5)

View File

@@ -335,16 +335,34 @@ export default function ClaudeToolCard({
: t("installCliPrompt", { tool: "Claude" })}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowInstallGuide(!showInstallGuide)}
>
<span className="material-symbols-outlined text-[18px] mr-1">
{showInstallGuide ? "expand_less" : "help"}
</span>
{showInstallGuide ? t("hide") : t("howToInstall")}
</Button>
<div className="flex items-center gap-2">
{/*
Always surface Manual Config even when the CLI is not
detected locally — typical of remote OmniRoute
deployments where the CLI lives on the user's machine,
not on the server. Upstream report: #589.
*/}
<Button
variant="ghost"
size="sm"
onClick={() => setShowManualConfigModal(true)}
>
<span className="material-symbols-outlined text-[18px] mr-1">
content_copy
</span>
{t("manualConfig")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowInstallGuide(!showInstallGuide)}
>
<span className="material-symbols-outlined text-[18px] mr-1">
{showInstallGuide ? "expand_less" : "help"}
</span>
{showInstallGuide ? t("hide") : t("howToInstall")}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">

View File

@@ -0,0 +1,176 @@
// @vitest-environment jsdom
//
// Regression test: when the Claude CLI is not detected locally (typical of
// remote OmniRoute deployments where the CLI lives on the user's laptop, not
// on the server), the card must still surface a "Manual Config" button so the
// user can copy the settings.json snippet and paste it into the CLI on their
// local machine. Before this fix the Manual Config button only rendered when
// `cliReady === true`, which made the card useless for remote deployments
// (upstream report: decolua/9router#589).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
const messages: Record<string, string> = {
cliNotInstalled: "{tool} CLI not detected locally",
cliNotRunnable: "{tool} CLI installed but not runnable",
installCliPrompt:
"Manual configuration is still available if OmniRoute is deployed on a remote server.",
cliFoundFailedHealthcheck: "{tool} CLI was found but failed runtime healthcheck{reason}.",
howToInstall: "How to Install",
hide: "Hide",
manualConfig: "Manual Config",
installationGuide: "Installation Guide",
platforms: "Platforms",
afterInstallationRun: "After installation run",
toVerify: "to verify.",
checkingCli: "Checking {tool}...",
claudeManualConfiguration: "Claude Manual Configuration",
};
const raw = messages[key] ?? key;
if (!values) return raw;
return Object.entries(values).reduce(
(acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v ?? "")),
raw
);
},
useLocale: () => "en",
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: () => <span data-testid="provider-icon" />,
}));
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
default: () => <span data-testid="status-badge" />,
}));
vi.mock("@/shared/services/claudeCliConfig", () => ({
getStoredClaudeAuthValue: () => null,
normalizeClaudeBaseUrl: (u: string) => u,
}));
// Surface ManualConfigModal as a marker so we can assert it gets rendered with
// isOpen=true after clicking the new Manual Config button.
vi.mock("@/shared/components", async () => {
const React = await import("react");
return {
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
ModelSelectModal: () => null,
ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) =>
isOpen ? <div data-testid="manual-config-modal">{title}</div> : null,
};
});
// ── Fetch stub ────────────────────────────────────────────────────────────────
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// Return: CLI not installed (the broken-on-remote case from upstream #589).
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/cli-tools/claude-settings")) {
return new Response(JSON.stringify({ installed: false }), { status: 200 });
}
if (url.includes("/api/models/alias")) {
return new Response(JSON.stringify({ aliases: {} }), { status: 200 });
}
if (url.includes("/api/cli-tools/backups")) {
return new Response(JSON.stringify({ backups: [] }), { status: 200 });
}
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
});
const containers: HTMLElement[] = [];
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
vi.restoreAllMocks();
});
// ── Import under test (after mocks) ───────────────────────────────────────────
const { default: ClaudeToolCard } = await import(
"@/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard"
);
async function renderExpanded() {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
await act(async () => {
root.render(
<ClaudeToolCard
tool={{
name: "Claude Code",
defaultModels: [],
}}
isExpanded={true}
onToggle={() => {}}
activeProviders={[]}
modelMappings={{}}
onModelMappingChange={() => {}}
baseUrl="http://localhost:20128"
hasActiveProviders={false}
apiKeys={[]}
cloudEnabled={false}
batchStatus={null}
lastConfiguredAt={null}
/>
);
});
// Allow microtasks for the fetch() promise + state update to flush.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
return container;
}
describe("ClaudeToolCard — manual-config CTA when CLI is not detected", () => {
it("renders the Manual Config button alongside How to Install when CLI is not installed", async () => {
const container = await renderExpanded();
const buttons = Array.from(container.querySelectorAll("button"));
const labels = buttons.map((b) => b.textContent ?? "");
expect(labels.some((l) => l.includes("Manual Config"))).toBe(true);
expect(labels.some((l) => l.includes("How to Install"))).toBe(true);
});
it("opens the ManualConfigModal when the Manual Config button is clicked", async () => {
const container = await renderExpanded();
const manualBtn = Array.from(container.querySelectorAll("button")).find((b) =>
(b.textContent ?? "").includes("Manual Config")
);
expect(manualBtn).toBeTruthy();
await act(async () => {
manualBtn!.click();
});
expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull();
});
});