chore(release): merge v3.4.1 stabilization fixes
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped

This commit is contained in:
diegosouzapw
2026-03-31 19:17:35 -03:00
23 changed files with 175 additions and 311 deletions
+9
View File
@@ -2,12 +2,17 @@
## [Unreleased]
## [3.4.1] - 2026-03-31
> [!WARNING]
> **BREAKING CHANGE: request logging, retention, and logging environment variables have been redesigned.**
> On the first startup after upgrading, OmniRoute archives legacy request logs from `DATA_DIR/logs/`, legacy `DATA_DIR/call_logs/`, and `DATA_DIR/log.txt` into `DATA_DIR/log_archives/*.zip`, then removes the deprecated layout and switches to the new unified artifact format under `DATA_DIR/call_logs/`.
### ✨ New Features
- **.ENV Migration Utility:** Included `scripts/migrate-env.mjs` to seamlessly migrate `<v3.3` configurations to `v3.4.x` strict security validation constraints (FASE-01), repairing startup crashes caused by short `JWT_SECRET` instances.
- **Kiro AI Cache Optimization:** Implemented deterministic `conversationId` generation (uuidv5) to enable AWS Builder ID Prompt Caching properly across invocations (#814).
- **Dashboard UI Restoration & Consolidation:** Resolved sidebar logic omitting the Debug section, and cleared Nextjs routing warnings by moving standalone `/dashboard/mcp` and `/dashboard/a2a` pages explicitly into embedded Endpoint Proxy UI components.
- **Unified Request Log Artifacts:** Request logging now stores one SQLite index row plus one JSON artifact per request under `DATA_DIR/call_logs/`, with optional pipeline capture embedded in the same file.
- **Language:** Improved the Chinese translation (#855)
- **Opencode-Zen Models:** Added 4 free models to opencode-zen registry (#854)
@@ -21,6 +26,10 @@
- **Provider Quota & Token Parsing:** Switched Antigravity limits to use `retrieveUserQuota` natively and correctly mapped Claude token refresh payloads to URL-encoded forms (#862)
- **Rate-Limiting Stability:** Universalized the 429 Retry-After parsing architecture to cap provider-induced cooldowns at 24 hours max (#862)
- **Dashboard Limit Rendering:** Re-architected `/dashboard/limits` quota mapping to render immediately inside chunks, fixing a major UI freezing delay on accounts exceeding 70 active connections (#784)
- **QWEN OAuth Authorization:** Mapped the OIDC `id_token` as the primary API Bearer token for Dashscope requests, fixing immediate 401 Unauthorized errors after connecting accounts or refreshing tokens (#864)
- **ZAI API Stability:** Hardened Server-Sent Events compiler to gracefully fallback to empty strings when DeepSeek providers stream mathematically null content during reasoning phases (#871)
- **Claude Code/Codex Translations:** Protected non-streaming payload conversions against empty responses from upstream Codex tools, avoiding catastrophic TypeErrors (#866)
- **NVIDIA NIM Rendering:** Conditionally stripped identical provider prefixes dynamically pushed by audio models, eliminating duplicate `nim/nim` tag structures throwing 404 on the Media Playground (#872)
### ⚠️ Breaking Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.3.11",
"version": "3.4.1",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
+2 -2
View File
@@ -341,7 +341,7 @@ export function getAllAudioModels() {
for (const [providerId, config] of Object.entries(AUDIO_TRANSCRIPTION_PROVIDERS)) {
for (const model of config.models) {
models.push({
id: `${providerId}/${model.id}`,
id: model.id.startsWith(`${providerId}/`) ? model.id : `${providerId}/${model.id}`,
name: model.name,
provider: providerId,
subtype: "transcription",
@@ -352,7 +352,7 @@ export function getAllAudioModels() {
for (const [providerId, config] of Object.entries(AUDIO_SPEECH_PROVIDERS)) {
for (const model of config.models) {
models.push({
id: `${providerId}/${model.id}`,
id: model.id.startsWith(`${providerId}/`) ? model.id : `${providerId}/${model.id}`,
name: model.name,
provider: providerId,
subtype: "speech",
+24 -1
View File
@@ -188,7 +188,7 @@ export class AntigravityExecutor extends BaseExecutor {
parseRetryFromErrorMessage(errorMessage) {
if (!errorMessage || typeof errorMessage !== "string") return null;
const match = errorMessage.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i);
if (!match) return null;
let totalMs = 0;
@@ -242,6 +242,29 @@ export class AntigravityExecutor extends BaseExecutor {
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
retryMs = this.parseRetryFromErrorMessage(errorMessage);
if (!retryMs) {
// Dynamic quota interpretation logic for Free vs Pro accounts
const lowerMsg = errorMessage.toLowerCase();
if (
lowerMsg.includes("free tier") ||
lowerMsg.includes("exhausted your capacity") ||
lowerMsg.includes("daily limit") ||
lowerMsg.includes("quota exceeded")
) {
// Hard limit hit for Free accounts (or exhausting general capacity), fallback immediately.
// Setting a massive retryMs forces an instant fallback.
retryMs = 24 * 60 * 60 * 1000; // 24 hours
} else if (
lowerMsg.includes("pro") ||
lowerMsg.includes("per minute") ||
lowerMsg.includes("rpm")
) {
// RPM limit for Pro counts, backoff up to 1 minute, then fallback
retryMs = 60 * 1000; // 60s
}
}
} catch (e) {
// Ignore parse errors, will fall back to exponential backoff
}
+16
View File
@@ -68,6 +68,8 @@ export class KiroExecutor extends BaseExecutor {
...this.config.headers,
"Amz-Sdk-Request": "attempt=1; max=3",
"Amz-Sdk-Invocation-Id": uuidv4(),
"x-amzn-bedrock-cache-control": "enable",
"anthropic-beta": "prompt-caching-2024-07-31",
};
if (credentials.accessToken) {
@@ -358,11 +360,25 @@ export class KiroExecutor extends BaseExecutor {
? ((metrics as JsonRecord).outputTokens as number)
: 0;
const cacheReadTokens =
typeof (metrics as JsonRecord).cacheReadTokens === "number"
? ((metrics as JsonRecord).cacheReadTokens as number)
: 0;
const cacheCreationTokens =
typeof (metrics as JsonRecord).cacheCreationTokens === "number"
? ((metrics as JsonRecord).cacheCreationTokens as number)
: 0;
if (inputTokens > 0 || outputTokens > 0) {
state.usage = {
prompt_tokens: inputTokens,
completion_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
...(cacheReadTokens > 0 && { cache_read_input_tokens: cacheReadTokens }),
...(cacheCreationTokens > 0 && {
cache_creation_input_tokens: cacheCreationTokens,
}),
};
}
}
+7 -4
View File
@@ -402,11 +402,14 @@ export function translateNonStreamingResponse(
* Helper to convert an OpenAI chat.completion JSON object to Claude format for non-streaming.
*/
function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonRecord {
const choice = Array.isArray(openaiResponse.choices) ? openaiResponse.choices[0] : null;
if (!choice) return openaiResponse; // If it doesn't look like OpenAI, return as-is
const isChoicesArray = Array.isArray(openaiResponse.choices);
if (!isChoicesArray && openaiResponse.object !== "chat.completion") {
return openaiResponse; // If it doesn't look like OpenAI, return as-is
}
const choiceObj = toRecord(choice);
const messageObj = toRecord(choiceObj.message);
const choice = isChoicesArray ? openaiResponse.choices[0] : null;
const choiceObj = choice ? toRecord(choice) : {};
const messageObj = choiceObj.message ? toRecord(choiceObj.message) : {};
const content: JsonRecord[] = [];
+7 -3
View File
@@ -53,7 +53,11 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
reasoningParts.push(delta.reasoning_content);
}
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
if (typeof delta.reasoning === "string" && delta.reasoning.length > 0 && !delta.reasoning_content) {
if (
typeof delta.reasoning === "string" &&
delta.reasoning.length > 0 &&
!delta.reasoning_content
) {
reasoningParts.push(delta.reasoning);
}
@@ -98,11 +102,11 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
}
}
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : "";
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
const message: Record<string, unknown> = {
role: "assistant",
content: joinedContent || null,
content: joinedContent,
};
if (joinedReasoning) {
message.reasoning_content = joinedReasoning;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.3.11",
"version": "3.4.1",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",
+1 -1
View File
@@ -324,7 +324,7 @@ export async function refreshQwenToken(refreshToken, log) {
});
return {
accessToken: tokens.access_token,
accessToken: tokens.id_token || tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
+15 -1
View File
@@ -287,7 +287,7 @@ export function buildKiroPayload(model, body, stream, credentials) {
} = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
conversationId: uuidv4(), // We must override this with deterministic ID
currentMessage: {
userInputMessage: {
content: finalContent,
@@ -302,6 +302,20 @@ export function buildKiroPayload(model, body, stream, credentials) {
},
};
// Determistic session caching for Kiro
const NAMESPACE_KIRO = "34f7193f-561d-4050-bc84-9547d953d6bf";
const firstContent =
history.length > 0 && history[0].userInputMessage?.content
? history[0].userInputMessage.content
: finalContent;
// Use uuidv5 with the hash of the system prompt / first message to maintain AWS Builder ID context cache
const { v5: uuidv5 } = require("uuid");
payload.conversationState.conversationId = uuidv5(
(firstContent || "").substring(0, 4000),
NAMESPACE_KIRO
);
if (profileArn) {
payload.profileArn = profileArn;
}
+6 -2
View File
@@ -26,7 +26,6 @@
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^8.0.0",
"jose": "^6.1.3",
"keytar": "^7.9.0",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.0.10",
@@ -77,6 +76,9 @@
},
"engines": {
"node": ">=18.0.0 <24.0.0"
},
"optionalDependencies": {
"keytar": "^7.9.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -12613,6 +12615,7 @@
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
@@ -12622,7 +12625,8 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/keyv": {
"version": "4.5.4",
+3 -1
View File
@@ -97,7 +97,6 @@
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^8.0.0",
"jose": "^6.1.3",
"keytar": "^7.9.0",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.0.10",
@@ -119,6 +118,9 @@
"zod": "^4.3.6",
"zustand": "^5.0.10"
},
"optionalDependencies": {
"keytar": "^7.9.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.1.18",
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* OmniRoute v3.3 -> v3.4 Environment Migration Script
* Resolves breaking changes in environment variables format and validation.
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) {
console.log("No .env file found. Migration skipped.");
process.exit(0);
}
let content = fs.readFileSync(envPath, "utf8");
let modified = false;
// 1. Migrate NEXTAUTH_SECRET to JWT_SECRET if missing
const nextAuthMatch = content.match(/^NEXTAUTH_SECRET=(.+)$/m);
const jwtMatch = content.match(/^JWT_SECRET=(.+)$/m);
if (nextAuthMatch && !jwtMatch) {
console.log("Migrating NEXTAUTH_SECRET to JWT_SECRET...");
let newJwt = nextAuthMatch[1].trim();
// Enforce 32 char minimum for secretsValidator.ts
if (newJwt.length < 32) {
console.warn(
`Original NEXTAUTH_SECRET was too short (${newJwt.length} chars). Generating a secure one...`
);
newJwt = crypto.randomBytes(48).toString("base64");
}
content += `\n# Migrated from NEXTAUTH_SECRET\nJWT_SECRET=${newJwt}\n`;
modified = true;
} else if (jwtMatch && jwtMatch[1].trim().length < 32) {
console.warn(
`JWT_SECRET is too short (${jwtMatch[1].trim().length} chars). Generating a secure one for v3.4.0+...`
);
const newJwt = crypto.randomBytes(48).toString("base64");
content = content.replace(/^JWT_SECRET=(.*)$/m, `JWT_SECRET=${newJwt}`);
modified = true;
}
// 2. Ensure API_KEY_SECRET exists (required in 3.4.0)
if (!content.match(/^API_KEY_SECRET=/m)) {
console.log("Adding required API_KEY_SECRET for v3.4.0...");
const newApiSecret = crypto.randomBytes(32).toString("hex");
content += `\n# Required for v3.4.0 API Key HMAC\nAPI_KEY_SECRET=${newApiSecret}\n`;
modified = true;
}
if (modified) {
// Backup old .env
fs.writeFileSync(envPath + ".bak", fs.readFileSync(envPath));
console.log("Created backup at .env.bak");
// Write new .env
fs.writeFileSync(envPath, content, "utf8");
console.log("Successfully migrated .env file for OmniRoute 3.4.x.");
} else {
console.log(".env file is already compatible with OmniRoute 3.4.x.");
}
+5 -1
View File
@@ -146,7 +146,11 @@ export function isNativeBinaryCompatible(
const target = readNativeBinaryTarget(binaryPath);
if (target) {
if (target.platform !== runtimePlatform || !target.architectures.includes(runtimeArch)) {
if (
(target.platform !== runtimePlatform &&
!(target.platform === "linux" && runtimePlatform === "android")) ||
!target.architectures.includes(runtimeArch)
) {
return false;
}
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
@@ -1,237 +0,0 @@
"use client";
/**
* Audit Log Viewer P-2
*
* Dashboard page for viewing administrative audit log entries.
* Fetches from /api/compliance/audit-log with filter support.
*/
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface AuditEntry {
id: number;
timestamp: string;
action: string;
actor: string;
target: string | null;
details: any;
ip_address: string | null;
}
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const t = useTranslations("auditLog");
const tc = useTranslations("common");
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState("");
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (actionFilter) params.set("action", actionFilter);
if (actorFilter) params.set("actor", actorFilter);
params.set("limit", String(PAGE_SIZE + 1));
params.set("offset", String(offset));
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: AuditEntry[] = await res.json();
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || t("failedFetchAuditLog"));
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset, t]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleSearch = () => {
setOffset(0);
fetchEntries();
};
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
const actionBadgeColor = (action: string) => {
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"))
return "bg-green-500/15 text-green-400 border-green-500/20";
if (action.includes("update") || action.includes("change"))
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
if (action.includes("login") || action.includes("auth"))
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">{t("title")}</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("description")}</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label={t("refreshAuditLogAria")}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? tc("loading") : tc("refresh")}
</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"
aria-label={t("filterEntriesAria")}
>
<input
type="text"
placeholder={t("filterByAction")}
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label={t("filterByActionTypeAria")}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder={t("filterByActor")}
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label={t("filterByActorAria")}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
{tc("search")}
</button>
</div>
{/* Error */}
{error && (
<div
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
role="alert"
>
{error}
</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>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("timestamp")}
</th>
<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("actor")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("target")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{tc("details")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("ipAddress")}
</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
{t("noEntries")}
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
>
{entry.action}
</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>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || t("notAvailable")}
</td>
</tr>
))
)}
</tbody>
</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 })}
</p>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
{t("previous")}
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
{tc("next")}
</button>
</div>
</div>
</div>
);
}
@@ -3,8 +3,8 @@
import { useState, useEffect, useCallback } from "react";
import { SegmentedControl } from "@/shared/components";
import EndpointPageClient from "./EndpointPageClient";
import McpDashboardPage from "../mcp/page";
import A2ADashboardPage from "../a2a/page";
import McpDashboardPage from "./components/MCPDashboard";
import A2ADashboardPage from "./components/A2ADashboard";
import ApiEndpointsTab from "./ApiEndpointsTab";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function ProfilePage() {
redirect("/dashboard/settings");
}
@@ -1,28 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
export default function UsagePage() {
const t = useTranslations("usage");
const [activeTab, setActiveTab] = useState("logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "logs", label: t("loggerTab") },
{ value: "proxy-logs", label: t("proxyTab") },
]}
value={activeTab}
onChange={setActiveTab}
/>
{/* Content */}
{activeTab === "logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
</div>
);
}
+1 -1
View File
@@ -60,7 +60,7 @@ export const qwen = {
}
return {
accessToken: tokens.access_token,
accessToken: tokens.id_token || tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
idToken: tokens.id_token,
@@ -4,6 +4,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"api-manager",
"providers",
"combos",
"auto-combo",
"costs",
"analytics",
"limits",
@@ -49,6 +50,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "api-manager", href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" },
{ id: "providers", href: "/dashboard/providers", i18nKey: "providers", icon: "dns" },
{ id: "combos", href: "/dashboard/combos", i18nKey: "combos", icon: "layers" },
{ id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" },
{ id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
+6 -20
View File
@@ -525,34 +525,20 @@ const locateCommand = async (command: string, env: Record<string, string | undef
if (isWindows()) {
const located = await runProcess("where", [command], { env, timeoutMs: 3000 });
if (!located.ok || !located.stdout) {
return { installed: false, commandPath: null, reason: "not_found" };
if (located.ok && located.stdout) {
return { installed: true, commandPath: command, reason: null };
}
const lines = located.stdout
.split(/\r?\n/)
.map((line) => normalizeMsys2Path(line.trim()))
.filter(Boolean);
// Issue #809: Prioritize executable wrappers (.cmd, .exe, .bat) over extensionless bash scripts
// that NPM often drops alongside the wrappers in global installs.
const first = lines.find((line) => /\.(cmd|exe|bat)$/i.test(line)) || lines[0] || null;
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
return { installed: false, commandPath: null, reason: "not_found" };
}
const located = await runProcess("sh", ["-c", 'command -v -- "$1"', "sh", command], {
env,
timeoutMs: 3000,
});
if (!located.ok || !located.stdout) {
return { installed: false, commandPath: null, reason: "not_found" };
if (located.ok && located.stdout) {
return { installed: true, commandPath: command, reason: null };
}
const first =
located.stdout
.split(/\r?\n/)
.map((line) => normalizeMsys2Path(line.trim()))
.find(Boolean) || null;
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
return { installed: false, commandPath: null, reason: "not_found" };
};
/**