diff --git a/CHANGELOG.md b/CHANGELOG.md index d63d6da93e..ae256d01cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx index 8d85109303..832ff024b2 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx @@ -335,16 +335,34 @@ export default function ClaudeToolCard({ : t("installCliPrompt", { tool: "Claude" })}

- +
+ {/* + 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. + */} + + +
{showInstallGuide && (
diff --git a/tests/unit/ui/ClaudeToolCard-manual-config-fallback.test.tsx b/tests/unit/ui/ClaudeToolCard-manual-config-fallback.test.tsx new file mode 100644 index 0000000000..c404218679 --- /dev/null +++ b/tests/unit/ui/ClaudeToolCard-manual-config-fallback.test.tsx @@ -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) => { + const messages: Record = { + 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: () => , +})); + +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({ + default: () => , +})); + +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 }) =>
{children}
, + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), + ModelSelectModal: () => null, + ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) => + isOpen ?
{title}
: 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( + {}} + 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(); + }); +});