From 38d89c519c7bcfe2b33ab3c37266a9cbcd0e5823 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 28 Apr 2026 23:04:21 -0300 Subject: [PATCH 01/12] docs(changelog): record PR #1748 for next release --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d829a062b0..5d8d7e928d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 🐛 Bug Fixes + +- **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748) + --- ## [3.7.4] — 2026-04-28 From b96b85f675c3e2debe3c655ee1176c0da62b0660 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 29 Apr 2026 00:45:18 -0300 Subject: [PATCH 02/12] fix(models): apply blocked providers filter to non-chat catalog models (#1752) --- CHANGELOG.md | 1 + package-lock.json | 246 ++++++++++++++++- package.json | 3 +- .../dashboard/endpoint/EndpointPageClient.tsx | 253 +++++++++++++++++- src/app/api/tunnels/ngrok/route.ts | 74 +++++ src/app/api/v1/models/catalog.ts | 11 + src/i18n/messages/en.json | 19 +- src/i18n/messages/pt-BR.json | 19 +- src/i18n/messages/pt.json | 19 +- src/lib/ngrokTunnel.ts | 142 ++++++++++ 10 files changed, 780 insertions(+), 7 deletions(-) create mode 100644 src/app/api/tunnels/ngrok/route.ts create mode 100644 src/lib/ngrokTunnel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8d7e928d..0311e61be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🐛 Bug Fixes +- **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752) - **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748) --- diff --git a/package-lock.json b/package-lock.json index dee96e1e29..dfab87f3b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -16,6 +16,7 @@ "@lobehub/icons": "^5.5.4", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", + "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", "axios": "^1.15.0", "bcryptjs": "^3.0.3", @@ -2322,6 +2323,247 @@ "node": ">= 10" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz", + "integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.7.0", + "@ngrok/ngrok-darwin-arm64": "1.7.0", + "@ngrok/ngrok-darwin-universal": "1.7.0", + "@ngrok/ngrok-darwin-x64": "1.7.0", + "@ngrok/ngrok-freebsd-x64": "1.7.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", + "@ngrok/ngrok-linux-arm64-musl": "1.7.0", + "@ngrok/ngrok-linux-x64-gnu": "1.7.0", + "@ngrok/ngrok-linux-x64-musl": "1.7.0", + "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", + "@ngrok/ngrok-win32-x64-msvc": "1.7.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz", + "integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz", + "integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz", + "integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz", + "integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz", + "integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz", + "integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz", + "integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz", + "integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz", + "integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz", + "integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz", + "integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz", + "integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz", + "integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index e17f1e9d4e..0a2a1d4300 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { @@ -110,6 +110,7 @@ "@lobehub/icons": "^5.5.4", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", + "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", "axios": "^1.15.0", "bcryptjs": "^3.0.3", diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index d07926b240..3c3395797b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -63,6 +63,26 @@ type TailscaleTunnelStatus = { pid: number | null; }; +type NgrokTunnelPhase = + | "unsupported" + | "not_installed" + | "stopped" + | "needs_auth" + | "starting" + | "running" + | "error"; + +type NgrokTunnelStatus = { + supported: boolean; + installed: boolean; + running: boolean; + publicUrl: string | null; + apiUrl: string | null; + targetUrl: string; + phase: NgrokTunnelPhase; + lastError: string | null; +}; + type TunnelNotice = { type: "success" | "error" | "info"; message: string; @@ -105,6 +125,11 @@ export default function APIPageClient({ machineId }) { const [tailscalePassword, setTailscalePassword] = useState(""); const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true); const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true); + const [ngrokStatus, setNgrokStatus] = useState(null); + const [ngrokBusy, setNgrokBusy] = useState(false); + const [ngrokNotice, setNgrokNotice] = useState(null); + const [ngrokToken, setNgrokToken] = useState(""); + const [showNgrokTunnel, setShowNgrokTunnel] = useState(true); const { copied, copy } = useCopyToClipboard(); @@ -201,6 +226,35 @@ export default function APIPageClient({ machineId }) { [translateOrFallback] ); + const fetchNgrokStatus = useCallback( + async (silent = false) => { + try { + const res = await fetch("/api/tunnels/ngrok", { cache: "no-store" }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + data?.error || translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status") + ); + } + + setNgrokStatus(data); + return data as NgrokTunnelStatus; + } catch (error) { + if (!silent) { + setNgrokNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status"), + }); + } + return null; + } + }, + [translateOrFallback] + ); + useEffect(() => { Promise.allSettled([ loadCloudSettings(), @@ -209,10 +263,11 @@ export default function APIPageClient({ machineId }) { fetchSearchProviders(), fetchCloudflaredStatus(true), fetchTailscaleStatus(true), + fetchNgrokStatus(true), ]).finally(() => { setLoading(false); }); - }, [fetchCloudflaredStatus, fetchTailscaleStatus]); + }, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]); const fetchModels = async () => { try { @@ -502,6 +557,52 @@ export default function APIPageClient({ machineId }) { } }; + const handleNgrokAction = async (action: "enable" | "disable") => { + setNgrokBusy(true); + setNgrokNotice(null); + + try { + const res = await fetch("/api/tunnels/ngrok", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, authToken: action === "enable" ? ngrokToken : undefined }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error( + data?.error || translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel") + ); + } + + if (data?.status) { + setNgrokStatus(data.status); + } + + setNgrokNotice({ + type: "success", + message: + action === "enable" + ? translateOrFallback("ngrokStarted", "ngrok tunnel started") + : translateOrFallback("ngrokStopped", "ngrok tunnel stopped"), + }); + if (action === "enable") { + setNgrokToken(""); + } + } catch (error) { + setNgrokNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel"), + }); + } finally { + setNgrokBusy(false); + await fetchNgrokStatus(true); + } + }; + const waitForTailscale = useCallback( async ( predicate: (status: TailscaleTunnelStatus) => boolean, @@ -891,6 +992,42 @@ export default function APIPageClient({ machineId }) { "Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use." ); + const ngrokPhase = ngrokStatus?.phase || "not_installed"; + const ngrokPhaseMeta: Record = { + running: { + label: translateOrFallback("ngrokRunning", "Running"), + className: "bg-green-500/10 border-green-500/30 text-green-400", + }, + starting: { + label: translateOrFallback("ngrokStarting", "Starting"), + className: "bg-blue-500/10 border-blue-500/30 text-blue-400", + }, + stopped: { + label: translateOrFallback("ngrokStoppedState", "Stopped"), + className: "bg-surface border-border/70 text-text-muted", + }, + needs_auth: { + label: translateOrFallback("ngrokNeedsAuth", "Needs Auth"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + not_installed: { + label: translateOrFallback("ngrokNotInstalled", "Not installed"), + className: "bg-surface border-border/70 text-text-muted", + }, + unsupported: { + label: translateOrFallback("ngrokUnsupported", "Unsupported"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + error: { + label: translateOrFallback("ngrokError", "Error"), + className: "bg-red-500/10 border-red-500/30 text-red-400", + }, + }; + const ngrokActionLabel = ngrokStatus?.running + ? translateOrFallback("ngrokDisable", "Stop Tunnel") + : translateOrFallback("ngrokEnable", "Enable Tunnel"); + const ngrokUrlNotice = translateOrFallback("ngrokUrlNotice", "Creates a public ngrok tunnel."); + return (
{/* Endpoint Card */} @@ -1229,6 +1366,120 @@ export default function APIPageClient({ machineId }) {
)} + + {showNgrokTunnel && ( +
+
+
+
+
+

+ {translateOrFallback("ngrokTitle", "ngrok Tunnel")} +

+ + {ngrokPhaseMeta[ngrokPhase].label} + +
+
+ + {ngrokStatus?.supported !== false && ( + + )} +
+ + {ngrokNotice && ( +
+ + {ngrokNotice.type === "success" + ? "check_circle" + : ngrokNotice.type === "info" + ? "info" + : "error"} + + {ngrokNotice.message} + +
+ )} + +

{ngrokUrlNotice}

+ {ngrokStatus?.phase === "needs_auth" && ( +
+ + setNgrokToken(event.target.value)} + placeholder={translateOrFallback( + "ngrokAuthTokenPlaceholder", + "Enter your ngrok authtoken" + )} + disabled={ngrokBusy} + className="font-mono text-sm" + /> +
+ )} +
+ + +
+ {ngrokStatus?.lastError && ( +

+ {translateOrFallback("ngrokLastError", "Last error: {error}", { + error: ngrokStatus.lastError, + })} +

+ )} +
+
+ )} diff --git a/src/app/api/tunnels/ngrok/route.ts b/src/app/api/tunnels/ngrok/route.ts new file mode 100644 index 0000000000..5fad841674 --- /dev/null +++ b/src/app/api/tunnels/ngrok/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel"; + +export const dynamic = "force-dynamic"; + +const actionSchema = z.object({ + action: z.enum(["enable", "disable"]), + authToken: z.string().optional(), +}); + +function unauthorized() { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return unauthorized(); + } + + try { + const status = await getNgrokTunnelStatus(); + return NextResponse.json(status); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return unauthorized(); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(actionSchema, rawBody); + if (isValidationFailure(validation)) { + return validation.response; + } + + const parsed = validation.data; + + try { + const status = + parsed.action === "enable" + ? await startNgrokTunnel(parsed.authToken) + : await stopNgrokTunnel(); + + return NextResponse.json({ + success: true, + action: parsed.action, + status, + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to update ngrok tunnel", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e291961e60..962ca76e07 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -478,6 +478,17 @@ export async function getUnifiedModelsResponse( const isProviderActive = (provider: string) => { if (activeAliases.size === 0) return false; // No active connections = show nothing const alias = providerIdToAlias[provider] || provider; + const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || provider; + + // FIX #1752: Ensure blocked providers are not returned for non-chat models + if ( + blockedProviders.has(alias) || + blockedProviders.has(canonicalProviderId) || + blockedProviders.has(provider) + ) { + return false; + } + return activeAliases.has(alias) || activeAliases.has(provider); }; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0c36d058eb..ac2a5e5f7e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1980,7 +1980,24 @@ "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", "tailscaleInstalling": "Installing", - "tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)" + "tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)", + "ngrokTitle": "ngrok Tunnel", + "ngrokRunning": "Running", + "ngrokStarting": "Starting", + "ngrokStoppedState": "Stopped", + "ngrokNeedsAuth": "Needs Auth", + "ngrokNotInstalled": "Not installed", + "ngrokUnsupported": "Unsupported", + "ngrokError": "Error", + "ngrokEnable": "Enable Tunnel", + "ngrokDisable": "Stop Tunnel", + "ngrokUrlNotice": "Creates a public ngrok tunnel.", + "ngrokAuthTokenLabel": "Authtoken (Required if NGROK_AUTHTOKEN not set)", + "ngrokAuthTokenPlaceholder": "Enter your ngrok authtoken", + "ngrokLastError": "Last error: {error}", + "ngrokStarted": "ngrok tunnel started", + "ngrokStopped": "ngrok tunnel stopped", + "ngrokRequestFailed": "Failed to update ngrok tunnel" }, "endpoints": { "tabProxy": "Endpoint Proxy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7699421d57..02dccb8b74 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1902,7 +1902,24 @@ "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", "tailscaleInstalling": "Installing", - "tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)" + "tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)", + "ngrokTitle": "Túnel ngrok", + "ngrokRunning": "Rodando", + "ngrokStarting": "Iniciando", + "ngrokStoppedState": "Parado", + "ngrokNeedsAuth": "Requer Autenticação", + "ngrokNotInstalled": "Não instalado", + "ngrokUnsupported": "Não suportado", + "ngrokError": "Erro", + "ngrokEnable": "Habilitar Túnel", + "ngrokDisable": "Parar Túnel", + "ngrokUrlNotice": "Cria um túnel público ngrok.", + "ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)", + "ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok", + "ngrokLastError": "Último erro: {error}", + "ngrokStarted": "Túnel ngrok iniciado", + "ngrokStopped": "Túnel ngrok parado", + "ngrokRequestFailed": "Falha ao atualizar túnel ngrok" }, "endpoints": { "tabProxy": "Proxy de terminal", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c7369919f5..106a115a3f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1874,7 +1874,24 @@ "tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.", "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", - "tailscaleInstalling": "Installing" + "tailscaleInstalling": "Installing", + "ngrokTitle": "Túnel ngrok", + "ngrokRunning": "Rodando", + "ngrokStarting": "Iniciando", + "ngrokStoppedState": "Parado", + "ngrokNeedsAuth": "Requer Autenticação", + "ngrokNotInstalled": "Não instalado", + "ngrokUnsupported": "Não suportado", + "ngrokError": "Erro", + "ngrokEnable": "Habilitar Túnel", + "ngrokDisable": "Parar Túnel", + "ngrokUrlNotice": "Cria um túnel público ngrok.", + "ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)", + "ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok", + "ngrokLastError": "Último erro: {error}", + "ngrokStarted": "Túnel ngrok iniciado", + "ngrokStopped": "Túnel ngrok parado", + "ngrokRequestFailed": "Falha ao atualizar túnel ngrok" }, "endpoints": { "tabProxy": "Endpoint Proxy", diff --git a/src/lib/ngrokTunnel.ts b/src/lib/ngrokTunnel.ts new file mode 100644 index 0000000000..369c3a8929 --- /dev/null +++ b/src/lib/ngrokTunnel.ts @@ -0,0 +1,142 @@ +import { getRuntimePorts } from "@/lib/runtime/ports"; + +export type TunnelPhase = + | "unsupported" + | "not_installed" + | "stopped" + | "needs_auth" + | "starting" + | "running" + | "error"; + +export type NgrokTunnelStatus = { + supported: boolean; + installed: boolean; + running: boolean; + publicUrl: string | null; + apiUrl: string | null; + targetUrl: string; + phase: TunnelPhase; + lastError: string | null; +}; + +// Next.js hot-reloading safe global storage for the listener +const globalForNgrok = globalThis as unknown as { + __ngrokListener: any; +}; + +let startPromise: Promise | null = null; + +function getLocalTargetUrl() { + const { apiPort } = getRuntimePorts(); + return `http://127.0.0.1:${apiPort}`; +} + +function getTunnelApiUrl(publicUrl: string | null) { + return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null; +} + +export async function getNgrokTunnelStatus(): Promise { + const targetUrl = getLocalTargetUrl(); + const tokenAvailable = !!( + process.env.NGROK_AUTHTOKEN && process.env.NGROK_AUTHTOKEN.trim() !== "" + ); + const listener = globalForNgrok.__ngrokListener; + let currentUrl = null; + + if (listener) { + try { + currentUrl = typeof listener.url === "function" ? listener.url() : listener.url; + } catch { + // Ignored + } + } + + return { + supported: true, + installed: true, + running: currentUrl !== null, + publicUrl: currentUrl || null, + apiUrl: currentUrl ? getTunnelApiUrl(currentUrl) : null, + targetUrl, + phase: currentUrl === null ? (tokenAvailable ? "stopped" : "needs_auth") : "running", + lastError: null, + }; +} + +export async function startNgrokTunnel(inputAuthToken?: string): Promise { + const current = await getNgrokTunnelStatus(); + if (current.running) return current; + if (startPromise) return startPromise; + + startPromise = (async () => { + try { + const authToken = + inputAuthToken && inputAuthToken.trim() !== "" + ? inputAuthToken.trim() + : process.env.NGROK_AUTHTOKEN; + + if (!authToken) { + return { + ...(await getNgrokTunnelStatus()), + phase: "needs_auth", + lastError: "An ngrok authtoken is required.", + }; + } + + // Dynamically import ngrok so it doesn't break environments where native build fails if not used + const ngrok = await import("@ngrok/ngrok"); + + const targetUrl = getLocalTargetUrl(); + const listenerOptions: any = { addr: targetUrl }; + + if (!inputAuthToken && process.env.NGROK_AUTHTOKEN) { + listenerOptions.authtoken_from_env = true; + } else { + listenerOptions.authtoken = authToken; + } + + const listener = await ngrok.forward(listenerOptions); + globalForNgrok.__ngrokListener = listener; + + let url = null; + try { + url = typeof listener.url === "function" ? listener.url() : listener.url; + } catch { + // Ignored + } + + if (!url) { + await stopNgrokTunnel(); + throw new Error("ngrok did not return a public URL."); + } + + return await getNgrokTunnelStatus(); + } catch (error) { + return { + ...(await getNgrokTunnelStatus()), + phase: "error", + lastError: error instanceof Error ? error.message : String(error), + }; + } finally { + startPromise = null; + } + })(); + + return startPromise; +} + +export async function stopNgrokTunnel(): Promise { + const listener = globalForNgrok.__ngrokListener; + if (listener) { + try { + if (typeof listener.close === "function") { + await listener.close(); + } + } catch (e) { + // Ignore close errors + } + globalForNgrok.__ngrokListener = undefined; + } + return await getNgrokTunnelStatus(); +} From 5fd56207cf5b3928988ac76e0d5db464da825cde Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 29 Apr 2026 01:11:33 -0300 Subject: [PATCH 03/12] =?UTF-8?q?chore(release):=20v3.7.5=20=E2=80=94=20in?= =?UTF-8?q?tegrate=20ngrok=20tunnel=20and=20fix=20models=20filter=20(#1753?= =?UTF-8?q?,=20#1752)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ docs/openapi.yaml | 2 +- electron/package.json | 2 +- llm.txt | 4 ++-- open-sse/package.json | 2 +- package-lock.json | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0311e61be1..de135439f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### ✨ New Features + +- **feat(tunnels):** integrate native ngrok tunnel support with dashboard UI parity (#1753) + ### 🐛 Bug Fixes - **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ccf1626744..40a309b584 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.7.4 + version: 3.7.5 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package.json b/electron/package.json index 7a023263f8..cb06672579 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.7.4", + "version": "3.7.5", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 99ba91f792..5903d37c52 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.7.4 +**Current version:** 3.7.5 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.7.4) +## Key Features (v3.7.5) ### Core Proxy - **160+ AI providers** with automatic format translation diff --git a/open-sse/package.json b/open-sse/package.json index 4d7bf34fe6..b57e8dec92 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.7.4", + "version": "3.7.5", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index dfab87f3b1..a22ea08b6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15214,7 +15214,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.7.4" + "version": "3.7.5" } } } From 3c63566d570fe1f486a2ab4299498c5f68fec1d9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 29 Apr 2026 01:20:20 -0300 Subject: [PATCH 04/12] chore(release): update changelog format for v3.7.5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de135439f1..e9f47f54b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +--- + +## [3.7.5] — 2026-04-29 + ### ✨ New Features - **feat(tunnels):** integrate native ngrok tunnel support with dashboard UI parity (#1753) From 2aaba386ec7700002d9d717e737eb08c3e9b97a2 Mon Sep 17 00:00:00 2001 From: clousky2020 <33016567+clousky2020@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:53:02 +0800 Subject: [PATCH 05/12] fix: resolve build issues and implement memory UPSERT logic (#1763) * fix: resolve build issues for v3.7.5 and apply memory/translation fixes 1. antigravityHeaders.ts: restore ANTIGRAVITY_LOAD_CODE_ASSIST_* exports for oauth.ts compatibility 2. next.config.mjs: add @ngrok/ngrok to serverExternalPackages and webpack externals to handle native .node modules 3. Memory system: UPSERT logic to prevent duplicate entries with same apiKeyId + key 4. Chinese translations: complete CLI tools and memory dashboard localizations 5. Test fixes: unique keys for pagination tests to comply with unique constraint Co-Authored-By: Claude Opus 4.6 * fix: address Gemini Code Assist review feedback 1. store.ts: add expires_at to UPDATE statement in UPSERT logic - Previously, expires_at was not being persisted to database on update - This caused state mismatch between returned Memory object and actual DB row 2. package-lock.json: revert react-markdown registry to official npmjs.org - Mirror-specific registry URL (npmmirror.com) should not be in lockfile Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- next.config.mjs | 7 ++ open-sse/services/antigravityHeaders.ts | 7 +- package-lock.json | 12 ---- src/app/(dashboard)/dashboard/memory/page.tsx | 38 ++++++----- src/i18n/messages/zh-CN.json | 31 ++++++--- src/lib/memory/store.ts | 67 ++++++++++++++++++- tests/unit/memory-store.test.ts | 12 ++++ 7 files changed, 131 insertions(+), 43 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index b1e835a4ae..79348ec773 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -67,6 +67,7 @@ const nextConfig = { "tls-client-node", "koffi", "tough-cookie", + "@ngrok/ngrok", "child_process", "fs", "path", @@ -100,6 +101,12 @@ const nextConfig = { contextRegExp: /thread-stream/, }) ); + + // Mark @ngrok/ngrok as external to prevent webpack from trying to bundle its .node binaries + config.externals = config.externals || []; + config.externals.push({ + "@ngrok/ngrok": "commonjs @ngrok/ngrok", + }); // ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ──────── // // Next.js 16 (with or without Turbopack) compiles the instrumentation hook diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 3e6b632337..4b38e90cf2 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -20,7 +20,8 @@ export const GEMINI_CLI_VERSION = "0.39.1"; export const GEMINI_SDK_VERSION = "1.30.0"; export const NODE_VERSION = "v22.21.1"; export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/10.3.0"; -export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1"; +export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = + "google-cloud-sdk vscode_cloudshelleditor/0.1"; const LOAD_CODE_ASSIST_METADATA = Object.freeze({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", @@ -133,6 +134,4 @@ export function googApiClientHeader(): string { return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`; } -export { - ANTIGRAVITY_VERSION, -}; +export { ANTIGRAVITY_VERSION }; diff --git a/package-lock.json b/package-lock.json index a22ea08b6e..17ae67cb5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2447,9 +2447,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2466,9 +2463,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2485,9 +2479,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2504,9 +2495,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/app/(dashboard)/dashboard/memory/page.tsx b/src/app/(dashboard)/dashboard/memory/page.tsx index 526cc9debe..885727721a 100644 --- a/src/app/(dashboard)/dashboard/memory/page.tsx +++ b/src/app/(dashboard)/dashboard/memory/page.tsx @@ -205,17 +205,21 @@ export default function MemoryPage() { {health !== null && ( )} {health === null && !checkingHealth && ( )} @@ -321,7 +325,7 @@ export default function MemoryPage() {
- Page {page} of {totalPages} ({total} total) + {t("pageInfo", { page, totalPages, total })}
@@ -356,47 +360,47 @@ export default function MemoryPage() { onClick={() => setAddDialogOpen(false)} disabled={isSubmitting} > - Cancel + {t("cancel")} } >
- +
- + setNewMemory({ ...newMemory, key: e.target.value })} - placeholder="e.g., user_preference_theme" + placeholder={t("keyPlaceholder")} className="w-full" />
- + setNewMemory({ ...newMemory, content: e.target.value })} - placeholder="e.g., Prefers dark mode" + placeholder={t("contentPlaceholder")} className="w-full" />
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 3f7f512e60..43ffa88793 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1335,13 +1335,17 @@ } } }, - "autoConfiguredTab": "Auto Configured Tab", - "toolCategoriesDesc": "Tool Categories Desc", - "allToolsTab": "All Tools Tab", - "guidedClientsTab": "Guided Clients Tab", - "mitmClientsTab": "Mitm Clients Tab", - "toolCategories": "Tool Categories", - "visibleToolsCount": "Visible Tools Count" + "autoConfiguredTab": "自动配置", + "toolCategoriesDesc": "配置 AI 编程助手通过 OmniRoute 路由", + "allToolsTab": "所有工具", + "guidedClientsTab": "引导客户端", + "mitmClientsTab": "MITM 客户端", + "customCliTab": "自定义 CLI", + "toolCategories": "工具分类", + "visibleToolsCount": "{count} 个工具可用", + "installationGuide": "安装指南", + "whenToUseLabel": "何时使用", + "openToolDocs": "打开工具文档" }, "combos": { "title": "组合", @@ -4324,7 +4328,9 @@ "addMemory": "添加记忆", "type": "类型", "key": "键", + "keyPlaceholder": "例如:user_preference_theme", "content": "内容", + "contentPlaceholder": "例如:偏好深色模式", "created": "创建时间", "actions": "操作", "delete": "删除", @@ -4332,7 +4338,16 @@ "episodic": "情景型", "procedural": "程序型", "semantic": "语义型", - "a": "A" + "previous": "上一页", + "next": "下一页", + "pageInfo": "第 {page} 页,共 {totalPages} 页(共 {total} 条)", + "checkingHealth": "检查中...", + "checkHealth": "检查健康", + "pipelineOk": "Pipeline 正常 ({latencyMs}ms)", + "pipelineError": "Pipeline 错误", + "healthUnknown": "健康状态未知", + "cancel": "取消", + "save": "保存" }, "skills": { "title": "技能", diff --git a/src/lib/memory/store.ts b/src/lib/memory/store.ts index df9f554f55..e724a0f2cf 100644 --- a/src/lib/memory/store.ts +++ b/src/lib/memory/store.ts @@ -77,15 +77,78 @@ function rowToMemory(row: MemoryRow): Memory { } /** - * Create a new memory entry + * Find existing memory by apiKeyId and key (for UPSERT logic) + */ +function findExistingMemory( + db: ReturnType, + apiKeyId: string, + key: string +): MemoryRow | undefined { + if (!key) return undefined; + const stmt = db.prepare( + "SELECT * FROM memories WHERE api_key_id = ? AND key = ? ORDER BY created_at DESC LIMIT 1" + ); + return stmt.get(apiKeyId, key) as MemoryRow | undefined; +} + +/** + * Create a new memory entry (UPSERT: updates existing if same apiKeyId + key) */ export async function createMemory( memory: Omit ): Promise { const db = getDbInstance(); - const id = crypto.randomUUID(); const now = new Date().toISOString(); + // Check for existing memory with same apiKeyId + key (UPSERT logic) + const existing = memory.key ? findExistingMemory(db, memory.apiKeyId, memory.key) : undefined; + + if (existing) { + // UPDATE existing record + const updatedMetadata = { ...parseJSON(existing.metadata), ...memory.metadata }; + const stmt = db.prepare( + "UPDATE memories SET content = ?, metadata = ?, updated_at = ?, session_id = ?, type = ?, expires_at = ? WHERE id = ?" + ); + stmt.run( + memory.content, + JSON.stringify(updatedMetadata), + now, + memory.sessionId, + memory.type, + memory.expiresAt ?? null, + existing.id + ); + + const updatedMemory: Memory = { + id: String(existing.id), + apiKeyId: memory.apiKeyId, + sessionId: memory.sessionId, + type: memory.type, + key: memory.key, + content: memory.content, + metadata: updatedMetadata, + createdAt: new Date(String(existing.created_at)), + updatedAt: new Date(now), + expiresAt: memory.expiresAt ?? null, + }; + + // Invalidate and update cache + invalidateMemoryCache(existing.id); + evictIfNeeded(_memoryCache); + _memoryCache.set(existing.id, { value: updatedMemory, timestamp: Date.now() }); + + log.info("memory.updated", { + apiKeyId: memory.apiKeyId, + type: memory.type, + id: existing.id, + key: memory.key, + }); + + return updatedMemory; + } + + // INSERT new record if not exists + const id = crypto.randomUUID(); const stmt = db.prepare( "INSERT INTO memories (id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" diff --git a/tests/unit/memory-store.test.ts b/tests/unit/memory-store.test.ts index de7009b5be..c86de71d37 100644 --- a/tests/unit/memory-store.test.ts +++ b/tests/unit/memory-store.test.ts @@ -177,18 +177,21 @@ test("listMemories filters by api key, type and session while preserving newest- test("listMemories supports limit and offset pagination even when only offset is provided", async () => { insertMemoryRow({ id: "page-1", + key: "pagination:1", content: "oldest", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }); insertMemoryRow({ id: "page-2", + key: "pagination:2", content: "middle", createdAt: "2026-04-02T00:00:00.000Z", updatedAt: "2026-04-02T00:00:00.000Z", }); insertMemoryRow({ id: "page-3", + key: "pagination:3", content: "newest", createdAt: "2026-04-03T00:00:00.000Z", updatedAt: "2026-04-03T00:00:00.000Z", @@ -251,18 +254,21 @@ test("listMemories applies query filtering before pagination and type stats", as test("listMemories supports page-based pagination (page 1)", async () => { insertMemoryRow({ id: "pg-1", + key: "page:test:1", content: "first", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }); insertMemoryRow({ id: "pg-2", + key: "page:test:2", content: "second", createdAt: "2026-04-02T00:00:00.000Z", updatedAt: "2026-04-02T00:00:00.000Z", }); insertMemoryRow({ id: "pg-3", + key: "page:test:3", content: "third", createdAt: "2026-04-03T00:00:00.000Z", updatedAt: "2026-04-03T00:00:00.000Z", @@ -279,18 +285,21 @@ test("listMemories supports page-based pagination (page 1)", async () => { test("listMemories supports page-based pagination (page 2 returns remainder)", async () => { insertMemoryRow({ id: "pg-1", + key: "page:test:1", content: "first", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }); insertMemoryRow({ id: "pg-2", + key: "page:test:2", content: "second", createdAt: "2026-04-02T00:00:00.000Z", updatedAt: "2026-04-02T00:00:00.000Z", }); insertMemoryRow({ id: "pg-3", + key: "page:test:3", content: "third", createdAt: "2026-04-03T00:00:00.000Z", updatedAt: "2026-04-03T00:00:00.000Z", @@ -307,6 +316,7 @@ test("listMemories supports page-based pagination (page 2 returns remainder)", a test("listMemories returns empty data for a page beyond the result set", async () => { insertMemoryRow({ id: "pg-1", + key: "page:test:1", content: "only entry", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", @@ -320,12 +330,14 @@ test("listMemories returns empty data for a page beyond the result set", async ( test("listMemories page parameter defaults to page 1 when omitted with limit", async () => { insertMemoryRow({ id: "pg-1", + key: "page:test:1", content: "first", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }); insertMemoryRow({ id: "pg-2", + key: "page:test:2", content: "second", createdAt: "2026-04-02T00:00:00.000Z", updatedAt: "2026-04-02T00:00:00.000Z", From c4736f31307fe5d7385ce6a8bc153a6b3c22d3d8 Mon Sep 17 00:00:00 2001 From: cloudy <37777261+uwuclxdy@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:53:13 +0200 Subject: [PATCH 06/12] fix(antigravity): normalize Gemini bridge payloads (#1769) * fix(antigravity): normalize Gemini bridge payloads Clamp Claude bridge output tokens, use Gemini-valid system roles and tool names, and serialize antigravity requests from a cloned body so Cloud Code payload shaping stays valid. * fix(cli): stop fallback after unsafe known paths Preserve known-path security checks by stopping command discovery when a configured CLI path is suspicious or non-executable, instead of falling through to PATH discovery. * test(memory): make query result assertion deterministic Avoid relying on database result ordering when checking filtered memory keys so the unit suite remains stable across runs. * fix(review): preserve safe cloning and CLI reasons Handle non-cloneable antigravity request bodies without throwing and preserve specific CLI known-path failure reasons instead of masking them as not_found. --- open-sse/config/toolCloaking.ts | 2 +- open-sse/executors/antigravity.ts | 24 ++++++++++++-- .../helpers/geminiToolsSanitizer.ts | 15 ++++++--- .../translator/request/claude-to-gemini.ts | 5 +-- .../translator/request/openai-to-gemini.ts | 33 +++++++++++++++---- src/shared/services/cliRuntime.ts | 13 ++++++++ tests/unit/antigravity-tool-cloaking.test.ts | 4 +++ tests/unit/cli-runtime-extended.test.ts | 4 +-- tests/unit/memory-route.test.ts | 8 ++--- .../unit/translator-claude-to-gemini.test.ts | 16 ++++++++- .../unit/translator-openai-to-gemini.test.ts | 26 ++++++++++++++- 11 files changed, 124 insertions(+), 26 deletions(-) diff --git a/open-sse/config/toolCloaking.ts b/open-sse/config/toolCloaking.ts index 7fa5cd3b35..429a6ca28b 100644 --- a/open-sse/config/toolCloaking.ts +++ b/open-sse/config/toolCloaking.ts @@ -27,7 +27,7 @@ const AG_DEFAULT_TOOL_NAMES = [ const AG_DECOY_TOOL_NAMES = [ ...AG_DEFAULT_TOOL_NAMES, - "mcp_sequential-thinking_sequentialthinking", + "mcp_sequential_thinking_sequentialthinking", ] as const; export const AG_DEFAULT_TOOLS = new Set(AG_DEFAULT_TOOL_NAMES); diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9cf0cb989d..9a6f23b479 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -28,15 +28,29 @@ const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]); +function cloneAntigravityRequestBody(body: unknown): unknown { + if (!body || typeof body !== "object") { + return body; + } + + try { + return structuredClone(body); + } catch { + return JSON.parse(JSON.stringify(body)); + } +} + function serializeAntigravityRequest( provider: string, headers: Record, body: unknown ): { headers: Record; bodyString: string } { + const serializedBody = cloneAntigravityRequestBody(body); + if (!isCliCompatEnabled(provider)) { - return { headers, bodyString: JSON.stringify(body) }; + return { headers, bodyString: JSON.stringify(serializedBody) }; } - return applyFingerprint(provider, { ...headers }, body); + return applyFingerprint(provider, { ...headers }, serializedBody); } type AntigravityCollectedStream = { @@ -585,7 +599,11 @@ export class AntigravityExecutor extends BaseExecutor { } try { - const serializedRequest = serializeAntigravityRequest(this.provider, headers, transformedBody); + const serializedRequest = serializeAntigravityRequest( + this.provider, + headers, + transformedBody + ); const finalHeaders = serializedRequest.headers; const response = await fetch(url, { diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts index e35a9df7b1..d76f23cf7c 100644 --- a/open-sse/translator/helpers/geminiToolsSanitizer.ts +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -30,12 +30,17 @@ function normalizeGeminiToolName( options: GeminiToolSanitizationOptions = {} ): string { const trimmed = name.trim(); - if (!options.stripNamespace) { - return trimmed; - } + const namespaceStripped = !options.stripNamespace + ? trimmed + : (() => { + const namespaceIndex = trimmed.indexOf(":"); + return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; + })(); - const namespaceIndex = trimmed.indexOf(":"); - return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; + return namespaceStripped + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); } function buildHashedGeminiToolName( diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index ff5e2a2803..4099dec224 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -7,6 +7,7 @@ import { } from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; +import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; /** * Direct Claude → Gemini request translator. @@ -49,7 +50,7 @@ export function claudeToGeminiRequest(model, body, stream) { result.generationConfig.topK = body.top_k; } if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = body.max_tokens; + result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); } // ── System instruction ───────────────────────────────────────── @@ -62,7 +63,7 @@ export function claudeToGeminiRequest(model, body, stream) { } if (systemText) { result.systemInstruction = { - role: "user", + role: "system", parts: [{ text: systemText }], }; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 73c20e5bc8..d4b240a568 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -186,7 +186,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (systemText) { if (!result.systemInstruction) { result.systemInstruction = { - role: "user", + role: "system", parts: [{ text: systemText }], }; } else { @@ -413,7 +413,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra if (envelope.request.systemInstruction?.parts) { envelope.request.systemInstruction.parts.unshift(defaultPart); } else { - envelope.request.systemInstruction = { role: "user", parts: [defaultPart] }; + envelope.request.systemInstruction = { role: "system", parts: [defaultPart] }; } // Add toolConfig for Antigravity @@ -449,6 +449,28 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu const cleanModel = model.includes("/") ? model.split("/").pop()! : model; + const requestedMaxOutputTokens = + typeof claudeRequest.max_tokens === "number" && Number.isFinite(claudeRequest.max_tokens) + ? claudeRequest.max_tokens + : 4096; + const generationConfig: GeminiGenerationConfig = { + temperature: claudeRequest.temperature || 1, + maxOutputTokens: capMaxOutputTokens(cleanModel, requestedMaxOutputTokens), + }; + const thinkingBudget = + claudeRequest.thinking?.type === "enabled" && + typeof claudeRequest.thinking.budget_tokens === "number" && + Number.isFinite(claudeRequest.thinking.budget_tokens) + ? Math.floor(claudeRequest.thinking.budget_tokens) + : null; + + if (thinkingBudget && thinkingBudget > 0) { + generationConfig.thinkingConfig = { + thinkingBudget: capThinkingBudget(cleanModel, thinkingBudget), + includeThoughts: true, + }; + } + const envelope: CloudCodeEnvelope = { project: projectId, model: cleanModel, @@ -458,10 +480,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu request: { sessionId: generateSessionId(), contents: [], - generationConfig: { - temperature: claudeRequest.temperature || 1, - maxOutputTokens: claudeRequest.max_tokens || 4096, - }, + generationConfig, }, }; @@ -555,7 +574,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } } - envelope.request.systemInstruction = { role: "user", parts: systemParts }; + envelope.request.systemInstruction = { role: "system", parts: systemParts }; const changedToolNameMap = buildChangedToolNameMap(toolNameMap); if (changedToolNameMap) { diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index cf021d9e8c..f5d9abcaae 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -717,6 +717,19 @@ const locateCommandCandidate = async ( reason: null, }; } + + if (result.installed && result.reason === "not_executable") { + return { + command: commands[0], + installed: true, + commandPath: result.commandPath, + reason: "not_executable", + }; + } + + if (result.reason && result.reason !== "not_found") { + return { command: commands[0], ...result }; + } } } diff --git a/tests/unit/antigravity-tool-cloaking.test.ts b/tests/unit/antigravity-tool-cloaking.test.ts index ccdb0cca96..cf413718e4 100644 --- a/tests/unit/antigravity-tool-cloaking.test.ts +++ b/tests/unit/antigravity-tool-cloaking.test.ts @@ -46,6 +46,10 @@ test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools an assert.ok(names.includes(`workspace_read${AG_TOOL_SUFFIX}`)); assert.ok(names.includes("run_command")); assert.ok(names.includes("browser_subagent")); + assert.ok(names.includes("mcp_sequential_thinking_sequentialthinking")); + for (const name of names) { + assert.match(name, /^[a-zA-Z0-9_]+$/); + } assert.equal( result.body.request.contents[0].parts[0].functionCall.name, `workspace_read${AG_TOOL_SUFFIX}` diff --git a/tests/unit/cli-runtime-extended.test.ts b/tests/unit/cli-runtime-extended.test.ts index c4aadfe3dc..b519e1c884 100644 --- a/tests/unit/cli-runtime-extended.test.ts +++ b/tests/unit/cli-runtime-extended.test.ts @@ -226,7 +226,7 @@ test("getCliRuntimeStatus ignores suspicious known-path binaries and symlink esc const suspiciousStatus = await cliRuntime.getCliRuntimeStatus("qoder"); assert.equal(suspiciousStatus.installed, false); - assert.equal(suspiciousStatus.reason, "not_found"); + assert.equal(suspiciousStatus.reason, "suspicious_size"); if (process.platform !== "win32") { const escapePrefix = createTempDir("omniroute-cli-escape-"); @@ -246,7 +246,7 @@ test("getCliRuntimeStatus ignores suspicious known-path binaries and symlink esc const escapedStatus = await escapedRuntime.getCliRuntimeStatus("qoder"); assert.equal(escapedStatus.installed, false); - assert.equal(escapedStatus.reason, "not_found"); + assert.equal(escapedStatus.reason, "symlink_escape"); } }); diff --git a/tests/unit/memory-route.test.ts b/tests/unit/memory-route.test.ts index f7dccce349..7ee3621394 100644 --- a/tests/unit/memory-route.test.ts +++ b/tests/unit/memory-route.test.ts @@ -67,10 +67,10 @@ test("GET /api/memory filters by q and returns matching stats", async () => { assert.equal(response.status, 200); const body = (await response.json()) as any; - assert.deepEqual( - body.data.map((memory) => memory.key), - ["typescript:tooling", "typescript:guide"] - ); + assert.deepEqual(body.data.map((memory) => memory.key).sort(), [ + "typescript:guide", + "typescript:tooling", + ]); assert.equal(body.total, 2); assert.equal(body.stats.total, 2); assert.deepEqual(body.stats.byType, { factual: 1, semantic: 1 }); diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index e1e7058604..b178a72b6b 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -76,7 +76,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", ); assert.deepEqual(result.systemInstruction, { - role: "user", + role: "system", parts: [{ text: "Rules" }], }); assert.equal(result.contents[0].role, "model"); @@ -92,6 +92,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", }, }); assert.equal(result.generationConfig.maxOutputTokens, 256); + assert.match((result as any).tools[0].functionDeclarations[0].name, /^[a-zA-Z0-9_]+$/); assert.equal(result.generationConfig.temperature, 0.4); assert.equal(result.generationConfig.topP, 0.8); assert.deepEqual(result.generationConfig.thinkingConfig, { @@ -105,6 +106,19 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", }); }); +test("Claude -> Gemini clamps maxOutputTokens to the model cap", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + max_tokens: 999999, + }, + false + ); + + assert.equal(result.generationConfig.maxOutputTokens, 8192); +}); + test("Claude -> Gemini converts text and base64 images to Gemini parts", () => { const result = claudeToGeminiRequest( "gemini-2.5-flash", diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index f9c3eababb..3ef2054d7a 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -224,7 +224,7 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools false ); - assert.equal((result as any).systemInstruction.role, "user"); + assert.equal((result as any).systemInstruction.role, "system"); assert.deepEqual((result as any).systemInstruction.parts, [ { text: "Rule A" }, { text: "Rule B" }, @@ -558,11 +558,15 @@ test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", () assert.equal(result.project, "proj-claude"); assert.equal(result.userAgent, "antigravity"); + assert.equal((result as any).request?.systemInstruction.role, "system"); assert.equal( (result as any).request?.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM ); assert.equal((result as any).request?.systemInstruction.parts[1].text, "Project rules"); + assert.equal((result as any).request?.generationConfig.maxOutputTokens, 8192); + assert.equal((result as any).request?.generationConfig.temperature, 1); + assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined); const modelTurn = result.request.contents.find( (content) => content.role === "model" && content.parts.some((part) => part.functionCall) @@ -624,6 +628,7 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name; assert.equal(sanitizedToolName.length, 64); + assert.match(sanitizedToolName, /^[a-zA-Z0-9_]+$/); assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName); const modelTurn = result.request.contents.find( @@ -638,3 +643,22 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res assert.ok(toolTurn, "expected a tool response turn"); assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName); }); + +test("OpenAI -> Antigravity Claude bridge clamps output tokens and keeps thinking budget separate", () => { + const result = openaiToAntigravityRequest( + "claude-3-7-sonnet", + { + messages: [{ role: "user", content: "Summarize this" }], + max_completion_tokens: 32000, + reasoning_effort: "high", + }, + false, + { projectId: "proj-claude-thinking" } as any + ); + + assert.equal((result as any).request?.generationConfig.maxOutputTokens, 8192); + assert.deepEqual((result as any).request?.generationConfig.thinkingConfig, { + thinkingBudget: 131072, + includeThoughts: true, + }); +}); From c20d0599d5240b81927ed7d7a340a7f5d77979f2 Mon Sep 17 00:00:00 2001 From: wucm667 <109257021+wucm667@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:53:16 +0800 Subject: [PATCH 07/12] fix(sse): propagate AbortSignal to pre-fetch semaphore and rate-limit awaits (#1771) When a combo target takes too long, the request-level deadline fires and calls abortController.abort() on the stream controller, but the abort signal never reaches pending awaits in acquireAccountSemaphore() or withRateLimit(). These awaits sit between stream controller creation and executor.execute(), causing requests to hang indefinitely past the 600s deadline. Pass streamController.signal to both functions so they can respond to abort events and terminate early when the request deadline expires. Signed-off-by: wucm667 --- open-sse/handlers/chatCore.ts | 115 ++++++++++++++------------ open-sse/services/accountSemaphore.ts | 72 +++++++++++++++- open-sse/services/rateLimitManager.ts | 43 +++++++++- 3 files changed, 174 insertions(+), 56 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 09740e64be..6d9f8526dd 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2162,67 +2162,78 @@ export async function handleChatCore({ accountSemaphoreKey && accountSemaphoreMaxConcurrency != null ? await acquireAccountSemaphore(accountSemaphoreKey, { maxConcurrency: accountSemaphoreMaxConcurrency, + signal: streamController.signal, }) : () => {}; try { - const rawResult = await withRateLimit(provider, connectionId, modelToCall, async () => { - let attempts = 0; - const maxAttempts = provider === "qwen" ? 3 : 1; + const rawResult = await withRateLimit( + provider, + connectionId, + modelToCall, + async () => { + let attempts = 0; + const maxAttempts = provider === "qwen" ? 3 : 1; - while (attempts < maxAttempts) { - const res = await executor.execute({ - model: modelToCall, - body: bodyToSend, - stream: upstreamStream, - credentials: executionCredentials, - signal: streamController.signal, - log, - extendedContext, - upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall), - clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), - onCredentialsRefreshed, - }); + while (attempts < maxAttempts) { + const res = await executor.execute({ + model: modelToCall, + body: bodyToSend, + stream: upstreamStream, + credentials: executionCredentials, + signal: streamController.signal, + log, + extendedContext, + upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall), + clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), + onCredentialsRefreshed, + }); - // Qwen 429 strict quota backoff (wait 1.5s, 3s and retry) - if (provider === "qwen" && res.response.status === 429 && attempts < maxAttempts - 1) { - const bodyPeek = await res.response - .clone() - .text() - .catch(() => ""); - if (bodyPeek.toLowerCase().includes("exceeded your current quota")) { - const delay = 1500 * (attempts + 1); - log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`); - await new Promise((r) => setTimeout(r, delay)); - attempts++; - continue; - } - } - - // For streaming: release the semaphore when the client drains or cancels the stream. - if (stream) { - const originalBody = res.response.body; - if (!originalBody) { - acquireAccountSemaphoreRelease(); - return res; + // Qwen 429 strict quota backoff (wait 1.5s, 3s and retry) + if ( + provider === "qwen" && + res.response.status === 429 && + attempts < maxAttempts - 1 + ) { + const bodyPeek = await res.response + .clone() + .text() + .catch(() => ""); + if (bodyPeek.toLowerCase().includes("exceeded your current quota")) { + const delay = 1500 * (attempts + 1); + log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`); + await new Promise((r) => setTimeout(r, delay)); + attempts++; + continue; + } } - return { - ...res, - response: new Response( - wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease), - { - status: res.response.status, - statusText: res.response.statusText, - headers: res.response.headers, - } - ), - }; - } + // For streaming: release the semaphore when the client drains or cancels the stream. + if (stream) { + const originalBody = res.response.body; + if (!originalBody) { + acquireAccountSemaphoreRelease(); + return res; + } - return res; - } - }); + return { + ...res, + response: new Response( + wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease), + { + status: res.response.status, + statusText: res.response.statusText, + headers: res.response.headers, + } + ), + }; + } + + return res; + } + }, + streamController.signal + ); if (stream) { return rawResult; diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index 0f96dec799..d210dedf06 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -28,6 +28,7 @@ interface AccountGate { export interface AcquireAccountSemaphoreOptions { maxConcurrency?: number | null; timeoutMs?: number; + signal?: AbortSignal | null; } export interface AccountSemaphoreStatsEntry { @@ -168,18 +169,34 @@ function createSemaphoreTimeoutError( return error; } +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + /** * Acquire a slot for a provider/model/account tuple. * Returns an idempotent release function that is safe to call in finally blocks. */ export function acquire( semaphoreKey: string, - { maxConcurrency = null, timeoutMs = DEFAULT_TIMEOUT_MS }: AcquireAccountSemaphoreOptions = {} + { + maxConcurrency = null, + timeoutMs = DEFAULT_TIMEOUT_MS, + signal = null, + }: AcquireAccountSemaphoreOptions = {} ): Promise<() => void> { if (isBypassed(maxConcurrency)) { return Promise.resolve(createNoopReleaseFn()); } + if (signal?.aborted) { + return Promise.reject(makeAbortError(signal)); + } + const gate = ensureGate(semaphoreKey, maxConcurrency); clearCleanupTimer(gate); @@ -189,7 +206,16 @@ export function acquire( } return new Promise((resolve, reject) => { + let abortListener: (() => void) | null = null; + + const cleanup = () => { + if (abortListener && signal) { + signal.removeEventListener("abort", abortListener); + } + }; + const timer = setTimeout(() => { + cleanup(); const nextGate = gates.get(semaphoreKey); if (!nextGate) { reject(createSemaphoreTimeoutError(semaphoreKey, timeoutMs)); @@ -209,7 +235,49 @@ export function acquire( }, timeoutMs); timer.unref?.(); - gate.queue.push({ resolve, reject, timer }); + + const queueItem: QueuedAcquire = { + resolve: (release) => { + cleanup(); + resolve(release); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + timer, + }; + + gate.queue.push(queueItem); + + if (signal) { + abortListener = () => { + cleanup(); + clearTimeout(timer); + + const nextGate = gates.get(semaphoreKey); + if (!nextGate) { + reject(makeAbortError(signal)); + return; + } + + const queueIndex = nextGate.queue.findIndex((item) => item.timer === timer); + if (queueIndex !== -1) { + nextGate.queue.splice(queueIndex, 1); + } + + if (nextGate.running === 0 && nextGate.queue.length === 0) { + scheduleCleanup(semaphoreKey); + } + + reject(makeAbortError(signal)); + }; + if (signal.aborted) { + abortListener(); + } else { + signal.addEventListener("abort", abortListener); + } + } }); } diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 4af4f86306..d0acd2410f 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -274,18 +274,57 @@ function getLimiter(provider, connectionId, model = null) { * @param {string} connectionId - Connection ID * @param {string} model - Model name (optional, for per-model limits) * @param {Function} fn - The async function to execute (e.g., executor.execute) + * @param {AbortSignal} signal - Optional abort signal to cancel waiting * @returns {Promise} Result of fn() */ -export async function withRateLimit(provider, connectionId, model, fn) { +export async function withRateLimit(provider, connectionId, model, fn, signal = null) { if (!enabledConnections.has(connectionId)) { return fn(); } + if (signal?.aborted) { + const reason = signal.reason; + if (reason instanceof Error) throw reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + throw err; + } + const limiter = getLimiter(provider, connectionId, model); const maxWaitMs = currentRequestQueueSettings.maxWaitMs; const scheduleOpts = maxWaitMs && maxWaitMs > 0 ? { expiration: maxWaitMs } : {}; + try { - return await limiter.schedule(scheduleOpts, fn); + if (signal) { + let abortListener: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + const onAbort = () => { + const reason = signal.reason; + const err = + reason instanceof Error + ? reason + : new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + reject(err); + }; + if (signal.aborted) { + onAbort(); + return; + } + abortListener = onAbort; + signal.addEventListener("abort", abortListener, { once: true }); + }); + + try { + return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]); + } finally { + if (abortListener) { + signal.removeEventListener("abort", abortListener); + } + } + } else { + return await limiter.schedule(scheduleOpts, fn); + } } catch (err) { // Bottleneck throws when a job exceeds its expiration timeout. // Surface as a clear rate-limit timeout so callers can fallback. From e6a0fd104d6641cc3d84a154c44434e400bdc229 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:53:20 -0400 Subject: [PATCH 08/12] Fix model sync import handling (#1755) * Fix model sync import handling * Align model import storage semantics * Address model review feedback --- .../dashboard/providers/[id]/page.tsx | 17 +- src/app/api/pricing/models/route.ts | 81 ++++--- .../api/providers/[id]/sync-models/route.ts | 162 +++++++++++--- src/app/api/v1beta/models/route.ts | 51 ++++- src/lib/combos/builderOptions.ts | 13 +- src/lib/db/models.ts | 74 ++++++- src/lib/providerModels/managedModelImport.ts | 171 ++++++++++----- src/lib/providerModels/modelDiscovery.ts | 2 +- src/shared/components/ModelSelectModal.tsx | 7 +- src/shared/utils/modelCatalogSearch.ts | 10 +- tests/unit/db-models-crud.test.ts | 10 +- tests/unit/model-catalog-search.test.ts | 8 +- tests/unit/model-catalog-source.test.ts | 10 +- tests/unit/model-sync-route.test.ts | 206 ++++++++++++------ tests/unit/models-catalog-route.test.ts | 10 +- tests/unit/provider-models-route.test.ts | 10 +- tests/unit/v1beta-models-route.test.ts | 55 +++++ 17 files changed, 651 insertions(+), 246 deletions(-) create mode 100644 tests/unit/v1beta-models-route.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 25ad3c7f7b..5cefea5c4d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -460,7 +460,7 @@ interface CooldownTimerProps { function getModelSourceBadgeClass(source?: string): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": + case "imported": return "border-sky-500/30 bg-sky-500/10 text-sky-300"; case "custom": return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"; @@ -1041,12 +1041,12 @@ export default function ProviderDetailPage() { const isOAuth = providerSupportsOAuth && !providerSupportsPat; const registryModels = getModelsByProviderId(providerId); // Prefer synced API-discovered models when available, then merge built-ins - // and user-managed custom/imported models without duplicating IDs. + // and user-managed custom models without duplicating IDs. const models = useMemo(() => { if (providerId === "gemini") { return syncedAvailableModels.map((model: any) => ({ ...model, - source: model?.source === "api-sync" ? "api-sync" : "api-sync", + source: "imported", })); } @@ -1061,7 +1061,7 @@ export default function ProviderDetailPage() { .map((model: any) => ({ id: model.id, name: model.name || model.id, - source: "api-sync", + source: "imported", })); const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); const customExtras = modelMeta.customModels @@ -1069,7 +1069,7 @@ export default function ProviderDetailPage() { .map((cm: any) => ({ id: cm.id, name: cm.name || cm.id, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); return [...builtInModels, ...syncedExtras, ...customExtras]; }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); @@ -2097,6 +2097,9 @@ export default function ProviderDetailPage() { typeof data.importedChanges?.total === "number" ? data.importedChanges.total : importedCount; + const totalChangedCount = + changedCount + + (typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0); if (importedModels.length === 0) { setImportProgress((prev) => ({ @@ -2113,7 +2116,7 @@ export default function ProviderDetailPage() { ], importedCount, })); - if (changedCount > 0) { + if (totalChangedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); @@ -2142,7 +2145,7 @@ export default function ProviderDetailPage() { importedCount, })); - if (changedCount > 0) { + if (totalChangedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); diff --git a/src/app/api/pricing/models/route.ts b/src/app/api/pricing/models/route.ts index 98882c4af1..e25ba2fcc1 100644 --- a/src/app/api/pricing/models/route.ts +++ b/src/app/api/pricing/models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { getAllCustomModels, getPricing } from "@/lib/localDb"; +import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb"; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -20,8 +20,9 @@ function asModelArray(value: unknown): Array<{ id?: string; name?: string }> { * GET /api/pricing/models * Returns the full model catalog merged from three sources: * 1. providerRegistry (hardcoded) - * 2. customModels (DB — user-added or imported via /models) - * 3. pricing data (DB — models with pricing configured but not in sources 1/2) + * 2. syncedAvailableModels (DB — discovered/imported from provider /models) + * 3. customModels (DB — manually added models) + * 4. pricing data (DB — models with pricing configured but not in sources 1/2/3) */ export async function GET() { try { @@ -46,25 +47,14 @@ export async function GET() { }; } - // ── 2. Custom models (DB) ─────────────────────────────────────── - let customModelsMap: Record = {}; - try { - customModelsMap = asRecord(await getAllCustomModels()); - } catch { - /* DB may not be ready */ - } - - for (const [providerId, rawModels] of Object.entries(customModelsMap)) { - const models = asModelArray(rawModels); - // Resolve alias — check if a registry entry maps this providerId - let alias = providerId; + const resolveAlias = (providerId: string) => { for (const entry of Object.values(REGISTRY)) { - if (entry.id === providerId) { - alias = entry.alias || entry.id; - break; - } + if (entry.id === providerId) return entry.alias || entry.id; } + return providerId; + }; + const ensureCatalogProvider = (providerId: string, alias: string) => { if (!catalog[alias]) { catalog[alias] = { id: providerId, @@ -75,25 +65,52 @@ export async function GET() { models: [], }; } + return catalog[alias]; + }; + + const appendDbModels = (providerId: string, rawModels: unknown) => { + const models = asModelArray(rawModels); + const alias = resolveAlias(providerId); + const providerCatalog = ensureCatalogProvider(providerId, alias); + const existingIds = new Set(providerCatalog.models.map((m) => m.id)); - const existingIds = new Set(catalog[alias].models.map((m) => m.id)); for (const model of models) { const modelId = typeof model.id === "string" ? model.id : null; - if (!modelId || existingIds.has(modelId)) { - continue; - } - if (!existingIds.has(modelId)) { - catalog[alias].models.push({ - id: modelId, - name: typeof model.name === "string" && model.name.trim() ? model.name : modelId, - custom: true, - }); - existingIds.add(modelId); - } + if (!modelId || existingIds.has(modelId)) continue; + providerCatalog.models.push({ + id: modelId, + name: typeof model.name === "string" && model.name.trim() ? model.name : modelId, + custom: true, + }); + existingIds.add(modelId); } + }; + + // ── 2. Synced available models (DB) ───────────────────────────── + let syncedModelsMap: Record = {}; + try { + syncedModelsMap = asRecord(await getAllSyncedAvailableModels()); + } catch { + /* DB may not be ready */ } - // ── 3. Pricing-only models (DB) ───────────────────────────────── + for (const [providerId, rawModels] of Object.entries(syncedModelsMap)) { + appendDbModels(providerId, rawModels); + } + + // ── 3. Custom models (DB) ─────────────────────────────────────── + let customModelsMap: Record = {}; + try { + customModelsMap = asRecord(await getAllCustomModels()); + } catch { + /* DB may not be ready */ + } + + for (const [providerId, rawModels] of Object.entries(customModelsMap)) { + appendDbModels(providerId, rawModels); + } + + // ── 4. Pricing-only models (DB) ───────────────────────────────── let pricingData: Record = {}; try { pricingData = await getPricing(); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 9844deeb4d..a8b807985b 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; +import { getSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { importManagedModels, type ManagedModelImportMode, @@ -10,6 +11,7 @@ import { buildModelSyncInternalHeaders, isModelSyncInternalRequest, } from "@/shared/services/modelSyncScheduler"; +import { GET as getProviderModels } from "../models/route"; type JsonRecord = Record; @@ -28,8 +30,8 @@ function normalizeModelForComparison(model: unknown) { const rawSource = toNonEmptyString(record.source)?.toLowerCase(); const source = rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported" - ? "api-sync" - : rawSource || "auto-sync"; + ? "imported" + : rawSource || "manual"; const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions"; const supportedEndpoints = Array.isArray(record.supportedEndpoints) ? Array.from( @@ -56,6 +58,41 @@ function isManagedSyncedModel(model: unknown) { return source === "api-sync" || source === "auto-sync" || source === "imported"; } +function getErrorMessageFromPayload(payload: JsonRecord): string | null { + const error = payload.error; + if (typeof error === "string" && error.trim().length > 0) { + return error.trim(); + } + + const errorRecord = asRecord(error); + return toNonEmptyString(errorRecord.message) || toNonEmptyString(payload.message); +} + +async function readJsonResponse(response: Response): Promise<{ + data: JsonRecord; + parseError: string | null; +}> { + const body = await response.text(); + if (!body.trim()) { + return { + data: {}, + parseError: "Empty response body from /models", + }; + } + + try { + return { + data: asRecord(JSON.parse(body)), + parseError: null, + }; + } catch { + return { + data: {}, + parseError: "Invalid JSON response from /models", + }; + } +} + function summarizeModelChanges(previousModels: unknown, nextModels: unknown) { const previousList = Array.isArray(previousModels) ? previousModels : []; const nextList = Array.isArray(nextModels) ? nextModels : []; @@ -117,13 +154,54 @@ function getModelSyncChannelLabel(connection: unknown) { ); } +async function fetchProviderModelsForSync(request: Request, connectionId: string) { + // Construct a safe localhost URL from the incoming request's origin. + // The route only accepts authenticated or internal-scheduler requests, + // and the path is hardcoded — no user-controlled URL components reach fetch. + const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + const incomingUrl = new URL(request.url); + const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) + ? incomingUrl.origin + : `http://127.0.0.1:${process.env.PORT || "20128"}`; + const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models?refresh=true`; + const headers = { + cookie: request.headers.get("cookie") || "", + ...buildModelSyncInternalHeaders(), + }; + + try { + return await fetch(new URL(modelsPath, safeOrigin).href, { + method: "GET", + cache: "no-store", + headers, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[ModelSync] Internal /models self-fetch failed for ${connectionId.slice( + 0, + 8 + )}; falling back to in-process route: ${message}` + ); + + return getProviderModels( + new Request(new URL(modelsPath, "http://localhost").href, { + method: "GET", + headers, + }), + { params: { id: connectionId } } + ); + } +} + /** * POST /api/providers/[id]/sync-models * - * Fetches the model list from a provider's /models endpoint and replaces the - * full custom models list for that provider while refreshing the per-connection - * discovery cache. Successful syncs only write a call log when the fetched - * channel actually changes the stored model list. + * Fetches the model list from a provider's /models endpoint, stores discovered + * models in the per-connection available-model cache, and removes matching + * upstream-discovered rows from the provider's custom model list. Successful + * syncs only write a call log when the fetched channel or custom model cleanup + * changes stored model state. * * Used by: * - modelSyncScheduler (auto-sync on interval) @@ -153,30 +231,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: logProvider = toNonEmptyString(connection.provider) || "unknown"; channelLabel = getModelSyncChannelLabel(connection); + const previousSyncedAvailableModelsForConnection = await getSyncedAvailableModelsForConnection( + logProvider, + id + ); - // Fetch models from the existing /api/providers/[id]/models endpoint. - // Construct a safe localhost URL from the incoming request's origin. - // The route only accepts authenticated or internal-scheduler requests, - // and the path is hardcoded — no user-controlled URL components reach fetch. - const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); - const incomingUrl = new URL(request.url); - const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) - ? incomingUrl.origin - : `http://127.0.0.1:${process.env.PORT || "20128"}`; - const modelsPath = `/api/providers/${encodeURIComponent(id)}/models?refresh=true`; - const modelsRes = await fetch(new URL(modelsPath, safeOrigin).href, { - method: "GET", - cache: "no-store", - headers: { - cookie: request.headers.get("cookie") || "", - ...buildModelSyncInternalHeaders(), - }, - }); + const modelsRes = await fetchProviderModelsForSync(request, id); const duration = Date.now() - start; - const modelsData = await modelsRes.json(); + const { data: modelsData, parseError } = await readJsonResponse(modelsRes); + const payloadError = getErrorMessageFromPayload(modelsData); - if (!modelsRes.ok) { + if (!modelsRes.ok || parseError) { + const responseStatus = modelsRes.ok ? 502 : modelsRes.status; + const logError = payloadError || parseError || `HTTP ${modelsRes.status}`; + const responseError = payloadError || parseError || "Failed to fetch models"; // Log the failed attempt await saveCallLog({ method: "GET", @@ -187,22 +256,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: sourceFormat: "-", connectionId: id, duration, - error: modelsData.error || `HTTP ${modelsRes.status}`, + error: logError, requestType: "model-sync", + ...(parseError + ? { + responseBody: { + upstreamStatus: modelsRes.status, + parseError, + }, + } + : {}), }); return NextResponse.json( - { error: modelsData.error || "Failed to fetch models" }, - { status: modelsRes.status } + { + error: responseError, + ...(parseError ? { upstreamStatus: modelsRes.status } : {}), + }, + { status: responseStatus } ); } const fetchedModels = modelsData.models || []; const { previousModels, + previousSyncedAvailableModels, persistedModels, importedModels, discoveredModels, + syncedAvailableModels, syncedAliases, importedChanges, } = await importManagedModels({ @@ -210,22 +292,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: connectionId: id, fetchedModels, mode, + previousSyncedAvailableModels: previousSyncedAvailableModelsForConnection, }); - const modelChanges = summarizeModelChanges(previousModels, persistedModels); + const effectiveAvailableModels = + discoveredModels.length > 0 ? discoveredModels : syncedAvailableModels; + const modelChanges = summarizeModelChanges( + previousSyncedAvailableModels, + effectiveAvailableModels + ); + const customModelChanges = summarizeModelChanges(previousModels, persistedModels); const syncedModelsCount = - discoveredModels.length > 0 - ? discoveredModels.length + effectiveAvailableModels.length > 0 + ? effectiveAvailableModels.length : persistedModels.filter((model) => isManagedSyncedModel(model)).length; const availableModelsCount = new Set( - [...persistedModels, ...discoveredModels] + [...persistedModels, ...effectiveAvailableModels] .map((model) => toNonEmptyString(asRecord(model).id)) .filter((modelId): modelId is string => Boolean(modelId)) ).size; const importedCount = importedChanges.added; const updatedCount = importedChanges.updated; + const shouldLog = modelChanges.total > 0 || customModelChanges.total > 0; - if (modelChanges.total > 0) { + if (shouldLog) { await saveCallLog({ method: "GET", path: `/api/providers/${id}/models`, @@ -243,6 +333,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: provider: logProvider, channel: channelLabel, modelChanges, + customModelChanges, importedCount, updatedCount, mode, @@ -258,10 +349,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: availableModelsCount, syncedAliases, modelChanges, + customModelChanges, importedCount, updatedCount, importedChanges, - logged: modelChanges.total > 0, + logged: shouldLog, models: persistedModels, importedModels, }); diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 37390e3f18..5d5aec0a8f 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,5 +1,9 @@ import { PROVIDER_MODELS } from "@/shared/constants/models"; -import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models"; +import { + getAllCustomModels, + getAllSyncedAvailableModels, + getSyncedAvailableModels, +} from "@/lib/db/models"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; import { getSyncedCapabilities } from "@/lib/modelsDevSync"; @@ -67,6 +71,46 @@ export async function GET() { console.error("[v1beta/models] Error fetching synced Gemini models:", err); } + const existingNames = new Set(models.map((model) => (model as any).name)); + + // Synced/imported models for non-Gemini providers + try { + const syncedModelsMap = await getAllSyncedAvailableModels(); + for (const [providerId, syncedModels] of Object.entries(syncedModelsMap)) { + if (providerId === "gemini") continue; + if (!Array.isArray(syncedModels)) continue; + for (const m of syncedModels) { + if (!m || typeof m.id !== "string") continue; + const name = `models/${providerId}/${m.id}`; + if (existingNames.has(name)) continue; + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: m.id, + }); + models.push({ + name, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: + typeof m.inputTokenLimit === "number" + ? m.inputTokenLimit + : resolved.maxInputTokens || resolved.contextWindow || 128000, + outputTokenLimit: + typeof m.outputTokenLimit === "number" + ? m.outputTokenLimit + : resolved.maxOutputTokens || 8192, + ...(m.supportsThinking === true || resolved.supportsThinking === true + ? { thinking: true } + : {}), + }); + existingNames.add(name); + } + } + } catch { + // Synced models are optional — skip on error + } + // Custom models (use stored metadata from provider APIs) try { const customModelsMap = (await getAllCustomModels()) as Record; @@ -83,8 +127,10 @@ export async function GET() { provider: providerId, model: String(m.id), }); + const name = `models/${providerId}/${m.id}`; + if (existingNames.has(name)) continue; models.push({ - name: `models/${providerId}/${m.id}`, + name, displayName: m.name || m.id, ...(typeof m.description === "string" ? { description: m.description } : {}), supportedGenerationMethods: ["generateContent"], @@ -100,6 +146,7 @@ export async function GET() { ? { thinking: true } : {}), }); + existingNames.add(name); } } } catch { diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 0aea042b83..6bb17a8a71 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -21,7 +21,7 @@ import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry. type JsonRecord = Record; -type BuilderModelSource = "api-sync" | "system" | "custom" | "fallback"; +type BuilderModelSource = "imported" | "system" | "custom" | "fallback"; type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error"; type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" }; @@ -159,7 +159,7 @@ function isChatCapable(supportedEndpoints: string[] | undefined): boolean { function getSourcePriority(source: BuilderModelSource): number { switch (source) { - case "api-sync": + case "imported": return 0; case "system": return 1; @@ -412,7 +412,7 @@ export async function getComboBuilderOptions(): Promise 0 ? value.trim() : null; +} + function getKeyValue(row: unknown): { key: string | null; value: string | null } { const record = asRecord(row); return { @@ -378,7 +382,7 @@ export async function addCustomModel( } /** - * Replace the entire custom models list for a provider (used by auto-sync). + * Replace the entire custom models list for a provider. * Preserves per-model compatibility overrides for models that still exist. */ export async function replaceCustomModels( @@ -397,7 +401,7 @@ export async function replaceCustomModels( { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { // Guard: skip destructive clear when the caller hasn't explicitly opted in. - // This prevents auto-sync from wiping manually-imported models when the + // This prevents callers from wiping manually added models when the // upstream /models endpoint fails, times out, or returns an empty list. if (models.length === 0 && !allowEmpty) { const existing = await getCustomModels(providerId); @@ -520,7 +524,7 @@ export async function removeCustomModel(providerId: string, modelId: string) { export interface SyncedAvailableModel { id: string; name: string; - source: "api-sync"; + source: "imported"; supportedEndpoints?: string[]; inputTokenLimit?: number; outputTokenLimit?: number; @@ -528,6 +532,57 @@ export interface SyncedAvailableModel { supportsThinking?: boolean; } +type SyncedAvailableModelInput = Omit & { + source?: string; +}; + +function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null { + const record = asRecord(model); + const id = + toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model); + if (!id) return null; + + const name = + toNonEmptyString(record.name) || + toNonEmptyString(record.displayName) || + toNonEmptyString(record.model) || + id; + const supportedEndpoints = Array.isArray(record.supportedEndpoints) + ? Array.from( + new Set( + record.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : undefined; + + return { + id, + name, + source: "imported", + ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), + ...(typeof record.inputTokenLimit === "number" + ? { inputTokenLimit: record.inputTokenLimit } + : {}), + ...(typeof record.outputTokenLimit === "number" + ? { outputTokenLimit: record.outputTokenLimit } + : {}), + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(record.supportsThinking === true ? { supportsThinking: true } : {}), + }; +} + +function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] { + if (!Array.isArray(models)) return []; + const deduped = new Map(); + for (const model of models) { + const normalized = normalizeSyncedAvailableModel(model); + if (normalized) deduped.set(normalized.id, normalized); + } + return Array.from(deduped.values()); +} + /** * Get synced available models for a specific provider connection. */ @@ -544,7 +599,7 @@ export async function getSyncedAvailableModelsForConnection( if (!value) return []; try { const models = JSON.parse(value); - return Array.isArray(models) ? models : []; + return normalizeSyncedAvailableModels(models); } catch { return []; } @@ -566,7 +621,7 @@ export async function getSyncedAvailableModels( for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; - const models: SyncedAvailableModel[] = JSON.parse(value); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); for (const m of models) { if (m.id) map.set(m.id, m); } @@ -591,7 +646,7 @@ export async function getAllSyncedAvailableModels(): Promise< if (!key || value === null) continue; const providerId = key.split(":")[0]; if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); - const models: SyncedAvailableModel[] = JSON.parse(value); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); const map = byProvider.get(providerId)!; for (const m of models) { if (m.id) map.set(m.id, m); @@ -611,18 +666,19 @@ export async function getAllSyncedAvailableModels(): Promise< export async function replaceSyncedAvailableModelsForConnection( providerId: string, connectionId: string, - models: SyncedAvailableModel[] + models: SyncedAvailableModelInput[] ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; - if (models.length === 0) { + const normalizedModels = normalizeSyncedAvailableModels(models); + if (normalizedModels.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run( key ); } else { db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" - ).run(key, JSON.stringify(models)); + ).run(key, JSON.stringify(normalizedModels)); } backupDbFile("pre-write"); // Return the full unioned list for the provider diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index 41a2555cec..227d96f9ae 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -1,7 +1,10 @@ import { getCustomModels, + getSyncedAvailableModelsForConnection, + mergeModelCompatOverride, replaceCustomModels, replaceSyncedAvailableModelsForConnection, + type ModelCompatPatch, type SyncedAvailableModel, } from "@/lib/db/models"; import { @@ -9,7 +12,6 @@ import { usesManagedAvailableModels, } from "@/lib/providerModels/managedAvailableModels"; import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; -import { getModelsByProviderId } from "@/shared/constants/models"; type JsonRecord = Record; @@ -18,7 +20,7 @@ export type ManagedModelImportMode = "merge" | "sync"; export type ManagedImportedModel = { id: string; name: string; - source: "api-sync"; + source: "imported"; apiFormat: "chat-completions"; supportedEndpoints?: string[]; inputTokenLimit?: number; @@ -34,42 +36,39 @@ function toNonEmptyString(value: unknown): string | null { function normalizeManagedSource(source: unknown): string { const normalized = toNonEmptyString(source)?.toLowerCase(); if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") { - return "api-sync"; + return "imported"; } return normalized || "manual"; } -function normalizeImportedModels( - providerId: string, - fetchedModels: unknown -): ManagedImportedModel[] { +function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] { const discovered = normalizeDiscoveredModels(fetchedModels); - const registryIds = new Set(getModelsByProviderId(providerId).map((model: any) => model.id)); - return discovered - .filter((model) => !registryIds.has(model.id)) - .map((model) => ({ - id: model.id, - name: model.name || model.id, - source: "api-sync", - apiFormat: "chat-completions", - ...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0 - ? { supportedEndpoints: model.supportedEndpoints } - : {}), - ...(typeof model.inputTokenLimit === "number" - ? { inputTokenLimit: model.inputTokenLimit } - : {}), - ...(typeof model.outputTokenLimit === "number" - ? { outputTokenLimit: model.outputTokenLimit } - : {}), - ...(typeof model.description === "string" ? { description: model.description } : {}), - ...(model.supportsThinking === true ? { supportsThinking: true } : {}), - })); + return discovered.map((model) => ({ + id: model.id, + name: model.name || model.id, + source: "imported", + apiFormat: "chat-completions", + ...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0 + ? { supportedEndpoints: model.supportedEndpoints } + : {}), + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(model.supportsThinking === true ? { supportsThinking: true } : {}), + })); } -function isManagedDiscoveredSource(source: unknown): boolean { - const normalized = toNonEmptyString(source)?.toLowerCase(); - return normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported"; +function isImportedSource(source: unknown): boolean { + return normalizeManagedSource(source) === "imported"; +} + +function getModelId(model: JsonRecord): string | null { + return toNonEmptyString(model.id); } function summarizeImportedChanges( @@ -86,9 +85,30 @@ function summarizeImportedChanges( const toComparable = (model: JsonRecord | undefined) => { if (!model) return null; + const id = toNonEmptyString(model.id) || ""; + const supportedEndpoints = Array.isArray(model.supportedEndpoints) + ? Array.from( + new Set( + model.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : ["chat"]; return { - ...model, + id, + name: toNonEmptyString(model.name) || id, source: normalizeManagedSource(model.source), + apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions", + supportedEndpoints, + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(model.supportsThinking === true ? { supportsThinking: true } : {}), }; }; @@ -125,37 +145,71 @@ function collectAddedImportedModels( return importedModels.filter((model) => !previousIds.has(model.id)); } +function getCompatPatchFromCustomModel(model: JsonRecord): ModelCompatPatch | null { + const patch: ModelCompatPatch = {}; + + if (typeof model.normalizeToolCallId === "boolean") { + patch.normalizeToolCallId = model.normalizeToolCallId; + } + if (typeof model.preserveOpenAIDeveloperRole === "boolean") { + patch.preserveOpenAIDeveloperRole = model.preserveOpenAIDeveloperRole; + } + if (typeof model.isHidden === "boolean") { + patch.isHidden = model.isHidden; + } + if (model.compatByProtocol && typeof model.compatByProtocol === "object") { + patch.compatByProtocol = model.compatByProtocol as ModelCompatPatch["compatByProtocol"]; + } + if (model.upstreamHeaders && typeof model.upstreamHeaders === "object") { + patch.upstreamHeaders = model.upstreamHeaders as Record; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +function preserveRemovedCustomModelCompat(providerId: string, removedModels: JsonRecord[]) { + for (const model of removedModels) { + const modelId = getModelId(model); + if (!modelId) continue; + const patch = getCompatPatchFromCustomModel(model); + if (!patch) continue; + mergeModelCompatOverride(providerId, modelId, patch); + } +} + export async function importManagedModels({ providerId, connectionId, fetchedModels, mode, + previousSyncedAvailableModels: previousSyncedAvailableModelsInput, }: { providerId: string; connectionId: string; fetchedModels: unknown; mode: ManagedModelImportMode; + previousSyncedAvailableModels?: SyncedAvailableModel[]; }) { const previousModels = (await getCustomModels(providerId)) as JsonRecord[]; - const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels); + const previousSyncedAvailableModels = + previousSyncedAvailableModelsInput ?? + (await getSyncedAvailableModelsForConnection(providerId, connectionId)); + const discoveredModels = normalizeDiscoveredModels(fetchedModels); + const candidateImportedModels = normalizeImportedModels(fetchedModels); const importedIds = new Set(candidateImportedModels.map((model) => model.id)); + const discoveredIds = new Set(discoveredModels.map((model) => model.id)); const nextModelsMap = new Map(); + const removedCustomModels: JsonRecord[] = []; - if (mode === "merge") { - for (const model of previousModels) { - if (model?.id) nextModelsMap.set(String(model.id), model); + for (const model of previousModels) { + const modelId = getModelId(model); + if (!modelId) continue; + if (isImportedSource(model.source) || discoveredIds.has(modelId)) { + removedCustomModels.push(model); + continue; } - } else { - for (const model of previousModels) { - if (!model?.id) continue; - if (isManagedDiscoveredSource(model.source)) continue; - nextModelsMap.set(String(model.id), model); - } - } - - for (const model of candidateImportedModels) { - nextModelsMap.set(model.id, model); + nextModelsMap.set(modelId, model); } const persistedModels = (await replaceCustomModels( @@ -170,11 +224,12 @@ export async function importManagedModels({ outputTokenLimit?: number; description?: string; supportsThinking?: boolean; - }> + }>, + { allowEmpty: true } )) as JsonRecord[]; + preserveRemovedCustomModelCompat(providerId, removedCustomModels); - const discoveredModels = normalizeDiscoveredModels(fetchedModels); - let syncedAvailableModels: SyncedAvailableModel[] = []; + let syncedAvailableModels: SyncedAvailableModel[] = previousSyncedAvailableModels; if (discoveredModels.length > 0) { syncedAvailableModels = await replaceSyncedAvailableModelsForConnection( providerId, @@ -184,24 +239,28 @@ export async function importManagedModels({ } let syncedAliases = 0; - if (usesManagedAvailableModels(providerId)) { + if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) { const aliasSync = await syncManagedAvailableModelAliases( providerId, - mode === "sync" - ? persistedModels - .map((model) => toNonEmptyString(model.id)) - .filter((modelId): modelId is string => Boolean(modelId)) - : candidateImportedModels.map((model) => model.id), + discoveredModels.map((model) => model.id), { pruneMissing: mode === "sync" } ); syncedAliases = aliasSync.assignedAliases.length; } - const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds); - const importedModels = collectAddedImportedModels(previousModels, candidateImportedModels); + const importedChanges = summarizeImportedChanges( + previousSyncedAvailableModels as JsonRecord[], + discoveredModels as JsonRecord[], + importedIds + ); + const importedModels = collectAddedImportedModels( + previousSyncedAvailableModels as JsonRecord[], + candidateImportedModels + ); return { previousModels, + previousSyncedAvailableModels, persistedModels, importedModels, discoveredModels, diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 0ed9dbcca5..89235b566a 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -48,7 +48,7 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel deduped.set(id, { id, name, - source: "api-sync", + source: "imported", ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), ...(typeof record.inputTokenLimit === "number" ? { inputTokenLimit: record.inputTokenLimit } diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 6937a970ad..dd41eadffe 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -9,6 +9,7 @@ import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableMod import { getModelCatalogSourceLabel, matchesModelCatalogQuery, + normalizeModelCatalogSource, } from "@/shared/utils/modelCatalogSearch"; import { OAUTH_PROVIDERS, @@ -144,7 +145,7 @@ export default function ModelSelectModal({ name: cm.name || cm.id, value: `${alias}/${cm.id}`, isCustom: true, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...aliasModels, ...customEntries]; @@ -198,7 +199,7 @@ export default function ModelSelectModal({ name: cm.name || cm.id, value: `${nodePrefix}/${cm.id}`, isCustom: true, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...nodeModels, ...fallbackEntries, ...customEntries]; @@ -231,7 +232,7 @@ export default function ModelSelectModal({ name: cm.name || cm.id, value: `${alias}/${cm.id}`, isCustom: true, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...systemEntries, ...customEntries]; diff --git a/src/shared/utils/modelCatalogSearch.ts b/src/shared/utils/modelCatalogSearch.ts index c842ff6515..7ab5459da2 100644 --- a/src/shared/utils/modelCatalogSearch.ts +++ b/src/shared/utils/modelCatalogSearch.ts @@ -1,4 +1,4 @@ -export type ModelCatalogSource = "system" | "custom" | "api-sync" | "fallback" | "alias"; +export type ModelCatalogSource = "system" | "custom" | "imported" | "fallback" | "alias"; type ModelCatalogTarget = { modelId?: string | null; @@ -20,7 +20,7 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo normalized === "auto-sync" || normalized === "imported" ) { - return "api-sync"; + return "imported"; } if (normalized === "fallback") return "fallback"; if (normalized === "alias") return "alias"; @@ -33,8 +33,8 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo export function getModelCatalogSourceLabel(source?: string | null): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": - return "Synced"; + case "imported": + return "Imported"; case "custom": return "Custom"; case "fallback": @@ -49,7 +49,7 @@ export function getModelCatalogSourceLabel(source?: string | null): string { function getModelCatalogSourceSearchText(source?: string | null): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": + case "imported": return "synced api imported discovered"; case "custom": return "custom manual imported"; diff --git a/tests/unit/db-models-crud.test.ts b/tests/unit/db-models-crud.test.ts index 7f8f63f76c..bd21ad6aca 100644 --- a/tests/unit/db-models-crud.test.ts +++ b/tests/unit/db-models-crud.test.ts @@ -115,7 +115,7 @@ test("replaceCustomModels preserves compat fields and respects the empty-list gu { id: "gpt-4.1", name: "GPT-4.1 Refreshed", - source: "api-sync", + source: "imported", supportsThinking: true, }, ]); @@ -147,12 +147,12 @@ test("removing a custom model also removes its compat override", async () => { test("synced available models are unioned across connections and cleaned per connection", async () => { await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-a", [ - { id: "gpt-4.1", name: "GPT-4.1", source: "api-sync" }, - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" }, + { id: "gpt-4.1", name: "GPT-4.1", source: "imported" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" }, ]); const union = await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-b", [ - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" }, - { id: "o3-mini", name: "o3-mini", source: "api-sync" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" }, + { id: "o3-mini", name: "o3-mini", source: "imported" }, ]); const remaining = await modelsDb.deleteSyncedAvailableModelsForConnection("openai", "conn-a"); const allProviders = await modelsDb.getAllSyncedAvailableModels(); diff --git a/tests/unit/model-catalog-search.test.ts b/tests/unit/model-catalog-search.test.ts index 00c6d45c79..cd1606ea50 100644 --- a/tests/unit/model-catalog-search.test.ts +++ b/tests/unit/model-catalog-search.test.ts @@ -9,8 +9,8 @@ import { test("model catalog source normalization groups manual and synced rows separately", () => { assert.equal(normalizeModelCatalogSource("manual"), "custom"); - assert.equal(normalizeModelCatalogSource("imported"), "api-sync"); - assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync"); + assert.equal(normalizeModelCatalogSource("imported"), "imported"); + assert.equal(normalizeModelCatalogSource("api-sync"), "imported"); assert.equal(normalizeModelCatalogSource("fallback"), "fallback"); assert.equal(normalizeModelCatalogSource("alias"), "alias"); assert.equal(normalizeModelCatalogSource(undefined), "system"); @@ -19,7 +19,7 @@ test("model catalog source normalization groups manual and synced rows separatel test("model catalog source labels stay user-facing", () => { assert.equal(getModelCatalogSourceLabel("system"), "Built-in"); assert.equal(getModelCatalogSourceLabel("custom"), "Custom"); - assert.equal(getModelCatalogSourceLabel("api-sync"), "Synced"); + assert.equal(getModelCatalogSourceLabel("imported"), "Imported"); assert.equal(getModelCatalogSourceLabel("fallback"), "Fallback"); assert.equal(getModelCatalogSourceLabel("alias"), "Alias"); }); @@ -29,7 +29,7 @@ test("model catalog query matches id, display name, alias and source label", () modelId: "qwen/qwen3-coder-480b-a35b-instruct", modelName: "Qwen3 Coder 480B", alias: "best-qwen", - source: "api-sync", + source: "imported", }; assert.equal(matchesModelCatalogQuery("", target), true); diff --git a/tests/unit/model-catalog-source.test.ts b/tests/unit/model-catalog-source.test.ts index a56ce76a7d..50e0461c69 100644 --- a/tests/unit/model-catalog-source.test.ts +++ b/tests/unit/model-catalog-source.test.ts @@ -5,9 +5,9 @@ const { getModelCatalogSourceLabel, normalizeModelCatalogSource } = await import("../../src/shared/utils/modelCatalogSearch.ts"); test("model catalog source normalizes synced import variants consistently", () => { - assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync"); - assert.equal(normalizeModelCatalogSource("auto-sync"), "api-sync"); - assert.equal(normalizeModelCatalogSource("imported"), "api-sync"); - assert.equal(getModelCatalogSourceLabel("auto-sync"), "Synced"); - assert.equal(getModelCatalogSourceLabel("imported"), "Synced"); + assert.equal(normalizeModelCatalogSource("api-sync"), "imported"); + assert.equal(normalizeModelCatalogSource("imported"), "imported"); + assert.equal(normalizeModelCatalogSource("auto-sync"), "imported"); + assert.equal(getModelCatalogSourceLabel("auto-sync"), "Imported"); + assert.equal(getModelCatalogSourceLabel("imported"), "Imported"); }); diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index 1a2b4b085b..6d5f8aff1d 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -53,11 +53,11 @@ test("model sync route skips success log when fetched models do not change store apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "custom-model-1", name: "Custom Model 1", - source: "auto-sync", + source: "imported", }, ]); @@ -85,6 +85,7 @@ test("model sync route skips success log when fetched models do not change store const body = (await response.json()) as any; assert.equal(body.logged, false); assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); + assert.deepEqual(body.models, []); const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(logs.length, 0); @@ -250,6 +251,45 @@ test("model sync route falls back to the upstream HTTP status when the models pa assert.equal(logs[0].error, "HTTP 429"); }); +test("model sync route reports invalid JSON /models responses without losing upstream status", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "Invalid JSON Sync", + apiKey: "test-key", + }); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return new Response("bad gateway", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }; + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + const body = (await response.json()) as any; + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + + assert.equal(response.status, 502); + assert.equal(body.error, "Invalid JSON response from /models"); + assert.equal(body.upstreamStatus, 200); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, 200); + assert.equal(logs[0].error, "Invalid JSON response from /models"); +}); + test("model sync route preserves previously synced models when the upstream omits the models list", async () => { await resetStorage(); @@ -260,11 +300,11 @@ test("model sync route preserves previously synced models when the upstream omit apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "persisted-model", name: "Persisted Model", - source: "auto-sync", + source: "imported", }, ]); @@ -290,13 +330,12 @@ test("model sync route preserves previously synced models when the upstream omit assert.equal(body.syncedModels, 1); assert.equal(body.logged, false); assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); - assert.deepEqual(body.models, [ + assert.deepEqual(body.models, []); + assert.deepEqual(await modelsDb.getSyncedAvailableModels("openrouter"), [ { id: "persisted-model", name: "Persisted Model", - source: "auto-sync", - apiFormat: "chat-completions", - supportedEndpoints: ["chat"], + source: "imported", }, ]); assert.equal(logs.length, 0); @@ -348,24 +387,12 @@ test("model sync route writes synced available models for Gemini connections", a assert.equal(body.syncedModels, 1); assert.equal(body.logged, true); assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); - assert.deepEqual(body.models, [ - { - id: "gemini-custom-preview", - name: "Gemini Custom Preview", - source: "api-sync", - apiFormat: "chat-completions", - supportedEndpoints: ["chat", "embeddings"], - inputTokenLimit: 32768, - outputTokenLimit: 8192, - description: "Custom Gemini preview model", - supportsThinking: true, - }, - ]); + assert.deepEqual(body.models, []); assert.deepEqual(synced, [ { id: "gemini-custom-preview", name: "Gemini Custom Preview", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat", "embeddings"], inputTokenLimit: 32768, outputTokenLimit: 8192, @@ -421,7 +448,7 @@ test("model sync route writes synced available models for non-Gemini providers t { id: "glm-5.1", name: "GLM 5.1", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat"], inputTokenLimit: 262144, }, @@ -439,6 +466,7 @@ test("model sync route import mode merges discovered models without deleting man }); await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual"); + await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual"); await localDb.setModelAlias("manual-only", "openrouter/manual-only"); globalThis.fetch = async (url) => { @@ -467,16 +495,21 @@ test("model sync route import mode merges discovered models without deleting man assert.equal(body.updatedCount, 0); assert.equal(body.syncedAliases, 1); assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); + assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 }); assert.deepEqual( body.models.map((model) => ({ id: model.id, source: model.source })), - [ - { id: "manual-only", source: "manual" }, - { id: "router-v4", source: "api-sync" }, - ] + [{ id: "manual-only", source: "manual" }] ); assert.deepEqual( body.importedModels.map((model) => ({ id: model.id, source: model.source })), - [{ id: "router-v4", source: "api-sync" }] + [{ id: "router-v4", source: "imported" }] + ); + assert.deepEqual( + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ + id: model.id, + source: model.source, + })), + [{ id: "router-v4", source: "imported" }] ); assert.equal(aliases["manual-only"], "openrouter/manual-only"); assert.equal(aliases["router-v4"], "openrouter/router-v4"); @@ -492,12 +525,11 @@ test("model sync route import mode ignores supported endpoint ordering changes", apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "router-v4", name: "Router V4", - source: "api-sync", - apiFormat: "chat-completions", + source: "imported", supportedEndpoints: ["chat", "embeddings"], }, ]); @@ -536,12 +568,13 @@ test("model sync route import mode ignores supported endpoint ordering changes", assert.equal(body.logged, false); assert.deepEqual(body.importedModels, []); assert.deepEqual( - body.models.map((model) => ({ + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ id: model.id, supportedEndpoints: model.supportedEndpoints, })), [{ id: "router-v4", supportedEndpoints: ["chat", "embeddings"] }] ); + assert.deepEqual(body.models, []); assert.equal(logs.length, 0); }); @@ -555,12 +588,11 @@ test("model sync route import mode reports updates without counting them as new apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "router-v4", name: "Router V4", - source: "api-sync", - apiFormat: "chat-completions", + source: "imported", supportedEndpoints: ["chat"], }, ]); @@ -597,7 +629,7 @@ test("model sync route import mode reports updates without counting them as new assert.deepEqual(body.importedChanges, { added: 0, updated: 1, unchanged: 0, total: 1 }); assert.deepEqual(body.importedModels, []); assert.deepEqual( - body.models.map((model) => ({ + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ id: model.id, name: model.name, supportedEndpoints: model.supportedEndpoints, @@ -610,6 +642,7 @@ test("model sync route import mode reports updates without counting them as new }, ] ); + assert.deepEqual(body.models, []); assert.equal(body.logged, true); assert.equal(logs.length, 1); }); @@ -624,16 +657,16 @@ test("model sync route records added, removed, and updated model diffs with fall accessToken: "sync-token", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "persisted-model", name: "Persisted Model", - source: "auto-sync", + source: "imported", }, { id: "removed-model", name: "Removed Model", - source: "auto-sync", + source: "imported", }, ]); @@ -673,7 +706,7 @@ test("model sync route records added, removed, and updated model diffs with fall assert.equal(body.logged, true); assert.deepEqual(body.modelChanges, { added: 1, removed: 1, updated: 1, total: 3 }); assert.deepEqual( - body.models.map((model) => ({ + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ id: model.id, name: model.name, supportedEndpoints: model.supportedEndpoints, @@ -689,7 +722,7 @@ test("model sync route records added, removed, and updated model diffs with fall { id: "fallback-model", name: "Fallback Model", - supportedEndpoints: ["chat"], + supportedEndpoints: undefined, description: "Fallback from model field", }, ] @@ -753,17 +786,12 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo assert.equal(body.provider, "openrouter"); assert.equal(body.syncedModels, 3); assert.equal(body.availableModelsCount, 3); - assert.equal(body.syncedAliases, 2); + assert.equal(body.syncedAliases, 3); assert.equal(body.logged, true); - assert.deepEqual(body.modelChanges, { added: 2, removed: 0, updated: 0, total: 2 }); - assert.deepEqual( - body.models.map((model) => ({ id: model.id, name: model.name })), - [ - { id: "router-v2", name: "Router V2" }, - { id: "router-v3", name: "Router V3" }, - ] - ); + assert.deepEqual(body.modelChanges, { added: 3, removed: 0, updated: 0, total: 3 }); + assert.deepEqual(body.models, []); assert.equal(aliases["stale-model"], undefined); + assert.equal(aliases["auto"], "openrouter/auto"); assert.equal(aliases["openrouter-router-v2"], "openrouter/router-v2"); assert.equal(aliases["router-v3"], "openrouter/router-v3"); assert.equal(logs.length, 1); @@ -782,6 +810,7 @@ test("model sync route reports synced managed models separately from preserved m }); await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual"); + await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual"); globalThis.fetch = async (url) => { assert.equal( @@ -807,12 +836,17 @@ test("model sync route reports synced managed models separately from preserved m assert.equal(body.availableModelsCount, 2); assert.equal(body.importedCount, 1); assert.equal(body.updatedCount, 0); + assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 }); assert.deepEqual( body.models.map((model) => ({ id: model.id, source: model.source })), - [ - { id: "manual-only", source: "manual" }, - { id: "router-v4", source: "api-sync" }, - ] + [{ id: "manual-only", source: "manual" }] + ); + assert.deepEqual( + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ + id: model.id, + source: model.source, + })), + [{ id: "router-v4", source: "imported" }] ); }); @@ -868,33 +902,71 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi assert.equal(aliases["cm-sonnet-4-6"], "anthropic-compatible-demo/sonnet-4-6"); }); -test("model sync route returns 500 and records a failure when the internal models fetch throws", async () => { +test("model sync route falls back to in-process discovery when internal self-fetch throws", async () => { await resetStorage(); const connection = await providersDb.createProviderConnection({ - provider: "openrouter", + provider: "openai-compatible-aio", authType: "apikey", - name: "Exploding Sync", + name: "AIO Import", apiKey: "test-key", + providerSpecificData: { + prefix: "aio", + apiType: "chat", + baseUrl: "https://api.bltcy.ai/v1", + nodeName: "aio", + autoSync: true, + }, }); - globalThis.fetch = async () => { - throw new Error("network exploded"); + const fetchCalls: string[] = []; + globalThis.fetch = async (url) => { + const urlString = String(url); + fetchCalls.push(urlString); + + if (urlString === `http://localhost/api/providers/${connection.id}/models?refresh=true`) { + throw new Error("fetch failed"); + } + + assert.equal(urlString, "https://api.bltcy.ai/v1/models"); + return Response.json({ + data: [{ id: "aio-model", name: "AIO Model" }], + }); }; const response = await modelSyncRoute.POST( - new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + new Request(`http://localhost/api/providers/${connection.id}/sync-models?mode=import`, { method: "POST", headers: scheduler.buildModelSyncInternalHeaders(), }), { params: { id: connection.id } } ); - const body = (await response.json()) as any; - const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + const body = (await response.json()) as { + importedCount: number; + importedModels: Array<{ id: string; source: string }>; + }; + const customModels = (await modelsDb.getCustomModels("openai-compatible-aio")) as Array<{ + id: string; + source: string; + }>; + const availableModels = await modelsDb.getSyncedAvailableModels("openai-compatible-aio"); - assert.equal(response.status, 500); - assert.equal(body.error, "network exploded"); - assert.equal(logs.length, 1); - assert.equal(logs[0].status, 500); - assert.equal(logs[0].provider, "openrouter"); + assert.equal(response.status, 200); + assert.equal(body.importedCount, 1); + assert.deepEqual( + body.importedModels.map((model) => ({ id: model.id, source: model.source })), + [{ id: "aio-model", source: "imported" }] + ); + assert.deepEqual( + customModels.map((model) => ({ id: model.id, source: model.source })), + [] + ); + assert.deepEqual( + availableModels.map((model) => ({ id: model.id, source: model.source })), + [{ id: "aio-model", source: "imported" }] + ); + assert.deepEqual(fetchCalls, [ + `http://localhost/api/providers/${connection.id}/models?refresh=true`, + "https://api.bltcy.ai/v1/models", + ]); }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 8d883df60f..234b7e2d33 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -354,21 +354,21 @@ test("v1 models catalog includes synced Gemini models and duplicates audio model { id: "gemini-audio-live", name: "Gemini Audio Live", - source: "api-sync", + source: "imported", supportedEndpoints: ["audio"], inputTokenLimit: 4096, }, { id: "text-embedding-004", name: "Text Embedding 004", - source: "api-sync", + source: "imported", supportedEndpoints: ["embeddings"], inputTokenLimit: 2048, }, { id: "gemini-hidden", name: "Gemini Hidden", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat"], }, ] @@ -402,7 +402,7 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a { id: "gemini-2.5-pro-live", name: "Gemini 2.5 Pro Live", - source: "api-sync", + source: "imported", inputTokenLimit: 8192, }, ]); @@ -430,7 +430,7 @@ test("v1 models catalog includes synced non-Gemini provider models from discover { id: "glm-5.1", name: "GLM 5.1", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat"], inputTokenLimit: 262144, }, diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 996f98af89..09ea2a6cc9 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -461,7 +461,7 @@ test("provider models route caches discovered opencode-go models per connection" assert.equal(firstResponse.status, 200); assert.equal(firstBody.source, "api"); assert.deepEqual(firstBody.models, [{ id: "glm-5.1", name: "GLM 5.1" }]); - assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]); + assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]); globalThis.fetch = async () => { throw new Error("cached route should not hit upstream"); @@ -472,7 +472,7 @@ test("provider models route caches discovered opencode-go models per connection" assert.equal(cachedResponse.status, 200); assert.equal(cachedBody.source, "cache"); - assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]); + assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]); assert.equal(fetchCalls, 1); }); @@ -481,7 +481,7 @@ test("provider models route falls back to cached models when a refresh fails", a apiKey: "opencode-go-key", }); await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [ - { id: "cached-go", name: "Cached Go", source: "api-sync" }, + { id: "cached-go", name: "Cached Go", source: "imported" }, ]); let fetchCalls = 0; @@ -496,7 +496,7 @@ test("provider models route falls back to cached models when a refresh fails", a assert.equal(response.status, 200); assert.equal(body.source, "cache"); assert.match(body.warning, /cached catalog/i); - assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "api-sync" }]); + assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "imported" }]); assert.equal(fetchCalls, 1); }); @@ -505,7 +505,7 @@ test("provider models route clears cached discovery when a refresh returns no re apiKey: "opencode-go-key", }); await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [ - { id: "cached-go", name: "Cached Go", source: "api-sync" }, + { id: "cached-go", name: "Cached Go", source: "imported" }, ]); globalThis.fetch = async () => { diff --git a/tests/unit/v1beta-models-route.test.ts b/tests/unit/v1beta-models-route.test.ts new file mode 100644 index 0000000000..21f6b210b4 --- /dev/null +++ b/tests/unit/v1beta-models-route.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1beta-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "v1beta-models-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const v1betaModelsRoute = await import("../../src/app/api/v1beta/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1beta models route deduplicates custom models against built-in and synced entries", async () => { + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-main", [ + { + id: "gpt-4o", + name: "GPT-4o From Sync", + source: "imported", + }, + { + id: "review-sync-only", + name: "Review Sync Only", + source: "imported", + }, + ]); + await modelsDb.addCustomModel("openai", "gpt-4o", "GPT-4o Manual Duplicate"); + await modelsDb.addCustomModel("openai", "review-sync-only", "Review Manual Duplicate"); + await modelsDb.addCustomModel("openai", "review-manual-only", "Review Manual Only"); + + const response = await v1betaModelsRoute.GET(); + const body = (await response.json()) as { models: Array<{ name: string }> }; + const names = body.models.map((model) => model.name); + + assert.equal(response.status, 200); + assert.equal(names.filter((name) => name === "models/openai/gpt-4o").length, 1); + assert.equal(names.filter((name) => name === "models/openai/review-sync-only").length, 1); + assert.equal(names.filter((name) => name === "models/openai/review-manual-only").length, 1); +}); From 71486566525748d72c9cf38ae1dec7c52d9907b6 Mon Sep 17 00:00:00 2001 From: ivan-mezentsev Date: Wed, 29 Apr 2026 14:53:23 +0300 Subject: [PATCH 09/12] fix(codex): stabilize copilot responses reasoning and tool replay (#1750) --- open-sse/executors/codex.ts | 63 +++++++- open-sse/handlers/chatCore.ts | 3 +- open-sse/services/responsesToolCallState.ts | 109 ++++++++++++++ open-sse/utils/stream.ts | 156 ++++++++++++++++++-- tests/unit/executor-codex.test.ts | 47 ++++++ tests/unit/stream-utilities.test.ts | 87 ++++++++++- 6 files changed, 447 insertions(+), 18 deletions(-) create mode 100644 open-sse/services/responsesToolCallState.ts diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 42b3049a7f..b95ab31a68 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -12,6 +12,7 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; +import { getRememberedResponseFunctionCalls } from "../services/responsesToolCallState.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; import { createRequire } from "module"; @@ -359,16 +360,61 @@ function convertSystemToDeveloperRole(body: Record): void { * 3. Strips the "id" field from any object in input whose id matches a * server-generated prefix (rs_, fc_, resp_, msg_) — so the content is * preserved but the backend won't try to look it up - * 4. Always deletes previous_response_id (endpoint doesn't persist responses) + * 4. Rehydrates missing function_call items for stateful tool-output follow-ups + * using locally remembered response state, then deletes previous_response_id */ function stripStoredItemReferences(body: Record): void { const hasInput = Array.isArray(body.input) && body.input.length > 0; + const inputItems = Array.isArray(body.input) ? body.input : []; + const previousResponseId = typeof body.previous_response_id === "string" ? body.previous_response_id : ""; + const inputFunctionCallIds = new Set(); + const inputFunctionCallOutputIds = new Set(); - // Always strip previous_response_id IF we have input. - // The /codex/responses endpoint does not persist responses, so any reference - // to a previous response would cause a 404. However, if input is missing (e.g. Cursor - // trying to continue generation), stripping it leaves the payload empty causing a 400 Schema error. - // We leave it intact so Codex returns 404, which correctly triggers Cursor's fallback to resend history. + for (const item of inputItems) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + const type = typeof record.type === "string" ? record.type : ""; + const callId = typeof record.call_id === "string" ? record.call_id : ""; + if (!callId) continue; + if (type === "function_call") { + inputFunctionCallIds.add(callId); + continue; + } + if (type === "function_call_output") { + inputFunctionCallOutputIds.add(callId); + } + } + + const missingFunctionCallIds = [...inputFunctionCallOutputIds].filter( + (callId) => !inputFunctionCallIds.has(callId) + ); + + if (hasInput && previousResponseId && missingFunctionCallIds.length > 0) { + const rememberedFunctionCalls = getRememberedResponseFunctionCalls(previousResponseId); + const injectedFunctionCalls = rememberedFunctionCalls + .filter((functionCall) => missingFunctionCallIds.includes(functionCall.call_id)) + .filter((functionCall) => !inputFunctionCallIds.has(functionCall.call_id)) + .map((functionCall) => ({ + type: "function_call", + call_id: functionCall.call_id, + name: functionCall.name, + arguments: functionCall.arguments, + })); + + if (injectedFunctionCalls.length > 0) { + body.input = [...injectedFunctionCalls, ...inputItems]; + for (const functionCall of injectedFunctionCalls) { + inputFunctionCallIds.add(functionCall.call_id); + } + } + } + + // Strip previous_response_id whenever the request already carries input items. + // Codex rejects this field outright, so stateful follow-up turns must be made + // self-contained via the local function_call replay above. + // + // If input is missing entirely (e.g. Cursor trying to continue generation), keep + // previous_response_id so upstream can decide whether to fall back. if (hasInput) { delete body.previous_response_id; } @@ -1133,6 +1179,11 @@ export class CodexExecutor extends BaseExecutor { // whether the request came via native passthrough or translation. delete body.max_tokens; delete body.max_output_tokens; + // VS Code Copilot BYOK Responses requests include `truncation` (for example + // "auto" or "disabled"). The Codex /responses backend currently rejects this + // field entirely with 400 Unsupported parameter: truncation, so strip it for + // both native passthrough and translated requests. + delete body.truncation; delete body.background; // Droid CLI sends this but Codex Responses API rejects it // Inject prompt_cache_key for Codex prompt caching. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6d9f8526dd..876ee51db1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3542,7 +3542,8 @@ export async function handleChatCore({ body, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + clientResponseFormat ); } diff --git a/open-sse/services/responsesToolCallState.ts b/open-sse/services/responsesToolCallState.ts new file mode 100644 index 0000000000..06e588d77a --- /dev/null +++ b/open-sse/services/responsesToolCallState.ts @@ -0,0 +1,109 @@ +type JsonRecord = Record; + +type RememberedFunctionCall = { + call_id: string; + name: string; + arguments: string; +}; + +type RememberedResponseToolState = { + functionCalls: RememberedFunctionCall[]; + expiresAt: number; + updatedAt: number; +}; + +const RESPONSE_TOOL_CALL_TTL_MS = 30 * 60 * 1000; +const RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES = 512; + +const rememberedResponseToolCalls = new Map(); + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function cleanupRememberedResponseToolCalls(now: number = Date.now()) { + for (const [responseId, entry] of rememberedResponseToolCalls.entries()) { + if (entry.expiresAt <= now) { + rememberedResponseToolCalls.delete(responseId); + } + } + + if (rememberedResponseToolCalls.size <= RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES) { + return; + } + + const oldestEntries = [...rememberedResponseToolCalls.entries()].sort( + (a, b) => a[1].updatedAt - b[1].updatedAt + ); + + while (rememberedResponseToolCalls.size > RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES) { + const oldest = oldestEntries.shift(); + if (!oldest) break; + rememberedResponseToolCalls.delete(oldest[0]); + } +} + +export function rememberResponseFunctionCalls(responseId: unknown, outputItems: readonly unknown[]) { + const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; + if (!normalizedResponseId || !Array.isArray(outputItems) || outputItems.length === 0) { + return; + } + + const functionCalls: RememberedFunctionCall[] = []; + + for (const item of outputItems) { + const record = toRecord(item); + if (!record || record.type !== "function_call") continue; + + const callId = typeof record.call_id === "string" ? record.call_id.trim() : ""; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const argumentsValue = + typeof record.arguments === "string" + ? record.arguments + : JSON.stringify(record.arguments ?? {}); + + if (!callId || !name) continue; + + functionCalls.push({ + call_id: callId, + name, + arguments: argumentsValue, + }); + } + + if (functionCalls.length === 0) { + return; + } + + cleanupRememberedResponseToolCalls(); + + rememberedResponseToolCalls.set(normalizedResponseId, { + functionCalls, + updatedAt: Date.now(), + expiresAt: Date.now() + RESPONSE_TOOL_CALL_TTL_MS, + }); +} + +export function getRememberedResponseFunctionCalls( + responseId: unknown +): RememberedFunctionCall[] { + cleanupRememberedResponseToolCalls(); + + const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; + if (!normalizedResponseId) { + return []; + } + + const entry = rememberedResponseToolCalls.get(normalizedResponseId); + if (!entry) { + return []; + } + + return entry.functionCalls.map((functionCall) => ({ ...functionCall })); +} + +export function clearRememberedResponseFunctionCallsForTesting() { + rememberedResponseToolCalls.clear(); +} \ No newline at end of file diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 7675ef04e3..0f8028069f 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -28,6 +28,7 @@ import { sanitizeStreamingChunk, extractThinkingFromContent, } from "../handlers/responseSanitizer.ts"; +import { rememberResponseFunctionCalls } from "../services/responsesToolCallState.ts"; import { buildErrorBody } from "./error.ts"; /** @@ -80,6 +81,7 @@ type StreamOptions = { mode?: string; targetFormat?: string; sourceFormat?: string; + clientResponseFormat?: string | null; provider?: string | null; reqLogger?: StreamLogger | null; toolNameMap?: unknown; @@ -476,6 +478,7 @@ export function createSSEStream(options: StreamOptions = {}) { mode = STREAM_MODE.TRANSLATE, targetFormat, sourceFormat, + clientResponseFormat = null, provider = null, reqLogger = null, toolNameMap = null, @@ -487,6 +490,11 @@ export function createSSEStream(options: StreamOptions = {}) { onFailure = null, } = options; + const clientExpectsResponsesStream = + (mode === STREAM_MODE.PASSTHROUGH + ? clientResponseFormat === FORMATS.OPENAI_RESPONSES + : sourceFormat === FORMATS.OPENAI_RESPONSES) === true; + let buffer = ""; let usage: UsageTokenRecord | null = null; /** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */ @@ -516,6 +524,8 @@ export function createSSEStream(options: StreamOptions = {}) { // used to backfill `response.completed.response.output` when upstream returns it // empty (which happens when `store: false` — see backfillResponsesCompletedOutput). const passthroughResponsesOutputItems: unknown[] = []; + let passthroughResponsesId: string | null = null; + const passthroughResponsesReasoningSummarySeen = new Set(); const streamStartedAt = Date.now(); // Guard against duplicate [DONE] events — ensures exactly one per stream @@ -683,6 +693,101 @@ export function createSSEStream(options: StreamOptions = {}) { controller.enqueue(encoder.encode(comment)); }; + const getResponsesReasoningKey = (payload: Record): string | null => { + if (typeof payload.item_id === "string" && payload.item_id) { + return payload.item_id; + } + + const item = + payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) + ? (payload.item as Record) + : null; + if (item && typeof item.id === "string" && item.id) { + return item.id; + } + + const responseId = + typeof payload.response_id === "string" && payload.response_id + ? payload.response_id + : passthroughResponsesId; + const outputIndex = + typeof payload.output_index === "number" && Number.isInteger(payload.output_index) + ? payload.output_index + : null; + + return responseId !== null && outputIndex !== null ? `${responseId}:${outputIndex}` : null; + }; + + const emitSyntheticResponsesReasoningSummary = ( + controller: TransformStreamDefaultController, + payload: Record + ) => { + const item = + payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) + ? (payload.item as Record) + : null; + if (!item || item.type !== "reasoning" || !Array.isArray(item.summary)) { + return; + } + + const summaryText = item.summary + .map((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) { + return ""; + } + return typeof (part as Record).text === "string" + ? ((part as Record).text as string) + : ""; + }) + .join(""); + + if (!summaryText) { + return; + } + + const reasoningKey = getResponsesReasoningKey(payload); + if (!reasoningKey || passthroughResponsesReasoningSummarySeen.has(reasoningKey)) { + return; + } + passthroughResponsesReasoningSummarySeen.add(reasoningKey); + + const itemId = typeof item.id === "string" && item.id ? item.id : reasoningKey; + const outputIndex = + typeof payload.output_index === "number" && Number.isInteger(payload.output_index) + ? payload.output_index + : 0; + + const syntheticEvents = [ + { + event: "response.reasoning_summary_text.delta", + body: { + type: "response.reasoning_summary_text.delta", + item_id: itemId, + output_index: outputIndex, + summary_index: 0, + delta: summaryText, + }, + }, + { + event: "response.reasoning_summary_part.done", + body: { + type: "response.reasoning_summary_part.done", + item_id: itemId, + output_index: outputIndex, + summary_index: 0, + part: { type: "summary_text", text: summaryText }, + }, + }, + ]; + + for (const syntheticEvent of syntheticEvents) { + clientPayloadCollector.push(syntheticEvent.body); + const output = `event: ${syntheticEvent.event}\ndata: ${JSON.stringify(syntheticEvent.body)}\n\n`; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + } + }; + return new TransformStream( { start(controller) { @@ -809,6 +914,15 @@ export function createSSEStream(options: StreamOptions = {}) { parsed.type === "error"); if (isResponsesSSE) { + const responseId = + typeof parsed.response?.id === "string" + ? parsed.response.id + : typeof parsed.response_id === "string" + ? parsed.response_id + : null; + if (responseId) { + passthroughResponsesId = responseId; + } // Responses SSE: only extract usage, forward payload as-is const extracted = extractUsage(parsed); if (extracted) { @@ -825,10 +939,21 @@ export function createSSEStream(options: StreamOptions = {}) { if (parsed.type === "response.failed") { failurePayload = normalizeStreamFailurePayload(parsed); } + if ( + parsed.type === "response.reasoning_summary_text.delta" || + parsed.type === "response.reasoning_summary_text.done" || + parsed.type === "response.reasoning_summary_part.done" + ) { + const reasoningKey = getResponsesReasoningKey(parsed); + if (reasoningKey) { + passthroughResponsesReasoningSummarySeen.add(reasoningKey); + } + } // Capture each completed output item so the final // response.completed snapshot can be backfilled when upstream // returns an empty `output` (happens with store: false). if (parsed.type === "response.output_item.done" && parsed.item) { + emitSyntheticResponsesReasoningSummary(controller, parsed); passthroughResponsesOutputItems.push(parsed.item); } // Two transport-level fixes for Responses passthrough: @@ -1288,6 +1413,13 @@ export function createSSEStream(options: StreamOptions = {}) { } clearPendingPassthroughEvent(); + if (passthroughResponsesId && passthroughResponsesOutputItems.length > 0) { + rememberResponseFunctionCalls( + passthroughResponsesId, + passthroughResponsesOutputItems + ); + } + // Estimate usage if provider didn't return valid usage if (!hasValidUsage(usage) && totalContentLength > 0) { usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI); @@ -1307,10 +1439,12 @@ export function createSSEStream(options: StreamOptions = {}) { if (!doneSent) { await emitFinalSseMetadata(controller, usage); doneSent = true; - clientPayloadCollector.push({ done: true }); - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(encoder.encode(doneOutput)); + if (!clientExpectsResponsesStream) { + clientPayloadCollector.push({ done: true }); + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(encoder.encode(doneOutput)); + } } // Notify caller for call log persistence (include full response body with accumulated content) if (onComplete) { @@ -1499,10 +1633,12 @@ export function createSSEStream(options: StreamOptions = {}) { if (!doneSent) { await emitFinalSseMetadata(controller, state?.usage as Record | null); doneSent = true; - clientPayloadCollector.push({ done: true }); - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(encoder.encode(doneOutput)); + if (!clientExpectsResponsesStream) { + clientPayloadCollector.push({ done: true }); + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(encoder.encode(doneOutput)); + } } // Estimate usage if provider didn't return valid usage (for translate mode) @@ -1632,7 +1768,8 @@ export function createPassthroughStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null + onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + clientResponseFormat: string | null = null ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, @@ -1645,5 +1782,6 @@ export function createPassthroughStreamWithLogger( body, onComplete, onFailure, + clientResponseFormat, }); } diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 5475c78e72..f39bb0a4e3 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -12,6 +12,10 @@ import { isCodexResponsesWebSocketRequired, parseCodexQuotaHeaders, } from "../../open-sse/executors/codex.ts"; +import { + clearRememberedResponseFunctionCallsForTesting, + rememberResponseFunctionCalls, +} from "../../open-sse/services/responsesToolCallState.ts"; import { DEFAULT_THINKING_CONFIG, setThinkingBudgetConfig, @@ -22,6 +26,7 @@ import { CODEX_CHAT_DEFAULT_INSTRUCTIONS } from "../../open-sse/config/codexInst test.afterEach(() => { setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); __setCodexWebSocketTransportForTesting(undefined); + clearRememberedResponseFunctionCallsForTesting(); }); async function withEnv(entries: Record, fn: () => any) { @@ -296,6 +301,48 @@ test("CodexExecutor.transformRequest preserves store-enabled responses state whe assert.equal(result.previous_response_id, "resp_prev_123"); }); +test("CodexExecutor.transformRequest rehydrates missing function_call items for stateful tool outputs", () => { + const executor = new CodexExecutor(); + rememberResponseFunctionCalls("resp_prev_tool_123", [ + { + type: "function_call", + call_id: "call_tool_123", + name: "workspace_read_file", + arguments: "{\"path\":\"README.md\"}", + }, + ]); + const body = { + _nativeCodexPassthrough: true, + previous_response_id: "resp_prev_tool_123", + input: [ + { + type: "function_call_output", + call_id: "call_tool_123", + output: "{\"ok\":true}", + }, + ], + stream: false, + }; + + const result = executor.transformRequest("gpt-5.5-low", body, false, { + requestEndpointPath: "/responses", + }); + + assert.equal(result.previous_response_id, undefined); + assert.equal(result.store, false); + assert.deepEqual(result.input[0], { + type: "function_call", + call_id: "call_tool_123", + name: "workspace_read_file", + arguments: "{\"path\":\"README.md\"}", + }); + assert.deepEqual(result.input[1], { + type: "function_call_output", + call_id: "call_tool_123", + output: "{\"ok\":true}", + }); +}); + test("CodexExecutor.transformRequest applies per-connection reasoning and service tier defaults", () => { const executor = new CodexExecutor(); const result = executor.transformRequest( diff --git a/tests/unit/stream-utilities.test.ts b/tests/unit/stream-utilities.test.ts index f273e5a464..4ba35d97f0 100644 --- a/tests/unit/stream-utilities.test.ts +++ b/tests/unit/stream-utilities.test.ts @@ -6,6 +6,7 @@ import { createStreamController, createDisconnectAwareStream, } from "../../open-sse/utils/streamHandler.ts"; +import { createPassthroughStreamWithLogger } from "../../open-sse/utils/stream.ts"; import { wantsProgress, createProgressTransform } from "../../open-sse/utils/progressTracker.ts"; @@ -50,6 +51,88 @@ test("createProgressTransform maps SSE text output to valid byte stream with pro assert.match(result, /done":true/); }); +test("createPassthroughStreamWithLogger omits [DONE] for Responses clients", async () => { + const transform = createPassthroughStreamWithLogger( + "codex", + null, + null, + "gpt-5.5-low", + null, + null, + null, + null, + null, + "openai-responses" + ); + + const writer = transform.writable.getWriter(); + await writer.write( + new TextEncoder().encode( + [ + "event: response.completed", + 'data: {"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.5-low","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}', + "", + ].join("\n") + ) + ); + await writer.close(); + + const reader = transform.readable.getReader(); + const decoder = new TextDecoder(); + let result = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value); + } + + assert.match(result, /event: response\.completed/); + assert.doesNotMatch(result, /data: \[DONE\]/); +}); + +test("createPassthroughStreamWithLogger synthesizes reasoning summary events from reasoning output items", async () => { + const transform = createPassthroughStreamWithLogger( + "codex", + null, + null, + "gpt-5.5-low", + null, + null, + null, + null, + null, + "openai-responses" + ); + + const writer = transform.writable.getWriter(); + await writer.write( + new TextEncoder().encode( + [ + "event: response.output_item.done", + 'data: {"type":"response.output_item.done","response_id":"resp_reasoning_1","output_index":0,"item":{"id":"rs_resp_reasoning_1_0","type":"reasoning","summary":[{"type":"summary_text","text":"Reasoning summary text"}]}}', + "", + ].join("\n") + ) + ); + await writer.close(); + + const reader = transform.readable.getReader(); + const decoder = new TextDecoder(); + let result = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value); + } + + assert.match(result, /event: response\.reasoning_summary_text\.delta/); + assert.match(result, /"delta":"Reasoning summary text"/); + assert.match(result, /event: response\.reasoning_summary_part\.done/); + assert.match(result, /event: response\.output_item\.done/); +}); + test("createStreamController returns valid controller", () => { let completeLogged = false; let disconnectLogged = false; @@ -61,8 +144,8 @@ test("createStreamController returns valid controller", () => { }; const sc = createStreamController({ - connectionId: "conn_1", - onStreamComplete: () => {}, + provider: "test", + model: "conn_1", }); assert.equal(typeof sc.signal, "object"); From e12df00c40cfb938f9054fd3505dbe4cce673299 Mon Sep 17 00:00:00 2001 From: backryun Date: Wed, 29 Apr 2026 20:53:28 +0900 Subject: [PATCH 10/12] chore(xiaomi): Update Xiaomi provider model list (#1759) --- open-sse/config/providerRegistry.ts | 2 +- src/shared/constants/providers.ts | 5 +++-- tests/unit/xiaomi-mimo-provider.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index d68401e0c1..416ebf894d 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -228,7 +228,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { codestral: buildModels(["codestral-2405", "codestral-latest"]), upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]), maritalk: buildModels(["sabia-3", "sabia-3-small"]), - "xiaomi-mimo": buildModels(["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-tts"]), + "xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]), "inference-net": buildModels([ "meta-llama/Llama-3.3-70B-Instruct", "deepseek-ai/DeepSeek-R1", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index e8e26bfa9f..959733cfd7 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1033,7 +1033,7 @@ export const APIKEY_PROVIDERS = { icon: "devices", color: "#EA580C", textIcon: "MM", - website: "https://www.mi.com", + website: "https://mimo.mi.com", }, "inference-net": { id: "inference-net", @@ -1542,7 +1542,8 @@ export const SEARCH_PROVIDERS = { color: "#1A237E", textIcon: "SX", website: "https://docs.searxng.org", - authHint: "API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access.", + authHint: + "API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access.", }, }; diff --git a/tests/unit/xiaomi-mimo-provider.test.ts b/tests/unit/xiaomi-mimo-provider.test.ts index e214bb3197..bcba6ac0d5 100644 --- a/tests/unit/xiaomi-mimo-provider.test.ts +++ b/tests/unit/xiaomi-mimo-provider.test.ts @@ -16,7 +16,7 @@ test("xiaomi-mimo registry uses the current default base URL and MiMo V2 models" assert.equal(entry.baseUrl, "https://api.xiaomimimo.com/v1"); assert.deepEqual( entry.models.map((model) => model.id), - ["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-tts"] + ["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"] ); }); @@ -24,7 +24,7 @@ test("xiaomi-mimo executor appends /chat/completions for regional base URLs", () const executor = new DefaultExecutor("xiaomi-mimo"); assert.equal( - executor.buildUrl("mimo-v2-pro", true, 0, { + executor.buildUrl("mimo-v2.5", true, 0, { providerSpecificData: { baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", }, @@ -33,7 +33,7 @@ test("xiaomi-mimo executor appends /chat/completions for regional base URLs", () ); assert.equal( - executor.buildUrl("mimo-v2-pro", true, 0, { + executor.buildUrl("mimo-v2.5", true, 0, { providerSpecificData: { baseUrl: "https://token-plan-cn.xiaomimimo.com/v1/chat/completions", }, From d39e5fb48f3c47827dc5fd8f1bfd9a8d3e2be968 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:53:31 -0400 Subject: [PATCH 11/12] Move DB health to management API (#1757) * Move DB health to management API * Address DB health review feedback --- .../mcp-server/__tests__/dbHealthTool.test.ts | 36 +++++----- open-sse/mcp-server/schemas/tools.ts | 2 +- open-sse/mcp-server/tools/advancedTools.ts | 7 +- src/app/(dashboard)/dashboard/health/page.tsx | 4 +- src/app/api/{v1 => }/db/health/route.ts | 0 tests/e2e/resilience-plan-alignment.spec.ts | 2 +- tests/unit/authz/classify.test.ts | 1 + tests/unit/authz/pipeline.test.ts | 14 ++++ tests/unit/db-health-route.test.ts | 65 ++++++++++--------- 9 files changed, 76 insertions(+), 55 deletions(-) rename src/app/api/{v1 => }/db/health/route.ts (100%) diff --git a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts index 9aa7565ca4..850074a7c5 100644 --- a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts +++ b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts @@ -7,15 +7,30 @@ import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); +const mockRunManagedDbHealthCheck = vi.hoisted(() => vi.fn()); + vi.mock("../audit.ts", () => ({ logToolCall: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../../../src/lib/db/core.ts", () => ({ + runManagedDbHealthCheck: mockRunManagedDbHealthCheck, +})); + describe("omniroute_db_health_check MCP tool", () => { let client: Client; beforeEach(async () => { mockFetch.mockReset(); + mockRunManagedDbHealthCheck.mockReset(); + mockRunManagedDbHealthCheck.mockReturnValue({ + isHealthy: false, + issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], + repairedCount: 1, + backupCreated: true, + autoRepair: true, + checkedAt: new Date().toISOString(), + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = createMcpServer(); await server.connect(serverTransport); @@ -38,32 +53,19 @@ describe("omniroute_db_health_check MCP tool", () => { expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false); }); - it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - isHealthy: false, - issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], - repairedCount: 1, - backupCreated: true, - autoRepair: true, - checkedAt: new Date().toISOString(), - }), - }); - + it("runs the database repair flow directly when autoRepair=true", async () => { const result = await client.callTool({ name: "omniroute_db_health_check", arguments: { autoRepair: true }, }); expect(result.isError).toBeFalsy(); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/db/health"), - expect.objectContaining({ method: "POST" }) - ); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockRunManagedDbHealthCheck).toHaveBeenCalledWith({ autoRepair: true }); const content = result.content[0] as { type: string; text: string }; const payload = JSON.parse(content.text); + expect(payload.autoRepair).toBe(true); expect(payload.repairedCount).toBe(1); expect(payload.backupCreated).toBe(true); }); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 85492cde97..047d877f79 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -882,7 +882,7 @@ export const dbHealthCheckTool: McpToolDefinition< scopes: ["read:health", "write:resilience"], auditLevel: "full", phase: 2, - sourceEndpoints: ["/api/v1/db/health"], + sourceEndpoints: ["/api/db/health"], }; // --- Tool 19: omniroute_sync_pricing --- diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index e9918f491b..9769244f88 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -893,11 +893,8 @@ export async function handleDbHealthCheck(args: { autoRepair?: boolean }) { const autoRepair = args.autoRepair === true; try { - const result = toRecord( - await apiFetch("/api/v1/db/health", { - method: autoRepair ? "POST" : "GET", - }) - ); + const { runManagedDbHealthCheck } = await import("../../../src/lib/db/core.ts"); + const result = runManagedDbHealthCheck({ autoRepair }); await logToolCall( "omniroute_db_health_check", diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 5dad4cd35f..d4af48798e 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -81,7 +81,7 @@ export default function HealthPage() { const fetchDbHealth = useCallback(async () => { try { - const res = await fetch("/api/v1/db/health"); + const res = await fetch("/api/db/health"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setDbHealth(json); @@ -138,7 +138,7 @@ export default function HealthPage() { const handleRepairDb = async () => { setRepairingDb(true); try { - const res = await fetch("/api/v1/db/health", { method: "POST" }); + const res = await fetch("/api/db/health", { method: "POST" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setDbHealth(json); diff --git a/src/app/api/v1/db/health/route.ts b/src/app/api/db/health/route.ts similarity index 100% rename from src/app/api/v1/db/health/route.ts rename to src/app/api/db/health/route.ts diff --git a/tests/e2e/resilience-plan-alignment.spec.ts b/tests/e2e/resilience-plan-alignment.spec.ts index 374830841b..18a0097c4f 100644 --- a/tests/e2e/resilience-plan-alignment.spec.ts +++ b/tests/e2e/resilience-plan-alignment.spec.ts @@ -167,7 +167,7 @@ async function mockHealthPageApis(page: Page) { }); }); - await page.route("**/api/v1/db/health", async (route) => { + await page.route("**/api/db/health", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index 3811697050..7f6021992e 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -153,6 +153,7 @@ const cases: Case[] = [ expectedClass: "MANAGEMENT", }, { name: "/api/keys MANAGEMENT", path: "/api/keys", expectedClass: "MANAGEMENT" }, + { name: "/api/db/health MANAGEMENT", path: "/api/db/health", expectedClass: "MANAGEMENT" }, { name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" }, { name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" }, diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 2b6e81c8c3..a2ee225445 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -174,6 +174,20 @@ test("runAuthzPipeline allows dashboard sessions to read model catalog aliases", assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API"); }); +test("runAuthzPipeline allows dashboard sessions to reach DB health management API", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline( + request("http://localhost/api/db/health", { + headers: { cookie: await dashboardCookie() }, + }), + { enforce: true } + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { await forceAuthRequired(); const secret = new TextEncoder().encode(process.env.JWT_SECRET); diff --git a/tests/unit/db-health-route.test.ts b/tests/unit/db-health-route.test.ts index fa8c136e20..8b36871b88 100644 --- a/tests/unit/db-health-route.test.ts +++ b/tests/unit/db-health-route.test.ts @@ -3,29 +3,44 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { SignJWT } from "jose"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-health-route-")); process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-api-key-secret"; const core = await import("../../src/lib/db/core.ts"); -const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); -const routeModule = await import("../../src/app/api/v1/db/health/route.ts"); +const routeModule = await import("../../src/app/api/db/health/route.ts"); + +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const TEST_JWT_SECRET = "db-health-route-jwt-secret"; +const TEST_INITIAL_PASSWORD = "db-health-route-password"; async function resetStorage() { core.resetDbInstance(); - apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + process.env.JWT_SECRET = TEST_JWT_SECRET; + process.env.INITIAL_PASSWORD = TEST_INITIAL_PASSWORD; } -function makeRequest(method, token) { - return new Request("http://localhost/api/v1/db/health", { +function makeRequest(method, cookie) { + return new Request("http://localhost/api/db/health", { method, - headers: token ? { Authorization: `Bearer ${token}` } : {}, + headers: cookie ? { cookie } : {}, }); } +async function dashboardCookie() { + const secret = new TextEncoder().encode(TEST_JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + return `auth_token=${token}`; +} + function insertBrokenRows(db) { db.prepare( `INSERT INTO quota_snapshots @@ -43,35 +58,27 @@ test.beforeEach(async () => { test.after(async () => { core.resetDbInstance(); - apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; }); -test("GET /api/v1/db/health requires authentication", async () => { - const previousInitialPassword = process.env.INITIAL_PASSWORD; - process.env.INITIAL_PASSWORD = "route-health-auth"; +test("GET /api/db/health requires authentication", async () => { + const response = await routeModule.GET(makeRequest("GET")); + const body = (await response.json()) as any; - try { - const response = await routeModule.GET(makeRequest("GET")); - const body = (await response.json()) as any; - - assert.equal(response.status, 401); - assert.equal(body.error.message, "Authentication required"); - } finally { - if (previousInitialPassword === undefined) { - delete process.env.INITIAL_PASSWORD; - } else { - process.env.INITIAL_PASSWORD = previousInitialPassword; - } - } + assert.equal(response.status, 401); + assert.equal(body.error.message, "Authentication required"); }); -test("GET /api/v1/db/health diagnoses without mutating database rows", async () => { - const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health"); +test("GET /api/db/health diagnoses without mutating database rows", async () => { + const cookie = await dashboardCookie(); const db = core.getDbInstance(); insertBrokenRows(db); - const response = await routeModule.GET(makeRequest("GET", authKey.key)); + const response = await routeModule.GET(makeRequest("GET", cookie)); const body = (await response.json()) as any; assert.equal(response.status, 200); @@ -81,12 +88,12 @@ test("GET /api/v1/db/health diagnoses without mutating database rows", async () assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 1); }); -test("POST /api/v1/db/health repairs broken rows for authenticated callers", async () => { - const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health"); +test("POST /api/db/health repairs broken rows for authenticated callers", async () => { + const cookie = await dashboardCookie(); const db = core.getDbInstance(); insertBrokenRows(db); - const response = await routeModule.POST(makeRequest("POST", authKey.key)); + const response = await routeModule.POST(makeRequest("POST", cookie)); const body = (await response.json()) as any; assert.equal(response.status, 200); From a07d9b62a877c789f44780aa32de9f14529b2b1c Mon Sep 17 00:00:00 2001 From: Dao Bao Ngoc <42265865+daongoc315@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:53:35 +0700 Subject: [PATCH 12/12] fix(kiro): support organization IDC OAuth with regional endpoints and refresh (#1754) * fix(kiro): support organization IDC OAuth with regional endpoints and refresh * fix(kiro): refresh IDC tokens with stored region --------- Co-authored-by: ngocdb --- open-sse/services/tokenRefresh.ts | 6 +- .../api/oauth/[provider]/[action]/route.ts | 21 ++- src/lib/oauth/providers.ts | 4 +- src/lib/oauth/providers/kiro.ts | 37 ++++-- src/shared/components/KiroAuthModal.tsx | 10 +- src/shared/components/OAuthModal.tsx | 86 ++++++++---- tests/unit/oauth-kiro-idc.test.ts | 123 ++++++++++++++++++ tests/unit/token-refresh-service.test.ts | 41 ++++++ 8 files changed, 280 insertions(+), 48 deletions(-) create mode 100644 tests/unit/oauth-kiro-idc.test.ts diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 5147c036dc..e1324acced 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -513,11 +513,7 @@ export async function refreshKiroToken( // AWS SSO OIDC (Builder ID or IDC) // If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified) if (clientId && clientSecret) { - const isIDC = authMethod === "idc"; - const endpoint = - isIDC && region - ? `https://oidc.${region}.amazonaws.com/token` - : "https://oidc.us-east-1.amazonaws.com/token"; + const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; const response = await runWithProxyContext(proxyConfig, () => fetch(endpoint, { diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index cf5dd090eb..0b31439e5c 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -81,6 +81,8 @@ export async function GET( } const authData = generateAuthData(provider, null); + const startUrl = searchParams.get("startUrl"); + const region = searchParams.get("region") || "us-east-1"; // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(provider); @@ -95,7 +97,24 @@ export async function GET( provider === "kilocode" ) { // GitHub, Kiro/Amazon Q, Kimi Coding, and KiloCode don't use PKCE for device code - deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider)); + if ((provider === "kiro" || provider === "amazon-q") && startUrl) { + const providerOverrideConfig = { + ...providerData.config, + startUrl, + region, + skipIssuerUrlForRegistration: true, + registerClientUrl: `https://oidc.${region}.amazonaws.com/client/register`, + deviceAuthUrl: `https://oidc.${region}.amazonaws.com/device_authorization`, + tokenUrl: `https://oidc.${region}.amazonaws.com/token`, + ssoOidcEndpoint: `https://oidc.${region}.amazonaws.com`, + }; + + deviceData = await runWithProxyContext(proxy, () => + (requestDeviceCode as any)(provider, null, providerOverrideConfig) + ); + } else { + deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider)); + } } else { // Qwen and other providers use PKCE deviceData = await runWithProxyContext(proxy, () => diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index e00cd994ec..5709042630 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -81,12 +81,12 @@ export async function exchangeTokens(providerName, code, redirectUri, codeVerifi /** * Request device code (for device_code flow) */ -export async function requestDeviceCode(providerName, codeChallenge) { +export async function requestDeviceCode(providerName, codeChallenge, configOverride = null) { const provider = getProvider(providerName); if (provider.flowType !== "device_code") { throw new Error(`Provider ${providerName} does not support device code flow`); } - return await provider.requestDeviceCode(provider.config, codeChallenge); + return await provider.requestDeviceCode(configOverride || provider.config, codeChallenge); } /** diff --git a/src/lib/oauth/providers/kiro.ts b/src/lib/oauth/providers/kiro.ts index 945c75cd74..80c03a7d81 100644 --- a/src/lib/oauth/providers/kiro.ts +++ b/src/lib/oauth/providers/kiro.ts @@ -4,6 +4,27 @@ export const kiro = { config: KIRO_CONFIG, flowType: "device_code", requestDeviceCode: async (config) => { + const regionMatch = String(config.tokenUrl || "").match(/oidc\.([a-z0-9-]+)\.amazonaws\.com/i); + const resolvedRegion = regionMatch?.[1] || "us-east-1"; + const registerPayload: { + clientName: string; + clientType: string; + scopes: string[]; + grantTypes: string[]; + issuerUrl?: string; + } = { + clientName: config.clientName, + clientType: config.clientType, + scopes: config.scopes, + grantTypes: config.grantTypes, + }; + + // For enterprise IDC custom startUrl flows, issuerUrl can differ per tenant. + // Sending a fixed issuerUrl often causes invalid_request during device auth. + if (config.issuerUrl && !config.skipIssuerUrlForRegistration) { + registerPayload.issuerUrl = config.issuerUrl; + } + // Step 1: Register client with AWS SSO OIDC const registerRes = await fetch(config.registerClientUrl, { method: "POST", @@ -11,13 +32,7 @@ export const kiro = { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify({ - clientName: config.clientName, - clientType: config.clientType, - scopes: config.scopes, - grantTypes: config.grantTypes, - issuerUrl: config.issuerUrl, - }), + body: JSON.stringify(registerPayload), }); if (!registerRes.ok) { @@ -57,10 +72,14 @@ export const kiro = { interval: deviceData.interval || 5, _clientId: clientInfo.clientId, _clientSecret: clientInfo.clientSecret, + _region: resolvedRegion, }; }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const response = await fetch(config.tokenUrl, { + const tokenRegion = extraData?._region || "us-east-1"; + const tokenUrl = `https://oidc.${tokenRegion}.amazonaws.com/token`; + + const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -91,6 +110,7 @@ export const kiro = { expires_in: data.expiresIn, _clientId: extraData?._clientId, _clientSecret: extraData?._clientSecret, + _region: tokenRegion, }, }; } @@ -110,6 +130,7 @@ export const kiro = { providerSpecificData: { clientId: tokens._clientId, clientSecret: tokens._clientSecret, + region: tokens._region, }, }), }; diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index 5a49d0d5bf..588771f40f 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -138,17 +138,19 @@ export default function KiroAuthModal({
- {/* AWS IAM Identity Center (IDC) - HIDDEN */} + {/* AWS IAM Identity Center (IDC) */}