diff --git a/CHANGELOG.md b/CHANGELOG.md index 242f19b837..6d1a354407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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, + }), }; } } diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index ecb78bbb72..ef2fe7d3df 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -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[] = []; diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index aa579010bc..39758d409e 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -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 = { role: "assistant", - content: joinedContent || null, + content: joinedContent, }; if (joinedReasoning) { message.reasoning_content = joinedReasoning; diff --git a/open-sse/package.json b/open-sse/package.json index d1af6b587f..e211c738ce 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -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", diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index cb2f020e14..c661326cb4 100644 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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, }; diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 626c77a7c6..ba36a5d2ce 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -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; } diff --git a/package-lock.json b/package-lock.json index 74af804f7a..5fdb8f5a09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 95ce7ae36b..3a2bf902ea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/migrate-env.mjs b/scripts/migrate-env.mjs new file mode 100755 index 0000000000..f6ac0c34aa --- /dev/null +++ b/scripts/migrate-env.mjs @@ -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."); +} diff --git a/scripts/native-binary-compat.mjs b/scripts/native-binary-compat.mjs index 1b5ab353e7..4e3217edd6 100644 --- a/scripts/native-binary-compat.mjs +++ b/scripts/native-binary-compat.mjs @@ -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) { diff --git a/src/app/(dashboard)/dashboard/audit-log/page.tsx b/src/app/(dashboard)/dashboard/audit-log/page.tsx deleted file mode 100644 index b0f4c4dcef..0000000000 --- a/src/app/(dashboard)/dashboard/audit-log/page.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
- {/* Header */} -
-
-

{t("title")}

-

{t("description")}

-
- -
- - {/* Filters */} -
- 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)]" - /> - 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)]" - /> - -
- - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Table */} -
- - - - - - - - - - - - - {entries.length === 0 && !loading ? ( - - - - ) : ( - entries.map((entry) => ( - - - - - - - - - )) - )} - -
- {t("timestamp")} - - {t("action")} - - {t("actor")} - - {t("target")} - - {tc("details")} - - {t("ipAddress")} -
- {t("noEntries")} -
- {formatTimestamp(entry.timestamp)} - - - {entry.action} - - {entry.actor} - {entry.target || t("notAvailable")} - - {entry.details ? JSON.stringify(entry.details) : t("notAvailable")} - - {entry.ip_address || t("notAvailable")} -
-
- - {/* Pagination */} -
-

- {t("showing", { count: entries.length, offset })} -

-
- - -
-
-
- ); -} diff --git a/src/app/(dashboard)/dashboard/a2a/page.tsx b/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx similarity index 100% rename from src/app/(dashboard)/dashboard/a2a/page.tsx rename to src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx diff --git a/src/app/(dashboard)/dashboard/mcp/page.tsx b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx similarity index 100% rename from src/app/(dashboard)/dashboard/mcp/page.tsx rename to src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx diff --git a/src/app/(dashboard)/dashboard/endpoint/page.tsx b/src/app/(dashboard)/dashboard/endpoint/page.tsx index d8277b31d7..fb97242642 100644 --- a/src/app/(dashboard)/dashboard/endpoint/page.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/page.tsx @@ -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"; diff --git a/src/app/(dashboard)/dashboard/profile/page.tsx b/src/app/(dashboard)/dashboard/profile/page.tsx deleted file mode 100644 index ea8b62cdf5..0000000000 --- a/src/app/(dashboard)/dashboard/profile/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function ProfilePage() { - redirect("/dashboard/settings"); -} diff --git a/src/app/(dashboard)/dashboard/usage/page.tsx b/src/app/(dashboard)/dashboard/usage/page.tsx deleted file mode 100644 index 51626bdc8c..0000000000 --- a/src/app/(dashboard)/dashboard/usage/page.tsx +++ /dev/null @@ -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 ( -
- - - {/* Content */} - {activeTab === "logs" && } - {activeTab === "proxy-logs" && } -
- ); -} diff --git a/src/lib/oauth/providers/qwen.ts b/src/lib/oauth/providers/qwen.ts index 0a31c09212..a400bdb2cb 100644 --- a/src/lib/oauth/providers/qwen.ts +++ b/src/lib/oauth/providers/qwen.ts @@ -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, diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index b3dd15904f..1b31878424 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -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" }, diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 8127ce29a2..784887f34d 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -525,34 +525,20 @@ const locateCommand = async (command: string, env: Record 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" }; }; /**