From 8b38a21779fd5ec4c63b590a5dc48aaa98f6ee34 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:21:47 -0300 Subject: [PATCH] fix: honor combo-level proxy assignments from the registry (#7149) (#7201) --- .../fixes/7149-combo-scope-proxy-dead.md | 1 + src/app/(dashboard)/dashboard/combos/page.tsx | 6 +- .../combos/useComboProxyAssignments.ts | 33 +++++++ src/lib/db/settings.ts | 57 +++++++----- ...combo-proxy-assignments-parse-7149.test.ts | 32 +++++++ .../unit/combo-scope-proxy-dead-7149.test.ts | 87 +++++++++++++++++++ 6 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 changelog.d/fixes/7149-combo-scope-proxy-dead.md create mode 100644 src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts create mode 100644 tests/unit/combo-proxy-assignments-parse-7149.test.ts create mode 100644 tests/unit/combo-scope-proxy-dead-7149.test.ts diff --git a/changelog.d/fixes/7149-combo-scope-proxy-dead.md b/changelog.d/fixes/7149-combo-scope-proxy-dead.md new file mode 100644 index 0000000000..fcbbccb065 --- /dev/null +++ b/changelog.d/fixes/7149-combo-scope-proxy-dead.md @@ -0,0 +1 @@ +- fix(db): honor combo-level proxy assignments from the registry when resolving a connection's proxy (#7149) diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index a91ac0c7f3..6857fbd79a 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -14,6 +14,7 @@ import Toggle from "@/shared/components/Toggle"; import Tooltip from "@/shared/components/Tooltip"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { FieldLabelWithHelp, WeightTotalBar } from "./parts"; +import { useComboProxyAssignments } from "./useComboProxyAssignments"; import { ResponseValidationEditor, type ResponseValidationValue } from "./ResponseValidationEditor"; import ReasoningTokenBufferToggle from "./ReasoningTokenBufferToggle"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; @@ -681,6 +682,7 @@ export default function CombosPage() { const notify = useNotificationStore(); const [proxyTargetCombo, setProxyTargetCombo] = useState(null); const [proxyConfig, setProxyConfig] = useState(null); + const { comboProxyAssignedIds, fetchComboProxyAssignments } = useComboProxyAssignments(); const [providerNodes, setProviderNodes] = useState([]); const [showUsageGuide, setShowUsageGuide] = useState(true); const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); @@ -1210,7 +1212,7 @@ export default function CombosPage() { onTest={() => handleTestCombo(combo)} testing={testingCombo === combo.name} onProxy={() => setProxyTargetCombo(combo)} - hasProxy={!!proxyConfig?.combos?.[combo.id]} + hasProxy={comboProxyAssignedIds.has(combo.id) || !!proxyConfig?.combos?.[combo.id]} onToggle={() => handleToggleCombo(combo)} dragDisabled={savingComboOrder || activeFilter !== "all" || combos.length < 2} isDragged={comboDragIndex === index} @@ -1260,7 +1262,7 @@ export default function CombosPage() { {proxyTargetCombo && ( setProxyTargetCombo(null)} + onClose={() => (setProxyTargetCombo(null), fetchComboProxyAssignments())} level="combo" levelId={proxyTargetCombo.id} levelLabel={proxyTargetCombo.name} diff --git a/src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts b/src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts new file mode 100644 index 0000000000..93a9a0a5a0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from "react"; + +// #7149: the Combo "Set Proxy" modal writes through the modern proxy_assignments +// registry (scope="combo"), not the legacy /api/settings/proxy `combos` map — the +// dashboard's "has a proxy" indicator must read from the same registry the modal +// actually writes to, or it stays stale/gray even after a successful save. +export function parseComboProxyAssignmentIds(data: unknown): string[] { + const items = (data as { items?: unknown })?.items; + if (!Array.isArray(items)) return []; + return items + .filter( + (entry): entry is { scopeId: string; proxyId: string } => + !!(entry as { scopeId?: unknown })?.scopeId && !!(entry as { proxyId?: unknown })?.proxyId + ) + .map((entry) => entry.scopeId); +} + +export function useComboProxyAssignments() { + const [comboProxyAssignedIds, setComboProxyAssignedIds] = useState>(new Set()); + + const fetchComboProxyAssignments = useCallback(() => { + fetch("/api/settings/proxies/assignments?scope=combo") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => setComboProxyAssignedIds(new Set(parseComboProxyAssignmentIds(data)))) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchComboProxyAssignments(); + }, [fetchComboProxyAssignments]); + + return { comboProxyAssignedIds, fetchComboProxyAssignments }; +} diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 390551d8af..710684d0a4 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -541,33 +541,46 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: } } - // Step 7: Legacy combo-level (only if proxy_enabled) - if (connectionProxyEnabled && config.combos && Object.keys(config.combos).length > 0) { + // Step 7: Combo-level (only if proxy_enabled). For every combo whose model + // list references this connection's provider, check the modern registry + // (proxy_assignments, scope='combo') first — this is the assignment the + // dashboard's Combo "Set Proxy" modal actually writes to (#7149, where the + // registry write path and this read path had diverged, leaving combo-level + // proxy assignment completely inert). Fall back to the legacy in-memory + // combos map for any pre-existing legacy data. + if (connectionProvider && connectionProxyEnabled) { const combos = db.prepare("SELECT id, data FROM combos").all(); for (const comboRow of combos) { const comboRecord = toRecord(comboRow); const comboId = typeof comboRecord.id === "string" ? comboRecord.id : null; - if (comboId && config.combos[comboId]) { - try { - const comboRaw = typeof comboRecord.data === "string" ? comboRecord.data : null; - if (!comboRaw) continue; - const combo = toRecord(JSON.parse(comboRaw)); - const comboModels = Array.isArray(combo.models) ? combo.models : []; - const usesProvider = comboModels.some( - (entry) => getComboModelProvider(entry) === connectionProvider - ); - if (usesProvider) { - const result = { - proxy: withFamilyDefault(config.combos[comboId]), - level: "combo", - levelId: comboId, - }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); - return result; - } - } catch { - // Ignore malformed combo records during proxy resolution. + if (!comboId) continue; + try { + const comboRaw = typeof comboRecord.data === "string" ? comboRecord.data : null; + if (!comboRaw) continue; + const combo = toRecord(JSON.parse(comboRaw)); + const comboModels = Array.isArray(combo.models) ? combo.models : []; + const usesProvider = comboModels.some( + (entry) => getComboModelProvider(entry) === connectionProvider + ); + if (!usesProvider) continue; + + const registryCombo = await resolveProxyForScopeFromRegistry("combo", comboId); + if (registryCombo?.proxy) { + cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryCombo); + return registryCombo; } + + if (config.combos?.[comboId]) { + const result = { + proxy: withFamilyDefault(config.combos[comboId]), + level: "combo", + levelId: comboId, + }; + cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + return result; + } + } catch { + // Ignore malformed combo records during proxy resolution. } } } diff --git a/tests/unit/combo-proxy-assignments-parse-7149.test.ts b/tests/unit/combo-proxy-assignments-parse-7149.test.ts new file mode 100644 index 0000000000..7afb82b487 --- /dev/null +++ b/tests/unit/combo-proxy-assignments-parse-7149.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseComboProxyAssignmentIds } from "../../src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts"; + +test("#7149: parseComboProxyAssignmentIds extracts scopeIds from valid combo assignments", () => { + const data = { + items: [ + { scopeId: "combo-1", proxyId: "proxy-1", scope: "combo" }, + { scopeId: "combo-2", proxyId: "proxy-2", scope: "combo" }, + ], + }; + assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1", "combo-2"]); +}); + +test("#7149: parseComboProxyAssignmentIds drops entries missing scopeId or proxyId", () => { + const data = { + items: [ + { scopeId: "combo-1", proxyId: "proxy-1" }, + { scopeId: "combo-2", proxyId: null }, + { scopeId: null, proxyId: "proxy-3" }, + {}, + ], + }; + assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1"]); +}); + +test("#7149: parseComboProxyAssignmentIds returns [] for missing/malformed items", () => { + assert.deepEqual(parseComboProxyAssignmentIds(null), []); + assert.deepEqual(parseComboProxyAssignmentIds(undefined), []); + assert.deepEqual(parseComboProxyAssignmentIds({}), []); + assert.deepEqual(parseComboProxyAssignmentIds({ items: "not-an-array" }), []); +}); diff --git a/tests/unit/combo-scope-proxy-dead-7149.test.ts b/tests/unit/combo-scope-proxy-dead-7149.test.ts new file mode 100644 index 0000000000..8d301763e9 --- /dev/null +++ b/tests/unit/combo-scope-proxy-dead-7149.test.ts @@ -0,0 +1,87 @@ +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-combo-proxy-7149-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +type ProxyResolutionLike = { + proxy?: { host?: string } | null; + level?: string; + levelId?: string | null; +} | null; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#7149: a proxy assigned to a Combo via the dashboard (registry scope='combo') is honored when resolving the proxy for a request routed through that combo", async () => { + await resetStorage(); + + const comboProxy = await proxiesDb.createProxy({ + name: "Combo-Assigned Proxy", + type: "http", + host: "10.20.30.40", + port: 8888, + }); + assert.ok(comboProxy?.id); + + const combo = await combosDb.createCombo({ + name: "diy_deepseek-v4-flash", + strategy: "round-robin", + models: ["openai/gpt-4"], + }); + const comboRecord = combo as Record; + assert.ok(comboRecord?.id); + const comboId = comboRecord.id as string; + + const assignment = await proxiesDb.assignProxyToScope("combo", comboId, comboProxy!.id); + assert.ok(assignment, "assignProxyToScope('combo', ...) should persist the assignment"); + + const directRegistryLookup = (await proxiesDb.resolveProxyForScopeFromRegistry( + "combo", + comboId + )) as ProxyResolutionLike; + assert.ok( + directRegistryLookup?.proxy, + "the registry must be able to answer a direct combo-scope lookup" + ); + assert.equal(directRegistryLookup?.proxy?.host, "10.20.30.40"); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-test-1234", + name: "openai-account-1", + }); + const connectionRecord = connection as Record | null; + const connectionId = connectionRecord?.id as string; + assert.ok(connectionId, "test setup requires a real connection id"); + + const resolved = (await settingsDb.resolveProxyForConnection( + connectionId + )) as ProxyResolutionLike; + + assert.equal( + resolved?.level, + "combo", + `expected the combo-assigned proxy to be resolved (level="combo"), got level="${resolved?.level}" — the registry-based combo proxy assignment is never consulted by resolveProxyForConnection()` + ); + assert.equal(resolved?.proxy?.host, "10.20.30.40"); +});