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); +});