From 47468636e4e4f40393f4499e3289dd6c93660c6f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 23 Apr 2026 14:39:06 -0300 Subject: [PATCH] feat(dashboard): expand observability and provider tooling Unify audit access under the logs experience and add richer active request visibility with sanitized client and provider payload previews. Expose provider warnings from upstream responses in compliance audit logs, surface learned rate-limit header data in health monitoring, and add MCP cache stats and cache flush tools with test coverage. Also add local provider catalog support for SD WebUI and ComfyUI, include video generation in endpoint docs and UI, add eval run storage migrations, and refresh docs and translations to match the expanded feature set. --- AGENTS.md | 12 +- open-sse/handlers/chatCore.ts | 38 +- .../mcp-server/__tests__/cacheTools.test.ts | 62 ++++ open-sse/mcp-server/schemas/tools.ts | 4 +- open-sse/mcp-server/server.ts | 30 +- open-sse/mcp-server/tools/advancedTools.ts | 90 ++++- src/app/(dashboard)/dashboard/agents/page.tsx | 128 +++---- .../dashboard/audit/ConfigAuditViewer.tsx | 203 ----------- src/app/(dashboard)/dashboard/audit/page.tsx | 44 +-- .../cli-tools/CLIToolsPageClient.tsx | 60 +++- .../dashboard/endpoint/EndpointPageClient.tsx | 35 +- src/app/(dashboard)/dashboard/health/page.tsx | 106 +++++- .../dashboard/logs/AuditLogTab.tsx | 173 ++++++++- src/app/(dashboard)/dashboard/logs/page.tsx | 13 +- .../components/ProviderCountBadge.tsx | 30 ++ .../(dashboard)/dashboard/providers/page.tsx | 327 ++++++++++++++---- .../translator/components/TestBenchMode.tsx | 4 +- .../dashboard/translator/exampleTemplates.tsx | 98 ++++++ .../translator/hooks/useAvailableModels.tsx | 1 + .../dashboard/usage/components/BudgetTab.tsx | 29 +- src/app/api/audit/route.ts | 37 -- src/app/api/logs/active/route.ts | 4 + src/app/api/monitoring/health/route.ts | 5 +- src/app/docs/content.ts | 163 +++++++++ src/app/docs/page.tsx | 182 +++++----- src/i18n/messages/en.json | 230 +++++++++++- src/i18n/messages/pt-BR.json | 269 ++++++++++++-- src/lib/compliance/providerAudit.ts | 64 ++++ src/lib/db/evals.ts | 289 ++++++++++++++++ src/lib/db/migrationRunner.ts | 6 + .../028_create_files_and_batches.sql | 2 +- ...29_provider_connection_max_concurrent.sql} | 4 +- .../db/migrations/030_create_eval_runs.sql | 28 ++ src/lib/monitoring/observability.ts | 3 + src/lib/providers/catalog.ts | 10 + src/lib/usage/usageHistory.ts | 134 +++++-- src/lib/usageDb.ts | 1 + src/shared/components/ActiveRequestsPanel.tsx | 89 ++++- src/shared/constants/providers.ts | 49 ++- src/shared/constants/sidebarVisibility.ts | 1 - src/shared/validation/schemas.ts | 49 ++- tests/unit/provider-audit.test.ts | 23 ++ tests/unit/providers-page-utils.test.ts | 30 +- .../providers-route-managed-catalog.test.ts | 12 +- tests/unit/usage-analytics.test.ts | 32 ++ 45 files changed, 2501 insertions(+), 702 deletions(-) create mode 100644 open-sse/mcp-server/__tests__/cacheTools.test.ts delete mode 100644 src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/components/ProviderCountBadge.tsx delete mode 100644 src/app/api/audit/route.ts create mode 100644 src/app/docs/content.ts create mode 100644 src/lib/db/evals.ts rename src/lib/db/migrations/{028_provider_connection_max_concurrent.sql => 029_provider_connection_max_concurrent.sql} (85%) create mode 100644 src/lib/db/migrations/030_create_eval_runs.sql create mode 100644 tests/unit/provider-audit.test.ts diff --git a/AGENTS.md b/AGENTS.md index f80682d4f0..39b6ae3687 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Unified AI proxy/router — route any LLM through one endpoint. Multi-provider s with **100+ providers** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) -with **MCP Server** (25 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. +with **MCP Server** (29 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. ## Stack @@ -303,12 +303,14 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`, ### MCP Server (`open-sse/mcp-server/`) -25 tools, 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (10 scopes), Zod schemas. +29 tools, 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (10 scopes), Zod schemas. -**Core tools** (18): get_health, list_combos, get_combo_metrics, switch_combo, check_quota, -route_request, cost_report, list_models_catalog, simulate_route, set_budget_guard, +**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota, +route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard, set_routing_strategy, set_resilience_profile, test_combo, get_provider_metrics, -best_combo_for_task, explain_route, get_session_snapshot, sync_pricing. +best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_pricing. + +**Cache tools** (2): cache_stats, cache_flush. **Memory tools** (3): memory_search, memory_add, memory_clear. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 15cf6abd51..6994285495 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -33,10 +33,12 @@ import { import { updateProviderConnection } from "@/lib/db/providers"; import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs"; import { logAuditEvent } from "@/lib/compliance"; +import { extractProviderWarnings } from "@/lib/compliance/providerAudit"; import { handleBypassRequest } from "../utils/bypassHandler.ts"; import { saveRequestUsage, trackPendingRequest, + updatePendingRequest, appendRequestLog, saveCallLog, } from "@/lib/usageDb"; @@ -1027,6 +1029,29 @@ export async function handleChatCore({ claudeCacheUsageMeta?: Record; cacheSource?: "upstream" | "semantic"; }) => { + const providerWarnings = extractProviderWarnings( + providerResponse, + clientResponse, + responseBody + ); + if (providerWarnings.length > 0) { + logAuditEvent({ + action: "provider.warning", + actor: "system", + target: [provider, connectionId].filter(Boolean).join(":") || provider || model, + resourceType: "provider_warning", + status: "warning", + requestId: skillRequestId, + details: { + provider, + model, + connectionId, + httpStatus: status, + warnings: providerWarnings, + }, + }); + } + const callLogId = generateRequestId(); const pipelinePayloads = detailedLoggingEnabled ? reqLogger?.getPipelinePayloads?.() : null; @@ -2027,7 +2052,10 @@ export async function handleChatCore({ }; // Track pending request - trackPendingRequest(model, provider, connectionId, true); + trackPendingRequest(model, provider, connectionId, true, { + clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions", + clientRequest: clientRawRequest?.body ?? body, + }); // T5: track which models we've tried for intra-family fallback const triedModels = new Set([effectiveModel]); @@ -2065,6 +2093,10 @@ export async function handleChatCore({ // Log target request (final request to provider) reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + updatePendingRequest(model, provider, connectionId, { + providerRequest: finalBody, + providerUrl, + }); // Update rate limiter from response headers (learn limits dynamically) updateFromHeaders( @@ -2208,6 +2240,10 @@ export async function handleChatCore({ providerHeaders = retryResult.headers; finalBody = retryResult.transformedBody; reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + updatePendingRequest(model, provider, connectionId, { + providerRequest: finalBody, + providerUrl, + }); upstreamErrorParsed = false; // Reset since new response is OK } else { providerResponse = retryResult.response; diff --git a/open-sse/mcp-server/__tests__/cacheTools.test.ts b/open-sse/mcp-server/__tests__/cacheTools.test.ts new file mode 100644 index 0000000000..9a893061da --- /dev/null +++ b/open-sse/mcp-server/__tests__/cacheTools.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { + MCP_TOOLS, + MCP_TOOL_MAP, + cacheStatsInput, + cacheFlushInput, + cacheStatsTool, + cacheFlushTool, +} from "../schemas/tools.ts"; +import { createMcpServer } from "../server.ts"; + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), + closeAuditDb: vi.fn(), +})); + +describe("cache MCP tools", () => { + it("should be registered in MCP_TOOLS and MCP_TOOL_MAP", () => { + expect(MCP_TOOLS.find((tool) => tool.name === "omniroute_cache_stats")).toBeDefined(); + expect(MCP_TOOLS.find((tool) => tool.name === "omniroute_cache_flush")).toBeDefined(); + expect(MCP_TOOL_MAP.omniroute_cache_stats).toBeDefined(); + expect(MCP_TOOL_MAP.omniroute_cache_flush).toBeDefined(); + }); + + it("should validate cache tool input schemas", () => { + expect(cacheStatsInput.safeParse({}).success).toBe(true); + expect(cacheFlushInput.safeParse({ model: "openai/gpt-4.1" }).success).toBe(true); + expect(cacheFlushInput.safeParse({ signature: "sig_123" }).success).toBe(true); + }); + + it("should expose the correct cache scopes", () => { + expect(cacheStatsTool.scopes).toContain("read:cache"); + expect(cacheFlushTool.scopes).toContain("write:cache"); + }); +}); + +describe("cache MCP tools registration", () => { + let client: Client; + + beforeEach(async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "cache-tools-test", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + }); + + it("should appear in tools/list after registration", async () => { + const { tools } = await client.listTools(); + const names = tools.map((tool) => tool.name); + + expect(names).toContain("omniroute_cache_stats"); + expect(names).toContain("omniroute_cache_flush"); + expect(tools.length).toBeGreaterThanOrEqual(29); + }); +}); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 3f4c6e1dc1..85492cde97 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1,8 +1,8 @@ /** - * MCP Tool Schemas — Contracts for all 16 OmniRoute MCP tools. + * MCP Tool Schemas — Contracts for all 22 core and advanced OmniRoute MCP tools. * * Defines input/output Zod schemas, descriptions, scopes, and audit levels - * for both essential (Phase 1) and advanced (Phase 3) MCP tools. + * for both essential (Phase 1) and advanced (Phase 2) MCP tools. * * Each tool wraps existing OmniRoute API endpoints and exposes them through * the Model Context Protocol, enabling AI agents in IDEs (VS Code, Cursor, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index c884204fe4..98eb8dd635 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -40,6 +40,8 @@ import { getSessionSnapshotInput, dbHealthCheckInput, syncPricingInput, + cacheStatsInput, + cacheFlushInput, } from "./schemas/tools.ts"; import { startMcpHeartbeat } from "./runtimeHeartbeat.ts"; @@ -62,6 +64,8 @@ import { handleGetSessionSnapshot, handleDbHealthCheck, handleSyncPricing, + handleCacheStats, + handleCacheFlush, } from "./tools/advancedTools.ts"; import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; @@ -79,6 +83,8 @@ const MCP_ALLOWED_SCOPES = new Set( .map((s) => s.trim()) .filter(Boolean) ); +const TOTAL_MCP_TOOL_COUNT = + MCP_TOOLS.length + Object.keys(memoryTools).length + Object.keys(skillTools).length; type JsonRecord = Record; @@ -803,6 +809,28 @@ export function createMcpServer(): McpServer { ) ); + server.registerTool( + "omniroute_cache_stats", + { + description: + "Returns cache statistics including semantic cache hit rate, prompt cache metrics by provider, and idempotency layer stats.", + inputSchema: cacheStatsInput, + }, + withScopeEnforcement("omniroute_cache_stats", () => handleCacheStats()) + ); + + server.registerTool( + "omniroute_cache_flush", + { + description: + "Flush cache entries. Provide signature to invalidate a single entry, model to invalidate all entries for a model, or omit both to clear all.", + inputSchema: cacheFlushInput, + }, + withScopeEnforcement("omniroute_cache_flush", (args) => + handleCacheFlush(cacheFlushInput.parse(args)) + ) + ); + // ── Memory Tools ────────────────────────────── Object.values(memoryTools).forEach((toolDef) => { server.registerTool( @@ -866,7 +894,7 @@ export async function startMcpStdio(): Promise { version, scopesEnforced: MCP_ENFORCE_SCOPES, allowedScopes: Array.from(MCP_ALLOWED_SCOPES), - toolCount: MCP_TOOLS.length, + toolCount: TOTAL_MCP_TOOL_COUNT, }); const stopHeartbeatOnce = () => { stopHeartbeat(); diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 655d22bf98..e9918f491b 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -1,5 +1,5 @@ /** - * OmniRoute MCP Advanced Tools — 11 intelligence tools that differentiate + * OmniRoute MCP Advanced Tools — 13 intelligence tools that differentiate * OmniRoute from all other AI gateways. * * Tools: @@ -14,6 +14,8 @@ * 9. omniroute_get_session_snapshot — Full session state snapshot * 10. omniroute_db_health_check — Diagnose and repair DB state drift * 11. omniroute_sync_pricing — Sync provider pricing from external source + * 12. omniroute_cache_stats — Cache statistics and hit rates + * 13. omniroute_cache_flush — Flush/invalidate cache entries */ import { logToolCall } from "../audit.ts"; @@ -915,3 +917,89 @@ export async function handleDbHealthCheck(args: { autoRepair?: boolean }) { return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } } + +export async function handleCacheStats() { + const start = Date.now(); + try { + const raw = toRecord(await apiFetch("/api/cache")); + const semanticCache = toRecord(raw.semanticCache); + const promptCache = raw.promptCache ? toRecord(raw.promptCache) : null; + const idempotency = toRecord(raw.idempotency); + const config = raw.config ? toRecord(raw.config) : null; + + const result = { + semanticCache: { + memoryEntries: toNumber(semanticCache.memoryEntries, 0), + dbEntries: toNumber(semanticCache.dbEntries, 0), + hits: toNumber(semanticCache.hits, 0), + misses: toNumber(semanticCache.misses, 0), + hitRate: toString(semanticCache.hitRate, "0%"), + tokensSaved: toNumber(semanticCache.tokensSaved, 0), + }, + promptCache: promptCache + ? { + totalRequests: toNumber(promptCache.totalRequests, 0), + requestsWithCacheControl: toNumber(promptCache.requestsWithCacheControl, 0), + totalInputTokens: toNumber(promptCache.totalInputTokens, 0), + totalCachedTokens: toNumber(promptCache.totalCachedTokens, 0), + totalCacheCreationTokens: toNumber(promptCache.totalCacheCreationTokens, 0), + tokensSaved: toNumber(promptCache.tokensSaved, 0), + estimatedCostSaved: toNumber(promptCache.estimatedCostSaved, 0), + } + : null, + idempotency: { + activeKeys: toNumber(idempotency.activeKeys, 0), + windowMs: toNumber(idempotency.windowMs, 0), + }, + config: config + ? { + semanticCacheEnabled: toBoolean(config.semanticCacheEnabled, true), + } + : undefined, + }; + + await logToolCall("omniroute_cache_stats", {}, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_cache_stats", {}, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +export async function handleCacheFlush(args: { signature?: string; model?: string }) { + const start = Date.now(); + try { + const params = new URLSearchParams(); + let scope = "all"; + + if (args.signature) { + params.set("signature", args.signature); + scope = "signature"; + } else if (args.model) { + params.set("model", args.model); + scope = "model"; + } + + const query = params.toString(); + const path = query ? `/api/cache?${query}` : "/api/cache"; + const raw = toRecord( + await apiFetch(path, { + method: "DELETE", + }) + ); + + const result = { + ok: toBoolean(raw.ok, true), + invalidated: toNumber(raw.invalidated ?? raw.cleared, 0), + scope, + }; + + await logToolCall("omniroute_cache_flush", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_cache_flush", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 5714e2b250..4bc021f94d 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -62,8 +62,6 @@ export default function AgentsPage() { const [showAddForm, setShowAddForm] = useState(false); const [addLoading, setAddLoading] = useState(false); const [settings, setSettings] = useState>({}); - const [opencodeConfigLoading, setOpencodeConfigLoading] = useState(false); - const [opencodeConfigDone, setOpencodeConfigDone] = useState(false); const [newAgent, setNewAgent] = useState({ name: "", binary: "", @@ -189,6 +187,46 @@ export default function AgentsPage() { + +
+
+
+

{t("architectureTitle")}

+

{t("architectureDescription")}

+
+ + open_in_new + {t("cliToolsRedirectCta")} + +
+
+ + {t("flowOmniRoute")} + + + {t("flowSpawn")} + + + {t("flowLocalBinary")} + + + {t("flowExecute")} + +
+
+ {t("cliToolsRedirectTitle")}{" "} + {t("cliToolsRedirectDesc")}{" "} + + {t("openCliTools")} + + . +
+
+
+ {/* Summary Cards */} {summary && (
@@ -386,92 +424,6 @@ export default function AgentsPage() { ))}
- {/* OpenCode Config Generator — shown only when opencode is detected */} - {agents.find((a) => a.id === "opencode" && a.installed) && ( - -
-
- code_blocks -
-
-
-

{t("opencodeIntegration")}

- - {t("opencodeDetected", { - version: agents.find((a) => a.id === "opencode")?.version || "", - })} - -
-

- {t("opencodeDesc", { - configFile: "opencode.json", - command: "opencode", - })} -

- -
-
-
- )} - {/* Add Custom Agent */}
diff --git a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx deleted file mode 100644 index 298c09b60a..0000000000 --- a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useTranslations } from "next-intl"; - -interface AuditEntry { - id: number; - timestamp: string; - action: string; - actor: string; - target?: string; - resource_type?: string; - ip_address?: string; - status?: string; - request_id?: string; - details?: any; - metadata?: any; -} - -export default function ConfigAuditViewer() { - const t = useTranslations("logs"); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedEntry, setSelectedEntry] = useState(null); - - useEffect(() => { - fetchLogs(); - }, []); - - const fetchLogs = async () => { - setLoading(true); - try { - const res = await fetch("/api/audit"); - const data = await res.json(); - setEntries(data.entries || []); - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - }; - - const getActionColor = (action: string) => { - const act = action.toLowerCase(); - if (act.includes("success") || act.includes("create")) { - return "text-green-400 bg-green-400/10 border-green-500/20"; - } - if (act.includes("update") || act.includes("modify")) { - return "text-blue-400 bg-blue-400/10 border-blue-500/20"; - } - if (act.includes("failed") || act.includes("delete")) { - return "text-red-400 bg-red-400/10 border-red-500/20"; - } - return "text-gray-400 bg-gray-400/10 border-gray-500/20"; - }; - - if (loading) { - return ( -
-
-
- ); - } - - if (entries.length === 0) { - return ( -
- - - -

No Configuration Audit Logs found.

-
- ); - } - - return ( -
-
- - - - - - - - - - - - - {entries.map((entry) => ( - - - - - - - - - ))} - -
TimestampActionTargetActorResource/IPDetails
- {new Date(entry.timestamp).toLocaleString()} - - - {entry.action} - - - {entry.target || "-"} - - {entry.actor} - - {entry.resource_type || entry.ip_address || "-"} - - -
-
- - {selectedEntry && ( -
-
- {/* Modal Header */} -
-
-

- {selectedEntry.action} -

-

- Actor: {selectedEntry.actor} • Target: {selectedEntry.target || "N/A"} -

-
- -
- - {/* Modal Content */} -
-
-
- ID: {selectedEntry.id} -
-
- Timestamp: {new Date(selectedEntry.timestamp).toLocaleString()} -
-
- Status: {selectedEntry.status || "-"} -
-
- IP Address: {selectedEntry.ip_address || "-"} -
-
- Resource Type: {selectedEntry.resource_type || "-"} -
-
- Request ID: {selectedEntry.request_id || "-"} -
-
- -

- Event Payload (Details/Metadata) -

-
-                {JSON.stringify(selectedEntry.metadata || selectedEntry.details || {}, null, 2)}
-              
-
-
-
- )} -
- ); -} diff --git a/src/app/(dashboard)/dashboard/audit/page.tsx b/src/app/(dashboard)/dashboard/audit/page.tsx index 4ee44b5eb5..4cc14f489f 100644 --- a/src/app/(dashboard)/dashboard/audit/page.tsx +++ b/src/app/(dashboard)/dashboard/audit/page.tsx @@ -1,45 +1,5 @@ -"use client"; - -import { useState, useEffect } from "react"; -import ConfigAuditViewer from "./ConfigAuditViewer"; +import { redirect } from "next/navigation"; export default function ConfigAuditPage() { - const [summary, setSummary] = useState(null); - - useEffect(() => { - fetch("/api/audit?summary=true") - .then((res) => res.json()) - .then((data) => setSummary(data)) - .catch((err) => console.error(err)); - }, []); - - return ( -
-
-
-

- Configuration & Security Audit -

-

- Track administrative actions, authentication events, and security logs across the - system. -

-
- {summary && ( -
-
- Total Audits - - {summary.totalEntries} - -
-
- )} -
- -
- -
-
- ); + redirect("/dashboard/logs?tab=audit-logs"); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index e9e5a6e28d..39ef5d0de5 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { Card, CardSkeleton } from "@/shared/components"; +import { Card, CardSkeleton, SegmentedControl } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { PROVIDER_MODELS, @@ -22,8 +22,27 @@ import { import { useTranslations } from "next-intl"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +const AUTO_CONFIGURED_TOOL_IDS = new Set([ + "claude", + "codex", + "droid", + "openclaw", + "cline", + "kilo", + "copilot", +]); +const GUIDED_TOOL_IDS = new Set([ + "cursor", + "windsurf", + "continue", + "opencode", + "hermes", + "amp", + "qwen", +]); +const MITM_TOOL_IDS = new Set(["antigravity", "kiro"]); -export default function CLIToolsPageClient({ machineId }) { +export default function CLIToolsPageClient({ machineId: _machineId }) { const t = useTranslations("cliTools"); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -34,10 +53,12 @@ export default function CLIToolsPageClient({ machineId }) { const [toolStatuses, setToolStatuses] = useState({}); const [statusesLoaded, setStatusesLoaded] = useState(false); const [dynamicModels, setDynamicModels] = useState([]); + const [activeCategory, setActiveCategory] = useState("auto"); const translateOrFallback = useCallback( (key, fallback, values = undefined) => { try { - return t(key, values); + const translated = t(key, values); + return translated === key || translated === `cliTools.${key}` ? fallback : translated; } catch { return fallback; } @@ -220,6 +241,13 @@ export default function CLIToolsPageClient({ machineId }) { const availableModels = getAllAvailableModels(); const hasActiveProviders = availableModels.length > 0; + const toolEntries = Object.entries(CLI_TOOLS).filter(([toolId]) => { + if (activeCategory === "all") return true; + if (activeCategory === "auto") return AUTO_CONFIGURED_TOOL_IDS.has(toolId); + if (activeCategory === "guided") return GUIDED_TOOL_IDS.has(toolId); + if (activeCategory === "mitm") return MITM_TOOL_IDS.has(toolId); + return true; + }); const renderToolCard = (toolId, tool) => { const commonProps = { @@ -377,6 +405,30 @@ export default function CLIToolsPageClient({ machineId }) {
+ +
+
+
+

{t("toolCategories")}

+

{t("toolCategoriesDesc")}

+
+ + {t("visibleToolsCount", { count: toolEntries.length })} + +
+ +
+
+ {!hasActiveProviders && (
@@ -392,7 +444,7 @@ export default function CLIToolsPageClient({ machineId }) { )}
- {Object.entries(CLI_TOOLS).map(([toolId, tool]) => { + {toolEntries.map(([toolId, tool]) => { const docsHref = getToolDocsHref(toolId, tool); const isExternalDocs = /^https?:\/\//i.test(docsHref); return ( diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 0e9a64b8d6..b4dcf261ac 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -248,6 +248,7 @@ export default function APIPageClient({ machineId }) { const chat = allModels.filter((m) => !m.type && !m.parent); const embeddings = allModels.filter((m) => m.type === "embedding" && !m.parent); const images = allModels.filter((m) => m.type === "image" && !m.parent); + const video = allModels.filter((m) => m.type === "video" && !m.parent); const rerank = allModels.filter((m) => m.type === "rerank" && !m.parent); const audioTranscription = allModels.filter( (m) => m.type === "audio" && m.subtype === "transcription" && !m.parent @@ -257,7 +258,17 @@ export default function APIPageClient({ machineId }) { ); const moderation = allModels.filter((m) => m.type === "moderation" && !m.parent); const music = allModels.filter((m) => m.type === "music" && !m.parent); - return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation, music }; + return { + chat, + embeddings, + images, + video, + rerank, + audioTranscription, + audioSpeech, + moderation, + music, + }; }, [allModels]); const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => { @@ -1210,6 +1221,7 @@ export default function APIPageClient({ machineId }) { endpointData.chat, endpointData.embeddings, endpointData.images, + endpointData.video, endpointData.rerank, endpointData.audioTranscription, endpointData.audioSpeech, @@ -1393,6 +1405,25 @@ export default function APIPageClient({ machineId }) { copied={copied} baseUrl={currentEndpoint} /> + + {/* Video Generation */} + setExpandedEndpoint(expandedEndpoint === "video" ? null : "video")} + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + />
@@ -1541,7 +1572,7 @@ export default function APIPageClient({ machineId }) {

{t("protocolToolsLabel") || "Tools"}:{" "} - {mcpToolCount || 16} + {mcpToolCount || 29}

{t("protocolLastActivity") || "Last activity"}:{" "} diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 54efd143e9..5dad4cd35f 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -32,6 +32,18 @@ function formatBytes(bytes) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +function formatRelativeTime(timestamp) { + if (!timestamp || !Number.isFinite(timestamp)) return null; + const diffMs = Math.max(0, Date.now() - timestamp); + const diffMinutes = Math.floor(diffMs / 60000); + if (diffMinutes < 1) return "<1m"; + if (diffMinutes < 60) return `${diffMinutes}m`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d`; +} + const CB_STYLES = { CLOSED: { bg: "bg-green-500/10", text: "text-green-500", labelKey: "healthy" }, OPEN: { bg: "bg-red-500/10", text: "text-red-500", labelKey: "down" }, @@ -177,6 +189,7 @@ export default function HealthPage() { providerHealth, providerSummary, rateLimitStatus, + learnedLimits, lockouts, sessions, quotaMonitor, @@ -929,17 +942,41 @@ export default function HealthPage() {

{entries.map( ({ key, displayName, providerInfo, connectionId, model, status }: any) => { + const learned = learnedLimits?.[key] || null; const isActive = (status.queued || 0) + (status.running || 0) > 0; const isQueued = (status.queued || 0) > 0; + const learnedLimit = + typeof learned?.limit === "number" && learned.limit > 0 + ? learned.limit + : null; + const learnedRemaining = + typeof learned?.remaining === "number" ? learned.remaining : null; + const learnedMinTime = + typeof learned?.minTime === "number" && learned.minTime > 0 + ? learned.minTime + : null; + const learnedLastUpdated = + typeof learned?.lastUpdated === "number" ? learned.lastUpdated : null; + const lowRemaining = + learnedLimit != null && + learnedRemaining != null && + learnedRemaining / learnedLimit <= 0.1; + const exhausted = learnedRemaining != null && learnedRemaining <= 0; + const quotaProgress = + learnedLimit != null && learnedRemaining != null + ? Math.max(0, Math.min(100, (learnedRemaining / learnedLimit) * 100)) + : null; return (
@@ -970,16 +1007,49 @@ export default function HealthPage() {
- {isQueued ? t("queued") : isActive ? tc("active") : t("ok")} + {exhausted + ? t("limitExhausted") + : isQueued || lowRemaining + ? t("queued") + : isActive + ? tc("active") + : t("ok")}
+ {quotaProgress != null && ( +
+
+ {t("learnedFromHeaders")} + + {t("remainingOfLimit", { + remaining: learnedRemaining, + limit: learnedLimit, + })} + +
+
+
+
+
+ )}
schedule @@ -992,6 +1062,20 @@ export default function HealthPage() { {t("runningCount", { count: status.running || 0 })}
+ {(learnedMinTime != null || learnedLastUpdated != null) && ( +
+ {learnedMinTime != null && ( +

{t("throttleStatus", { value: `${learnedMinTime}ms/req` })}

+ )} + {learnedLastUpdated != null && ( +

+ {t("lastHeaderUpdate", { + age: formatRelativeTime(learnedLastUpdated) || t("notAvailable"), + })} +

+ )} +
+ )}
); } diff --git a/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx b/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx index dc5fcfa12e..303b956e12 100644 --- a/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx +++ b/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx @@ -13,9 +13,13 @@ interface AuditEntry { timestamp: string; action: string; actor: string; - target: string | null; - details: any; - ip_address: string | null; + target?: string | null; + details?: unknown; + metadata?: unknown; + ip_address?: string | null; + resourceType?: string | null; + status?: string | null; + requestId?: string | null; } const PAGE_SIZE = 25; @@ -28,6 +32,8 @@ export default function AuditLogTab() { const [actorFilter, setActorFilter] = useState(""); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); + const [totalCount, setTotalCount] = useState(0); + const [selectedEntry, setSelectedEntry] = useState(null); const t = useTranslations("logs"); const fetchEntries = useCallback(async () => { @@ -42,10 +48,12 @@ export default function AuditLogTab() { const res = await fetch(`/api/compliance/audit-log?${params.toString()}`); if (!res.ok) throw new Error(t("failedFetchAuditLog")); - const data: AuditEntry[] = await res.json(); + const data = (await res.json()) as AuditEntry[]; + const total = Number(res.headers.get("x-total-count") || "0"); setHasMore(data.length > PAGE_SIZE); setEntries(data.slice(0, PAGE_SIZE)); + setTotalCount(Number.isFinite(total) ? total : 0); } catch (err: any) { setError(err.message || t("failedFetchAuditLog")); } finally { @@ -58,8 +66,11 @@ export default function AuditLogTab() { }, [fetchEntries]); const handleSearch = () => { + if (offset === 0) { + fetchEntries(); + return; + } setOffset(0); - fetchEntries(); }; const formatTimestamp = (ts: string) => { @@ -71,6 +82,7 @@ export default function AuditLogTab() { }; const actionBadgeColor = (action: string) => { + if (action === "provider.warning") return "bg-amber-500/15 text-amber-300 border-amber-500/20"; if (action.includes("delete") || action.includes("remove")) return "bg-red-500/15 text-red-400 border-red-500/20"; if (action.includes("create") || action.includes("add")) @@ -82,13 +94,25 @@ export default function AuditLogTab() { return "bg-gray-500/15 text-gray-400 border-gray-500/20"; }; + const statusBadgeColor = (status?: string | null) => { + if (!status) return "bg-gray-500/15 text-gray-400 border-gray-500/20"; + if (status === "success") return "bg-green-500/15 text-green-400 border-green-500/20"; + if (status === "warning" || status === "blocked") + return "bg-amber-500/15 text-amber-300 border-amber-500/20"; + if (status === "error" || status === "failed") + return "bg-red-500/15 text-red-400 border-red-500/20"; + return "bg-blue-500/15 text-blue-400 border-blue-500/20"; + }; + return (
- {/* Header */}

{t("auditLog")}

{t("auditLogDesc")}

+

+ {t("totalEntries", { count: totalCount })} +

- {/* Filters */}
)} - {/* Table */}
@@ -153,6 +175,9 @@ export default function AuditLogTab() { + @@ -160,17 +185,23 @@ export default function AuditLogTab() { {t("target")} + + {entries.length === 0 && !loading ? ( - @@ -187,19 +218,43 @@ export default function AuditLogTab() { - {entry.action} + + {entry.action === "provider.warning" && ( + warning + )} + {entry.action} + + + + - + + )) )} @@ -207,7 +262,6 @@ export default function AuditLogTab() {
{t("action")} + {t("status")} + {t("actor")} - {t("details")} + {t("resourceType")} {t("ipAddress")} + {t("requestId")} + + {t("details")} +
+ {t("noEntries")}
+ + {entry.status || t("notAvailable")} {entry.actor} {entry.target || t("notAvailable")} - {entry.details ? JSON.stringify(entry.details) : t("notAvailable")} + + {entry.resourceType || t("notAvailable")} {entry.ip_address || t("notAvailable")} + {entry.requestId || t("notAvailable")} + + +
- {/* Pagination */}

{t("showing", { count: entries.length, offset })} @@ -229,6 +283,97 @@ export default function AuditLogTab() {

+ + {selectedEntry && ( +
+
+
+
+

+ {selectedEntry.action} +

+

+ {t("auditModalSubtitle", { + actor: selectedEntry.actor || t("notAvailable"), + target: selectedEntry.target || t("notAvailable"), + })} +

+
+ +
+ +
+ {selectedEntry.action === "provider.warning" && ( +
+
+ warning +
+

{t("providerWarningTitle")}

+

{t("providerWarningDesc")}

+
+
+
+ )} + +
+
+

+ {t("eventMetadata")} +

+
+
+
{t("timestamp")}
+
+ {formatTimestamp(selectedEntry.timestamp)} +
+
+
+
{t("status")}
+
+ {selectedEntry.status || t("notAvailable")} +
+
+
+
{t("resourceType")}
+
+ {selectedEntry.resourceType || t("notAvailable")} +
+
+
+
{t("requestId")}
+
+ {selectedEntry.requestId || t("notAvailable")} +
+
+
+
{t("ipAddress")}
+
+ {selectedEntry.ip_address || t("notAvailable")} +
+
+
+
+ +
+

+ {t("eventPayload")} +

+
+                    {JSON.stringify(selectedEntry.metadata || selectedEntry.details || {}, null, 2)}
+                  
+
+
+
+
+
+ )}
); } diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index f547814b7a..8c7c64eeeb 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components"; import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer"; import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel"; @@ -22,12 +23,22 @@ const TAB_TO_LOG_TYPE: Record = { }; export default function LogsPage() { - const [activeTab, setActiveTab] = useState("request-logs"); + const searchParams = useSearchParams(); + const requestedTab = searchParams.get("tab"); + const [activeTab, setActiveTab] = useState( + requestedTab && TAB_TO_LOG_TYPE[requestedTab] ? requestedTab : "request-logs" + ); const [showExport, setShowExport] = useState(false); const [exporting, setExporting] = useState(false); const dropdownRef = useRef(null); const t = useTranslations("logs"); + useEffect(() => { + if (requestedTab && TAB_TO_LOG_TYPE[requestedTab] && requestedTab !== activeTab) { + setActiveTab(requestedTab); + } + }, [activeTab, requestedTab]); + useEffect(() => { function handleClickOutside(e: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCountBadge.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCountBadge.tsx new file mode 100644 index 0000000000..33bae801d9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCountBadge.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +interface ProviderCountBadgeProps { + configured: number; + total: number; +} + +export default function ProviderCountBadge({ configured, total }: ProviderCountBadgeProps) { + const t = useTranslations("providers"); + + if (total === 0) return null; + + const colorClass = + configured === 0 + ? "text-text-muted" + : configured === total + ? "text-green-500" + : "text-amber-500"; + + return ( + + {configured}/{total} + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 0921de4317..053743947e 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -32,11 +32,37 @@ import { buildStaticProviderEntries, filterConfiguredProviderEntries, } from "./providerPageUtils"; +import type { ProviderEntry } from "./providerPageUtils"; import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage"; +import ProviderCountBadge from "./components/ProviderCountBadge"; -const CC_COMPATIBLE_LABEL = "CC Compatible"; -const ADD_CC_COMPATIBLE_LABEL = "Add CC Compatible"; const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; +const IMAGE_ONLY_PROVIDER_IDS = new Set([ + "nanobanana", + "fal-ai", + "stability-ai", + "black-forest-labs", + "recraft", + "topaz", +]); +const AGGREGATOR_PROVIDER_IDS = new Set([ + "openrouter", + "synthetic", + "kilo-gateway", + "aimlapi", + "novita", + "piapi", + "getgoapi", + "laozhang", + "vercel-ai-gateway", +]); + +function countConfigured(entries: ProviderEntry[]) { + return { + configured: entries.filter((entry) => Number(entry.stats?.total || 0) > 0).length, + total: entries.length, + }; +} // Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard function getStatusDisplay(connected, error, errorCode, t) { @@ -127,6 +153,8 @@ export default function ProvidersPage() { const notify = useNotificationStore(); const t = useTranslations("providers"); const tc = useTranslations("common"); + const ccCompatibleLabel = t("ccCompatibleLabel"); + const addCcCompatibleLabel = t("addCcCompatible"); useEffect(() => { setShowConfiguredOnly(readConfiguredOnlyPreference()); @@ -194,20 +222,20 @@ export default function ProvidersPage() { if (res.ok && data.success) { if (data.count > 0) { notify.success( - `Imported ${data.count} credentials from Zed IDE (${data.providers.join(", ")}).` + t("zedImportSuccess", { count: data.count, providers: data.providers.join(", ") }) ); // Refresh connections silently const connectionsRes = await fetch("/api/providers"); const connectionsData = await connectionsRes.json(); if (connectionsRes.ok) setConnections(connectionsData.connections || []); } else { - notify.info("No supported OAuth credentials found in Zed IDE."); + notify.info(t("zedImportNone")); } } else { - notify.error(data.error || "Failed to import from Zed IDE."); + notify.error(data.error || t("zedImportFailed")); } } catch (error) { - notify.error("Network error while trying to import from Zed."); + notify.error(t("zedImportNetworkError")); } finally { setImportingZed(false); } @@ -386,65 +414,103 @@ export default function ProvidersPage() { ) .map((node) => ({ id: node.id, - name: node.name || CC_COMPATIBLE_LABEL, + name: node.name || ccCompatibleLabel, color: "#B45309", textIcon: "CC", })); + const oauthProviderEntriesAll = buildMergedOAuthProviderEntries( + OAUTH_PROVIDERS, + FREE_PROVIDERS, + getProviderStats + ); const oauthProviderEntries = filterConfiguredProviderEntries( - buildMergedOAuthProviderEntries(OAUTH_PROVIDERS, FREE_PROVIDERS, getProviderStats), + oauthProviderEntriesAll, showConfiguredOnly, searchQuery ); - const apiKeyProviderEntries = filterConfiguredProviderEntries( - buildStaticProviderEntries("apikey", getProviderStats), + const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats); + const llmProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter( + (entry) => + !IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) && + !AGGREGATOR_PROVIDER_IDS.has(entry.providerId) + ), + showConfiguredOnly, + searchQuery + ); + const aggregatorProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); + const imageProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)), showConfiguredOnly, searchQuery ); + const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); const webCookieProviderEntries = filterConfiguredProviderEntries( - buildStaticProviderEntries("web-cookie", getProviderStats), + webCookieProviderEntriesAll, showConfiguredOnly, searchQuery ); + const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats); + const localProviderEntries = filterConfiguredProviderEntries( + localProviderEntriesAll, + showConfiguredOnly, + searchQuery + ); + + const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats); const searchProviderEntries = filterConfiguredProviderEntries( - buildStaticProviderEntries("search", getProviderStats), + searchProviderEntriesAll, showConfiguredOnly, searchQuery ); + const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats); const audioProviderEntries = filterConfiguredProviderEntries( - buildStaticProviderEntries("audio", getProviderStats), + audioProviderEntriesAll, showConfiguredOnly, searchQuery ); + const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats); + const upstreamProxyEntries = filterConfiguredProviderEntries( + upstreamProxyEntriesAll, + showConfiguredOnly, + searchQuery + ); + + const compatibleProviderEntriesAll = [ + ...compatibleProviders.map((provider) => ({ + providerId: provider.id, + provider, + stats: getProviderStats(provider.id, "apikey"), + displayAuthType: "compatible" as const, + toggleAuthType: "apikey" as const, + })), + ...anthropicCompatibleProviders.map((provider) => ({ + providerId: provider.id, + provider, + stats: getProviderStats(provider.id, "apikey"), + displayAuthType: "compatible" as const, + toggleAuthType: "apikey" as const, + })), + ...ccCompatibleProviders.map((provider) => ({ + providerId: provider.id, + provider, + stats: getProviderStats(provider.id, "apikey"), + displayAuthType: "compatible" as const, + toggleAuthType: "apikey" as const, + })), + ]; const compatibleProviderEntries = filterConfiguredProviderEntries( - [ - ...compatibleProviders.map((provider) => ({ - providerId: provider.id, - provider, - stats: getProviderStats(provider.id, "apikey"), - displayAuthType: "compatible" as const, - toggleAuthType: "apikey" as const, - })), - ...anthropicCompatibleProviders.map((provider) => ({ - providerId: provider.id, - provider, - stats: getProviderStats(provider.id, "apikey"), - displayAuthType: "compatible" as const, - toggleAuthType: "apikey" as const, - })), - ...ccCompatibleProviders.map((provider) => ({ - providerId: provider.id, - provider, - stats: getProviderStats(provider.id, "apikey"), - displayAuthType: "compatible" as const, - toggleAuthType: "apikey" as const, - })), - ], + compatibleProviderEntriesAll, showConfiguredOnly, searchQuery ); @@ -507,13 +573,15 @@ export default function ProvidersPage() { className={`font-semibold ${expirations.summary.expired > 0 ? "text-red-500" : "text-amber-500"}`} > {expirations.summary.expired > 0 - ? `${expirations.summary.expired} Provider connection(s) expired` - : `${expirations.summary.expiringSoon} Provider connection(s) expiring soon`} + ? t("expirationBannerExpired", { count: expirations.summary.expired }) + : t("expirationBannerExpiringSoon", { + count: expirations.summary.expiringSoon, + })}

{expirations.summary.expired > 0 - ? "Immediate action required. Expired connections will permanently fail." - : "Please review and renew expiring connections to avoid disruption."} + ? t("expirationBannerExpiredDesc") + : t("expirationBannerExpiringSoonDesc")}

@@ -525,6 +593,7 @@ export default function ProvidersPage() {

{t("oauthProviders")}{" "} +

{importingZed ? "sync" : "download"} - {importingZed ? "Importing..." : "Import from Zed"} + {importingZed ? t("zedImporting") : t("zedImportButton")} {oauthEnvRepairStatus?.available && oauthEnvRepairStatus.missingCount > 0 && (
-
- {apiKeyProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
+ {llmProviderEntries.length > 0 && ( +
+

+ {t("llmProviders")} +

+
+ {llmProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {aggregatorProviderEntries.length > 0 && ( +
+

+ {t("aggregatorsGateways")} +

+
+ {aggregatorProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {imageProviderEntries.length > 0 && ( +
+

+ {t("imageProviders")} +

+
+ {imageProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} {/* Web / Cookie Providers */} @@ -644,8 +765,12 @@ export default function ProvidersPage() {

- Web / Cookie Providers{" "} - + {t("webCookieProviders")}{" "} + +

)} + {/* Local / Self-Hosted Providers */} + {localProviderEntries.length > 0 && ( +
+
+

+ {t("localProviders")}{" "} + + +

+
+
+ {localProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {/* Upstream Proxy Providers */} + {upstreamProxyEntries.length > 0 && ( +
+
+

+ {t("upstreamProxyProviders")}{" "} + + +

+
+
+ {upstreamProxyEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}

{t("compatibleProviders")}{" "} +

{(compatibleProviders.length > 0 || @@ -789,7 +982,7 @@ export default function ProvidersPage() { )} {ccCompatibleProviderEnabled && ( )}
diff --git a/src/app/(dashboard)/dashboard/translator/exampleTemplates.tsx b/src/app/(dashboard)/dashboard/translator/exampleTemplates.tsx index 47959451d8..7868366a0f 100644 --- a/src/app/(dashboard)/dashboard/translator/exampleTemplates.tsx +++ b/src/app/(dashboard)/dashboard/translator/exampleTemplates.tsx @@ -20,6 +20,12 @@ export function getExampleTemplates(t: TranslatorMessage) { const systemPromptInstruction = t("templatePayloads.systemPrompt.systemInstruction"); const systemPromptQuestion = t("templatePayloads.systemPrompt.question"); const streamingPrompt = t("templatePayloads.streaming.prompt"); + const visionSystem = t("templatePayloads.vision.system"); + const visionUserPrompt = t("templatePayloads.vision.userPrompt"); + const visionImageUrl = t("templatePayloads.vision.imageUrl"); + const schemaCoercionPrompt = t("templatePayloads.schemaCoercion.userPrompt"); + const schemaCoercionDescription = t("templatePayloads.schemaCoercion.toolDescription"); + const schemaCoercionFieldDescription = t("templatePayloads.schemaCoercion.cityDescription"); return [ { @@ -291,6 +297,97 @@ export function getExampleTemplates(t: TranslatorMessage) { }, }, }, + { + id: "vision", + name: t("templateNames.vision"), + icon: "image", + description: t("templateDescriptions.vision"), + formats: { + openai: { + model: "gpt-4o", + messages: [ + { role: "system", content: visionSystem }, + { + role: "user", + content: [ + { type: "text", text: visionUserPrompt }, + { + type: "image_url", + image_url: { url: visionImageUrl }, + }, + ], + }, + ], + stream: true, + }, + gemini: { + model: "gemini-2.5-flash", + contents: [ + { + role: "user", + parts: [ + { text: visionUserPrompt }, + { fileData: { mimeType: "image/jpeg", fileUri: visionImageUrl } }, + ], + }, + ], + systemInstruction: { + parts: [{ text: visionSystem }], + }, + }, + "openai-responses": { + model: "gpt-4o", + instructions: visionSystem, + input: [ + { + role: "user", + content: [ + { type: "input_text", text: visionUserPrompt }, + { type: "input_image", image_url: visionImageUrl }, + ], + }, + ], + }, + }, + }, + { + id: "schema-coercion", + name: t("templateNames.schema-coercion"), + icon: "schema", + description: t("templateDescriptions.schema-coercion"), + formats: { + openai: { + model: "gpt-4o", + messages: [{ role: "user", content: schemaCoercionPrompt }], + tools: [ + { + type: "function", + function: { + name: "lookup_city_weather", + description: schemaCoercionDescription, + parameters: { + type: "object", + additionalProperties: false, + properties: { + city: { type: "string", description: schemaCoercionFieldDescription }, + options: { + type: "object", + additionalProperties: false, + properties: { + units: { type: "string", enum: ["metric", "imperial"] }, + includeHourly: { type: "boolean" }, + }, + }, + }, + required: ["city"], + }, + }, + }, + ], + stream: true, + }, + }, + }, ]; } @@ -302,6 +399,7 @@ export const FORMAT_META = { "openai-responses": { label: "OpenAI Responses", color: "amber", icon: "swap_horiz" }, claude: { label: "Claude", color: "orange", icon: "psychology" }, gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" }, + "gemini-cli": { label: "Gemini CLI", color: "sky", icon: "terminal" }, antigravity: { label: "Antigravity", color: "purple", icon: "rocket_launch" }, kiro: { label: "Kiro", color: "cyan", icon: "terminal" }, cursor: { label: "Cursor", color: "pink", icon: "edit" }, diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx index 20891d215f..90cb6d3087 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx @@ -11,6 +11,7 @@ const FORMAT_MODEL_PREFIXES = { "openai-responses": ["gpt-", "o1-", "o3-", "o4-"], claude: ["claude-"], gemini: ["gemini-"], + "gemini-cli": ["gemini-"], }; /** diff --git a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx index 42d9eb8b42..c5d424aa20 100644 --- a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx @@ -246,7 +246,7 @@ export default function BudgetTab() { )}
-

Active Period Spend

+

{t("activePeriodSpend")}

{formatCurrency(activeCost)}

{activeLimit > 0 && (

- Interval: {(budget?.resetInterval || form.resetInterval || "daily").toUpperCase()} + {t("intervalLabel")}:{" "} + {t((budget?.resetInterval || form.resetInterval || "daily").toLowerCase())}

-

Next reset (UTC): {formatDateTime(budget?.budgetResetAt)}

+

{t("nextResetUtc", { value: formatDateTime(budget?.budgetResetAt) })}

@@ -279,11 +280,11 @@ export default function BudgetTab() { onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })} /> setForm({ ...form, weeklyLimitUsd: e.target.value })} /> @@ -308,27 +309,31 @@ export default function BudgetTab() {
- +
setForm({ ...form, resetTime: e.target.value })} />
-

Weekly limit: {weeklyLimit > 0 ? formatCurrency(weeklyLimit) : "Not configured"}

-

Next reset (UTC): {formatDateTime(budget?.budgetResetAt)}

+

+ {t("weeklyLimitSummary", { + value: weeklyLimit > 0 ? formatCurrency(weeklyLimit) : t("notConfigured"), + })} +

+

{t("nextResetUtc", { value: formatDateTime(budget?.budgetResetAt) })}

+ + +
+
+
+
{t("clientPayload")}
+

+ {selectedRow.clientEndpoint || t("notAvailable")} +

+
+
+                  {JSON.stringify(selectedRow.clientRequest || {}, null, 2)}
+                
+
+ +
+
+
{t("upstreamPayload")}
+

+ {selectedRow.providerUrl || t("upstreamNotSentYet")} +

+
+
+                  {JSON.stringify(selectedRow.providerRequest || {}, null, 2)}
+                
+
+
+ + + )} ); } diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 2e9789c5fd..17843fe5d4 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -424,24 +424,6 @@ export const APIKEY_PROVIDERS = { textIcon: "OC", website: "https://ollama.com/settings/api-keys", }, - sdwebui: { - id: "sdwebui", - alias: "sdwebui", - name: "SD WebUI", - icon: "brush", - color: "#FF7043", - textIcon: "SD", - website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", - }, - comfyui: { - id: "comfyui", - alias: "comfyui", - name: "ComfyUI", - icon: "account_tree", - color: "#4CAF50", - textIcon: "CF", - website: "https://github.com/comfyanonymous/ComfyUI", - }, huggingface: { id: "huggingface", alias: "hf", @@ -978,6 +960,34 @@ export const APIKEY_PROVIDERS = { }, }; +// Local / Self-Hosted Providers +export const LOCAL_PROVIDERS = { + sdwebui: { + id: "sdwebui", + alias: "sdwebui", + name: "SD WebUI", + icon: "brush", + color: "#FF7043", + textIcon: "SD", + website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", + authHint: + "No API key required. Configure the local WebUI base URL (default: http://localhost:7860).", + localDefault: "http://localhost:7860", + }, + comfyui: { + id: "comfyui", + alias: "comfyui", + name: "ComfyUI", + icon: "account_tree", + color: "#4CAF50", + textIcon: "CF", + website: "https://github.com/comfyanonymous/ComfyUI", + authHint: + "No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188).", + localDefault: "http://localhost:8188", + }, +}; + // Search Providers export const SEARCH_PROVIDERS = { "perplexity-search": { @@ -1170,6 +1180,7 @@ export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS, ...WEB_COOKIE_PROVIDERS, + ...LOCAL_PROVIDERS, ...SEARCH_PROVIDERS, ...AUDIO_ONLY_PROVIDERS, ...UPSTREAM_PROXY_PROVIDERS, @@ -1237,5 +1248,7 @@ validateProviders(FREE_PROVIDERS, "FREE_PROVIDERS"); validateProviders(OAUTH_PROVIDERS, "OAUTH_PROVIDERS"); validateProviders(APIKEY_PROVIDERS, "APIKEY_PROVIDERS"); validateProviders(WEB_COOKIE_PROVIDERS, "WEB_COOKIE_PROVIDERS"); +validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS"); validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS"); validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS"); +validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS"); diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 94933e007e..1d071ef65b 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -81,7 +81,6 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" }, { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, - { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" }, { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, ]; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index c3fe80c1fd..0ebe539221 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -243,7 +243,8 @@ export const createProviderSchema = z }) .superRefine((data, ctx) => { const apiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : ""; - if (data.provider !== "searxng-search" && apiKey.length === 0) { + const apiKeyOptionalProviders = new Set(["searxng-search", "sdwebui", "comfyui"]); + if (!apiKeyOptionalProviders.has(data.provider) && apiKey.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "API key is required", @@ -1358,10 +1359,48 @@ export const dbBackupRestoreSchema = z.object({ backupId: z.string().trim().min(1, "backupId is required"), }); -export const evalRunSuiteSchema = z.object({ - suiteId: z.string().trim().min(1, "suiteId is required"), - outputs: z.record(z.string(), z.string()), -}); +const evalTargetSchema = z + .object({ + type: z.enum(["suite-default", "model", "combo"]), + id: z.string().trim().min(1).optional().nullable(), + }) + .superRefine((value, ctx) => { + if (value.type === "suite-default") { + return; + } + + if (typeof value.id !== "string" || value.id.trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "target.id is required for model and combo targets", + path: ["id"], + }); + } + }); + +export const evalRunSuiteSchema = z + .object({ + suiteId: z.string().trim().min(1, "suiteId is required"), + outputs: z.record(z.string(), z.string()).optional(), + target: evalTargetSchema.optional(), + compareTarget: evalTargetSchema.optional(), + apiKeyId: z.string().trim().min(1, "apiKeyId must not be empty").optional(), + }) + .superRefine((value, ctx) => { + if (value.compareTarget) { + const primaryType = value.target?.type || "suite-default"; + const primaryId = value.target?.id?.trim() || ""; + const compareId = value.compareTarget.id?.trim() || ""; + + if (primaryType === value.compareTarget.type && primaryId === compareId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "compareTarget must differ from target", + path: ["compareTarget"], + }); + } + } + }); const accessScheduleSchema = z.object({ enabled: z.boolean(), diff --git a/tests/unit/provider-audit.test.ts b/tests/unit/provider-audit.test.ts new file mode 100644 index 0000000000..023f9377fe --- /dev/null +++ b/tests/unit/provider-audit.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const providerAudit = await import("../../src/lib/compliance/providerAudit.ts"); + +test("extractProviderWarnings finds sanitizer and warning fields without duplicating entries", () => { + const warnings = providerAudit.extractProviderWarnings( + { + message: "[SANITIZER] Prompt injection detected: prompt_leak", + warnings: ["[SANITIZER] Prompt injection detected: prompt_leak"], + }, + { + detail: { + warning: "Content was filtered by the upstream safety filter.", + }, + } + ); + + assert.deepEqual(warnings, [ + "[SANITIZER] Prompt injection detected: prompt_leak", + "Content was filtered by the upstream safety filter.", + ]); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index e119ba1221..e1320efd46 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -214,7 +214,8 @@ test("configured-only preference storage round-trips correctly", () => { assert.equal(providerPageStorage.readConfiguredOnlyPreference(mockStorage), false); }); -test("static catalog entries resolve search, audio, web-cookie and upstream providers", () => { +test("static catalog entries resolve local, search, audio, web-cookie and upstream providers", () => { + const localProvider = providerPageUtils.resolveDashboardProviderInfo("sdwebui"); const searchProvider = providerPageUtils.resolveDashboardProviderInfo("brave-search"); const audioProvider = providerPageUtils.resolveDashboardProviderInfo("assemblyai"); const webCookieProvider = providerPageUtils.resolveDashboardProviderInfo("grok-web"); @@ -223,6 +224,9 @@ test("static catalog entries resolve search, audio, web-cookie and upstream prov const museSparkWebProvider = providerPageUtils.resolveDashboardProviderInfo("muse-spark-web"); const upstreamProvider = providerPageUtils.resolveDashboardProviderInfo("cliproxyapi"); + assert.equal(localProvider?.category, "local"); + assert.equal(localProvider?.name, providers.LOCAL_PROVIDERS.sdwebui.name); + assert.equal(searchProvider?.category, "search"); assert.equal(searchProvider?.name, providers.SEARCH_PROVIDERS["brave-search"].name); @@ -250,6 +254,7 @@ test("static catalog entries resolve search, audio, web-cookie and upstream prov test("managed provider connection ids include supported static categories and exclude upstream proxy", () => { assert.equal(providerCatalog.isManagedProviderConnectionId("qoder"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("sdwebui"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("assemblyai"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("grok-web"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("perplexity-web"), true); @@ -263,6 +268,10 @@ test("managed provider connection ids include supported static categories and ex test("grok-web taxonomy stays web-cookie only and does not leak into api-key entries", () => { assert.equal("grok-web" in providers.APIKEY_PROVIDERS, false); assert.equal("grok-web" in providers.WEB_COOKIE_PROVIDERS, true); + assert.equal("sdwebui" in providers.APIKEY_PROVIDERS, false); + assert.equal("sdwebui" in providers.LOCAL_PROVIDERS, true); + assert.equal("comfyui" in providers.APIKEY_PROVIDERS, false); + assert.equal("comfyui" in providers.LOCAL_PROVIDERS, true); assert.equal("blackbox-web" in providers.APIKEY_PROVIDERS, false); assert.equal("blackbox-web" in providers.WEB_COOKIE_PROVIDERS, true); assert.equal("muse-spark-web" in providers.APIKEY_PROVIDERS, false); @@ -271,14 +280,33 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent const apiKeyEntries = providerPageUtils.buildStaticProviderEntries("apikey", () => ({ total: 0, })); + const localEntries = providerPageUtils.buildStaticProviderEntries("local", () => ({ + total: 0, + })); const webCookieEntries = providerPageUtils.buildStaticProviderEntries("web-cookie", () => ({ total: 0, })); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "sdwebui"), + false + ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "comfyui"), + false + ); assert.equal( apiKeyEntries.some((entry) => entry.providerId === "grok-web"), false ); + assert.equal( + localEntries.some((entry) => entry.providerId === "sdwebui"), + true + ); + assert.equal( + localEntries.some((entry) => entry.providerId === "comfyui"), + true + ); assert.equal( webCookieEntries.some((entry) => entry.providerId === "grok-web"), true diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 607d7fa665..09375c9cb8 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -28,8 +28,18 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("providers route accepts managed audio, web-cookie and search providers", async () => { +test("providers route accepts managed local, audio, web-cookie and search providers", async () => { const cases = [ + { + provider: "sdwebui", + body: { + provider: "sdwebui", + name: "SD WebUI Local", + providerSpecificData: { + baseUrl: "http://localhost:7860", + }, + }, + }, { provider: "assemblyai", body: { diff --git a/tests/unit/usage-analytics.test.ts b/tests/unit/usage-analytics.test.ts index 45b4c25a45..78e934b30b 100644 --- a/tests/unit/usage-analytics.test.ts +++ b/tests/unit/usage-analytics.test.ts @@ -19,6 +19,7 @@ function clearPendingRequests() { const pending = usageHistory.getPendingRequests(); for (const key of Object.keys(pending.byModel)) delete pending.byModel[key]; for (const key of Object.keys(pending.byAccount)) delete pending.byAccount[key]; + for (const key of Object.keys(pending.details || {})) delete pending.details[key]; } async function resetStorage() { @@ -310,3 +311,34 @@ test("recent request summaries are generated from SQLite call logs", async () => assert.match(recent[0], /Named Account/); assert.match(recent[0], /205 \| 206 \| 200$/); }); + +test("pending request metadata stores sanitized payload previews and clears after completion", async () => { + usageHistory.trackPendingRequest("gpt-test", "openai", "conn-preview", true, { + clientEndpoint: "/v1/chat/completions", + clientRequest: { + token: "super-secret-token", + messages: [{ role: "user", content: "hello" }], + }, + }); + + usageHistory.updatePendingRequest("gpt-test", "openai", "conn-preview", { + providerUrl: "https://api.example.com/v1/chat/completions", + providerRequest: { + authorization: "Bearer super-secret-token", + messages: [{ role: "user", content: "hello" }], + }, + }); + + const pending = usageHistory.getPendingRequests(); + const detail = pending.details["conn-preview"]["gpt-test (openai)"]; + const clientRequestPreview = detail.clientRequest as Record; + const providerRequestPreview = detail.providerRequest as Record; + + assert.equal(detail.clientEndpoint, "/v1/chat/completions"); + assert.equal(clientRequestPreview.token, "[REDACTED]"); + assert.equal(providerRequestPreview.authorization, "[REDACTED]"); + assert.equal(detail.providerUrl, "https://api.example.com/v1/chat/completions"); + + usageHistory.trackPendingRequest("gpt-test", "openai", "conn-preview", false); + assert.equal(pending.details["conn-preview"], undefined); +});