mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-04-28 06:19:46 +00:00
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.
This commit is contained in:
parent
05005acabd
commit
47468636e4
45 changed files with 2501 additions and 702 deletions
12
AGENTS.md
12
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string>([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;
|
||||
|
|
|
|||
62
open-sse/mcp-server/__tests__/cacheTools.test.ts
Normal file
62
open-sse/mcp-server/__tests__/cacheTools.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
||||
|
|
@ -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<void> {
|
|||
version,
|
||||
scopesEnforced: MCP_ENFORCE_SCOPES,
|
||||
allowedScopes: Array.from(MCP_ALLOWED_SCOPES),
|
||||
toolCount: MCP_TOOLS.length,
|
||||
toolCount: TOTAL_MCP_TOOL_COUNT,
|
||||
});
|
||||
const stopHeartbeatOnce = () => {
|
||||
stopHeartbeat();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,8 +62,6 @@ export default function AgentsPage() {
|
|||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [opencodeConfigLoading, setOpencodeConfigLoading] = useState(false);
|
||||
const [opencodeConfigDone, setOpencodeConfigDone] = useState(false);
|
||||
const [newAgent, setNewAgent] = useState({
|
||||
name: "",
|
||||
binary: "",
|
||||
|
|
@ -189,6 +187,46 @@ export default function AgentsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-main">{t("architectureTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">{t("architectureDescription")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/cli-tools"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-blue-500/20 px-3 py-1.5 text-xs text-blue-500 hover:bg-blue-500/10 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
{t("cliToolsRedirectCta")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
|
||||
{t("flowOmniRoute")}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
|
||||
{t("flowSpawn")}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
|
||||
{t("flowLocalBinary")}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
|
||||
{t("flowExecute")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-500/15 bg-surface/40 p-3 text-sm text-text-muted">
|
||||
<span className="font-medium text-text-main">{t("cliToolsRedirectTitle")}</span>{" "}
|
||||
{t("cliToolsRedirectDesc")}{" "}
|
||||
<Link href="/dashboard/cli-tools" className="text-blue-500 hover:underline">
|
||||
{t("openCliTools")}
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
|
|
@ -386,92 +424,6 @@ export default function AgentsPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* OpenCode Config Generator — shown only when opencode is detected */}
|
||||
{agents.find((a) => a.id === "opencode" && a.installed) && (
|
||||
<Card>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]">code_blocks</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base font-semibold">{t("opencodeIntegration")}</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
{t("opencodeDetected", {
|
||||
version: agents.find((a) => a.id === "opencode")?.version || "",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
{t("opencodeDesc", {
|
||||
configFile: "opencode.json",
|
||||
command: "opencode",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={opencodeConfigLoading}
|
||||
onClick={async () => {
|
||||
setOpencodeConfigLoading(true);
|
||||
setOpencodeConfigDone(false);
|
||||
try {
|
||||
// Fetch available models
|
||||
const modelsRes = await fetch("/v1/models");
|
||||
const modelsData = modelsRes.ok ? await modelsRes.json() : { data: [] };
|
||||
const models: Record<string, { name: string }> = {};
|
||||
for (const m of modelsData.data || []) {
|
||||
models[m.id] = { name: m.id };
|
||||
}
|
||||
// Build opencode.json
|
||||
const baseURL = window.location.origin + "/v1";
|
||||
const config = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
omniroute: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "OmniRoute",
|
||||
options: {
|
||||
baseURL,
|
||||
apiKey: "YOUR_OMNIROUTE_API_KEY",
|
||||
},
|
||||
models:
|
||||
Object.keys(models).length > 0
|
||||
? models
|
||||
: { "gpt-4o": { name: "gpt-4o" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
// Download as file
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "opencode.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setOpencodeConfigDone(true);
|
||||
setTimeout(() => setOpencodeConfigDone(false), 3000);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate opencode.json:", err);
|
||||
} finally {
|
||||
setOpencodeConfigLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] mr-1">
|
||||
{opencodeConfigDone ? "check" : "download"}
|
||||
</span>
|
||||
{opencodeConfigDone
|
||||
? t("downloaded")
|
||||
: t("downloadConfig", { file: "opencode.json" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Custom Agent */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
|
|
|||
|
|
@ -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<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEntry, setSelectedEntry] = useState<AuditEntry | null>(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 (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-12 text-[var(--text-muted,#666)]">
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
className="w-12 h-12 mb-4 opacity-50"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No Configuration Audit Logs found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border,#333)] text-[var(--text-secondary,#aaa)] text-sm">
|
||||
<th className="px-6 py-4 font-medium">Timestamp</th>
|
||||
<th className="px-6 py-4 font-medium">Action</th>
|
||||
<th className="px-6 py-4 font-medium">Target</th>
|
||||
<th className="px-6 py-4 font-medium">Actor</th>
|
||||
<th className="px-6 py-4 font-medium">Resource/IP</th>
|
||||
<th className="px-6 py-4 font-medium text-right">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border,#333)]">
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="hover:bg-[var(--hover-bg,#2a2a3e)] transition-colors group"
|
||||
>
|
||||
<td className="px-6 py-3 whitespace-nowrap text-sm text-[var(--text-secondary,#aaa)]">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-3 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-md border capitalize ${getActionColor(entry.action)}`}
|
||||
>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 whitespace-nowrap text-sm text-[var(--text-primary,#fff)] font-medium capitalize">
|
||||
{entry.target || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-3 whitespace-nowrap text-sm text-[var(--text-muted,#666)] capitalize">
|
||||
{entry.actor}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-[var(--text-secondary,#aaa)] font-mono">
|
||||
{entry.resource_type || entry.ip_address || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-3 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
className="px-3 py-1 text-xs font-medium text-[var(--text-primary,#fff)] bg-[var(--accent,#7c3aed)] hover:bg-opacity-80 rounded-md transition-colors invisible group-hover:visible"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedEntry && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)] rounded-2xl w-full max-w-4xl max-h-[85vh] flex flex-col shadow-2xl overflow-hidden scale-in">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-[var(--border,#333)] bg-[#15151f]">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary,#fff)] capitalize">
|
||||
{selectedEntry.action}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary,#aaa)] font-mono mt-1">
|
||||
Actor: {selectedEntry.actor} • Target: {selectedEntry.target || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
className="p-2 text-[var(--text-muted,#666)] hover:text-white bg-[var(--hover-bg,#2a2a3e)] hover:bg-[#333] rounded-full transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" className="w-5 h-5">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 bg-[#1a1a24] text-sm text-[var(--text-secondary,#aaa)]">
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<strong>ID:</strong> {selectedEntry.id}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Timestamp:</strong> {new Date(selectedEntry.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> {selectedEntry.status || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>IP Address:</strong> {selectedEntry.ip_address || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Resource Type:</strong> {selectedEntry.resource_type || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Request ID:</strong> {selectedEntry.request_id || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider">
|
||||
Event Payload (Details/Metadata)
|
||||
</h4>
|
||||
<pre className="bg-[#111116] border border-gray-500/20 rounded-xl p-4 overflow-x-auto text-xs font-mono text-gray-300 shadow-inner">
|
||||
{JSON.stringify(selectedEntry.metadata || selectedEntry.details || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/audit?summary=true")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSummary(data))
|
||||
.catch((err) => console.error(err));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary,#fff)]">
|
||||
Configuration & Security Audit
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary,#aaa)] mt-1">
|
||||
Track administrative actions, authentication events, and security logs across the
|
||||
system.
|
||||
</p>
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="flex items-center gap-4 text-sm bg-[var(--card-bg,#1e1e2e)] px-4 py-2 rounded-xl border border-[var(--border,#333)]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[var(--text-muted,#666)]">Total Audits</span>
|
||||
<span className="font-mono text-[var(--text-primary,#fff)] font-semibold">
|
||||
{summary.totalEntries}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)] rounded-xl overflow-hidden shadow-sm">
|
||||
<ConfigAuditViewer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect("/dashboard/logs?tab=audit-logs");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t("toolCategories")}</h2>
|
||||
<p className="text-xs text-text-muted mt-1">{t("toolCategoriesDesc")}</p>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("visibleToolsCount", { count: toolEntries.length })}
|
||||
</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "auto", label: t("autoConfiguredTab") },
|
||||
{ value: "guided", label: t("guidedClientsTab") },
|
||||
{ value: "mitm", label: t("mitmClientsTab") },
|
||||
{ value: "all", label: t("allToolsTab") },
|
||||
]}
|
||||
value={activeCategory}
|
||||
onChange={setActiveCategory}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!hasActiveProviders && (
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -392,7 +444,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
|||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.entries(CLI_TOOLS).map(([toolId, tool]) => {
|
||||
{toolEntries.map(([toolId, tool]) => {
|
||||
const docsHref = getToolDocsHref(toolId, tool);
|
||||
const isExternalDocs = /^https?:\/\//i.test(docsHref);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<EndpointSection
|
||||
icon="videocam"
|
||||
iconColor="text-red-500"
|
||||
iconBg="bg-red-500/10"
|
||||
title={t("videoGeneration") || "Video Generation"}
|
||||
path="/v1/videos/generations"
|
||||
description={
|
||||
t("videoDesc") ||
|
||||
"Generate videos via ComfyUI, Stable Diffusion WebUI, and compatible providers"
|
||||
}
|
||||
models={endpointData.video}
|
||||
expanded={expandedEndpoint === "video"}
|
||||
onToggle={() => setExpandedEndpoint(expandedEndpoint === "video" ? null : "video")}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1541,7 +1572,7 @@ export default function APIPageClient({ machineId }) {
|
|||
<div className="mt-3 text-xs text-text-muted space-y-1">
|
||||
<p>
|
||||
{t("protocolToolsLabel") || "Tools"}:{" "}
|
||||
<span className="text-text-main font-semibold">{mcpToolCount || 16}</span>
|
||||
<span className="text-text-main font-semibold">{mcpToolCount || 29}</span>
|
||||
</p>
|
||||
<p>
|
||||
{t("protocolLastActivity") || "Last activity"}:{" "}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{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 (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-lg p-3 border transition-colors ${
|
||||
isQueued
|
||||
? "bg-amber-500/5 border-amber-500/20"
|
||||
: isActive
|
||||
? "bg-blue-500/5 border-blue-500/15"
|
||||
: "bg-surface/30 border-white/5"
|
||||
exhausted
|
||||
? "bg-red-500/5 border-red-500/20"
|
||||
: isQueued || lowRemaining
|
||||
? "bg-amber-500/5 border-amber-500/20"
|
||||
: isActive
|
||||
? "bg-blue-500/5 border-blue-500/15"
|
||||
: "bg-surface/30 border-white/5"
|
||||
}`}
|
||||
title={key}
|
||||
>
|
||||
|
|
@ -970,16 +1007,49 @@ export default function HealthPage() {
|
|||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||||
isQueued
|
||||
? "bg-amber-500/15 text-amber-400"
|
||||
: isActive
|
||||
? "bg-blue-500/15 text-blue-400"
|
||||
: "bg-green-500/10 text-green-400"
|
||||
exhausted
|
||||
? "bg-red-500/15 text-red-400"
|
||||
: isQueued || lowRemaining
|
||||
? "bg-amber-500/15 text-amber-400"
|
||||
: isActive
|
||||
? "bg-blue-500/15 text-blue-400"
|
||||
: "bg-green-500/10 text-green-400"
|
||||
}`}
|
||||
>
|
||||
{isQueued ? t("queued") : isActive ? tc("active") : t("ok")}
|
||||
{exhausted
|
||||
? t("limitExhausted")
|
||||
: isQueued || lowRemaining
|
||||
? t("queued")
|
||||
: isActive
|
||||
? tc("active")
|
||||
: t("ok")}
|
||||
</span>
|
||||
</div>
|
||||
{quotaProgress != null && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center justify-between text-[11px] text-text-muted">
|
||||
<span>{t("learnedFromHeaders")}</span>
|
||||
<span>
|
||||
{t("remainingOfLimit", {
|
||||
remaining: learnedRemaining,
|
||||
limit: learnedLimit,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-surface/70">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
exhausted
|
||||
? "bg-red-500"
|
||||
: lowRemaining
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
}`}
|
||||
style={{ width: `${quotaProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
|
|
@ -992,6 +1062,20 @@ export default function HealthPage() {
|
|||
{t("runningCount", { count: status.running || 0 })}
|
||||
</span>
|
||||
</div>
|
||||
{(learnedMinTime != null || learnedLastUpdated != null) && (
|
||||
<div className="mt-3 space-y-1 text-[11px] text-text-muted">
|
||||
{learnedMinTime != null && (
|
||||
<p>{t("throttleStatus", { value: `${learnedMinTime}ms/req` })}</p>
|
||||
)}
|
||||
{learnedLastUpdated != null && (
|
||||
<p>
|
||||
{t("lastHeaderUpdate", {
|
||||
age: formatRelativeTime(learnedLastUpdated) || t("notAvailable"),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuditEntry | null>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--color-text-main)]">{t("auditLog")}</h2>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("auditLogDesc")}</p>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
{t("totalEntries", { count: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchEntries}
|
||||
|
|
@ -100,7 +124,6 @@ export default function AuditLogTab() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div
|
||||
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
|
||||
role="search"
|
||||
|
|
@ -142,7 +165,6 @@ export default function AuditLogTab() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
|
||||
<thead>
|
||||
|
|
@ -153,6 +175,9 @@ export default function AuditLogTab() {
|
|||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("action")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("status")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("actor")}
|
||||
</th>
|
||||
|
|
@ -160,17 +185,23 @@ export default function AuditLogTab() {
|
|||
{t("target")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("details")}
|
||||
{t("resourceType")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("ipAddress")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("requestId")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("details")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
|
||||
{t("noEntries")}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -187,19 +218,43 @@ export default function AuditLogTab() {
|
|||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
|
||||
>
|
||||
{entry.action}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{entry.action === "provider.warning" && (
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
)}
|
||||
{entry.action}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${statusBadgeColor(entry.status)}`}
|
||||
>
|
||||
{entry.status || t("notAvailable")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
|
||||
{entry.target || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
|
||||
{entry.details ? JSON.stringify(entry.details) : t("notAvailable")}
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{entry.resourceType || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.ip_address || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.requestId || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-main)] transition-colors hover:bg-[var(--color-bg-alt)]"
|
||||
>
|
||||
{t("viewDetails")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
|
@ -207,7 +262,6 @@ export default function AuditLogTab() {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{t("showing", { count: entries.length, offset })}
|
||||
|
|
@ -229,6 +283,97 @@ export default function AuditLogTab() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEntry && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-6">
|
||||
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-main)]">
|
||||
{selectedEntry.action}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{t("auditModalSubtitle", {
|
||||
actor: selectedEntry.actor || t("notAvailable"),
|
||||
target: selectedEntry.target || t("notAvailable"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-alt)] hover:text-[var(--color-text-main)]"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-6 py-5">
|
||||
{selectedEntry.action === "provider.warning" && (
|
||||
<div className="mb-5 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">warning</span>
|
||||
<div>
|
||||
<p className="font-medium">{t("providerWarningTitle")}</p>
|
||||
<p className="mt-1 text-amber-200">{t("providerWarningDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-[var(--color-text-main)]">
|
||||
{t("eventMetadata")}
|
||||
</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("timestamp")}</dt>
|
||||
<dd className="text-[var(--color-text-main)]">
|
||||
{formatTimestamp(selectedEntry.timestamp)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("status")}</dt>
|
||||
<dd className="text-[var(--color-text-main)]">
|
||||
{selectedEntry.status || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("resourceType")}</dt>
|
||||
<dd className="text-[var(--color-text-main)]">
|
||||
{selectedEntry.resourceType || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("requestId")}</dt>
|
||||
<dd className="font-mono text-[var(--color-text-main)]">
|
||||
{selectedEntry.requestId || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("ipAddress")}</dt>
|
||||
<dd className="font-mono text-[var(--color-text-main)]">
|
||||
{selectedEntry.ip_address || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-[var(--color-text-main)]">
|
||||
{t("eventPayload")}
|
||||
</h4>
|
||||
<pre className="overflow-x-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||
{JSON.stringify(selectedEntry.metadata || selectedEntry.details || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
};
|
||||
|
||||
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<HTMLDivElement>(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)) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span
|
||||
className={`text-xs font-medium ${colorClass}`}
|
||||
title={t("configuredCount", { configured, total })}
|
||||
>
|
||||
{configured}/{total}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<T>(entries: ProviderEntry<T>[]) {
|
||||
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,
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-sm mt-1 opacity-80 text-text-main">
|
||||
{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")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -525,6 +593,7 @@ export default function ProvidersPage() {
|
|||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("oauthProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-blue-500" title={t("oauthLabel")} />
|
||||
<ProviderCountBadge {...countConfigured(oauthProviderEntriesAll)} />
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
|
|
@ -538,14 +607,14 @@ export default function ProvidersPage() {
|
|||
onClick={handleZedImport}
|
||||
disabled={importingZed}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40`}
|
||||
title="Import credentials from Zed IDE"
|
||||
title={t("zedImportHint")}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${importingZed ? "animate-spin" : ""}`}
|
||||
>
|
||||
{importingZed ? "sync" : "download"}
|
||||
</span>
|
||||
{importingZed ? "Importing..." : "Import from Zed"}
|
||||
{importingZed ? t("zedImporting") : t("zedImportButton")}
|
||||
</button>
|
||||
{oauthEnvRepairStatus?.available && oauthEnvRepairStatus.missingCount > 0 && (
|
||||
<button
|
||||
|
|
@ -605,6 +674,7 @@ export default function ProvidersPage() {
|
|||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("apiKeyProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-amber-500" title={t("apiKeyLabel")} />
|
||||
<ProviderCountBadge {...countConfigured(apiKeyProviderEntriesAll)} />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("apikey")}
|
||||
|
|
@ -623,20 +693,71 @@ export default function ProvidersPage() {
|
|||
{testingMode === "apikey" ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{apiKeyProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ApiKeyProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{llmProviderEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("llmProviders")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{llmProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ApiKeyProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aggregatorProviderEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("aggregatorsGateways")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{aggregatorProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ApiKeyProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageProviderEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("imageProviders")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{imageProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ApiKeyProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web / Cookie Providers */}
|
||||
|
|
@ -644,8 +765,12 @@ export default function ProvidersPage() {
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
Web / Cookie Providers{" "}
|
||||
<span className="size-2.5 rounded-full bg-purple-500" title="Web/Cookie" />
|
||||
{t("webCookieProviders")}{" "}
|
||||
<span
|
||||
className="size-2.5 rounded-full bg-purple-500"
|
||||
title={t("webCookieProviders")}
|
||||
/>
|
||||
<ProviderCountBadge {...countConfigured(webCookieProviderEntriesAll)} />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("web-cookie")}
|
||||
|
|
@ -685,7 +810,12 @@ export default function ProvidersPage() {
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
Search Providers <span className="size-2.5 rounded-full bg-teal-500" title="Search" />
|
||||
{t("searchProvidersHeading")}{" "}
|
||||
<span
|
||||
className="size-2.5 rounded-full bg-teal-500"
|
||||
title={t("searchProvidersHeading")}
|
||||
/>
|
||||
<ProviderCountBadge {...countConfigured(searchProviderEntriesAll)} />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("search")}
|
||||
|
|
@ -725,7 +855,12 @@ export default function ProvidersPage() {
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
Audio Providers <span className="size-2.5 rounded-full bg-rose-500" title="Audio" />
|
||||
{t("audioProvidersHeading")}{" "}
|
||||
<span
|
||||
className="size-2.5 rounded-full bg-rose-500"
|
||||
title={t("audioProvidersHeading")}
|
||||
/>
|
||||
<ProviderCountBadge {...countConfigured(audioProviderEntriesAll)} />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("audio")}
|
||||
|
|
@ -760,12 +895,70 @@ export default function ProvidersPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Local / Self-Hosted Providers */}
|
||||
{localProviderEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("localProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-emerald-500" title={t("localProviders")} />
|
||||
<ProviderCountBadge {...countConfigured(localProviderEntriesAll)} />
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{localProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ApiKeyProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upstream Proxy Providers */}
|
||||
{upstreamProxyEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("upstreamProxyProviders")}{" "}
|
||||
<span
|
||||
className="size-2.5 rounded-full bg-indigo-500"
|
||||
title={t("upstreamProxyProviders")}
|
||||
/>
|
||||
<ProviderCountBadge {...countConfigured(upstreamProxyEntriesAll)} />
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{upstreamProxyEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ApiKeyProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("compatibleProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
|
||||
<ProviderCountBadge {...countConfigured(compatibleProviderEntriesAll)} />
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(compatibleProviders.length > 0 ||
|
||||
|
|
@ -789,7 +982,7 @@ export default function ProvidersPage() {
|
|||
)}
|
||||
{ccCompatibleProviderEnabled && (
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddCcCompatibleModal(true)}>
|
||||
{ADD_CC_COMPATIBLE_LABEL}
|
||||
{addCcCompatibleLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
|
||||
|
|
@ -846,6 +1039,8 @@ export default function ProvidersPage() {
|
|||
{ccCompatibleProviderEnabled && (
|
||||
<AddCcCompatibleModal
|
||||
isOpen={showAddCcCompatibleModal}
|
||||
addLabel={addCcCompatibleLabel}
|
||||
compatibleLabel={ccCompatibleLabel}
|
||||
onClose={() => setShowAddCcCompatibleModal(false)}
|
||||
onCreated={(node) => {
|
||||
setProviderNodes((prev) => [...prev, node]);
|
||||
|
|
@ -1552,7 +1747,7 @@ AddAnthropicCompatibleModal.propTypes = {
|
|||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCreated }) {
|
||||
const t = useTranslations("providers");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
|
|
@ -1633,13 +1828,13 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={ADD_CC_COMPATIBLE_LABEL} onClose={onClose}>
|
||||
<Modal isOpen={isOpen} title={addLabel} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t("compatibleProdPlaceholder", { type: CC_COMPATIBLE_LABEL })}
|
||||
placeholder={t("compatibleProdPlaceholder", { type: compatibleLabel })}
|
||||
hint={t("nameHint")}
|
||||
/>
|
||||
<Input
|
||||
|
|
@ -1654,7 +1849,7 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
|||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder="https://api.anthropic.com"
|
||||
hint={t("compatibleBaseUrlHint", { type: CC_COMPATIBLE_LABEL })}
|
||||
hint={t("compatibleBaseUrlHint", { type: compatibleLabel })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1732,6 +1927,8 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
|||
|
||||
AddCcCompatibleModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
addLabel: PropTypes.string.isRequired,
|
||||
compatibleLabel: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -178,9 +178,7 @@ export default function TestBenchMode() {
|
|||
setSourceFormat(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
options={FORMAT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-2">
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const FORMAT_MODEL_PREFIXES = {
|
|||
"openai-responses": ["gpt-", "o1-", "o3-", "o4-"],
|
||||
claude: ["claude-"],
|
||||
gemini: ["gemini-"],
|
||||
"gemini-cli": ["gemini-"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export default function BudgetTab() {
|
|||
)}
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
|
||||
<p className="text-sm text-text-muted mb-2">Active Period Spend</p>
|
||||
<p className="text-sm text-text-muted mb-2">{t("activePeriodSpend")}</p>
|
||||
<p className="text-2xl font-bold text-text-main">{formatCurrency(activeCost)}</p>
|
||||
{activeLimit > 0 && (
|
||||
<ProgressBar
|
||||
|
|
@ -258,9 +258,10 @@ export default function BudgetTab() {
|
|||
)}
|
||||
<div className="mt-3 space-y-1 text-xs text-text-muted">
|
||||
<p>
|
||||
Interval: {(budget?.resetInterval || form.resetInterval || "daily").toUpperCase()}
|
||||
{t("intervalLabel")}:{" "}
|
||||
{t((budget?.resetInterval || form.resetInterval || "daily").toLowerCase())}
|
||||
</p>
|
||||
<p>Next reset (UTC): {formatDateTime(budget?.budgetResetAt)}</p>
|
||||
<p>{t("nextResetUtc", { value: formatDateTime(budget?.budgetResetAt) })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -279,11 +280,11 @@ export default function BudgetTab() {
|
|||
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Weekly Limit (USD)"
|
||||
label={t("weeklyLimitUsd")}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="e.g. 25.00"
|
||||
placeholder={t("weeklyLimitPlaceholder")}
|
||||
value={form.weeklyLimitUsd}
|
||||
onChange={(e) => setForm({ ...form, weeklyLimitUsd: e.target.value })}
|
||||
/>
|
||||
|
|
@ -308,27 +309,31 @@ export default function BudgetTab() {
|
|||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-text-muted block">Reset Interval</label>
|
||||
<label className="text-sm text-text-muted block">{t("resetInterval")}</label>
|
||||
<select
|
||||
value={form.resetInterval}
|
||||
onChange={(e) => setForm({ ...form, resetInterval: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border/50 bg-surface/30 text-text-main text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="daily">{t("daily")}</option>
|
||||
<option value="weekly">{t("weekly")}</option>
|
||||
<option value="monthly">{t("monthly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label="Reset Time (UTC)"
|
||||
label={t("resetTimeUtc")}
|
||||
type="time"
|
||||
value={form.resetTime}
|
||||
onChange={(e) => setForm({ ...form, resetTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg border border-border/30 bg-surface/20 px-4 py-3 text-sm text-text-muted">
|
||||
<p>Weekly limit: {weeklyLimit > 0 ? formatCurrency(weeklyLimit) : "Not configured"}</p>
|
||||
<p>Next reset (UTC): {formatDateTime(budget?.budgetResetAt)}</p>
|
||||
<p>
|
||||
{t("weeklyLimitSummary", {
|
||||
value: weeklyLimit > 0 ? formatCurrency(weeklyLimit) : t("notConfigured"),
|
||||
})}
|
||||
</p>
|
||||
<p>{t("nextResetUtc", { value: formatDateTime(budget?.budgetResetAt) })}</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving}>
|
||||
{t("saveLimits")}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuditLog, countAuditLog } from "@/lib/compliance/index";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const summary = url.searchParams.get("summary");
|
||||
|
||||
if (summary === "true") {
|
||||
const totalEntries = countAuditLog({});
|
||||
return NextResponse.json({ totalEntries });
|
||||
}
|
||||
|
||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
|
||||
const target = url.searchParams.get("target") || undefined;
|
||||
const action = url.searchParams.get("action") || undefined;
|
||||
const actor = url.searchParams.get("actor") || undefined;
|
||||
const from = url.searchParams.get("since") || undefined;
|
||||
|
||||
const options: any = { limit, offset };
|
||||
if (target) options.target = target;
|
||||
if (action) options.action = action;
|
||||
if (actor) options.actor = actor;
|
||||
if (from) options.from = from;
|
||||
|
||||
const entries = getAuditLog(options);
|
||||
const total = countAuditLog(options);
|
||||
|
||||
return NextResponse.json({ entries, total });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/audit GET:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch audit log." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,10 @@ export async function GET(request: Request) {
|
|||
startedAt: detail.startedAt,
|
||||
runningTimeMs: Math.max(0, now - detail.startedAt),
|
||||
count: pending.byAccount?.[connectionId]?.[modelKey] || 0,
|
||||
clientEndpoint: detail.clientEndpoint || null,
|
||||
clientRequest: detail.clientRequest ?? null,
|
||||
providerRequest: detail.providerRequest ?? null,
|
||||
providerUrl: detail.providerUrl || null,
|
||||
}))
|
||||
)
|
||||
.filter((requestRow) => requestRow.count > 0)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import { AI_PROVIDERS } from "@/shared/constants/providers";
|
|||
export async function GET() {
|
||||
try {
|
||||
const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker");
|
||||
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
|
||||
const { getAllRateLimitStatus, getLearnedLimits } =
|
||||
await import("@omniroute/open-sse/services/rateLimitManager");
|
||||
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback");
|
||||
const { getInflightCount } = await import("@omniroute/open-sse/services/requestDedup.ts");
|
||||
const { getQuotaMonitorSummary, getQuotaMonitorSnapshots } =
|
||||
|
|
@ -25,6 +26,7 @@ export async function GET() {
|
|||
const connections = await getProviderConnections();
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const rateLimitStatus = getAllRateLimitStatus();
|
||||
const learnedLimits = getLearnedLimits();
|
||||
const lockouts = getAllModelLockouts();
|
||||
const quotaMonitorSummary = getQuotaMonitorSummary();
|
||||
const quotaMonitorMonitors = getQuotaMonitorSnapshots();
|
||||
|
|
@ -38,6 +40,7 @@ export async function GET() {
|
|||
connections,
|
||||
circuitBreakers,
|
||||
rateLimitStatus,
|
||||
learnedLimits,
|
||||
lockouts,
|
||||
localProviders: getAllHealthStatuses(),
|
||||
inflightRequests: getInflightCount(),
|
||||
|
|
|
|||
163
src/app/docs/content.ts
Normal file
163
src/app/docs/content.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
export const DOCS_ENDPOINT_ROWS = [
|
||||
{ path: "/v1/chat/completions", method: "POST", noteKey: "endpointChatNote" },
|
||||
{ path: "/v1/responses", method: "POST", noteKey: "endpointResponsesNote" },
|
||||
{ path: "/v1/completions", method: "POST", noteKey: "endpointCompletionsNote" },
|
||||
{ path: "/v1/models", method: "GET", noteKey: "endpointModelsNote" },
|
||||
{ path: "/v1/embeddings", method: "POST", noteKey: "endpointEmbeddingsNote" },
|
||||
{ path: "/v1/moderations", method: "POST", noteKey: "endpointModerationsNote" },
|
||||
{ path: "/v1/rerank", method: "POST", noteKey: "endpointRerankNote" },
|
||||
{ path: "/v1/search", method: "POST", noteKey: "endpointSearchNote" },
|
||||
{ path: "/v1/search/analytics", method: "GET", noteKey: "endpointSearchAnalyticsNote" },
|
||||
{ path: "/v1/audio/transcriptions", method: "POST", noteKey: "endpointAudioNote" },
|
||||
{ path: "/v1/audio/speech", method: "POST", noteKey: "endpointSpeechNote" },
|
||||
{ path: "/v1/images/generations", method: "POST", noteKey: "endpointImagesNote" },
|
||||
{ path: "/v1/videos/generations", method: "POST", noteKey: "endpointVideoNote" },
|
||||
{ path: "/v1/music/generations", method: "POST", noteKey: "endpointMusicNote" },
|
||||
{ path: "/v1/messages", method: "POST", noteKey: "endpointMessagesNote" },
|
||||
{ path: "/v1/messages/count_tokens", method: "POST", noteKey: "endpointCountTokensNote" },
|
||||
{ path: "/v1/files", method: "POST", noteKey: "endpointFilesNote" },
|
||||
{ path: "/v1/batches", method: "POST", noteKey: "endpointBatchesNote" },
|
||||
{ path: "/v1/ws", method: "GET", noteKey: "endpointWsNote" },
|
||||
{ path: "/chat/completions", method: "POST", noteKey: "endpointRewriteChatNote" },
|
||||
{ path: "/responses", method: "POST", noteKey: "endpointRewriteResponsesNote" },
|
||||
{ path: "/models", method: "GET", noteKey: "endpointRewriteModelsNote" },
|
||||
] as const;
|
||||
|
||||
export const DOCS_MANAGEMENT_ENDPOINT_ROWS = [
|
||||
{ path: "/api/providers", method: "GET", noteKey: "mgmtProvidersListNote" },
|
||||
{ path: "/api/providers", method: "POST", noteKey: "mgmtProvidersCreateNote" },
|
||||
{ path: "/api/providers/:id", method: "PUT", noteKey: "mgmtProvidersUpdateNote" },
|
||||
{ path: "/api/providers/:id", method: "DELETE", noteKey: "mgmtProvidersDeleteNote" },
|
||||
{ path: "/api/providers/:id/test", method: "POST", noteKey: "mgmtProvidersTestNote" },
|
||||
{ path: "/api/providers/:id/models", method: "GET", noteKey: "mgmtProvidersModelsNote" },
|
||||
{ path: "/api/settings", method: "GET", noteKey: "mgmtSettingsGetNote" },
|
||||
{ path: "/api/settings", method: "PUT", noteKey: "mgmtSettingsUpdateNote" },
|
||||
{ path: "/api/settings/payload-rules", method: "GET", noteKey: "mgmtPayloadRulesGetNote" },
|
||||
{ path: "/api/settings/payload-rules", method: "PUT", noteKey: "mgmtPayloadRulesUpdateNote" },
|
||||
{ path: "/api/v1/management/proxies", method: "GET", noteKey: "mgmtProxiesListNote" },
|
||||
{ path: "/api/v1/management/proxies", method: "POST", noteKey: "mgmtProxiesCreateNote" },
|
||||
{
|
||||
path: "/api/v1/management/proxies/health",
|
||||
method: "GET",
|
||||
noteKey: "mgmtProxiesHealthNote",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/management/proxies/bulk-assign",
|
||||
method: "PUT",
|
||||
noteKey: "mgmtProxiesBulkAssignNote",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/management/proxies/assignments",
|
||||
method: "GET",
|
||||
noteKey: "mgmtAssignmentsListNote",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/management/proxies/assignments",
|
||||
method: "PUT",
|
||||
noteKey: "mgmtAssignmentsUpdateNote",
|
||||
},
|
||||
{
|
||||
path: "/api/settings/proxies/migrate",
|
||||
method: "POST",
|
||||
noteKey: "mgmtLegacyMigrationNote",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DOCS_FEATURE_ITEMS = [
|
||||
{ icon: "hub", titleKey: "featureRoutingTitle", textKey: "featureRoutingText" },
|
||||
{ icon: "layers", titleKey: "featureCombosTitle", textKey: "featureCombosText" },
|
||||
{ icon: "auto_awesome", titleKey: "featureAutoComboTitle", textKey: "featureAutoComboText" },
|
||||
{ icon: "travel_explore", titleKey: "featureSearchTitle", textKey: "featureSearchText" },
|
||||
{ icon: "bar_chart", titleKey: "featureUsageTitle", textKey: "featureUsageText" },
|
||||
{ icon: "analytics", titleKey: "featureAnalyticsTitle", textKey: "featureAnalyticsText" },
|
||||
{ icon: "health_and_safety", titleKey: "featureHealthTitle", textKey: "featureHealthText" },
|
||||
{ icon: "psychology", titleKey: "featureMemoryTitle", textKey: "featureMemoryText" },
|
||||
{ icon: "auto_fix_high", titleKey: "featureSkillsTitle", textKey: "featureSkillsText" },
|
||||
{ icon: "smart_toy", titleKey: "featureAcpTitle", textKey: "featureAcpText" },
|
||||
{ icon: "terminal", titleKey: "featureCliTitle", textKey: "featureCliText" },
|
||||
{ icon: "shield", titleKey: "featureSecurityTitle", textKey: "featureSecurityText" },
|
||||
] as const;
|
||||
|
||||
export const DOCS_USE_CASE_ITEMS = [
|
||||
{ titleKey: "useCaseSingleEndpointTitle", textKey: "useCaseSingleEndpointText" },
|
||||
{ titleKey: "useCaseFallbackTitle", textKey: "useCaseFallbackText" },
|
||||
{ titleKey: "useCaseUsageVisibilityTitle", textKey: "useCaseUsageVisibilityText" },
|
||||
] as const;
|
||||
|
||||
export const DOCS_TROUBLESHOOTING_KEYS = [
|
||||
"troubleshootingModelRouting",
|
||||
"troubleshootingAmbiguousModels",
|
||||
"troubleshootingCodexFamily",
|
||||
"troubleshootingTestConnection",
|
||||
"troubleshootingCircuitBreaker",
|
||||
"troubleshootingOAuth",
|
||||
] as const;
|
||||
|
||||
export const DOCS_TOC_ITEMS = [
|
||||
{ href: "#quick-start", labelKey: "quickStart" },
|
||||
{ href: "#features", labelKey: "features" },
|
||||
{ href: "#supported-providers", labelKey: "supportedProvidersToc" },
|
||||
{ href: "#use-cases", labelKey: "commonUseCases" },
|
||||
{ href: "#client-compatibility", labelKey: "clientCompatibility" },
|
||||
{ href: "#protocols", labelKey: "protocolsToc" },
|
||||
{ href: "#mcp-tools", labelKey: "mcpToolsToc" },
|
||||
{ href: "#api-reference", labelKey: "apiReference" },
|
||||
{ href: "#management-api", labelKey: "managementApiReference" },
|
||||
{ href: "#model-prefixes", labelKey: "modelPrefixes" },
|
||||
{ href: "#troubleshooting", labelKey: "troubleshooting" },
|
||||
] as const;
|
||||
|
||||
export const DOCS_MCP_TOOL_GROUPS = [
|
||||
{
|
||||
titleKey: "mcpToolsRoutingTitle",
|
||||
textKey: "mcpToolsRoutingDesc",
|
||||
tools: [
|
||||
"omniroute_get_health",
|
||||
"omniroute_list_combos",
|
||||
"omniroute_get_combo_metrics",
|
||||
"omniroute_switch_combo",
|
||||
"omniroute_check_quota",
|
||||
"omniroute_route_request",
|
||||
"omniroute_cost_report",
|
||||
"omniroute_list_models_catalog",
|
||||
"omniroute_web_search",
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "mcpToolsOperationsTitle",
|
||||
textKey: "mcpToolsOperationsDesc",
|
||||
tools: [
|
||||
"omniroute_simulate_route",
|
||||
"omniroute_set_budget_guard",
|
||||
"omniroute_set_routing_strategy",
|
||||
"omniroute_set_resilience_profile",
|
||||
"omniroute_test_combo",
|
||||
"omniroute_get_provider_metrics",
|
||||
"omniroute_best_combo_for_task",
|
||||
"omniroute_explain_route",
|
||||
"omniroute_get_session_snapshot",
|
||||
"omniroute_db_health_check",
|
||||
"omniroute_sync_pricing",
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "mcpToolsCacheTitle",
|
||||
textKey: "mcpToolsCacheDesc",
|
||||
tools: ["omniroute_cache_stats", "omniroute_cache_flush"],
|
||||
},
|
||||
{
|
||||
titleKey: "mcpToolsMemoryTitle",
|
||||
textKey: "mcpToolsMemoryDesc",
|
||||
tools: ["omniroute_memory_search", "omniroute_memory_add", "omniroute_memory_clear"],
|
||||
},
|
||||
{
|
||||
titleKey: "mcpToolsSkillsTitle",
|
||||
textKey: "mcpToolsSkillsDesc",
|
||||
tools: [
|
||||
"omniroute_skills_list",
|
||||
"omniroute_skills_enable",
|
||||
"omniroute_skills_execute",
|
||||
"omniroute_skills_executions",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -2,88 +2,15 @@ import Link from "next/link";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const ENDPOINT_ROWS = [
|
||||
{ path: "/v1/chat/completions", method: "POST", noteKey: "endpointChatNote" },
|
||||
{ path: "/v1/responses", method: "POST", noteKey: "endpointResponsesNote" },
|
||||
{ path: "/v1/models", method: "GET", noteKey: "endpointModelsNote" },
|
||||
{ path: "/v1/embeddings", method: "POST", noteKey: "endpointEmbeddingsNote" },
|
||||
{ path: "/v1/audio/transcriptions", method: "POST", noteKey: "endpointAudioNote" },
|
||||
{ path: "/v1/audio/speech", method: "POST", noteKey: "endpointSpeechNote" },
|
||||
{ path: "/v1/images/generations", method: "POST", noteKey: "endpointImagesNote" },
|
||||
{ path: "/chat/completions", method: "POST", noteKey: "endpointRewriteChatNote" },
|
||||
{ path: "/responses", method: "POST", noteKey: "endpointRewriteResponsesNote" },
|
||||
{ path: "/models", method: "GET", noteKey: "endpointRewriteModelsNote" },
|
||||
] as const;
|
||||
|
||||
const MANAGEMENT_ENDPOINT_ROWS = [
|
||||
{ path: "/api/v1/management/proxies", method: "GET", noteKey: "mgmtProxiesListNote" },
|
||||
{ path: "/api/v1/management/proxies", method: "POST", noteKey: "mgmtProxiesCreateNote" },
|
||||
{
|
||||
path: "/api/v1/management/proxies/health",
|
||||
method: "GET",
|
||||
noteKey: "mgmtProxiesHealthNote",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/management/proxies/bulk-assign",
|
||||
method: "PUT",
|
||||
noteKey: "mgmtProxiesBulkAssignNote",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/management/proxies/assignments",
|
||||
method: "GET",
|
||||
noteKey: "mgmtAssignmentsListNote",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/management/proxies/assignments",
|
||||
method: "PUT",
|
||||
noteKey: "mgmtAssignmentsUpdateNote",
|
||||
},
|
||||
{
|
||||
path: "/api/settings/proxies/migrate",
|
||||
method: "POST",
|
||||
noteKey: "mgmtLegacyMigrationNote",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const FEATURE_ITEMS = [
|
||||
{ icon: "hub", titleKey: "featureRoutingTitle", textKey: "featureRoutingText" },
|
||||
{ icon: "layers", titleKey: "featureCombosTitle", textKey: "featureCombosText" },
|
||||
{ icon: "bar_chart", titleKey: "featureUsageTitle", textKey: "featureUsageText" },
|
||||
{ icon: "analytics", titleKey: "featureAnalyticsTitle", textKey: "featureAnalyticsText" },
|
||||
{ icon: "health_and_safety", titleKey: "featureHealthTitle", textKey: "featureHealthText" },
|
||||
{ icon: "terminal", titleKey: "featureCliTitle", textKey: "featureCliText" },
|
||||
{ icon: "shield", titleKey: "featureSecurityTitle", textKey: "featureSecurityText" },
|
||||
{ icon: "cloud_sync", titleKey: "featureCloudSyncTitle", textKey: "featureCloudSyncText" },
|
||||
] as const;
|
||||
|
||||
const USE_CASE_ITEMS = [
|
||||
{ titleKey: "useCaseSingleEndpointTitle", textKey: "useCaseSingleEndpointText" },
|
||||
{ titleKey: "useCaseFallbackTitle", textKey: "useCaseFallbackText" },
|
||||
{ titleKey: "useCaseUsageVisibilityTitle", textKey: "useCaseUsageVisibilityText" },
|
||||
] as const;
|
||||
|
||||
const TROUBLESHOOTING_KEYS = [
|
||||
"troubleshootingModelRouting",
|
||||
"troubleshootingAmbiguousModels",
|
||||
"troubleshootingCodexFamily",
|
||||
"troubleshootingTestConnection",
|
||||
"troubleshootingCircuitBreaker",
|
||||
"troubleshootingOAuth",
|
||||
] as const;
|
||||
|
||||
const TOC_ITEMS = [
|
||||
{ href: "#quick-start", labelKey: "quickStart" },
|
||||
{ href: "#features", labelKey: "features" },
|
||||
{ href: "#supported-providers", labelKey: "supportedProvidersToc" },
|
||||
{ href: "#use-cases", labelKey: "commonUseCases" },
|
||||
{ href: "#client-compatibility", labelKey: "clientCompatibility" },
|
||||
{ href: "#protocols", labelKey: "protocolsToc" },
|
||||
{ href: "#api-reference", labelKey: "apiReference" },
|
||||
{ href: "#management-api", labelKey: "managementApiReference" },
|
||||
{ href: "#model-prefixes", labelKey: "modelPrefixes" },
|
||||
{ href: "#troubleshooting", labelKey: "troubleshooting" },
|
||||
] as const;
|
||||
import {
|
||||
DOCS_ENDPOINT_ROWS,
|
||||
DOCS_FEATURE_ITEMS,
|
||||
DOCS_MANAGEMENT_ENDPOINT_ROWS,
|
||||
DOCS_MCP_TOOL_GROUPS,
|
||||
DOCS_TOC_ITEMS,
|
||||
DOCS_TROUBLESHOOTING_KEYS,
|
||||
DOCS_USE_CASE_ITEMS,
|
||||
} from "./content";
|
||||
|
||||
function ProviderTable({
|
||||
title,
|
||||
|
|
@ -131,29 +58,35 @@ export default function DocsPage() {
|
|||
Object.keys(OAUTH_PROVIDERS).length +
|
||||
Object.keys(APIKEY_PROVIDERS).length;
|
||||
|
||||
const endpointRows = ENDPOINT_ROWS.map((row) => ({
|
||||
const endpointRows = DOCS_ENDPOINT_ROWS.map((row) => ({
|
||||
...row,
|
||||
note: t(row.noteKey),
|
||||
}));
|
||||
const managementEndpointRows = MANAGEMENT_ENDPOINT_ROWS.map((row) => ({
|
||||
const managementEndpointRows = DOCS_MANAGEMENT_ENDPOINT_ROWS.map((row) => ({
|
||||
...row,
|
||||
note: t(row.noteKey),
|
||||
}));
|
||||
|
||||
const featureItems = FEATURE_ITEMS.map((item) => ({
|
||||
const featureItems = DOCS_FEATURE_ITEMS.map((item) => ({
|
||||
...item,
|
||||
title: t(item.titleKey),
|
||||
text: t(item.textKey),
|
||||
}));
|
||||
|
||||
const useCases = USE_CASE_ITEMS.map((item) => ({
|
||||
const useCases = DOCS_USE_CASE_ITEMS.map((item) => ({
|
||||
...item,
|
||||
title: t(item.titleKey),
|
||||
text: t(item.textKey),
|
||||
}));
|
||||
|
||||
const troubleshootingItems = TROUBLESHOOTING_KEYS.map((key) => t(key));
|
||||
const tocItems = TOC_ITEMS.map((item) => ({ ...item, label: t(item.labelKey) }));
|
||||
const troubleshootingItems = DOCS_TROUBLESHOOTING_KEYS.map((key) => t(key));
|
||||
const tocItems = DOCS_TOC_ITEMS.map((item) => ({ ...item, label: t(item.labelKey) }));
|
||||
const mcpToolGroups = DOCS_MCP_TOOL_GROUPS.map((group) => ({
|
||||
...group,
|
||||
title: t(group.titleKey),
|
||||
text: t(group.textKey),
|
||||
}));
|
||||
const totalMcpTools = DOCS_MCP_TOOL_GROUPS.reduce((sum, group) => sum + group.tools.length, 0);
|
||||
|
||||
const providerPrefixRows = [
|
||||
...Object.values(FREE_PROVIDERS).map((p) => ({ ...p, type: "free" as const })),
|
||||
|
|
@ -401,6 +334,30 @@ export default function DocsPage() {
|
|||
<li>{t("fullStreaming")}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article className="rounded-lg border border-border p-4 bg-bg">
|
||||
<h3 className="font-semibold">{t("clientWindsurfTitle")}</h3>
|
||||
<ul className="mt-2 text-text-muted space-y-1">
|
||||
<li>{t("clientWindsurfBullet1")}</li>
|
||||
<li>{t("clientWindsurfBullet2")}</li>
|
||||
<li>{t("clientWindsurfBullet3")}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article className="rounded-lg border border-border p-4 bg-bg">
|
||||
<h3 className="font-semibold">{t("clientClineTitle")}</h3>
|
||||
<ul className="mt-2 text-text-muted space-y-1">
|
||||
<li>{t("clientClineBullet1")}</li>
|
||||
<li>{t("clientClineBullet2")}</li>
|
||||
<li>{t("clientClineBullet3")}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article className="rounded-lg border border-border p-4 bg-bg">
|
||||
<h3 className="font-semibold">{t("clientKimiTitle")}</h3>
|
||||
<ul className="mt-2 text-text-muted space-y-1">
|
||||
<li>{t("clientKimiBullet1")}</li>
|
||||
<li>{t("clientKimiBullet2")}</li>
|
||||
<li>{t("clientKimiBullet3")}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -408,7 +365,7 @@ export default function DocsPage() {
|
|||
<h2 className="text-xl font-semibold">{t("protocolsTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-2">{t("protocolsDescription")}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
|
||||
<div className="mt-4 grid grid-cols-1 lg:grid-cols-3 gap-4 text-sm">
|
||||
<article className="rounded-lg border border-border p-4 bg-bg">
|
||||
<h3 className="font-semibold">{t("protocolMcpTitle")}</h3>
|
||||
<p className="text-text-muted mt-1">{t("protocolMcpDesc")}</p>
|
||||
|
|
@ -435,6 +392,20 @@ export default function DocsPage() {
|
|||
POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
|
||||
</pre>
|
||||
</article>
|
||||
|
||||
<article className="rounded-lg border border-border p-4 bg-bg">
|
||||
<h3 className="font-semibold">{t("protocolAcpTitle")}</h3>
|
||||
<p className="text-text-muted mt-1">{t("protocolAcpDesc")}</p>
|
||||
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
|
||||
<li>{t("protocolAcpStep1")}</li>
|
||||
<li>{t("protocolAcpStep2")}</li>
|
||||
<li>{t("protocolAcpStep3")}</li>
|
||||
</ol>
|
||||
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
|
||||
<code>{`Dashboard -> Agents
|
||||
Dashboard -> CLI Tools`}</code>
|
||||
</pre>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-border p-4 bg-bg">
|
||||
|
|
@ -447,6 +418,39 @@ POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mcp-tools" className="rounded-2xl border border-border bg-bg-subtle p-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("mcpToolsTitle")}</h2>
|
||||
<p className="mt-2 text-sm text-text-muted">
|
||||
{t("mcpToolsDescription", { count: totalMcpTools })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-border bg-bg px-3 py-1 text-xs text-text-muted">
|
||||
{t("mcpToolsCount", { count: totalMcpTools })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{mcpToolGroups.map((group) => (
|
||||
<article key={group.titleKey} className="rounded-lg border border-border bg-bg p-4">
|
||||
<h3 className="font-semibold">{group.title}</h3>
|
||||
<p className="mt-1 text-sm text-text-muted">{group.text}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{group.tools.map((tool) => (
|
||||
<code
|
||||
key={tool}
|
||||
className="rounded-md border border-border/70 bg-bg-subtle px-2 py-1 text-xs text-text-muted"
|
||||
>
|
||||
{tool}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="api-reference" className="rounded-2xl border border-border bg-bg-subtle p-6">
|
||||
<h2 className="text-xl font-semibold">{t("apiReference")}</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -636,6 +636,13 @@
|
|||
"kiloManualConfiguration": "Kilo Code Manual Configuration",
|
||||
"whenToUseLabel": "When to use",
|
||||
"openToolDocs": "Open tool docs",
|
||||
"toolCategories": "Tool Categories",
|
||||
"toolCategoriesDesc": "Group CLIs by how OmniRoute configures or intercepts them so this page stays easier to scan.",
|
||||
"autoConfiguredTab": "Auto-configured",
|
||||
"guidedClientsTab": "Guided clients",
|
||||
"mitmClientsTab": "MITM clients",
|
||||
"allToolsTab": "All tools",
|
||||
"visibleToolsCount": "{count} visible tools",
|
||||
"toolUseCases": {
|
||||
"claude": "Use when you want strong planning workflows and long multi-file refactors with Claude Code.",
|
||||
"codex": "Use when your team is standardized on OpenAI Codex CLI flows and profile-based auth.",
|
||||
|
|
@ -646,6 +653,7 @@
|
|||
"cursor": "Use when coding in Cursor and you need custom OpenAI-compatible models through OmniRoute.",
|
||||
"continue": "Use when running Continue in IDEs and you need portable JSON-based provider configuration.",
|
||||
"opencode": "Use when you prefer terminal-native agent runs and scripted automation via OpenCode.",
|
||||
"amp": "Use when you want Amp shorthand workflows but still need OmniRoute aliases and routing rules behind them.",
|
||||
"kiro": "Use when integrating Kiro and controlling model routing centrally from OmniRoute.",
|
||||
"windsurf": "Use when you want an AI-first IDE with Codeium/Windsurf models routed through OmniRoute.",
|
||||
"antigravity": "Use when Antigravity/Kiro traffic must be intercepted through MITM and routed to OmniRoute.",
|
||||
|
|
@ -663,6 +671,7 @@
|
|||
"cursor": "Cursor AI Code Editor",
|
||||
"continue": "Continue AI Assistant",
|
||||
"opencode": "OpenCode AI coding agent (Terminal)",
|
||||
"amp": "Sourcegraph Amp coding assistant CLI",
|
||||
"kiro": "Amazon Kiro — AI-powered IDE",
|
||||
"windsurf": "Windsurf AI Code Editor",
|
||||
"copilot": "GitHub Copilot AI Assistant",
|
||||
|
|
@ -1162,6 +1171,8 @@
|
|||
"embeddingsDesc": "Text embeddings for search & RAG pipelines",
|
||||
"imageGeneration": "Image Generation",
|
||||
"imageDesc": "Generate images from text prompts",
|
||||
"videoGeneration": "Video Generation",
|
||||
"videoDesc": "Generate videos via ComfyUI, Stable Diffusion WebUI, and compatible providers",
|
||||
"rerank": "Rerank",
|
||||
"rerankDesc": "Rerank documents by relevance to a query",
|
||||
"audioTranscription": "Audio Transcription",
|
||||
|
|
@ -1524,6 +1535,11 @@
|
|||
"running": "running",
|
||||
"runningCount": "{count} running",
|
||||
"ok": "OK",
|
||||
"limitExhausted": "Exhausted",
|
||||
"learnedFromHeaders": "Learned from provider headers",
|
||||
"remainingOfLimit": "{remaining} / {limit} RPM remaining",
|
||||
"throttleStatus": "Throttling: {value}",
|
||||
"lastHeaderUpdate": "Header update: {age} ago",
|
||||
"activeLockouts": "Active Lockouts",
|
||||
"resetConfirm": "Reset all circuit breakers to healthy state? This will clear all failure counts and restore all providers to operational status.",
|
||||
"resetAllTitle": "Reset all circuit breakers to healthy state",
|
||||
|
|
@ -1546,8 +1562,11 @@
|
|||
"auditLog": "Audit Log",
|
||||
"console": "Console",
|
||||
"auditLogDesc": "Administrative actions and security events",
|
||||
"runningRequests": "Running Requests",
|
||||
"runningRequestsDesc": "Requests still in flight across providers and accounts, including sanitized payload previews.",
|
||||
"loading": "Loading...",
|
||||
"refresh": "Refresh",
|
||||
"activeCount": "{count} active",
|
||||
"filterByAction": "Filter by action...",
|
||||
"filterByActor": "Filter by actor...",
|
||||
"filterEntriesAria": "Filter audit log entries",
|
||||
|
|
@ -1560,10 +1579,32 @@
|
|||
"search": "Search",
|
||||
"timestamp": "Timestamp",
|
||||
"action": "Action",
|
||||
"status": "Status",
|
||||
"actor": "Actor",
|
||||
"target": "Target",
|
||||
"resourceType": "Resource Type",
|
||||
"details": "Details",
|
||||
"ipAddress": "IP Address",
|
||||
"requestId": "Request ID",
|
||||
"model": "Model",
|
||||
"provider": "Provider",
|
||||
"account": "Account",
|
||||
"elapsed": "Elapsed",
|
||||
"count": "Count",
|
||||
"payloads": "Payloads",
|
||||
"viewPayloads": "View Payloads",
|
||||
"clientPayload": "Client Request",
|
||||
"upstreamPayload": "Upstream Request",
|
||||
"upstreamNotSentYet": "Upstream request not dispatched yet",
|
||||
"runningRequestDetailMeta": "Account: {account} • Elapsed: {elapsed}",
|
||||
"viewDetails": "View Details",
|
||||
"providerWarningTitle": "Provider warning captured",
|
||||
"providerWarningDesc": "This request completed with a provider-side warning or sanitizer alert instead of a hard failure.",
|
||||
"eventMetadata": "Event Metadata",
|
||||
"eventPayload": "Event Payload",
|
||||
"auditModalSubtitle": "Actor: {actor} • Target: {target}",
|
||||
"totalEntries": "{count} total events",
|
||||
"close": "Close",
|
||||
"notAvailable": "—",
|
||||
"noEntries": "No audit log entries found",
|
||||
"previous": "Previous",
|
||||
|
|
@ -1638,6 +1679,11 @@
|
|||
"oauthProviders": "OAuth Providers",
|
||||
"freeProviders": "Free Providers",
|
||||
"apiKeyProviders": "API Key Providers",
|
||||
"llmProviders": "LLM Providers",
|
||||
"localProviders": "Local / Self-Hosted",
|
||||
"upstreamProxyProviders": "Upstream Proxy",
|
||||
"aggregatorsGateways": "Aggregators / Gateways",
|
||||
"imageProviders": "Image Generation",
|
||||
"compatibleProviders": "API Key Compatible Providers",
|
||||
"testAll": "Test All",
|
||||
"testAllOAuth": "Test all OAuth connections",
|
||||
|
|
@ -1683,6 +1729,12 @@
|
|||
"modelStatus": "Model Status",
|
||||
"showConfiguredOnly": "Configured only",
|
||||
"searchProviders": "Search providers...",
|
||||
"searchProvidersHeading": "Search Providers",
|
||||
"audioProvidersHeading": "Audio Providers",
|
||||
"webCookieProviders": "Web / Cookie Providers",
|
||||
"ccCompatibleLabel": "CC Compatible",
|
||||
"addCcCompatible": "Add CC Compatible",
|
||||
"configuredCount": "{configured} of {total} configured",
|
||||
"allModelsOperational": "All models operational",
|
||||
"modelsWithIssues": "{count} model(s) with issues",
|
||||
"allModelsNormal": "All models are responding normally.",
|
||||
|
|
@ -1704,6 +1756,17 @@
|
|||
"noActiveConnectionsInGroup": "No active connections found for this group.",
|
||||
"allTestsPassed": "All {total} tests passed",
|
||||
"testSummary": "{passed}/{total} passed, {failed} failed",
|
||||
"expirationBannerExpired": "{count} Provider connection(s) expired",
|
||||
"expirationBannerExpiringSoon": "{count} Provider connection(s) expiring soon",
|
||||
"expirationBannerExpiredDesc": "Immediate action required. Expired connections will permanently fail.",
|
||||
"expirationBannerExpiringSoonDesc": "Please review and renew expiring connections to avoid disruption.",
|
||||
"zedImportButton": "Import from Zed",
|
||||
"zedImporting": "Importing...",
|
||||
"zedImportNone": "No supported OAuth credentials found in Zed IDE.",
|
||||
"zedImportHint": "Import credentials from Zed IDE",
|
||||
"zedImportSuccess": "Imported {count} credential(s) from Zed IDE ({providers}).",
|
||||
"zedImportFailed": "Failed to import from Zed IDE.",
|
||||
"zedImportNetworkError": "Network error while trying to import from Zed.",
|
||||
"nameLabel": "Name",
|
||||
"prefixLabel": "Prefix",
|
||||
"baseUrlLabel": "Base URL",
|
||||
|
|
@ -2544,7 +2607,9 @@
|
|||
"multi-turn": "Multi-turn",
|
||||
"thinking": "Thinking",
|
||||
"system-prompt": "System Prompt",
|
||||
"streaming": "Streaming"
|
||||
"streaming": "Streaming",
|
||||
"vision": "Vision",
|
||||
"schema-coercion": "Schema Coercion"
|
||||
},
|
||||
"templateDescriptions": {
|
||||
"simple-chat": "Basic text message",
|
||||
|
|
@ -2552,7 +2617,9 @@
|
|||
"multi-turn": "Conversation with history",
|
||||
"thinking": "Extended thinking / reasoning",
|
||||
"system-prompt": "Complex system instructions",
|
||||
"streaming": "SSE streaming request"
|
||||
"streaming": "SSE streaming request",
|
||||
"vision": "Multi-modal request with image input",
|
||||
"schema-coercion": "Strict tool schema to verify request sanitization"
|
||||
},
|
||||
"templatePayloads": {
|
||||
"simpleChat": {
|
||||
|
|
@ -2579,6 +2646,16 @@
|
|||
},
|
||||
"streaming": {
|
||||
"prompt": "Tell me a short story about a robot learning to paint."
|
||||
},
|
||||
"vision": {
|
||||
"system": "You are a visual assistant. Describe images with concise, concrete observations.",
|
||||
"userPrompt": "Describe the image and call out notable objects or colors.",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1516117172878-fd2c41f4a759?auto=format&fit=crop&w=1200&q=80"
|
||||
},
|
||||
"schemaCoercion": {
|
||||
"userPrompt": "Check the weather for Tokyo and include hourly details.",
|
||||
"toolDescription": "Look up weather details for a city",
|
||||
"cityDescription": "City name to inspect"
|
||||
}
|
||||
},
|
||||
"openaiCompatibleLabel": "OpenAI Compatible",
|
||||
|
|
@ -2636,11 +2713,23 @@
|
|||
"thisMonth": "This Month",
|
||||
"setLimits": "Set Limits",
|
||||
"dailyLimitUsd": "Daily Limit (USD)",
|
||||
"weeklyLimitUsd": "Weekly Limit (USD)",
|
||||
"monthlyLimitUsd": "Monthly Limit (USD)",
|
||||
"warningThresholdPercent": "Warning Threshold (%)",
|
||||
"dailyLimitPlaceholder": "e.g. 5.00",
|
||||
"weeklyLimitPlaceholder": "e.g. 25.00",
|
||||
"monthlyLimitPlaceholder": "e.g. 50.00",
|
||||
"warningThresholdPlaceholder": "80",
|
||||
"activePeriodSpend": "Active Period Spend",
|
||||
"intervalLabel": "Interval",
|
||||
"resetInterval": "Reset Interval",
|
||||
"resetTimeUtc": "Reset Time (UTC)",
|
||||
"nextResetUtc": "Next reset (UTC): {value}",
|
||||
"weeklyLimitSummary": "Weekly limit: {value}",
|
||||
"notConfigured": "Not configured",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"saveLimits": "Save Limits",
|
||||
"budgetOk": "Budget OK — {remaining} remaining",
|
||||
"budgetExceeded": "Budget exceeded — requests may be blocked",
|
||||
|
|
@ -2716,6 +2805,47 @@
|
|||
"modelComparison": "Model Comparison",
|
||||
"regressionDetection": "Regression Detection",
|
||||
"latencyBenchmarks": "Latency Benchmarks",
|
||||
"evalControlsTitle": "Run Targets",
|
||||
"evalControlsHint": "Choose a model or combo, optionally compare two targets, and persist the run history in SQLite.",
|
||||
"evalTarget": "Primary Target",
|
||||
"evalTargetHint": "Suite defaults preserve the model configured on each test case.",
|
||||
"evalCompareTarget": "Compare Against",
|
||||
"evalCompareOptional": "No compare target",
|
||||
"evalCompareHint": "Optional side-by-side comparison using the same suite.",
|
||||
"evalApiKey": "Proxy API Key",
|
||||
"evalApiKeyAuto": "Use dashboard session / no key",
|
||||
"evalApiKeyHint": "Used only when your `/v1` endpoint requires a bearer key.",
|
||||
"targetSuiteDefaults": "Suite defaults",
|
||||
"targetTypeCombo": "Combo",
|
||||
"targetTypeModel": "Model",
|
||||
"scorecardTitle": "Persisted Scorecard",
|
||||
"scorecardHint": "Latest stored pass rates across recent suite runs and targets.",
|
||||
"scorecardSuites": "Tracked Runs",
|
||||
"scorecardCases": "Cases Evaluated",
|
||||
"scorecardPassed": "Cases Passed",
|
||||
"scorecardPassRate": "Overall Pass Rate",
|
||||
"recentRunsTitle": "Recent Runs",
|
||||
"recentRunsHint": "History survives refreshes and now records combo-aware comparisons.",
|
||||
"historyEmpty": "No persisted runs yet",
|
||||
"historyLatency": "{value}ms average latency",
|
||||
"historyColumnSuiteName": "Suite",
|
||||
"historyColumnTarget": "Target",
|
||||
"historyColumnPassRate": "Pass Rate",
|
||||
"historyColumnAvgLatencyMs": "Avg Latency",
|
||||
"historyColumnCreatedAt": "Ran At",
|
||||
"runEvalRunning": "Running...",
|
||||
"runCompletedWithScore": "Run completed at {score}% pass rate",
|
||||
"compareCompletedWithScore": "Comparison completed. Primary run finished at {score}% pass rate",
|
||||
"notifySelectDifferentCompareTarget": "Pick a different compare target",
|
||||
"notifyEvalRunFailedWithReason": "Eval run failed: {reason}",
|
||||
"notifyEvalLoadFailed": "Failed to load eval dashboard data",
|
||||
"targetComparisonTitle": "Target Comparison",
|
||||
"targetComparisonHint": "The same suite was executed against multiple targets.",
|
||||
"suiteLatestRuns": "Latest Persisted Runs",
|
||||
"suiteLatestRunsHint": "Stored runs stay available after a page refresh.",
|
||||
"errorBadge": "Error",
|
||||
"resultErrorLabel": "Error",
|
||||
"actualOutputLabel": "Actual: {value}",
|
||||
"modelLockouts": "Model Lockouts",
|
||||
"noLockouts": "No models currently locked",
|
||||
"activeSessions": "Active Sessions",
|
||||
|
|
@ -2990,9 +3120,10 @@
|
|||
"commonUseCases": "Common Use Cases",
|
||||
"clientCompatibility": "Client Compatibility",
|
||||
"protocolsToc": "Protocols",
|
||||
"mcpToolsToc": "MCP Tools",
|
||||
"apiReference": "API Reference",
|
||||
"managementApiReference": "Management API Reference",
|
||||
"managementApiDescription": "Automation endpoints for proxy registry, scope assignments, and legacy proxy migration.",
|
||||
"managementApiDescription": "Operational endpoints for provider management, runtime settings, payload rules, and proxy registry workflows.",
|
||||
"method": "Method",
|
||||
"path": "Path",
|
||||
"notes": "Notes",
|
||||
|
|
@ -3003,7 +3134,7 @@
|
|||
"oauthAutoRefresh": "OAuth connection with automatic token refresh.",
|
||||
"fullStreaming": "Full streaming support for all models.",
|
||||
"docsLabel": "Docs",
|
||||
"docsHeroDescription": "AI gateway for multi-provider LLMs. One endpoint for OpenAI, Anthropic, Gemini, GitHub Copilot, Claude Code, Cursor, and 20+ more providers.",
|
||||
"docsHeroDescription": "AI gateway for multi-provider LLMs. One endpoint for OpenAI, Anthropic, Gemini, GitHub Copilot, Claude Code, Cursor, and 100+ more providers.",
|
||||
"openDashboard": "Open Dashboard",
|
||||
"endpointPage": "Endpoint Page",
|
||||
"github": "GitHub",
|
||||
|
|
@ -3024,12 +3155,22 @@
|
|||
"featureRoutingText": "Route requests to 30+ AI providers through a single OpenAI-compatible endpoint. Supports chat, responses, audio, and image APIs.",
|
||||
"featureCombosTitle": "Combos and Balancing",
|
||||
"featureCombosText": "Create model combos with fallback chains and balancing strategies: round-robin, priority, random, least-used, and cost-optimized.",
|
||||
"featureAutoComboTitle": "AutoCombo Routing",
|
||||
"featureAutoComboText": "Use intent-aware routing, emergency fallback, context-aware model selection, and resilience policies without changing the client payload.",
|
||||
"featureSearchTitle": "Search, Rerank, and Multimodal APIs",
|
||||
"featureSearchText": "Expose search, rerank, moderations, images, video, music, speech, and transcription endpoints behind the same OmniRoute base URL.",
|
||||
"featureUsageTitle": "Usage and Cost Tracking",
|
||||
"featureUsageText": "Real-time token counting, cost calculation per provider/model, and detailed usage breakdown by API key and account.",
|
||||
"featureAnalyticsTitle": "Analytics Dashboard",
|
||||
"featureAnalyticsText": "Visual analytics with charts for requests, tokens, errors, latency, costs, and model popularity over time.",
|
||||
"featureHealthTitle": "Health Monitoring",
|
||||
"featureHealthText": "Live health checks, provider status, circuit breaker states, and automatic rate limit detection with exponential backoff.",
|
||||
"featureMemoryTitle": "Memory and Context Handoffs",
|
||||
"featureMemoryText": "Persist reusable facts, inject retrieved context into requests, and hand off long-running context across sessions and agent flows.",
|
||||
"featureSkillsTitle": "Skills and Tool Execution",
|
||||
"featureSkillsText": "Run registered skills, intercept tool calls, and expose skill execution through dashboard, MCP, and agent workflows.",
|
||||
"featureAcpTitle": "ACP Workers and Local Agents",
|
||||
"featureAcpText": "Detect local binaries OmniRoute can spawn as ACP workers, then coordinate them through the Agents and CLI Tools dashboards.",
|
||||
"featureCliTitle": "CLI Tools",
|
||||
"featureCliText": "Manage IDE configurations, export/import backups, discover codex profiles, and configure settings from the dashboard.",
|
||||
"featureSecurityTitle": "Security and Policies",
|
||||
|
|
@ -3064,8 +3205,20 @@
|
|||
"clientClaudeBullet1Prefix": "Use",
|
||||
"clientClaudeBullet1Middle": "(Claude) or",
|
||||
"clientClaudeBullet1Suffix": "(Antigravity) prefix.",
|
||||
"protocolsTitle": "Protocols: MCP & A2A",
|
||||
"protocolsDescription": "OmniRoute exposes two operational protocols in addition to OpenAI-compatible APIs: MCP for tool execution and A2A for agent-to-agent workflows.",
|
||||
"clientWindsurfTitle": "Windsurf",
|
||||
"clientWindsurfBullet1": "Use OmniRoute as the OpenAI-compatible base URL and keep explicit provider prefixes for deterministic routing.",
|
||||
"clientWindsurfBullet2": "Point models to `/v1/chat/completions` for general chat traffic and preserve `/v1/responses` for Codex-style flows.",
|
||||
"clientWindsurfBullet3": "Use Dashboard -> CLI Tools when you want a ready-to-copy Windsurf-oriented setup guide.",
|
||||
"clientClineTitle": "Cline",
|
||||
"clientClineBullet1": "Cline works well with explicit provider/model prefixes so the router never has to guess the backend.",
|
||||
"clientClineBullet2": "Use `/v1/chat/completions` for general models and reuse the same OmniRoute base URL across different accounts.",
|
||||
"clientClineBullet3": "Use the Providers dashboard to verify OAuth/API-key health before debugging Cline runtime issues.",
|
||||
"clientKimiTitle": "Kimi Coding",
|
||||
"clientKimiBullet1": "Use OmniRoute as a stable base URL while rotating provider accounts or combos underneath.",
|
||||
"clientKimiBullet2": "Prefer prefixed models for coding workflows so fallback and audit trails stay explicit.",
|
||||
"clientKimiBullet3": "Use `/v1/responses` when you want native Responses-style routing for tool-aware clients.",
|
||||
"protocolsTitle": "Protocols: MCP, A2A, and ACP",
|
||||
"protocolsDescription": "OmniRoute exposes MCP, A2A, and ACP-oriented worker flows in addition to OpenAI-compatible APIs.",
|
||||
"protocolMcpTitle": "MCP (Model Context Protocol)",
|
||||
"protocolMcpDesc": "Use MCP over stdio to let clients discover and call OmniRoute tools with audit visibility.",
|
||||
"protocolMcpStep1": "Start MCP transport with `omniroute --mcp`.",
|
||||
|
|
@ -3076,20 +3229,60 @@
|
|||
"protocolA2aStep1": "Read `/.well-known/agent.json` for agent discovery.",
|
||||
"protocolA2aStep2": "Send `message/send` or `message/stream` requests to `POST /a2a`.",
|
||||
"protocolA2aStep3": "Manage task lifecycle with `tasks/get` and `tasks/cancel`.",
|
||||
"protocolAcpTitle": "ACP (Agent Communication Protocol Workers)",
|
||||
"protocolAcpDesc": "Use ACP-backed local workers when OmniRoute should spawn and supervise a local binary instead of forwarding over HTTP.",
|
||||
"protocolAcpStep1": "Open Dashboard -> Agents to verify which local binaries are installed and available as workers.",
|
||||
"protocolAcpStep2": "Use Dashboard -> CLI Tools when you need client setup instead of worker spawning.",
|
||||
"protocolAcpStep3": "Pair ACP workers with audit logs and health views to understand what OmniRoute launched and why.",
|
||||
"protocolTroubleshootingTitle": "Protocol Troubleshooting",
|
||||
"protocolTroubleshooting1": "If MCP status is offline, verify the stdio process is running and heartbeat file is updating.",
|
||||
"protocolTroubleshooting2": "If A2A tasks stay in `working`, inspect `/api/a2a/tasks/:id` and stream events for terminal state.",
|
||||
"protocolTroubleshooting3": "Use `/dashboard/mcp` and `/dashboard/a2a` for operational controls and audit visibility.",
|
||||
"endpointChatNote": "OpenAI-compatible chat endpoint (default).",
|
||||
"endpointResponsesNote": "Responses API endpoint (Codex, o-series).",
|
||||
"endpointCompletionsNote": "Legacy completions endpoint for clients that still send text-completions payloads.",
|
||||
"endpointModelsNote": "Model catalog for all connected providers.",
|
||||
"endpointAudioNote": "Audio transcription (Deepgram, AssemblyAI).",
|
||||
"endpointSpeechNote": "Text-to-speech generation (ElevenLabs, OpenAI TTS).",
|
||||
"endpointEmbeddingsNote": "Text embedding generation (OpenAI, Cohere, Voyage).",
|
||||
"endpointModerationsNote": "Moderation and content-safety scoring across supported moderation providers.",
|
||||
"endpointRerankNote": "Document reranking for retrieval and search post-processing.",
|
||||
"endpointSearchNote": "Unified web search with provider fallback, analytics, and cost tracking.",
|
||||
"endpointSearchAnalyticsNote": "Search telemetry and provider analytics for the unified search pipeline.",
|
||||
"endpointImagesNote": "Image generation (NanoBanana).",
|
||||
"endpointVideoNote": "Video generation for ComfyUI and SD WebUI-compatible backends.",
|
||||
"endpointMusicNote": "Music generation via ComfyUI workflow integrations.",
|
||||
"endpointMessagesNote": "Anthropic-style messages compatibility endpoint for clients that speak message-native payloads.",
|
||||
"endpointCountTokensNote": "Token counting helper for planning requests before execution.",
|
||||
"endpointFilesNote": "File upload and artifact storage for batches and larger workflows.",
|
||||
"endpointBatchesNote": "Batch job creation and retrieval for queued request execution.",
|
||||
"endpointWsNote": "WebSocket-compatible upgrade path for real-time protocol clients.",
|
||||
"endpointRewriteChatNote": "Rewrite helper for clients without /v1.",
|
||||
"endpointRewriteResponsesNote": "Rewrite helper for Responses without /v1.",
|
||||
"endpointRewriteModelsNote": "Rewrite helper for model discovery without /v1.",
|
||||
"mcpToolsTitle": "MCP Tool Catalog",
|
||||
"mcpToolsDescription": "OmniRoute ships {count} MCP tools grouped across routing, operations, cache, memory, and skills.",
|
||||
"mcpToolsCount": "{count} tools documented",
|
||||
"mcpToolsRoutingTitle": "Routing and Catalog",
|
||||
"mcpToolsRoutingDesc": "Read runtime health, inspect combos, check quotas, discover models, and call web search from MCP clients.",
|
||||
"mcpToolsOperationsTitle": "Operations and Control",
|
||||
"mcpToolsOperationsDesc": "Dry-run routing, change strategies, test combos, sync pricing, inspect session state, and diagnose runtime issues.",
|
||||
"mcpToolsCacheTitle": "Cache Controls",
|
||||
"mcpToolsCacheDesc": "Inspect hit rates and flush cache state without leaving the MCP client.",
|
||||
"mcpToolsMemoryTitle": "Memory",
|
||||
"mcpToolsMemoryDesc": "Search, add, and clear persisted OmniRoute memory entries from the same protocol surface.",
|
||||
"mcpToolsSkillsTitle": "Skills",
|
||||
"mcpToolsSkillsDesc": "List enabled skills, turn them on, execute them, and inspect execution history from MCP.",
|
||||
"mgmtProvidersListNote": "List configured provider connections and their current status.",
|
||||
"mgmtProvidersCreateNote": "Create a new managed provider connection from the dashboard control plane.",
|
||||
"mgmtProvidersUpdateNote": "Update provider credentials, metadata, and operational flags.",
|
||||
"mgmtProvidersDeleteNote": "Delete a provider connection and remove it from routing.",
|
||||
"mgmtProvidersTestNote": "Run a live connection test against the selected provider account.",
|
||||
"mgmtProvidersModelsNote": "Discover, cache, or inspect models exposed by one provider connection.",
|
||||
"mgmtSettingsGetNote": "Read the main runtime settings used by the dashboard and router.",
|
||||
"mgmtSettingsUpdateNote": "Update shared runtime settings without editing environment files directly.",
|
||||
"mgmtPayloadRulesGetNote": "Read payload normalization and provider-specific payload rule configuration.",
|
||||
"mgmtPayloadRulesUpdateNote": "Update payload rewrite rules used before provider dispatch.",
|
||||
"mgmtProxiesListNote": "List saved proxy registry items (supports pagination).",
|
||||
"mgmtProxiesCreateNote": "Create a reusable proxy item in the registry.",
|
||||
"mgmtProxiesHealthNote": "Get 24h/rolling health metrics per saved proxy from proxy logs.",
|
||||
|
|
@ -3169,16 +3362,25 @@
|
|||
"termsSection6Text": "OmniRoute is open-source software. You are free to inspect, modify, and distribute it under the terms of its license."
|
||||
},
|
||||
"agents": {
|
||||
"title": "CLI Agents",
|
||||
"description": "Discover installed CLI agents on your system. Add custom agents for auto-detection.",
|
||||
"title": "Local ACP Agents (Workers)",
|
||||
"description": "Discover installed CLI binaries that OmniRoute can spawn locally as ACP workers for execution tasks.",
|
||||
"architectureTitle": "ACP execution flow",
|
||||
"architectureDescription": "This screen is for binaries OmniRoute launches locally as workers. OmniRoute detects the binary, spawns it, and uses it as the final execution endpoint.",
|
||||
"cliToolsRedirectTitle": "Looking for client configuration?",
|
||||
"cliToolsRedirectDesc": "Use CLI Tools when you want your editor or terminal client to call OmniRoute over HTTP instead of being spawned locally.",
|
||||
"cliToolsRedirectCta": "Go to CLI Tools",
|
||||
"flowOmniRoute": "OmniRoute",
|
||||
"flowSpawn": "Spawns worker",
|
||||
"flowLocalBinary": "Local binary",
|
||||
"flowExecute": "Executes task",
|
||||
"refresh": "Refresh",
|
||||
"installed": "Installed",
|
||||
"notFound": "Not Found",
|
||||
"builtIn": "Built-in",
|
||||
"custom": "Custom",
|
||||
"remove": "Remove",
|
||||
"addCustomAgent": "Add Custom Agent",
|
||||
"addCustomAgentDesc": "Register any CLI tool for detection. It will be scanned automatically on refresh.",
|
||||
"addCustomAgent": "Add Custom ACP Worker",
|
||||
"addCustomAgentDesc": "Register any local binary OmniRoute should detect and manage as a worker. It will be rescanned automatically on refresh.",
|
||||
"agentName": "Agent Name",
|
||||
"binaryName": "Binary Name",
|
||||
"versionCommand": "Version Command",
|
||||
|
|
@ -3192,10 +3394,10 @@
|
|||
"downloaded": "Downloaded!",
|
||||
"setupGuideTitle": "Setup guide",
|
||||
"openCliTools": "Open CLI Tools",
|
||||
"setupGuideDetectCliTitle": "Detect installed CLIs",
|
||||
"setupGuideDetectCliDesc": "Click Refresh after installing or updating a CLI so OmniRoute can rescan binaries and versions.",
|
||||
"setupGuideCustomAgentTitle": "Register custom binary",
|
||||
"setupGuideCustomAgentDesc": "Use Add Custom Agent when your CLI is not in the built-in list. Provide binary name and version command.",
|
||||
"setupGuideDetectCliTitle": "Discover local binaries",
|
||||
"setupGuideDetectCliDesc": "Click Refresh after installing or updating a CLI so OmniRoute can rescan binaries, versions, and worker availability.",
|
||||
"setupGuideCustomAgentTitle": "Register custom worker",
|
||||
"setupGuideCustomAgentDesc": "Use Add Custom ACP Worker when your binary is not in the built-in list. Provide the binary name, version command, and optional spawn args.",
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -631,6 +631,13 @@
|
|||
"kiloManualConfiguration": "Configuração manual do Kilo Code",
|
||||
"whenToUseLabel": "Quando usar",
|
||||
"openToolDocs": "Abrir documentos de ferramentas",
|
||||
"toolCategories": "Categorias de Ferramentas",
|
||||
"toolCategoriesDesc": "Agrupe CLIs pelo modo como o OmniRoute os configura ou intercepta para deixar esta página mais fácil de escanear.",
|
||||
"autoConfiguredTab": "Auto-configurados",
|
||||
"guidedClientsTab": "Clientes guiados",
|
||||
"mitmClientsTab": "Clientes MITM",
|
||||
"allToolsTab": "Todas as ferramentas",
|
||||
"visibleToolsCount": "{count} ferramentas visíveis",
|
||||
"toolUseCases": {
|
||||
"claude": "Use quando desejar fluxos de trabalho de planejamento robustos e refatorações longas de vários arquivos com Claude Code.",
|
||||
"codex": "Use quando sua equipe estiver padronizada em fluxos OpenAI Codex CLI e autenticação baseada em perfil.",
|
||||
|
|
@ -641,10 +648,11 @@
|
|||
"cursor": "Use ao codificar no Cursor e você precisar de modelos personalizados compatíveis com OpenAI por meio do OmniRoute.",
|
||||
"continue": "Use ao executar Continue em IDEs e você precisar de uma configuração de provedor portátil baseada em JSON.",
|
||||
"opencode": "Use quando preferir execuções de agentes nativos de terminal e automação com script via OpenCode.",
|
||||
"amp": "Use quando quiser fluxos com atalhos do Amp, mas ainda precisar de aliases e regras de roteamento do OmniRoute.",
|
||||
"kiro": "Use ao integrar o Kiro e controlar o roteamento do modelo centralmente no OmniRoute.",
|
||||
"antigravity": "Use quando o tráfego Antigravity/Kiro deve ser interceptado através do MITM e roteado para OmniRoute.",
|
||||
"copilot": "Use quando desejar UX no estilo de bate-papo do Copilot enquanto impõe chaves OmniRoute e regras de roteamento.",
|
||||
"windsurf": "Use when you want an AI-first IDE with Codeium/Windsurf models routed through OmniRoute."
|
||||
"windsurf": "Use quando quiser uma IDE AI-first com modelos Codeium/Windsurf roteados pelo OmniRoute."
|
||||
},
|
||||
"toolDescriptions": {
|
||||
"antigravity": "Google Antigravity IDE com MITM",
|
||||
|
|
@ -656,7 +664,8 @@
|
|||
"kilo": "CLI assistente de IA Kilo Code",
|
||||
"cursor": "Editor de código com IA Cursor",
|
||||
"continue": "Assistente de IA Continue",
|
||||
"opencode": "OpenCode AI coding agent (Terminal)",
|
||||
"opencode": "Agente de código OpenCode (Terminal)",
|
||||
"amp": "CLI do assistente de código Sourcegraph Amp",
|
||||
"kiro": "Amazon Kiro — IDE com IA",
|
||||
"windsurf": "Windsurf — Editor de Código com IA",
|
||||
"copilot": "GitHub Copilot — Assistente de IA",
|
||||
|
|
@ -1167,6 +1176,8 @@
|
|||
"embeddingsDesc": "Embeddings de texto para busca e pipelines RAG",
|
||||
"imageGeneration": "Geração de Imagens",
|
||||
"imageDesc": "Gerar imagens a partir de prompts de texto",
|
||||
"videoGeneration": "Geração de Vídeo",
|
||||
"videoDesc": "Gerar vídeos via ComfyUI, Stable Diffusion WebUI e provedores compatíveis",
|
||||
"rerank": "Rerank",
|
||||
"rerankDesc": "Reordenar documentos por relevância a uma consulta",
|
||||
"audioTranscription": "Transcrição de Áudio",
|
||||
|
|
@ -1529,6 +1540,11 @@
|
|||
"running": "executando",
|
||||
"runningCount": "{count} executando",
|
||||
"ok": "OK",
|
||||
"limitExhausted": "Esgotado",
|
||||
"learnedFromHeaders": "Aprendido a partir dos headers do provedor",
|
||||
"remainingOfLimit": "{remaining} / {limit} RPM restantes",
|
||||
"throttleStatus": "Acelerador: {value}",
|
||||
"lastHeaderUpdate": "Atualização do header: há {age}",
|
||||
"activeLockouts": "Bloqueios Ativos",
|
||||
"resetConfirm": "Resetar todos os circuit breakers para estado saudável? Isso limpará todos os contadores de falha e restaurará todos os provedores ao status operacional.",
|
||||
"resetAllTitle": "Resetar todos os circuit breakers para estado saudável",
|
||||
|
|
@ -1551,8 +1567,11 @@
|
|||
"auditLog": "Log de Auditoria",
|
||||
"console": "Console",
|
||||
"auditLogDesc": "Ações administrativas e eventos de segurança",
|
||||
"runningRequests": "Requisições em Execução",
|
||||
"runningRequestsDesc": "Requisições ainda em voo entre provedores e contas, com previews sanitizados dos payloads.",
|
||||
"loading": "Carregando...",
|
||||
"refresh": "Atualizar",
|
||||
"activeCount": "{count} ativas",
|
||||
"filterByAction": "Filtrar por ação...",
|
||||
"filterByActor": "Filtrar por ator...",
|
||||
"filterEntriesAria": "Filtrar entradas do log de auditoria",
|
||||
|
|
@ -1565,10 +1584,32 @@
|
|||
"search": "Buscar",
|
||||
"timestamp": "Data/Hora",
|
||||
"action": "Ação",
|
||||
"status": "Status",
|
||||
"actor": "Ator",
|
||||
"target": "Alvo",
|
||||
"resourceType": "Tipo de Recurso",
|
||||
"details": "Detalhes",
|
||||
"ipAddress": "Endereço IP",
|
||||
"requestId": "Request ID",
|
||||
"model": "Modelo",
|
||||
"provider": "Provedor",
|
||||
"account": "Conta",
|
||||
"elapsed": "Tempo",
|
||||
"count": "Qtd.",
|
||||
"payloads": "Payloads",
|
||||
"viewPayloads": "Ver Payloads",
|
||||
"clientPayload": "Requisição do Cliente",
|
||||
"upstreamPayload": "Requisição Upstream",
|
||||
"upstreamNotSentYet": "Requisição upstream ainda não foi enviada",
|
||||
"runningRequestDetailMeta": "Conta: {account} • Tempo: {elapsed}",
|
||||
"viewDetails": "Ver Detalhes",
|
||||
"providerWarningTitle": "Aviso de provedor capturado",
|
||||
"providerWarningDesc": "Esta requisição terminou com um aviso do provedor ou alerta do sanitizador em vez de uma falha dura.",
|
||||
"eventMetadata": "Metadados do Evento",
|
||||
"eventPayload": "Payload do Evento",
|
||||
"auditModalSubtitle": "Ator: {actor} • Alvo: {target}",
|
||||
"totalEntries": "{count} eventos no total",
|
||||
"close": "Fechar",
|
||||
"notAvailable": "—",
|
||||
"noEntries": "Nenhuma entrada de log de auditoria encontrada",
|
||||
"previous": "Anterior",
|
||||
|
|
@ -1643,6 +1684,11 @@
|
|||
"oauthProviders": "Provedores OAuth",
|
||||
"freeProviders": "Provedores Gratuitos",
|
||||
"apiKeyProviders": "Provedores por Chave de API",
|
||||
"llmProviders": "Provedores LLM",
|
||||
"localProviders": "Local / Self-Hosted",
|
||||
"upstreamProxyProviders": "Proxy Upstream",
|
||||
"aggregatorsGateways": "Agregadores / Gateways",
|
||||
"imageProviders": "Geração de Imagem",
|
||||
"compatibleProviders": "Provedores Compatíveis por Chave de API",
|
||||
"testAll": "Testar Todos",
|
||||
"testAllOAuth": "Testar todas as conexões OAuth",
|
||||
|
|
@ -1686,8 +1732,14 @@
|
|||
"failedCreate": "Falha ao criar provedor",
|
||||
"errorOccurred": "Ocorreu um erro. Tente novamente.",
|
||||
"modelStatus": "Status dos Modelos",
|
||||
"showConfiguredOnly": "Configured only",
|
||||
"showConfiguredOnly": "Somente configurados",
|
||||
"searchProviders": "Buscar provedores...",
|
||||
"searchProvidersHeading": "Provedores de Busca",
|
||||
"audioProvidersHeading": "Provedores de Áudio",
|
||||
"webCookieProviders": "Provedores Web / Cookie",
|
||||
"ccCompatibleLabel": "CC Compatível",
|
||||
"addCcCompatible": "Adicionar CC Compatível",
|
||||
"configuredCount": "{configured} de {total} configurados",
|
||||
"allModelsOperational": "Todos os modelos operacionais",
|
||||
"modelsWithIssues": "{count} modelo(s) com problemas",
|
||||
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",
|
||||
|
|
@ -1698,7 +1750,7 @@
|
|||
"clearing": "Limpando...",
|
||||
"until": "Até {time}",
|
||||
"providerTestFailed": "Teste de provedor falhou",
|
||||
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
|
||||
"providerTestTimeout": "O teste do provedor expirou — conexões demais para testar de uma vez",
|
||||
"modeTest": "Teste {mode}",
|
||||
"passedCount": "{count} passaram",
|
||||
"failedCount": "{count} falharam",
|
||||
|
|
@ -1709,6 +1761,17 @@
|
|||
"noActiveConnectionsInGroup": "Nenhuma conexão ativa encontrada para este grupo.",
|
||||
"allTestsPassed": "Todos os {total} testes passaram",
|
||||
"testSummary": "{passed}/{total} passaram, {failed} falharam",
|
||||
"expirationBannerExpired": "{count} conexão(ões) de provedor expirada(s)",
|
||||
"expirationBannerExpiringSoon": "{count} conexão(ões) de provedor expirando em breve",
|
||||
"expirationBannerExpiredDesc": "Ação imediata necessária. Conexões expiradas falharão permanentemente.",
|
||||
"expirationBannerExpiringSoonDesc": "Revise e renove as conexões prestes a expirar para evitar interrupções.",
|
||||
"zedImportButton": "Importar do Zed",
|
||||
"zedImporting": "Importando...",
|
||||
"zedImportNone": "Nenhuma credencial OAuth suportada encontrada no Zed IDE.",
|
||||
"zedImportHint": "Importar credenciais do Zed IDE",
|
||||
"zedImportSuccess": "Importadas {count} credencial(is) do Zed IDE ({providers}).",
|
||||
"zedImportFailed": "Falha ao importar do Zed IDE.",
|
||||
"zedImportNetworkError": "Erro de rede ao tentar importar do Zed.",
|
||||
"nameLabel": "Nome",
|
||||
"prefixLabel": "Prefixo",
|
||||
"baseUrlLabel": "URL Base",
|
||||
|
|
@ -2542,7 +2605,9 @@
|
|||
"multi-turn": "Multiturno",
|
||||
"thinking": "Raciocínio",
|
||||
"system-prompt": "Prompt de Sistema",
|
||||
"streaming": "Streaming"
|
||||
"streaming": "Streaming",
|
||||
"vision": "Visão",
|
||||
"schema-coercion": "Coerção de Schema"
|
||||
},
|
||||
"templateDescriptions": {
|
||||
"simple-chat": "Mensagem de texto básica",
|
||||
|
|
@ -2550,7 +2615,9 @@
|
|||
"multi-turn": "Conversa com histórico",
|
||||
"thinking": "Raciocínio estendido",
|
||||
"system-prompt": "Instruções de sistema complexas",
|
||||
"streaming": "Requisição de streaming SSE"
|
||||
"streaming": "Requisição de streaming SSE",
|
||||
"vision": "Requisição multimodal com entrada de imagem",
|
||||
"schema-coercion": "Schema estrito de ferramenta para validar sanitização"
|
||||
},
|
||||
"templatePayloads": {
|
||||
"simpleChat": {
|
||||
|
|
@ -2577,6 +2644,16 @@
|
|||
},
|
||||
"streaming": {
|
||||
"prompt": "Conte uma história curta sobre um robô aprendendo a pintar."
|
||||
},
|
||||
"vision": {
|
||||
"system": "Você é um assistente visual. Descreva imagens com observações concretas e concisas.",
|
||||
"userPrompt": "Descreva a imagem e destaque objetos ou cores notáveis.",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1516117172878-fd2c41f4a759?auto=format&fit=crop&w=1200&q=80"
|
||||
},
|
||||
"schemaCoercion": {
|
||||
"userPrompt": "Verifique o clima em Tóquio e inclua detalhes por hora.",
|
||||
"toolDescription": "Consultar detalhes do clima para uma cidade",
|
||||
"cityDescription": "Nome da cidade para consulta"
|
||||
}
|
||||
},
|
||||
"openaiCompatibleLabel": "Compatível com OpenAI",
|
||||
|
|
@ -2634,11 +2711,23 @@
|
|||
"thisMonth": "Este Mês",
|
||||
"setLimits": "Definir Limites",
|
||||
"dailyLimitUsd": "Limite diário (USD)",
|
||||
"weeklyLimitUsd": "Limite semanal (USD)",
|
||||
"monthlyLimitUsd": "Limite mensal (USD)",
|
||||
"warningThresholdPercent": "Limite de aviso (%)",
|
||||
"dailyLimitPlaceholder": "ex.: 5.00",
|
||||
"weeklyLimitPlaceholder": "ex.: 25.00",
|
||||
"monthlyLimitPlaceholder": "ex.: 50.00",
|
||||
"warningThresholdPlaceholder": "80",
|
||||
"activePeriodSpend": "Gasto do período ativo",
|
||||
"intervalLabel": "Intervalo",
|
||||
"resetInterval": "Intervalo de reset",
|
||||
"resetTimeUtc": "Horário do reset (UTC)",
|
||||
"nextResetUtc": "Próximo reset (UTC): {value}",
|
||||
"weeklyLimitSummary": "Limite semanal: {value}",
|
||||
"notConfigured": "Não configurado",
|
||||
"daily": "Diário",
|
||||
"weekly": "Semanal",
|
||||
"monthly": "Mensal",
|
||||
"saveLimits": "Salvar limites",
|
||||
"budgetOk": "Orçamento OK — {remaining} restantes",
|
||||
"budgetExceeded": "Orçamento excedido — requisições podem ser bloqueadas",
|
||||
|
|
@ -2714,6 +2803,47 @@
|
|||
"modelComparison": "Comparação de Modelos",
|
||||
"regressionDetection": "Detecção de Regressão",
|
||||
"latencyBenchmarks": "Benchmarks de Latência",
|
||||
"evalControlsTitle": "Alvos de Execução",
|
||||
"evalControlsHint": "Escolha um modelo ou combo, compare opcionalmente dois alvos e persista o histórico no SQLite.",
|
||||
"evalTarget": "Alvo Principal",
|
||||
"evalTargetHint": "Os padrões da suíte preservam o modelo configurado em cada caso de teste.",
|
||||
"evalCompareTarget": "Comparar Com",
|
||||
"evalCompareOptional": "Sem alvo de comparação",
|
||||
"evalCompareHint": "Comparação lado a lado opcional usando a mesma suíte.",
|
||||
"evalApiKey": "Chave de API do Proxy",
|
||||
"evalApiKeyAuto": "Usar sessão do dashboard / sem chave",
|
||||
"evalApiKeyHint": "Usada somente quando seu endpoint `/v1` exige bearer token.",
|
||||
"targetSuiteDefaults": "Padrões da suíte",
|
||||
"targetTypeCombo": "Combo",
|
||||
"targetTypeModel": "Modelo",
|
||||
"scorecardTitle": "Scorecard Persistido",
|
||||
"scorecardHint": "Últimas taxas de aprovação armazenadas para execuções e alvos recentes.",
|
||||
"scorecardSuites": "Execuções Rastreadas",
|
||||
"scorecardCases": "Casos Avaliados",
|
||||
"scorecardPassed": "Casos Aprovados",
|
||||
"scorecardPassRate": "Taxa Geral de Aprovação",
|
||||
"recentRunsTitle": "Execuções Recentes",
|
||||
"recentRunsHint": "O histórico sobrevive ao refresh e agora registra comparações combo-aware.",
|
||||
"historyEmpty": "Ainda não há execuções persistidas",
|
||||
"historyLatency": "{value}ms de latência média",
|
||||
"historyColumnSuiteName": "Suíte",
|
||||
"historyColumnTarget": "Alvo",
|
||||
"historyColumnPassRate": "Taxa de Aprovação",
|
||||
"historyColumnAvgLatencyMs": "Latência Média",
|
||||
"historyColumnCreatedAt": "Executado Em",
|
||||
"runEvalRunning": "Executando...",
|
||||
"runCompletedWithScore": "Execução concluída com {score}% de aprovação",
|
||||
"compareCompletedWithScore": "Comparação concluída. A execução principal terminou com {score}% de aprovação",
|
||||
"notifySelectDifferentCompareTarget": "Escolha um alvo de comparação diferente",
|
||||
"notifyEvalRunFailedWithReason": "Falha na execução da avaliação: {reason}",
|
||||
"notifyEvalLoadFailed": "Falha ao carregar os dados do dashboard de evals",
|
||||
"targetComparisonTitle": "Comparação de Alvos",
|
||||
"targetComparisonHint": "A mesma suíte foi executada contra múltiplos alvos.",
|
||||
"suiteLatestRuns": "Últimas Execuções Persistidas",
|
||||
"suiteLatestRunsHint": "As execuções armazenadas continuam disponíveis após atualizar a página.",
|
||||
"errorBadge": "Erro",
|
||||
"resultErrorLabel": "Erro",
|
||||
"actualOutputLabel": "Resposta: {value}",
|
||||
"modelLockouts": "Bloqueios de Modelo",
|
||||
"noLockouts": "Nenhum modelo bloqueado",
|
||||
"activeSessions": "Sessões Ativas",
|
||||
|
|
@ -2988,9 +3118,10 @@
|
|||
"commonUseCases": "Casos de Uso Comuns",
|
||||
"clientCompatibility": "Compatibilidade de Clientes",
|
||||
"protocolsToc": "Protocolos",
|
||||
"mcpToolsToc": "Ferramentas MCP",
|
||||
"apiReference": "Referência da API",
|
||||
"managementApiReference": "Management API Reference",
|
||||
"managementApiDescription": "Automation endpoints for proxy registry, scope assignments, and legacy proxy migration.",
|
||||
"managementApiReference": "Referência da API de Gestão",
|
||||
"managementApiDescription": "Endpoints operacionais para gestão de provedores, configurações de runtime, payload rules e fluxos do registro de proxies.",
|
||||
"method": "Método",
|
||||
"path": "Caminho",
|
||||
"notes": "Notas",
|
||||
|
|
@ -3001,7 +3132,7 @@
|
|||
"oauthAutoRefresh": "Conexão OAuth com atualização automática de token.",
|
||||
"fullStreaming": "Suporte completo a streaming para todos os modelos.",
|
||||
"docsLabel": "Docs",
|
||||
"docsHeroDescription": "Gateway de IA para LLMs multi-provedor. Um endpoint para OpenAI, Anthropic, Gemini, GitHub Copilot, Claude Code, Cursor e mais de 20 provedores.",
|
||||
"docsHeroDescription": "Gateway de IA para LLMs multi-provedor. Um endpoint para OpenAI, Anthropic, Gemini, GitHub Copilot, Claude Code, Cursor e mais de 100 provedores.",
|
||||
"openDashboard": "Abrir Painel",
|
||||
"endpointPage": "Página de Endpoint",
|
||||
"github": "GitHub",
|
||||
|
|
@ -3022,12 +3153,22 @@
|
|||
"featureRoutingText": "Roteie requisições para mais de 30 provedores de IA por um único endpoint compatível com OpenAI. Suporta APIs de chat, responses, áudio e imagem.",
|
||||
"featureCombosTitle": "Combos e Balanceamento",
|
||||
"featureCombosText": "Crie combos de modelos com cadeias de fallback e estratégias de balanceamento: round-robin, prioridade, aleatório, menos usado e otimizado por custo.",
|
||||
"featureAutoComboTitle": "Roteamento AutoCombo",
|
||||
"featureAutoComboText": "Use roteamento por intenção, fallback emergencial, seleção por contexto e perfis de resiliência sem mudar o payload do cliente.",
|
||||
"featureSearchTitle": "APIs de Busca, Rerank e Multimodal",
|
||||
"featureSearchText": "Exponha endpoints de search, rerank, moderação, imagem, vídeo, música, voz e transcrição atrás da mesma base URL do OmniRoute.",
|
||||
"featureUsageTitle": "Rastreamento de Uso e Custo",
|
||||
"featureUsageText": "Contagem de tokens em tempo real, cálculo de custo por provedor/modelo e detalhamento de uso por chave de API e conta.",
|
||||
"featureAnalyticsTitle": "Painel de Analytics",
|
||||
"featureAnalyticsText": "Análises visuais com gráficos de requisições, tokens, erros, latência, custos e popularidade de modelos ao longo do tempo.",
|
||||
"featureHealthTitle": "Monitoramento de Saúde",
|
||||
"featureHealthText": "Health checks em tempo real, status de provedores, estados de circuit breaker e detecção automática de rate limit com backoff exponencial.",
|
||||
"featureMemoryTitle": "Memória e Handoffs de Contexto",
|
||||
"featureMemoryText": "Persista fatos reutilizáveis, injete contexto recuperado nas requisições e carregue contexto entre sessões e fluxos de agentes.",
|
||||
"featureSkillsTitle": "Skills e Execução de Ferramentas",
|
||||
"featureSkillsText": "Execute skills registradas, intercepte tool calls e exponha a execução por dashboard, MCP e fluxos de agentes.",
|
||||
"featureAcpTitle": "Workers ACP e Agentes Locais",
|
||||
"featureAcpText": "Detecte binários locais que o OmniRoute pode iniciar como workers ACP e coordene-os pelas telas de Agents e CLI Tools.",
|
||||
"featureCliTitle": "Ferramentas CLI",
|
||||
"featureCliText": "Gerencie configurações de IDE, exporte/importe backups, descubra perfis de codex e configure opções pelo painel.",
|
||||
"featureSecurityTitle": "Segurança e Políticas",
|
||||
|
|
@ -3062,8 +3203,20 @@
|
|||
"clientClaudeBullet1Prefix": "Use",
|
||||
"clientClaudeBullet1Middle": "(Claude) ou",
|
||||
"clientClaudeBullet1Suffix": "(Antigravity) como prefixo.",
|
||||
"protocolsTitle": "Protocolos: MCP e A2A",
|
||||
"protocolsDescription": "O OmniRoute expõe dois protocolos operacionais além das APIs compatíveis com OpenAI: MCP para execução de ferramentas e A2A para fluxos agente-para-agente.",
|
||||
"clientWindsurfTitle": "Windsurf",
|
||||
"clientWindsurfBullet1": "Use o OmniRoute como base URL compatível com OpenAI e mantenha prefixos explícitos de provedor para roteamento determinístico.",
|
||||
"clientWindsurfBullet2": "Aponte modelos para `/v1/chat/completions` no tráfego geral e preserve `/v1/responses` para fluxos tipo Codex.",
|
||||
"clientWindsurfBullet3": "Use Painel -> CLI Tools quando quiser um guia pronto de configuração focado em Windsurf.",
|
||||
"clientClineTitle": "Cline",
|
||||
"clientClineBullet1": "O Cline funciona melhor com prefixos explícitos de provedor/modelo para o roteador nunca precisar adivinhar o backend.",
|
||||
"clientClineBullet2": "Use `/v1/chat/completions` para modelos gerais e reutilize a mesma base URL do OmniRoute entre contas diferentes.",
|
||||
"clientClineBullet3": "Use o dashboard de Providers para validar OAuth/API key antes de depurar problemas do runtime do Cline.",
|
||||
"clientKimiTitle": "Kimi Coding",
|
||||
"clientKimiBullet1": "Use o OmniRoute como base URL estável enquanto gira contas ou combos de provedores por baixo.",
|
||||
"clientKimiBullet2": "Prefira modelos com prefixo em fluxos de coding para fallback e trilha de auditoria ficarem explícitos.",
|
||||
"clientKimiBullet3": "Use `/v1/responses` quando quiser roteamento nativo estilo Responses para clientes com tools.",
|
||||
"protocolsTitle": "Protocolos: MCP, A2A e ACP",
|
||||
"protocolsDescription": "O OmniRoute expõe MCP, A2A e fluxos orientados a workers ACP além das APIs compatíveis com OpenAI.",
|
||||
"protocolMcpTitle": "MCP (Model Context Protocol)",
|
||||
"protocolMcpDesc": "Use MCP via stdio para permitir descoberta e execução de ferramentas OmniRoute com visibilidade de auditoria.",
|
||||
"protocolMcpStep1": "Inicie o transporte MCP com `omniroute --mcp`.",
|
||||
|
|
@ -3074,27 +3227,67 @@
|
|||
"protocolA2aStep1": "Leia `/.well-known/agent.json` para descoberta do agente.",
|
||||
"protocolA2aStep2": "Envie `message/send` ou `message/stream` para `POST /a2a`.",
|
||||
"protocolA2aStep3": "Gerencie ciclo de vida das tarefas com `tasks/get` e `tasks/cancel`.",
|
||||
"protocolAcpTitle": "ACP (Workers do Agent Communication Protocol)",
|
||||
"protocolAcpDesc": "Use workers locais com ACP quando o OmniRoute deve iniciar e supervisionar um binário local em vez de encaminhar por HTTP.",
|
||||
"protocolAcpStep1": "Abra Painel -> Agents para verificar quais binários locais estão instalados e disponíveis como workers.",
|
||||
"protocolAcpStep2": "Use Painel -> CLI Tools quando você precisa de configuração de cliente em vez de spawn de worker.",
|
||||
"protocolAcpStep3": "Combine workers ACP com logs de auditoria e health para entender o que o OmniRoute iniciou e por quê.",
|
||||
"protocolTroubleshootingTitle": "Troubleshooting de protocolos",
|
||||
"protocolTroubleshooting1": "Se o status MCP estiver offline, verifique se o processo stdio está rodando e atualizando o heartbeat.",
|
||||
"protocolTroubleshooting2": "Se tarefas A2A ficarem em `working`, inspecione `/api/a2a/tasks/:id` e os eventos de stream até estado terminal.",
|
||||
"protocolTroubleshooting3": "Use `/dashboard/mcp` e `/dashboard/a2a` para controles operacionais e visibilidade de auditoria.",
|
||||
"endpointChatNote": "Endpoint de chat compatível com OpenAI (padrão).",
|
||||
"endpointResponsesNote": "Endpoint da API Responses (Codex, o-series).",
|
||||
"endpointCompletionsNote": "Endpoint legado de completions para clientes que ainda enviam payloads de text-completions.",
|
||||
"endpointModelsNote": "Catálogo de modelos para todos os provedores conectados.",
|
||||
"endpointAudioNote": "Transcrição de áudio (Deepgram, AssemblyAI).",
|
||||
"endpointSpeechNote": "Geração de texto para fala (ElevenLabs, OpenAI TTS).",
|
||||
"endpointEmbeddingsNote": "Geração de incorporação de texto (OpenAI, Cohere, Voyage).",
|
||||
"endpointModerationsNote": "Moderação e scoring de segurança de conteúdo em provedores compatíveis.",
|
||||
"endpointRerankNote": "Reranking de documentos para retrieval e pós-processamento de busca.",
|
||||
"endpointSearchNote": "Busca web unificada com fallback de provedor, analytics e rastreamento de custo.",
|
||||
"endpointSearchAnalyticsNote": "Telemetria de busca e analytics de provedores do pipeline unificado de search.",
|
||||
"endpointImagesNote": "Geração de imagens (NanoBanana).",
|
||||
"endpointVideoNote": "Geração de vídeo para backends compatíveis com ComfyUI e SD WebUI.",
|
||||
"endpointMusicNote": "Geração de música via integrações de workflow do ComfyUI.",
|
||||
"endpointMessagesNote": "Endpoint de compatibilidade estilo Anthropic Messages para clientes que falam payload nativo de mensagens.",
|
||||
"endpointCountTokensNote": "Helper de contagem de tokens para planejar requisições antes da execução.",
|
||||
"endpointFilesNote": "Upload de arquivos e armazenamento de artefatos para batches e workflows maiores.",
|
||||
"endpointBatchesNote": "Criação e consulta de jobs em lote para execução enfileirada.",
|
||||
"endpointWsNote": "Caminho compatível com upgrade WebSocket para clientes de protocolo em tempo real.",
|
||||
"endpointRewriteChatNote": "Auxiliar de reescrita para clientes sem /v1.",
|
||||
"endpointRewriteResponsesNote": "Auxiliar de reescrita para Responses sem /v1.",
|
||||
"endpointRewriteModelsNote": "Auxiliar de reescrita para descoberta de modelos sem /v1.",
|
||||
"mgmtProxiesListNote": "List saved proxy registry items (supports pagination).",
|
||||
"mgmtProxiesCreateNote": "Create a reusable proxy item in the registry.",
|
||||
"mgmtProxiesHealthNote": "Get 24h/rolling health metrics per saved proxy from proxy logs.",
|
||||
"mgmtProxiesBulkAssignNote": "Assign or clear one proxy across many scope IDs in one request.",
|
||||
"mgmtAssignmentsListNote": "List proxy assignments by scope, scope_id, or proxy_id.",
|
||||
"mgmtAssignmentsUpdateNote": "Assign or clear proxy for global/provider/account/combo scope.",
|
||||
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
|
||||
"mcpToolsTitle": "Catálogo de Ferramentas MCP",
|
||||
"mcpToolsDescription": "O OmniRoute entrega {count} ferramentas MCP agrupadas entre roteamento, operações, cache, memória e skills.",
|
||||
"mcpToolsCount": "{count} ferramentas documentadas",
|
||||
"mcpToolsRoutingTitle": "Roteamento e Catálogo",
|
||||
"mcpToolsRoutingDesc": "Leia o health do runtime, inspecione combos, confira cotas, descubra modelos e chame web search a partir de clientes MCP.",
|
||||
"mcpToolsOperationsTitle": "Operações e Controle",
|
||||
"mcpToolsOperationsDesc": "Simule roteamento, troque estratégias, teste combos, sincronize pricing, inspecione sessão e diagnostique problemas de runtime.",
|
||||
"mcpToolsCacheTitle": "Controles de Cache",
|
||||
"mcpToolsCacheDesc": "Inspecione hit rates e limpe o estado do cache sem sair do cliente MCP.",
|
||||
"mcpToolsMemoryTitle": "Memória",
|
||||
"mcpToolsMemoryDesc": "Busque, adicione e limpe entradas persistidas de memória do OmniRoute na mesma superfície de protocolo.",
|
||||
"mcpToolsSkillsTitle": "Skills",
|
||||
"mcpToolsSkillsDesc": "Liste skills habilitadas, ative, execute e inspecione histórico de execuções a partir do MCP.",
|
||||
"mgmtProvidersListNote": "Lista conexões de provedores configuradas e o status atual de cada uma.",
|
||||
"mgmtProvidersCreateNote": "Cria uma nova conexão gerenciada de provedor a partir do plano de controle do dashboard.",
|
||||
"mgmtProvidersUpdateNote": "Atualiza credenciais, metadados e flags operacionais de um provedor.",
|
||||
"mgmtProvidersDeleteNote": "Remove uma conexão de provedor do runtime e do roteamento.",
|
||||
"mgmtProvidersTestNote": "Executa um teste real de conexão contra a conta de provedor selecionada.",
|
||||
"mgmtProvidersModelsNote": "Descobre, faz cache ou inspeciona modelos expostos por uma conexão específica.",
|
||||
"mgmtSettingsGetNote": "Lê as configurações principais de runtime usadas pelo dashboard e pelo roteador.",
|
||||
"mgmtSettingsUpdateNote": "Atualiza configurações compartilhadas de runtime sem editar arquivos de ambiente manualmente.",
|
||||
"mgmtPayloadRulesGetNote": "Lê a configuração de normalização de payload e payload rules específicas por provedor.",
|
||||
"mgmtPayloadRulesUpdateNote": "Atualiza as regras de rewrite de payload antes do dispatch para o provedor.",
|
||||
"mgmtProxiesListNote": "Lista itens salvos no registro de proxies com suporte a paginação.",
|
||||
"mgmtProxiesCreateNote": "Cria um item de proxy reutilizável no registro.",
|
||||
"mgmtProxiesHealthNote": "Obtém métricas de health por proxy salvo com janela móvel/24h a partir dos proxy logs.",
|
||||
"mgmtProxiesBulkAssignNote": "Atribui ou limpa um proxy em vários escopos numa única requisição.",
|
||||
"mgmtAssignmentsListNote": "Lista atribuições de proxy por scope, scope_id ou proxy_id.",
|
||||
"mgmtAssignmentsUpdateNote": "Atribui ou limpa proxy em escopo global/provedor/conta/combo.",
|
||||
"mgmtLegacyMigrationNote": "Importa mapas legados de proxyConfig para o registro de atribuições.",
|
||||
"modelPrefixesDescriptionStart": "Use o prefixo do provedor antes do nome do modelo para rotear para um provedor específico. Exemplo:",
|
||||
"modelPrefixesDescriptionEnd": "roteia para o GitHub Copilot.",
|
||||
"provider": "Provedor",
|
||||
|
|
@ -3167,35 +3360,39 @@
|
|||
"termsSection6Text": "O OmniRoute é um software de código aberto. Você pode inspecionar, modificar e distribuí-lo de acordo com os termos da licença."
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agentes CLI",
|
||||
"description": "Descubra agentes CLI instalados no seu sistema. Adicione agentes customizados para auto-detecção.",
|
||||
"title": "Agentes ACP Locais (Workers)",
|
||||
"description": "Descubra binários CLI instalados que o OmniRoute pode iniciar localmente como workers ACP para tarefas de execução.",
|
||||
"architectureTitle": "Fluxo de execução ACP",
|
||||
"architectureDescription": "Esta tela é para binários que o OmniRoute inicia localmente como workers. O OmniRoute detecta o binário, faz o spawn e o usa como endpoint final de execução.",
|
||||
"cliToolsRedirectTitle": "Procurando configuração de cliente?",
|
||||
"cliToolsRedirectDesc": "Use CLI Tools quando quiser que seu editor ou cliente de terminal chame o OmniRoute por HTTP em vez de ser iniciado localmente.",
|
||||
"cliToolsRedirectCta": "Ir para CLI Tools",
|
||||
"flowOmniRoute": "OmniRoute",
|
||||
"flowSpawn": "Inicia worker",
|
||||
"flowLocalBinary": "Binário local",
|
||||
"flowExecute": "Executa tarefa",
|
||||
"refresh": "Atualizar",
|
||||
"installed": "Instalado",
|
||||
"notFound": "Não encontrado",
|
||||
"builtIn": "Nativo",
|
||||
"custom": "Customizado",
|
||||
"remove": "Remover",
|
||||
"addCustomAgent": "Adicionar Agente Customizado",
|
||||
"addCustomAgentDesc": "Registre qualquer ferramenta CLI para detecção. Ela será verificada automaticamente ao atualizar.",
|
||||
"addCustomAgent": "Adicionar Worker ACP Customizado",
|
||||
"addCustomAgentDesc": "Registre qualquer binário local que o OmniRoute deve detectar e gerenciar como worker. Ele será reavaliado automaticamente ao atualizar.",
|
||||
"agentName": "Nome do Agente",
|
||||
"binaryName": "Nome do Binário",
|
||||
"versionCommand": "Comando de Versão",
|
||||
"spawnArgs": "Argumentos",
|
||||
"addAgent": "Adicionar Agente",
|
||||
"scanning": "Scanning system for CLI agents...",
|
||||
"opencodeIntegration": "OpenCode Integration",
|
||||
"opencodeDetected": "opencode {version} detected",
|
||||
"opencodeDesc": "Generate a ready-to-use {configFile} with your OmniRoute base URL and all available models — drop it in your project root and run {command}.",
|
||||
"downloadConfig": "Download {file}",
|
||||
"downloaded": "Downloaded!",
|
||||
"setupGuideTitle": "Setup guide",
|
||||
"openCliTools": "Open CLI Tools",
|
||||
"setupGuideDetectCliTitle": "Detect installed CLIs",
|
||||
"setupGuideDetectCliDesc": "Click Refresh after installing or updating a CLI so OmniRoute can rescan binaries and versions.",
|
||||
"setupGuideCustomAgentTitle": "Register custom binary",
|
||||
"setupGuideCustomAgentDesc": "Use Add Custom Agent when your CLI is not in the built-in list. Provide binary name and version command.",
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
"scanning": "Escaneando o sistema em busca de agentes CLI...",
|
||||
"setupGuideTitle": "Guia de configuração",
|
||||
"openCliTools": "Abrir CLI Tools",
|
||||
"setupGuideDetectCliTitle": "Descobrir binários locais",
|
||||
"setupGuideDetectCliDesc": "Clique em Atualizar após instalar ou atualizar um CLI para que o OmniRoute reescaneie binários, versões e disponibilidade do worker.",
|
||||
"setupGuideCustomAgentTitle": "Registrar worker customizado",
|
||||
"setupGuideCustomAgentDesc": "Use Adicionar Worker ACP Customizado quando seu binário não estiver na lista nativa. Informe o nome do binário, o comando de versão e args opcionais.",
|
||||
"setupGuideCommandMissingTitle": "Corrigir 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Garanta que o comando do CLI exista no PATH, abra uma nova sessão de terminal e execute Atualizar novamente."
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Bate-papo simples",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,67 @@
|
|||
type JsonRecord = Record<string, unknown>;
|
||||
const WARNING_PATTERNS = [
|
||||
/\[sanitizer\]/i,
|
||||
/prompt injection detected/i,
|
||||
/content(?:\s+has\s+been|\s+was)?\s+filtered/i,
|
||||
/safety filter/i,
|
||||
/policy violation/i,
|
||||
] as const;
|
||||
const WARNING_KEY_PATTERN = /warning/i;
|
||||
const MAX_WARNING_HITS = 5;
|
||||
const MAX_WARNING_DEPTH = 6;
|
||||
const MAX_WARNING_LENGTH = 400;
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function truncateWarning(value: string) {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length > MAX_WARNING_LENGTH
|
||||
? `${compact.slice(0, MAX_WARNING_LENGTH)}...`
|
||||
: compact;
|
||||
}
|
||||
|
||||
function matchesProviderWarning(value: string, keyHint?: string) {
|
||||
if (!value) return false;
|
||||
if (WARNING_PATTERNS.some((pattern) => pattern.test(value))) return true;
|
||||
return Boolean(keyHint && WARNING_KEY_PATTERN.test(keyHint));
|
||||
}
|
||||
|
||||
function collectProviderWarnings(value: unknown, hits: Set<string>, keyHint?: string, depth = 0) {
|
||||
if (
|
||||
hits.size >= MAX_WARNING_HITS ||
|
||||
depth >= MAX_WARNING_DEPTH ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (matchesProviderWarning(value, keyHint)) {
|
||||
hits.add(truncateWarning(value));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => collectProviderWarnings(item, hits, keyHint, depth + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, entryValue] of Object.entries(value as JsonRecord)) {
|
||||
collectProviderWarnings(entryValue, hits, key, depth + 1);
|
||||
if (hits.size >= MAX_WARNING_HITS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeProviderConnectionForAudit(connection: unknown) {
|
||||
const record = toRecord(connection);
|
||||
if (Object.keys(record).length === 0) return null;
|
||||
|
|
@ -32,3 +90,9 @@ export function getProviderAuditTarget(connection: unknown) {
|
|||
|
||||
return [provider, name || id].filter(Boolean).join(":") || "provider-connection";
|
||||
}
|
||||
|
||||
export function extractProviderWarnings(...payloads: unknown[]) {
|
||||
const hits = new Set<string>();
|
||||
payloads.forEach((payload) => collectProviderWarnings(payload, hits));
|
||||
return Array.from(hits);
|
||||
}
|
||||
|
|
|
|||
289
src/lib/db/evals.ts
Normal file
289
src/lib/db/evals.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createScorecard } from "@/lib/evals/evalRunner";
|
||||
import { getDbInstance, rowToCamel } from "./core";
|
||||
|
||||
export type EvalTargetType = "suite-default" | "model" | "combo";
|
||||
|
||||
export interface EvalTargetDescriptor {
|
||||
type: EvalTargetType;
|
||||
id: string | null;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface EvalRunSummary {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
export interface PersistedEvalRun {
|
||||
id: string;
|
||||
runGroupId: string | null;
|
||||
suiteId: string;
|
||||
suiteName: string;
|
||||
target: EvalTargetDescriptor;
|
||||
apiKeyId: string | null;
|
||||
avgLatencyMs: number;
|
||||
summary: EvalRunSummary;
|
||||
results: Array<Record<string, unknown>>;
|
||||
outputs: Record<string, string>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
all: (...params: unknown[]) => TRow[];
|
||||
run: (...params: unknown[]) => { changes: number };
|
||||
}
|
||||
|
||||
interface DbLike {
|
||||
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
|
||||
}
|
||||
|
||||
function parseJsonRecord(value: unknown): JsonRecord {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as JsonRecord)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
!!entry && typeof entry === "object" && !Array.isArray(entry)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
!!entry && typeof entry === "object" && !Array.isArray(entry)
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function serializeEvalTargetKey(type: EvalTargetType, id?: string | null): string {
|
||||
return `${type}:${typeof id === "string" && id.trim().length > 0 ? id.trim() : "__default__"}`;
|
||||
}
|
||||
|
||||
function toTargetDescriptor(row: JsonRecord): EvalTargetDescriptor {
|
||||
const type = row.targetType;
|
||||
const rawId = row.targetId;
|
||||
const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null;
|
||||
const normalizedType: EvalTargetType =
|
||||
type === "combo" || type === "model" || type === "suite-default" ? type : "suite-default";
|
||||
|
||||
return {
|
||||
type: normalizedType,
|
||||
id,
|
||||
key: serializeEvalTargetKey(normalizedType, id),
|
||||
label:
|
||||
typeof row.targetLabel === "string" && row.targetLabel.trim().length > 0
|
||||
? row.targetLabel.trim()
|
||||
: normalizedType === "combo"
|
||||
? `Combo: ${id || "Unknown"}`
|
||||
: normalizedType === "model"
|
||||
? `Model: ${id || "Unknown"}`
|
||||
: "Suite defaults",
|
||||
};
|
||||
}
|
||||
|
||||
function toPersistedEvalRun(row: unknown): PersistedEvalRun | null {
|
||||
const camel = rowToCamel(row) as JsonRecord | null;
|
||||
if (!camel) return null;
|
||||
|
||||
const summaryRecord = parseJsonRecord(camel.summaryJson);
|
||||
const outputsRecord = parseJsonRecord(camel.outputsJson);
|
||||
const outputs = Object.fromEntries(
|
||||
Object.entries(outputsRecord)
|
||||
.filter((entry): entry is [string, string] => typeof entry[0] === "string")
|
||||
.map(([key, value]) => [key, typeof value === "string" ? value : String(value ?? "")])
|
||||
);
|
||||
|
||||
return {
|
||||
id: typeof camel.id === "string" ? camel.id : "",
|
||||
runGroupId:
|
||||
typeof camel.runGroupId === "string" && camel.runGroupId.trim().length > 0
|
||||
? camel.runGroupId
|
||||
: null,
|
||||
suiteId: typeof camel.suiteId === "string" ? camel.suiteId : "",
|
||||
suiteName: typeof camel.suiteName === "string" ? camel.suiteName : "",
|
||||
target: toTargetDescriptor(camel),
|
||||
apiKeyId:
|
||||
typeof camel.apiKeyId === "string" && camel.apiKeyId.trim().length > 0
|
||||
? camel.apiKeyId
|
||||
: null,
|
||||
avgLatencyMs: parseNumber(camel.avgLatencyMs),
|
||||
summary: {
|
||||
total: parseNumber(summaryRecord.total ?? camel.total),
|
||||
passed: parseNumber(summaryRecord.passed ?? camel.passed),
|
||||
failed: parseNumber(summaryRecord.failed ?? camel.failed),
|
||||
passRate: parseNumber(summaryRecord.passRate ?? camel.passRate),
|
||||
},
|
||||
results: parseJsonArray(camel.resultsJson),
|
||||
outputs,
|
||||
createdAt: typeof camel.createdAt === "string" ? camel.createdAt : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function saveEvalRun(input: {
|
||||
runGroupId?: string | null;
|
||||
suiteId: string;
|
||||
suiteName: string;
|
||||
target: { type: EvalTargetType; id?: string | null; label: string };
|
||||
apiKeyId?: string | null;
|
||||
avgLatencyMs?: number;
|
||||
summary: EvalRunSummary;
|
||||
results: Array<Record<string, unknown>>;
|
||||
outputs?: Record<string, string>;
|
||||
createdAt?: string;
|
||||
}): PersistedEvalRun {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const createdAt = input.createdAt || new Date().toISOString();
|
||||
const id = randomUUID();
|
||||
const targetId =
|
||||
typeof input.target.id === "string" && input.target.id.trim().length > 0
|
||||
? input.target.id.trim()
|
||||
: null;
|
||||
const avgLatencyMs = Number.isFinite(Number(input.avgLatencyMs))
|
||||
? Math.max(0, Math.round(Number(input.avgLatencyMs)))
|
||||
: 0;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO eval_runs
|
||||
(id, run_group_id, suite_id, suite_name, target_type, target_id, target_label, api_key_id,
|
||||
pass_rate, total, passed, failed, avg_latency_ms, summary_json, results_json, outputs_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.runGroupId || null,
|
||||
input.suiteId,
|
||||
input.suiteName,
|
||||
input.target.type,
|
||||
targetId,
|
||||
input.target.label,
|
||||
input.apiKeyId || null,
|
||||
input.summary.passRate,
|
||||
input.summary.total,
|
||||
input.summary.passed,
|
||||
input.summary.failed,
|
||||
avgLatencyMs,
|
||||
JSON.stringify(input.summary),
|
||||
JSON.stringify(input.results || []),
|
||||
JSON.stringify(input.outputs || {}),
|
||||
createdAt
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
runGroupId: input.runGroupId || null,
|
||||
suiteId: input.suiteId,
|
||||
suiteName: input.suiteName,
|
||||
target: {
|
||||
type: input.target.type,
|
||||
id: targetId,
|
||||
key: serializeEvalTargetKey(input.target.type, targetId),
|
||||
label: input.target.label,
|
||||
},
|
||||
apiKeyId: input.apiKeyId || null,
|
||||
avgLatencyMs,
|
||||
summary: input.summary,
|
||||
results: input.results || [],
|
||||
outputs: input.outputs || {},
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEvalRuns(
|
||||
options: {
|
||||
suiteId?: string;
|
||||
runGroupId?: string;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): PersistedEvalRun[] {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (options.suiteId) {
|
||||
conditions.push("suite_id = ?");
|
||||
params.push(options.suiteId);
|
||||
}
|
||||
|
||||
if (options.runGroupId) {
|
||||
conditions.push("run_group_id = ?");
|
||||
params.push(options.runGroupId);
|
||||
}
|
||||
|
||||
const limit = Number.isFinite(Number(options.limit))
|
||||
? Math.min(200, Math.max(1, Math.floor(Number(options.limit))))
|
||||
: 20;
|
||||
params.push(limit);
|
||||
|
||||
const sql = `SELECT *
|
||||
FROM eval_runs
|
||||
${conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`;
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
return rows
|
||||
.map((row) => toPersistedEvalRun(row))
|
||||
.filter((row): row is PersistedEvalRun => row !== null);
|
||||
}
|
||||
|
||||
export function getEvalScorecard(
|
||||
options: {
|
||||
suiteId?: string;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): ReturnType<typeof createScorecard> | null {
|
||||
const runs = listEvalRuns({ suiteId: options.suiteId, limit: options.limit || 50 });
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
const latestByScope = new Map<string, PersistedEvalRun>();
|
||||
for (const run of runs) {
|
||||
const scopeKey = `${run.suiteId}:${run.target.key}`;
|
||||
if (!latestByScope.has(scopeKey)) {
|
||||
latestByScope.set(scopeKey, run);
|
||||
}
|
||||
}
|
||||
|
||||
return createScorecard(
|
||||
Array.from(latestByScope.values()).map((run) => ({
|
||||
suiteId: `${run.suiteId}:${run.target.key}`,
|
||||
suiteName: `${run.suiteName} · ${run.target.label}`,
|
||||
results: run.results,
|
||||
summary: run.summary,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
@ -65,6 +65,12 @@ const RENAMED_MIGRATION_COMPATIBILITY = [
|
|||
toVersion: "025",
|
||||
toName: "call_logs_summary_storage",
|
||||
},
|
||||
{
|
||||
fromVersion: "028",
|
||||
fromName: "provider_connection_max_concurrent",
|
||||
toVersion: "029",
|
||||
toName: "provider_connection_max_concurrent",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const PHYSICAL_SCHEMA_SENTINELS = [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
-- 027_create_files_and_batches.sql
|
||||
-- 028_create_files_and_batches.sql
|
||||
-- Creates the files and batches tables with their complete final schema.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
-- 028_provider_connection_max_concurrent.sql
|
||||
-- 029_provider_connection_max_concurrent.sql
|
||||
-- Adds account-native concurrency cap to provider_connections.
|
||||
-- This defines the maximum concurrent requests allowed for a specific account (connection).
|
||||
-- Coexists with existing combo-level concurrencyPerModel, which is separate.
|
||||
|
|
@ -7,4 +7,4 @@
|
|||
ALTER TABLE provider_connections ADD COLUMN max_concurrent INTEGER;
|
||||
|
||||
-- Index for provider-level filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_pc_max_concurrent ON provider_connections(provider, max_concurrent);
|
||||
CREATE INDEX IF NOT EXISTS idx_pc_max_concurrent ON provider_connections(provider, max_concurrent);
|
||||
28
src/lib/db/migrations/030_create_eval_runs.sql
Normal file
28
src/lib/db/migrations/030_create_eval_runs.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CREATE TABLE IF NOT EXISTS eval_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_group_id TEXT,
|
||||
suite_id TEXT NOT NULL,
|
||||
suite_name TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_label TEXT NOT NULL,
|
||||
api_key_id TEXT,
|
||||
pass_rate INTEGER NOT NULL DEFAULT 0,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
passed INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
summary_json TEXT NOT NULL,
|
||||
results_json TEXT NOT NULL,
|
||||
outputs_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_runs_suite_created_at
|
||||
ON eval_runs(suite_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_runs_group_id
|
||||
ON eval_runs(run_group_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_runs_created_at
|
||||
ON eval_runs(created_at DESC);
|
||||
|
|
@ -72,6 +72,7 @@ interface BuildHealthPayloadOptions {
|
|||
connections: Array<{ provider?: string; isActive?: boolean | null }>;
|
||||
circuitBreakers: CircuitBreakerStatus[];
|
||||
rateLimitStatus: JsonRecord;
|
||||
learnedLimits: JsonRecord;
|
||||
lockouts: JsonRecord;
|
||||
localProviders: JsonRecord;
|
||||
inflightRequests: number;
|
||||
|
|
@ -138,6 +139,7 @@ export function buildHealthPayload({
|
|||
connections,
|
||||
circuitBreakers,
|
||||
rateLimitStatus,
|
||||
learnedLimits,
|
||||
lockouts,
|
||||
localProviders,
|
||||
inflightRequests,
|
||||
|
|
@ -226,6 +228,7 @@ export function buildHealthPayload({
|
|||
},
|
||||
localProviders,
|
||||
rateLimitStatus,
|
||||
learnedLimits,
|
||||
lockouts,
|
||||
quotaMonitor: {
|
||||
...quotaMonitorSummary,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
APIKEY_PROVIDERS,
|
||||
AUDIO_ONLY_PROVIDERS,
|
||||
FREE_PROVIDERS,
|
||||
LOCAL_PROVIDERS,
|
||||
OAUTH_PROVIDERS,
|
||||
SEARCH_PROVIDERS,
|
||||
UPSTREAM_PROXY_PROVIDERS,
|
||||
|
|
@ -16,6 +17,7 @@ export type StaticProviderCatalogCategory =
|
|||
| "free"
|
||||
| "oauth"
|
||||
| "web-cookie"
|
||||
| "local"
|
||||
| "search"
|
||||
| "audio"
|
||||
| "upstream-proxy"
|
||||
|
|
@ -101,6 +103,12 @@ export const STATIC_PROVIDER_CATALOG_GROUPS: Record<
|
|||
displayAuthType: "apikey",
|
||||
toggleAuthType: "apikey",
|
||||
},
|
||||
local: {
|
||||
category: "local",
|
||||
providers: LOCAL_PROVIDERS as ProviderRecord,
|
||||
displayAuthType: "apikey",
|
||||
toggleAuthType: "apikey",
|
||||
},
|
||||
search: {
|
||||
category: "search",
|
||||
providers: SEARCH_PROVIDERS as ProviderRecord,
|
||||
|
|
@ -131,6 +139,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate
|
|||
"free",
|
||||
"oauth",
|
||||
"web-cookie",
|
||||
"local",
|
||||
"search",
|
||||
"audio",
|
||||
"upstream-proxy",
|
||||
|
|
@ -140,6 +149,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate
|
|||
const MANAGED_PROVIDER_CONNECTION_CATEGORIES = new Set<StaticProviderCatalogCategory>([
|
||||
"apikey",
|
||||
"web-cookie",
|
||||
"local",
|
||||
"search",
|
||||
"audio",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { protectPayloadForLog } from "../logPayloads";
|
||||
import { shouldPersistToDisk } from "./migrations";
|
||||
import {
|
||||
getLoggedInputTokens,
|
||||
|
|
@ -18,6 +19,22 @@ import {
|
|||
} from "./tokenAccounting";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type PendingRequestMetadata = {
|
||||
clientEndpoint?: string | null;
|
||||
clientRequest?: unknown;
|
||||
providerRequest?: unknown;
|
||||
providerUrl?: string | null;
|
||||
};
|
||||
type PendingRequestDetail = {
|
||||
model: string;
|
||||
provider: string;
|
||||
connectionId: string | null;
|
||||
startedAt: number;
|
||||
clientEndpoint?: string | null;
|
||||
clientRequest?: unknown;
|
||||
providerRequest?: unknown;
|
||||
providerUrl?: string | null;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
|
|
@ -50,38 +67,80 @@ function stdDev(values: number[], avg: number): number {
|
|||
return Math.sqrt(Math.max(0, variance));
|
||||
}
|
||||
|
||||
const MAX_PREVIEW_DEPTH = 6;
|
||||
const MAX_PREVIEW_STRING = 1200;
|
||||
const MAX_PREVIEW_ARRAY_ITEMS = 12;
|
||||
const MAX_PREVIEW_OBJECT_KEYS = 24;
|
||||
|
||||
function truncatePendingPreview(value: unknown, depth = 0): unknown {
|
||||
if (depth >= MAX_PREVIEW_DEPTH) {
|
||||
return "[TRUNCATED_DEPTH]";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.length > MAX_PREVIEW_STRING ? `${value.slice(0, MAX_PREVIEW_STRING)}...` : value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const preview = value
|
||||
.slice(0, MAX_PREVIEW_ARRAY_ITEMS)
|
||||
.map((item) => truncatePendingPreview(item, depth + 1));
|
||||
if (value.length > MAX_PREVIEW_ARRAY_ITEMS) {
|
||||
preview.push({ _truncatedItems: value.length - MAX_PREVIEW_ARRAY_ITEMS });
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as JsonRecord);
|
||||
const truncatedEntries = entries
|
||||
.slice(0, MAX_PREVIEW_OBJECT_KEYS)
|
||||
.map(([key, entryValue]) => [key, truncatePendingPreview(entryValue, depth + 1)]);
|
||||
const preview = Object.fromEntries(truncatedEntries);
|
||||
|
||||
if (entries.length > MAX_PREVIEW_OBJECT_KEYS) {
|
||||
preview._truncatedKeys = entries.length - MAX_PREVIEW_OBJECT_KEYS;
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingRequestMetadata {
|
||||
if (!metadata) return {};
|
||||
|
||||
const normalized: PendingRequestMetadata = {};
|
||||
|
||||
if (metadata.clientEndpoint !== undefined) {
|
||||
normalized.clientEndpoint = toStringOrNull(metadata.clientEndpoint) || null;
|
||||
}
|
||||
if (metadata.providerUrl !== undefined) {
|
||||
normalized.providerUrl = toStringOrNull(metadata.providerUrl) || null;
|
||||
}
|
||||
if (metadata.clientRequest !== undefined) {
|
||||
normalized.clientRequest = truncatePendingPreview(protectPayloadForLog(metadata.clientRequest));
|
||||
}
|
||||
if (metadata.providerRequest !== undefined) {
|
||||
normalized.providerRequest = truncatePendingPreview(
|
||||
protectPayloadForLog(metadata.providerRequest)
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ──────────────── Pending Requests (in-memory) ────────────────
|
||||
|
||||
const pendingRequests: {
|
||||
byModel: Record<string, number>;
|
||||
byAccount: Record<string, Record<string, number>>;
|
||||
details: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
provider: string;
|
||||
connectionId: string | null;
|
||||
startedAt: number;
|
||||
}
|
||||
>
|
||||
>;
|
||||
details: Record<string, Record<string, PendingRequestDetail>>;
|
||||
} = {
|
||||
byModel: Object.create(null) as Record<string, number>,
|
||||
byAccount: Object.create(null) as Record<string, Record<string, number>>,
|
||||
details: Object.create(null) as Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
provider: string;
|
||||
connectionId: string | null;
|
||||
startedAt: number;
|
||||
}
|
||||
>
|
||||
>,
|
||||
details: Object.create(null) as Record<string, Record<string, PendingRequestDetail>>,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -91,9 +150,11 @@ export function trackPendingRequest(
|
|||
model: string,
|
||||
provider: string,
|
||||
connectionId: string | null,
|
||||
started: boolean
|
||||
started: boolean,
|
||||
metadata?: PendingRequestMetadata
|
||||
) {
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
const normalizedMetadata = normalizePendingMetadata(metadata);
|
||||
|
||||
// Use hasOwnProperty guard to prevent prototype pollution via crafted keys
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byModel, modelKey)) {
|
||||
|
|
@ -111,12 +172,7 @@ export function trackPendingRequest(
|
|||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.details, connectionId)) {
|
||||
pendingRequests.details[connectionId] = Object.create(null) as Record<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
provider: string;
|
||||
connectionId: string | null;
|
||||
startedAt: number;
|
||||
}
|
||||
PendingRequestDetail
|
||||
>;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount[connectionId], modelKey)) {
|
||||
|
|
@ -135,7 +191,10 @@ export function trackPendingRequest(
|
|||
provider,
|
||||
connectionId,
|
||||
startedAt: Date.now(),
|
||||
...normalizedMetadata,
|
||||
};
|
||||
} else {
|
||||
Object.assign(pendingRequests.details[connectionId][modelKey], normalizedMetadata);
|
||||
}
|
||||
} else if (!started && nextCount === 0) {
|
||||
delete pendingRequests.details[connectionId][modelKey];
|
||||
|
|
@ -146,6 +205,19 @@ export function trackPendingRequest(
|
|||
}
|
||||
}
|
||||
|
||||
export function updatePendingRequest(
|
||||
model: string,
|
||||
provider: string,
|
||||
connectionId: string | null,
|
||||
metadata: PendingRequestMetadata
|
||||
) {
|
||||
if (!connectionId) return;
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
const existing = pendingRequests.details[connectionId]?.[modelKey];
|
||||
if (!existing) return;
|
||||
Object.assign(existing, normalizePendingMetadata(metadata));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pending requests state (for usageStats).
|
||||
* @returns {{ byModel: Object, byAccount: Object }}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import "./usage/migrations";
|
|||
// Re-export everything for backward compatibility
|
||||
export {
|
||||
trackPendingRequest,
|
||||
updatePendingRequest,
|
||||
getUsageDb,
|
||||
saveRequestUsage,
|
||||
getUsageHistory,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ActiveRequestRow = {
|
||||
model: string;
|
||||
|
|
@ -9,6 +10,10 @@ type ActiveRequestRow = {
|
|||
startedAt: number;
|
||||
runningTimeMs: number;
|
||||
count: number;
|
||||
clientEndpoint?: string | null;
|
||||
clientRequest?: unknown;
|
||||
providerRequest?: unknown;
|
||||
providerUrl?: string | null;
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
|
|
@ -22,8 +27,10 @@ function formatDuration(ms: number): string {
|
|||
}
|
||||
|
||||
export default function ActiveRequestsPanel() {
|
||||
const t = useTranslations("logs");
|
||||
const [rows, setRows] = useState<ActiveRequestRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRow, setSelectedRow] = useState<ActiveRequestRow | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -64,15 +71,13 @@ export default function ActiveRequestsPanel() {
|
|||
<div className="flex items-center justify-between gap-4 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-main">
|
||||
Running Requests
|
||||
{t("runningRequests")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Requests that are still in flight across providers and accounts.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t("runningRequestsDesc")}</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-300">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{loading ? "Loading..." : `${rows.length} active`}
|
||||
{loading ? t("loading") : t("activeCount", { count: rows.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -80,11 +85,12 @@ export default function ActiveRequestsPanel() {
|
|||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-sidebar/40 text-left text-xs uppercase tracking-wide text-text-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Model</th>
|
||||
<th className="px-4 py-3 font-medium">Provider</th>
|
||||
<th className="px-4 py-3 font-medium">Account</th>
|
||||
<th className="px-4 py-3 font-medium">Elapsed</th>
|
||||
<th className="px-4 py-3 font-medium">Count</th>
|
||||
<th className="px-4 py-3 font-medium">{t("model")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("provider")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("account")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("elapsed")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("count")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("payloads")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -98,11 +104,74 @@ export default function ActiveRequestsPanel() {
|
|||
<td className="px-4 py-3 text-text-muted">{row.account}</td>
|
||||
<td className="px-4 py-3 text-text-main">{formatDuration(row.runningTimeMs)}</td>
|
||||
<td className="px-4 py-3 text-text-main">{row.count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRow(row)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-sidebar/40"
|
||||
>
|
||||
{t("viewPayloads")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedRow && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-6">
|
||||
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-text-main">
|
||||
{selectedRow.provider} / {selectedRow.model}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("runningRequestDetailMeta", {
|
||||
account: selectedRow.account,
|
||||
elapsed: formatDuration(selectedRow.runningTimeMs),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRow(null)}
|
||||
className="rounded-full border border-border p-2 text-text-muted transition-colors hover:bg-sidebar/40 hover:text-text-main"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 overflow-y-auto px-5 py-5 md:grid-cols-2">
|
||||
<section className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="mb-3">
|
||||
<h5 className="text-sm font-semibold text-text-main">{t("clientPayload")}</h5>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{selectedRow.clientEndpoint || t("notAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/70 bg-bg p-3 text-xs text-text-muted">
|
||||
{JSON.stringify(selectedRow.clientRequest || {}, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="mb-3">
|
||||
<h5 className="text-sm font-semibold text-text-main">{t("upstreamPayload")}</h5>
|
||||
<p className="mt-1 break-all text-xs text-text-muted">
|
||||
{selectedRow.providerUrl || t("upstreamNotSentYet")}
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/70 bg-bg p-3 text-xs text-text-muted">
|
||||
{JSON.stringify(selectedRow.providerRequest || {}, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
23
tests/unit/provider-audit.test.ts
Normal file
23
tests/unit/provider-audit.test.ts
Normal file
|
|
@ -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.",
|
||||
]);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
const providerRequestPreview = detail.providerRequest as Record<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue