From 242785259300fd3b6ff82d6f4eb17189a1656247 Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sun, 12 Jul 2026 16:54:40 +0700 Subject: [PATCH] fix: update the permission access for combo page --- open-sse/services/combo.js | 19 +++--- src/app/(dashboard)/dashboard/combos/page.js | 36 +++++------ .../media-providers/combo/[id]/page.js | 13 ++-- src/app/api/combos/[id]/route.js | 41 +++++++++--- src/app/api/combos/[id]/strategy/route.js | 50 +++++++++++++++ src/app/api/combos/route.js | 18 +++++- src/app/api/settings/route.js | 14 ++++- src/app/api/v1/models/[kind]/route.js | 6 +- src/app/api/v1/models/route.js | 23 +++++-- src/dashboardGuard.js | 8 +-- src/lib/db/index.js | 10 +-- src/lib/db/migrations/006-combo-owners.js | 63 +++++++++++++++++++ src/lib/db/migrations/index.js | 3 +- src/lib/db/repos/apiKeysRepo.js | 6 ++ src/lib/db/repos/combosRepo.js | 47 +++++++++----- src/lib/db/repos/connectionsRepo.js | 7 ++- src/lib/db/repos/settingsRepo.js | 22 +++++++ src/lib/db/schema.js | 11 +++- src/lib/localDb.js | 4 +- src/models/index.js | 1 + src/shared/components/Sidebar.js | 2 +- src/sse/handlers/chat.js | 46 ++++++++------ src/sse/handlers/embeddings.js | 6 +- src/sse/handlers/fetch.js | 12 ++-- src/sse/handlers/imageGeneration.js | 22 ++++--- src/sse/handlers/search.js | 12 ++-- src/sse/handlers/stt.js | 9 +-- src/sse/handlers/tts.js | 24 ++++--- src/sse/services/auth.js | 20 +++++- src/sse/services/model.js | 17 +++-- tests/unit/combo-ownership.test.js | 47 ++++++++++++++ tests/unit/combo-routing.test.js | 26 +++++--- tests/unit/db-migration-chain.test.js | 2 + tests/unit/db-sqlite-vs-lowdb.test.js | 3 + 34 files changed, 494 insertions(+), 156 deletions(-) create mode 100644 src/app/api/combos/[id]/strategy/route.js create mode 100644 src/lib/db/migrations/006-combo-owners.js create mode 100644 tests/unit/combo-ownership.test.js diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2f..7e67ab30 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -149,17 +149,17 @@ function rotateModelsFromIndex(models, currentIndex) { /** * Get rotated model list based on strategy * @param {string[]} models - Array of model strings - * @param {string} comboName - Name of the combo + * @param {string} comboKey - Stable combo identifier used for rotation state * @param {string} strategy - "fallback" or "round-robin" * @param {number|string} [stickyLimit=1] - Requests per combo model before switching * @returns {string[]} Rotated models array */ -export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { +export function getRotatedModels(models, comboKey, strategy, stickyLimit = 1) { if (!models || models.length <= 1 || strategy !== "round-robin") { return models; } - const rotationKey = comboName || "__default__"; + const rotationKey = comboKey || "__default__"; const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); const existingState = comboRotationState.get(rotationKey); const state = typeof existingState === "number" @@ -187,10 +187,10 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { /** * Reset in-memory rotation state when combo/settings change - * @param {string} [comboName] - Combo name to reset; omit to clear all + * @param {string} [comboKey] - Stable combo identifier to reset; omit to clear all */ -export function resetComboRotation(comboName) { - if (comboName) comboRotationState.delete(comboName); +export function resetComboRotation(comboKey) { + if (comboKey) comboRotationState.delete(comboKey); else comboRotationState.clear(); } @@ -221,14 +221,15 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {string[]} options.models - Array of model strings to try * @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise * @param {Object} options.log - Logger object - * @param {string} [options.comboName] - Name of the combo (for round-robin tracking) + * @param {string} [options.comboName] - Name of the combo (for logs) + * @param {string} [options.comboId] - Stable combo ID (for round-robin tracking) * @param {string} [options.comboStrategy] - Strategy: "fallback" or "round-robin" * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { +export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboId, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { // Apply rotation strategy if enabled - let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); + let rotatedModels = getRotatedModels(models, comboId || comboName, comboStrategy, comboStickyLimit); // Auto-switch: float models that satisfy the request's required capabilities to the front. if (autoSwitch) { diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index abfc215c..b1bc45df 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -27,7 +27,7 @@ export default function CombosPage() { fetchData(); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const fetchData = async () => { + async function fetchData() { try { const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([ fetch("/api/combos"), @@ -57,7 +57,7 @@ export default function CombosPage() { } finally { setLoading(false); } - }; + } const handleCreate = async (data) => { try { @@ -115,26 +115,26 @@ export default function CombosPage() { }); }; - // Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty - // patch (strategy back to default "fallback") drops the entry entirely. - const handleSetComboStrategy = async (comboName, patch) => { + // Update only this combo's strategy through an owner-authorized endpoint. + const handleSetComboStrategy = async (comboId, patch) => { try { - const updated = { ...comboStrategies }; - const next = { ...(updated[comboName] || {}), ...patch }; + const next = { ...(comboStrategies[comboId] || {}), ...patch }; // Prune to keep settings clean: default fallback with no extras = no entry. - if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") { - delete updated[comboName]; - } else { - updated[comboName] = next; - } + const strategy = !next.fallbackStrategy || next.fallbackStrategy === "fallback" ? {} : next; - await fetch("/api/settings", { + const res = await fetch(`/api/combos/${comboId}/strategy`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStrategies: updated }), + body: JSON.stringify({ strategy }), }); - setComboStrategies(updated); + if (!res.ok) throw new Error("Failed to update combo strategy"); + setComboStrategies((current) => { + const updated = { ...current }; + if (Object.keys(strategy).length === 0) delete updated[comboId]; + else updated[comboId] = strategy; + return updated; + }); } catch (error) { console.log("Error updating combo strategy:", error); } @@ -195,8 +195,8 @@ export default function CombosPage() { onCopy={copy} onEdit={() => setEditingCombo(combo)} onDelete={() => handleDelete(combo.id)} - strategy={comboStrategies[combo.name] || {}} - onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} + strategy={comboStrategies[combo.id] || {}} + onSetStrategy={(patch) => handleSetComboStrategy(combo.id, patch)} /> ))} diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index 45c15460..80aff003 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -84,7 +84,7 @@ export default function ComboDetailPage() { setName(c.name); setProviders(c.models || []); const s = settingsRes.ok ? await settingsRes.json() : {}; - setRoundRobin(s.comboStrategies?.[c.name]?.fallbackStrategy === "round-robin"); + setRoundRobin(s.comboStrategies?.[c.id]?.fallbackStrategy === "round-robin"); const allLogs = logsRes.ok ? await logsRes.json() : []; setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50)); } catch { /* noop */ } @@ -150,17 +150,12 @@ export default function ComboDetailPage() { }; const handleToggleRoundRobin = async (enabled) => { - setRoundRobin(enabled); - const settingsRes = await fetch("/api/settings", { cache: "no-store" }); - const s = settingsRes.ok ? await settingsRes.json() : {}; - const updated = { ...(s.comboStrategies || {}) }; - if (enabled) updated[combo.name] = { fallbackStrategy: "round-robin" }; - else delete updated[combo.name]; - await fetch("/api/settings", { + const res = await fetch(`/api/combos/${id}/strategy`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStrategies: updated }), + body: JSON.stringify({ strategy: enabled ? { fallbackStrategy: "round-robin" } : {} }), }); + if (res.ok) setRoundRobin(enabled); }; const handleDelete = async () => { diff --git a/src/app/api/combos/[id]/route.js b/src/app/api/combos/[id]/route.js index d641da7f..8e379ce9 100644 --- a/src/app/api/combos/[id]/route.js +++ b/src/app/api/combos/[id]/route.js @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb"; import { resetComboRotation } from "open-sse/services/combo.js"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; @@ -8,8 +9,9 @@ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; // GET /api/combos/[id] - Get combo by ID export async function GET(request, { params }) { try { + const user = await requireUsageDashboardUser(); const { id } = await params; - const combo = await getComboById(id); + const combo = await getComboById(id, user.role === "admin" ? undefined : user.id); if (!combo) { return NextResponse.json({ error: "Combo not found" }, { status: 404 }); @@ -17,6 +19,9 @@ export async function GET(request, { params }) { return NextResponse.json(combo); } catch (error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } console.log("Error fetching combo:", error); return NextResponse.json({ error: "Failed to fetch combo" }, { status: 500 }); } @@ -25,8 +30,16 @@ export async function GET(request, { params }) { // PUT /api/combos/[id] - Update combo export async function PUT(request, { params }) { try { + const user = await requireUsageDashboardUser(); const { id } = await params; const body = await request.json(); + const ownerId = user.role === "admin" ? undefined : user.id; + // Read before name validation so administrators validate uniqueness within + // the target combo's owner scope rather than across every user's combos. + const prev = await getComboById(id, ownerId); + if (!prev) { + return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + } // Validate name format if provided if (body.name) { @@ -35,26 +48,31 @@ export async function PUT(request, { params }) { } // Check if name already exists (exclude current combo) - const existing = await getComboByName(body.name); + const existing = await getComboByName(body.name, prev.ownerId); if (existing && existing.id !== id) { return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); } } // Capture previous name to invalidate rotation state on rename - const prev = await getComboById(id); - const combo = await updateCombo(id, body); + const combo = await updateCombo(id, body, ownerId); if (!combo) { return NextResponse.json({ error: "Combo not found" }, { status: 404 }); } // Invalidate rotation state (models/strategy/name may have changed) - if (prev?.name) resetComboRotation(prev.name); - if (combo.name && combo.name !== prev?.name) resetComboRotation(combo.name); + if (prev?.id) resetComboRotation(prev.id); + if (combo.id && combo.id !== prev?.id) resetComboRotation(combo.id); return NextResponse.json(combo); } catch (error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message.includes("UNIQUE constraint failed")) { + return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); + } console.log("Error updating combo:", error); return NextResponse.json({ error: "Failed to update combo" }, { status: 500 }); } @@ -63,18 +81,23 @@ export async function PUT(request, { params }) { // DELETE /api/combos/[id] - Delete combo export async function DELETE(request, { params }) { try { + const user = await requireUsageDashboardUser(); const { id } = await params; - const prev = await getComboById(id); - const success = await deleteCombo(id); + const ownerId = user.role === "admin" ? undefined : user.id; + const prev = await getComboById(id, ownerId); + const success = await deleteCombo(id, ownerId); if (!success) { return NextResponse.json({ error: "Combo not found" }, { status: 404 }); } - if (prev?.name) resetComboRotation(prev.name); + if (prev?.id) resetComboRotation(prev.id); return NextResponse.json({ success: true }); } catch (error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } console.log("Error deleting combo:", error); return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 }); } diff --git a/src/app/api/combos/[id]/strategy/route.js b/src/app/api/combos/[id]/strategy/route.js new file mode 100644 index 00000000..3af80d2a --- /dev/null +++ b/src/app/api/combos/[id]/strategy/route.js @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { getComboById, updateComboStrategy } from "@/lib/localDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; +import { resetComboRotation } from "open-sse/services/combo.js"; + +export const dynamic = "force-dynamic"; + +const STRATEGIES = new Set(["fallback", "round-robin", "fusion"]); + +function normalizeStrategy(strategy) { + const normalized = {}; + if (strategy.fallbackStrategy !== undefined) { + if (!STRATEGIES.has(strategy.fallbackStrategy)) return null; + normalized.fallbackStrategy = strategy.fallbackStrategy; + } + if (strategy.judgeModel !== undefined) { + if (typeof strategy.judgeModel !== "string" || strategy.judgeModel.length > 256) return null; + normalized.judgeModel = strategy.judgeModel.trim(); + } + return normalized; +} + +export async function PATCH(request, { params }) { + try { + const user = await requireUsageDashboardUser(); + const { id } = await params; + const ownerId = user.role === "admin" ? undefined : user.id; + const combo = await getComboById(id, ownerId); + if (!combo) return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + + const { strategy } = await request.json(); + if (!strategy || typeof strategy !== "object" || Array.isArray(strategy)) { + return NextResponse.json({ error: "Strategy must be an object" }, { status: 400 }); + } + const normalizedStrategy = normalizeStrategy(strategy); + if (!normalizedStrategy) { + return NextResponse.json({ error: "Invalid combo strategy" }, { status: 400 }); + } + + const settings = await updateComboStrategy(combo.id, normalizedStrategy); + resetComboRotation(combo.id); + return NextResponse.json({ strategy: settings.comboStrategies[combo.id] || {} }); + } catch (error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.log("Error updating combo strategy:", error); + return NextResponse.json({ error: "Failed to update combo strategy" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/combos/route.js b/src/app/api/combos/route.js index db9f02de..f7a7d8f7 100644 --- a/src/app/api/combos/route.js +++ b/src/app/api/combos/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getCombos, createCombo, getComboByName } from "@/lib/localDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; export const dynamic = "force-dynamic"; @@ -9,9 +10,13 @@ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; // GET /api/combos - Get all combos export async function GET() { try { - const combos = await getCombos(); + const user = await requireUsageDashboardUser(); + const combos = await getCombos(user.role === "admin" ? undefined : user.id); return NextResponse.json({ combos }); } catch (error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } console.log("Error fetching combos:", error); return NextResponse.json({ error: "Failed to fetch combos" }, { status: 500 }); } @@ -20,6 +25,7 @@ export async function GET() { // POST /api/combos - Create new combo export async function POST(request) { try { + const user = await requireUsageDashboardUser(); const body = await request.json(); const { name, models, kind } = body; @@ -33,15 +39,21 @@ export async function POST(request) { } // Check if name already exists - const existing = await getComboByName(name); + const existing = await getComboByName(name, user.id || null); if (existing) { return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); } - const combo = await createCombo({ name, models: models || [], kind: kind || null }); + const combo = await createCombo({ name, ownerId: user.id || null, models: models || [], kind: kind || null }); return NextResponse.json(combo, { status: 201 }); } catch (error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message.includes("UNIQUE constraint failed")) { + return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); + } console.log("Error creating combo:", error); return NextResponse.json({ error: "Failed to create combo" }, { status: 500 }); } diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 5ba3944d..0429109d 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; -import { getSettings, updateSettings } from "@/lib/localDb"; +import { getSettings, getCombos, updateSettings } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; import { resetComboRotation } from "open-sse/services/combo.js"; import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing"; -import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; +import { requireCurrentDashboardUser, requireUsageDashboardUser } from "@/lib/auth/currentUser"; import { updateUser, verifyUserPassword } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -20,6 +20,13 @@ export async function GET() { try { const settings = await getSettings(); const { password, oidcClientSecret, ...safeSettings } = settings; + const user = await requireUsageDashboardUser(); + if (user.role !== "admin") { + const ownedComboIds = new Set((await getCombos(user.id)).map((combo) => combo.id)); + safeSettings.comboStrategies = Object.fromEntries( + Object.entries(safeSettings.comboStrategies || {}).filter(([comboId]) => ownedComboIds.has(comboId)) + ); + } safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true"; @@ -43,7 +50,8 @@ export async function PATCH(request) { if ( Object.prototype.hasOwnProperty.call(body, "requireApiKey") || - Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess") + Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess") || + Object.prototype.hasOwnProperty.call(body, "comboStrategies") ) { let user; try { diff --git a/src/app/api/v1/models/[kind]/route.js b/src/app/api/v1/models/[kind]/route.js index b0f715ba..8ee764c2 100644 --- a/src/app/api/v1/models/[kind]/route.js +++ b/src/app/api/v1/models/[kind]/route.js @@ -1,4 +1,4 @@ -import { buildModelsList } from "../route.js"; +import { buildModelsList, getApiKeyOwnerId } from "../route.js"; // URL slug → service kind(s). `web` covers both webSearch and webFetch. const KIND_SLUG_MAP = { @@ -24,7 +24,7 @@ export async function OPTIONS() { * GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability. * Supported kinds: image, tts, stt, embedding, image-to-text, web. */ -export async function GET(_request, { params }) { +export async function GET(request, { params }) { try { const { kind } = await params; const kindFilter = KIND_SLUG_MAP[kind]; @@ -41,7 +41,7 @@ export async function GET(_request, { params }) { ); } - const data = await buildModelsList(kindFilter); + const data = await buildModelsList(kindFilter, await getApiKeyOwnerId(request)); return Response.json({ object: "list", data }, { headers: { "Access-Control-Allow-Origin": "*" }, }); diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 32647304..96fc018b 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -5,7 +5,7 @@ import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; +import { getApiKeyByKey, getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; @@ -189,7 +189,7 @@ function comboMatchesKinds(combo, kindFilter) { * Build OpenAI-format models list filtered by service kinds. * @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]). */ -export async function buildModelsList(kindFilter) { +export async function buildModelsList(kindFilter, ownerId = undefined) { let connections = []; try { connections = await getProviderConnections(); @@ -200,7 +200,7 @@ export async function buildModelsList(kindFilter) { let combos = []; try { - combos = await getCombos(); + combos = await getCombos(ownerId); } catch (e) { console.log("Could not fetch combos"); } @@ -471,13 +471,26 @@ export async function OPTIONS() { }); } +function extractApiKey(request) { + const authorization = request.headers.get("Authorization"); + if (authorization?.startsWith("Bearer ")) return authorization.slice(7); + return request.headers.get("x-api-key") || request.headers.get("x-goog-api-key") || request.nextUrl.searchParams.get("key") || null; +} + +export async function getApiKeyOwnerId(request) { + const apiKey = extractApiKey(request); + if (!apiKey) return undefined; + const record = await getApiKeyByKey(apiKey); + return record?.isActive ? (record.ownerId ?? null) : undefined; +} + /** * GET /v1/models - OpenAI compatible models list (LLM/chat models only by default). * For other capabilities use /v1/models/{kind} (image, tts, stt, embedding, image-to-text, web). */ -export async function GET() { +export async function GET(request) { try { - const data = await buildModelsList([LLM_KIND]); + const data = await buildModelsList([LLM_KIND], await getApiKeyOwnerId(request)); return Response.json({ object: "list", data }, { headers: { "Access-Control-Allow-Origin": "*" }, }); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 8c4d9373..ef2fbaa2 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -46,11 +46,11 @@ const ALWAYS_PROTECTED = [ // User administration is never exposed to normal users, even if dashboard login // is disabled for local single-user deployments. -const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/combos"]; +const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel"]; -// Combo definitions affect routing and fallback behavior, so only administrators -// may view or change them. The Models page is available read-only to all users. -const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/combos"]; +// Dashboard paths requiring an administrator. Combo access is handled by its +// owner-scoped API routes and is available to authenticated users. +const ADMIN_ONLY_DASHBOARD_PATHS = []; // Require auth, but allow through if requireLogin is disabled const PROTECTED_API_PATHS = [ diff --git a/src/lib/db/index.js b/src/lib/db/index.js index fc820715..bea9d172 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -4,7 +4,7 @@ import { stringifyJson, parseJson } from "./helpers/jsonCol.js"; // Settings export { - getSettings, updateSettings, isCloudEnabled, getCloudUrl, exportSettings, + getSettings, updateSettings, updateComboStrategy, isCloudEnabled, getCloudUrl, exportSettings, } from "./repos/settingsRepo.js"; // Users @@ -36,7 +36,7 @@ export { // API keys export { getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId, - createApiKey, updateApiKey, deleteApiKey, validateApiKey, + getApiKeyByKey, createApiKey, updateApiKey, deleteApiKey, validateApiKey, } from "./repos/apiKeysRepo.js"; // Combos @@ -86,7 +86,7 @@ export async function exportDb() { providerNodes: db.all(`SELECT * FROM providerNodes`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, type: r.type, name: r.name, createdAt: r.createdAt, updatedAt: r.updatedAt })), proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })), apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, ownerId: r.ownerId, isActive: r.isActive === 1, createdAt: r.createdAt })), - combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })), + combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, ownerId: r.ownerId, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })), modelAliases: {}, customModels: [], mitmAlias: {}, @@ -177,8 +177,8 @@ export async function importDb(payload) { } for (const c of payload.combos || []) { db.run( - `INSERT OR REPLACE INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, - [c.id, c.name, c.kind || null, stringifyJson(c.models || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()] + `INSERT OR REPLACE INTO combos(id, name, ownerId, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [c.id, c.name, c.ownerId || fallbackOwnerId, c.kind || null, stringifyJson(c.models || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()] ); } for (const [a, m] of Object.entries(payload.modelAliases || {})) { diff --git a/src/lib/db/migrations/006-combo-owners.js b/src/lib/db/migrations/006-combo-owners.js new file mode 100644 index 00000000..b3db21a4 --- /dev/null +++ b/src/lib/db/migrations/006-combo-owners.js @@ -0,0 +1,63 @@ +import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; + +// Combos are private to dashboard users. Rebuild the table because the legacy +// `name TEXT UNIQUE` constraint is column-level and cannot be dropped in place. +const comboOwnersMigration = { + version: 6, + name: "combo-owners", + up(db) { + const columns = db.all(`PRAGMA table_info(combos)`); + const hasOwnerId = columns.some((column) => column.name === "ownerId"); + + // A database already rebuilt by a prior interrupted/manual migration only + // needs the indexes and owner backfill below. + if (!hasOwnerId) { + db.exec(` + CREATE TABLE combos_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ownerId TEXT, + kind TEXT, + models TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + db.exec(` + INSERT INTO combos_new(id, name, kind, models, createdAt, updatedAt) + SELECT id, name, kind, models, createdAt, updatedAt FROM combos + `); + db.exec(`DROP TABLE combos`); + db.exec(`ALTER TABLE combos_new RENAME TO combos`); + } + + const admin = db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`); + if (admin) { + db.run(`UPDATE combos SET ownerId = ? WHERE ownerId IS NULL OR ownerId = ''`, [admin.id]); + } + + // Legacy strategy settings were keyed by globally unique combo names. + // Move them to stable IDs before allowing different users to reuse names. + const settings = db.get(`SELECT data FROM settings WHERE id = 1`); + const settingsData = parseJson(settings?.data, {}); + const legacyStrategies = settingsData.comboStrategies; + if (legacyStrategies && typeof legacyStrategies === "object" && !Array.isArray(legacyStrategies)) { + const combos = db.all(`SELECT id, name FROM combos`); + const migratedStrategies = { ...legacyStrategies }; + for (const combo of combos) { + if (legacyStrategies[combo.name] !== undefined && migratedStrategies[combo.id] === undefined) { + migratedStrategies[combo.id] = legacyStrategies[combo.name]; + delete migratedStrategies[combo.name]; + } + } + db.run(`UPDATE settings SET data = ? WHERE id = 1`, [stringifyJson({ ...settingsData, comboStrategies: migratedStrategies })]); + } + + db.exec(`DROP INDEX IF EXISTS idx_combo_owner_name`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_combo_owner_name ON combos(ownerId, name) WHERE ownerId IS NOT NULL`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_combo_global_name ON combos(name) WHERE ownerId IS NULL`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_combo_owner ON combos(ownerId)`); + }, +}; + +export default comboOwnersMigration; \ No newline at end of file diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index d9bbdaa2..39a9e158 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -6,8 +6,9 @@ import m002 from "./002-users-table.js"; import m003 from "./003-api-key-owners.js"; import m004 from "./004-provider-connection-owners.js"; import m005 from "./005-usage-user-attribution.js"; +import m006 from "./006-combo-owners.js"; -export const MIGRATIONS = [m001, m002, m003, m004, m005].sort((a, b) => a.version - b.version); +export const MIGRATIONS = [m001, m002, m003, m004, m005, m006].sort((a, b) => a.version - b.version); export function latestVersion() { return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0; diff --git a/src/lib/db/repos/apiKeysRepo.js b/src/lib/db/repos/apiKeysRepo.js index 0bb87bc1..c665b4c0 100644 --- a/src/lib/db/repos/apiKeysRepo.js +++ b/src/lib/db/repos/apiKeysRepo.js @@ -32,6 +32,12 @@ export async function getApiKeyById(id) { return rowToKey(row); } +export async function getApiKeyByKey(key) { + const db = await getAdapter(); + const row = db.get(`SELECT * FROM apiKeys WHERE key = ?`, [key]); + return rowToKey(row); +} + export async function getApiKeyByIdAndOwnerId(id, ownerId) { const db = await getAdapter(); const row = db.get(`SELECT * FROM apiKeys WHERE id = ? AND ownerId = ?`, [id, ownerId]); diff --git a/src/lib/db/repos/combosRepo.js b/src/lib/db/repos/combosRepo.js index 11e72a33..c155d5b5 100644 --- a/src/lib/db/repos/combosRepo.js +++ b/src/lib/db/repos/combosRepo.js @@ -7,6 +7,7 @@ function rowToCombo(row) { return { id: row.id, name: row.name, + ownerId: row.ownerId, kind: row.kind, models: parseJson(row.models, []), createdAt: row.createdAt, @@ -14,21 +15,30 @@ function rowToCombo(row) { }; } -export async function getCombos() { +export async function getCombos(ownerId = undefined) { const db = await getAdapter(); - const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`); + const hasOwnerScope = ownerId !== undefined; + const rows = hasOwnerScope + ? db.all(`SELECT * FROM combos WHERE ownerId IS ? ORDER BY createdAt ASC`, [ownerId]) + : db.all(`SELECT * FROM combos ORDER BY createdAt ASC`); return rows.map(rowToCombo); } -export async function getComboById(id) { +export async function getComboById(id, ownerId = undefined) { const db = await getAdapter(); - const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); + const hasOwnerScope = ownerId !== undefined; + const row = hasOwnerScope + ? db.get(`SELECT * FROM combos WHERE id = ? AND ownerId IS ?`, [id, ownerId]) + : db.get(`SELECT * FROM combos WHERE id = ?`, [id]); return rowToCombo(row); } -export async function getComboByName(name) { +export async function getComboByName(name, ownerId = undefined) { const db = await getAdapter(); - const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]); + const hasOwnerScope = ownerId !== undefined; + const row = hasOwnerScope + ? db.get(`SELECT * FROM combos WHERE name = ? AND ownerId IS ?`, [name, ownerId]) + : db.get(`SELECT * FROM combos WHERE name = ? ORDER BY createdAt ASC LIMIT 1`, [name]); return rowToCombo(row); } @@ -38,36 +48,45 @@ export async function createCombo(data) { const combo = { id: uuidv4(), name: data.name, + ownerId: data.ownerId ?? null, kind: data.kind || null, models: data.models || [], createdAt: now, updatedAt: now, }; db.run( - `INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, - [combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt] + `INSERT INTO combos(id, name, ownerId, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [combo.id, combo.name, combo.ownerId, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt] ); return combo; } -export async function updateCombo(id, data) { +export async function updateCombo(id, data, ownerId = undefined) { const db = await getAdapter(); let result = null; db.transaction(() => { - const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); + const hasOwnerScope = ownerId !== undefined; + const row = hasOwnerScope + ? db.get(`SELECT * FROM combos WHERE id = ? AND ownerId IS ?`, [id, ownerId]) + : db.get(`SELECT * FROM combos WHERE id = ?`, [id]); if (!row) return; const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() }; db.run( - `UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`, - [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id] + `UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?${hasOwnerScope ? " AND ownerId IS ?" : ""}`, + hasOwnerScope + ? [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id, ownerId] + : [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id] ); result = merged; }); return result; } -export async function deleteCombo(id) { +export async function deleteCombo(id, ownerId = undefined) { const db = await getAdapter(); - const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]); + const hasOwnerScope = ownerId !== undefined; + const res = hasOwnerScope + ? db.run(`DELETE FROM combos WHERE id = ? AND ownerId IS ?`, [id, ownerId]) + : db.run(`DELETE FROM combos WHERE id = ?`, [id]); return (res?.changes ?? 0) > 0; } diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index beb0e8df..b7cac877 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -116,7 +116,12 @@ export async function getProviderConnections(filter = {}) { const where = []; const params = []; if (filter.provider) { where.push("provider = ?"); params.push(filter.provider); } - if (filter.ownerId) { where.push("ownerId = ?"); params.push(filter.ownerId); } + // `undefined` is an explicit administrative, unscoped query. `null` scopes + // to legacy/global connections rather than exposing every user's accounts. + if (Object.prototype.hasOwnProperty.call(filter, "ownerId")) { + where.push("ownerId IS ?"); + params.push(filter.ownerId); + } if (filter.isActive !== undefined) { where.push("isActive = ?"); params.push(filter.isActive ? 1 : 0); } const sql = `SELECT * FROM providerConnections${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`; const rows = db.all(sql, params); diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 7b147e1e..72cec376 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -94,6 +94,28 @@ export async function updateSettings(updates) { return mergeWithDefaults(next); } +/** + * Atomically update one combo's strategy without replacing other combos' + * entries in the shared settings document. + */ +export async function updateComboStrategy(comboId, strategy) { + const db = await getAdapter(); + let next; + db.transaction(() => { + const row = db.get(`SELECT data FROM settings WHERE id = 1`); + const current = row ? parseJson(row.data, {}) : {}; + const comboStrategies = { ...(current.comboStrategies || {}) }; + if (!strategy || Object.keys(strategy).length === 0) delete comboStrategies[comboId]; + else comboStrategies[comboId] = strategy; + next = { ...current, comboStrategies }; + db.run( + `INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, + [stringifyJson(next)] + ); + }); + return mergeWithDefaults(next); +} + export async function isCloudEnabled() { const settings = await getSettings(); return settings.cloudEnabled === true; diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index a11279bb..c5cec44a 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 5; +export const SCHEMA_VERSION = 6; export const PRAGMA_SQL = ` PRAGMA journal_mode = WAL; @@ -110,13 +110,18 @@ export const TABLES = { combos: { columns: { id: "TEXT PRIMARY KEY", - name: "TEXT UNIQUE NOT NULL", + name: "TEXT NOT NULL", + ownerId: "TEXT", kind: "TEXT", models: "TEXT NOT NULL", createdAt: "TEXT NOT NULL", updatedAt: "TEXT NOT NULL", }, - indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"], + indexes: [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_combo_owner_name ON combos(ownerId, name) WHERE ownerId IS NOT NULL", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_combo_global_name ON combos(name) WHERE ownerId IS NULL", + "CREATE INDEX IF NOT EXISTS idx_combo_owner ON combos(ownerId)", + ], }, kv: { columns: { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 33d97564..4eed423a 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -1,7 +1,7 @@ // Shim → re-export from new SQLite-based DB layer (src/lib/db/) // Kept for backward compatibility with existing imports. export { - getSettings, updateSettings, isCloudEnabled, getCloudUrl, + getSettings, updateSettings, updateComboStrategy, isCloudEnabled, getCloudUrl, getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser, countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword, getProviderConnections, getProviderConnectionById, @@ -12,7 +12,7 @@ export { createProviderNode, updateProviderNode, deleteProviderNode, getProxyPools, getProxyPoolById, createProxyPool, updateProxyPool, deleteProxyPool, - getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId, + getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByKey, getApiKeyByIdAndOwnerId, createApiKey, updateApiKey, deleteApiKey, validateApiKey, getCombos, getComboById, getComboByName, createCombo, updateCombo, deleteCombo, diff --git a/src/models/index.js b/src/models/index.js index 99444f62..d2d993e2 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -31,6 +31,7 @@ export { getMitmAlias, setMitmAliasAll, getApiKeys, + getApiKeyByKey, createApiKey, deleteApiKey, validateApiKey, diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index b438748c..bfe3e76b 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -23,7 +23,7 @@ const navItems = [ { href: "/dashboard/providers", label: "Providers", icon: "dns" }, { href: "/dashboard/models", label: "Models", icon: "view_list" }, // { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden - { href: "/dashboard/combos", label: "Combos", icon: "layers", adminOnly: true }, + { href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" }, { href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" }, diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index dfc50cf7..0d3bafb1 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -5,11 +5,12 @@ import { markAccountUnavailable, clearAccountError, extractApiKey, + getApiKeyOwnerId, isValidApiKey, } from "../services/auth.js"; import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js"; import { getSettings } from "@/lib/localDb"; -import { getModelInfo, getComboModels } from "../services/model.js"; +import { getModelInfo, getCombo } from "../services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect"; import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js"; @@ -56,6 +57,7 @@ export async function handleChat(request, clientRawRequest = null) { // Log API key (masked) const authHeader = request.headers.get("Authorization"); const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); if (authHeader && apiKey) { const masked = log.maskKey(apiKey); log.debug("AUTH", `API Key: ${masked}`); @@ -88,11 +90,12 @@ export async function handleChat(request, clientRawRequest = null) { if (bypassResponse) return bypassResponse.response || bypassResponse; // Check if model is a combo (has multiple models with fallback) - const comboModels = await getComboModels(modelStr); - if (comboModels) { + const combo = await getCombo(modelStr, ownerId); + if (combo) { + const comboModels = combo.models; // Check for combo-specific strategy first, fallback to global const comboStrategies = settings.comboStrategies || {}; - const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy; + const comboSpecificStrategy = comboStrategies[combo.id]?.fallbackStrategy; const comboStrategy = comboSpecificStrategy || settings.comboStrategy || "fallback"; if (comboStrategy === "fusion") { @@ -106,12 +109,13 @@ export async function handleChat(request, clientRawRequest = null) { const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {}; cleanRawReq = { ...clientRawRequest, body: cleanBody }; } - return handleSingleModelChat(b, m, cleanRawReq, request, apiKey); + return handleSingleModelChat(b, m, cleanRawReq, request, apiKey, ownerId); }, log, comboName: modelStr, - judgeModel: comboStrategies[modelStr]?.judgeModel, - tuning: comboStrategies[modelStr]?.fusionTuning, + comboId: combo.id, + judgeModel: comboStrategies[combo.id]?.judgeModel, + tuning: comboStrategies[combo.id]?.fusionTuning, }); } @@ -120,32 +124,34 @@ export async function handleChat(request, clientRawRequest = null) { return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, ownerId), log, comboName: modelStr, + comboId: combo.id, comboStrategy, comboStickyLimit }); } // Single model request - return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey); + return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey, ownerId); } /** * Handle single model chat request */ -async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) { - const modelInfo = await getModelInfo(modelStr); +async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, ownerId = undefined) { + const modelInfo = await getModelInfo(modelStr, ownerId); // If provider is null, this might be a combo name - check and handle if (!modelInfo.provider) { - const comboModels = await getComboModels(modelStr); - if (comboModels) { + const combo = await getCombo(modelStr, ownerId); + if (combo) { + const comboModels = combo.models; const chatSettings = await getSettings(); // Check for combo-specific strategy first, fallback to global const comboStrategies = chatSettings.comboStrategies || {}; - const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy; + const comboSpecificStrategy = comboStrategies[combo.id]?.fallbackStrategy; const comboStrategy = comboSpecificStrategy || chatSettings.comboStrategy || "fallback"; if (comboStrategy === "fusion") { @@ -159,12 +165,13 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {}; cleanRawReq = { ...clientRawRequest, body: cleanBody }; } - return handleSingleModelChat(b, m, cleanRawReq, request, apiKey); + return handleSingleModelChat(b, m, cleanRawReq, request, apiKey, ownerId); }, log, comboName: modelStr, - judgeModel: comboStrategies[modelStr]?.judgeModel, - tuning: comboStrategies[modelStr]?.fusionTuning, + comboId: combo.id, + judgeModel: comboStrategies[combo.id]?.judgeModel, + tuning: comboStrategies[combo.id]?.fusionTuning, }); } @@ -173,9 +180,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, ownerId), log, comboName: modelStr, + comboId: combo.id, comboStrategy, comboStickyLimit }); @@ -200,7 +208,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId }); // All accounts unavailable if (!credentials || credentials.allRateLimited) { diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index 97777eb9..66fd030c 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -3,6 +3,7 @@ import { markAccountUnavailable, clearAccountError, extractApiKey, + getApiKeyOwnerId, isValidApiKey, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; @@ -36,6 +37,7 @@ export async function handleEmbeddings(request) { // Log API key (masked) const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { @@ -66,7 +68,7 @@ export async function handleEmbeddings(request) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); } - const modelInfo = await getModelInfo(modelStr); + const modelInfo = await getModelInfo(modelStr, ownerId); if (!modelInfo.provider) { log.warn("EMBEDDINGS", "Invalid model format", { model: modelStr }); return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); @@ -89,7 +91,7 @@ export async function handleEmbeddings(request) { let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId }); // All accounts unavailable if (!credentials || credentials.allRateLimited) { diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index db62a8c7..6144580c 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -3,6 +3,7 @@ import { markAccountUnavailable, clearAccountError, extractApiKey, + getApiKeyOwnerId, isValidApiKey, } from "../services/auth.js"; import { getSettings, getCombos } from "@/lib/localDb"; @@ -41,6 +42,7 @@ export async function handleFetch(request) { // Log API key (masked) const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { @@ -88,11 +90,12 @@ export async function handleFetch(request) { } // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers - const combos = await getCombos(); - const comboModels = getComboModelsFromData(providerInput, combos); + const combos = await getCombos(ownerId); + const combo = combos.find((entry) => entry.name === providerInput && entry.models?.length > 0); + const comboModels = getComboModelsFromData(providerInput, combo ? [combo] : []); if (comboModels) { const comboStrategies = settings.comboStrategies || {}; - const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback"; + const comboStrategy = comboStrategies[combo.id]?.fallbackStrategy || settings.comboStrategy || "fallback"; const comboStickyLimit = settings.comboStickyRoundRobinLimit; log.info("FETCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`); return handleComboChat({ @@ -101,6 +104,7 @@ export async function handleFetch(request) { handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings), log, comboName: providerInput, + comboId: combo.id, comboStrategy, comboStickyLimit }); @@ -159,7 +163,7 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(providerId, excludeConnectionIds); + const credentials = await getProviderCredentials(providerId, excludeConnectionIds, null, { ownerId }); if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { diff --git a/src/sse/handlers/imageGeneration.js b/src/sse/handlers/imageGeneration.js index c6b29fff..5dc4bd95 100644 --- a/src/sse/handlers/imageGeneration.js +++ b/src/sse/handlers/imageGeneration.js @@ -3,10 +3,11 @@ import { markAccountUnavailable, clearAccountError, extractApiKey, + getApiKeyOwnerId, isValidApiKey, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; -import { getModelInfo, getComboModels } from "../services/model.js"; +import { getModelInfo, getCombo } from "../services/model.js"; import { handleImageGenerationCore } from "open-sse/handlers/imageGenerationCore.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; @@ -37,6 +38,7 @@ export async function handleImageGeneration(request) { const modelStr = body.model; const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); const settings = await getSettings(); if (settings.requireApiKey) { if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); @@ -48,28 +50,30 @@ export async function handleImageGeneration(request) { if (!body.prompt) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt"); // Combo expansion: model may be a combo name → run fallback/round-robin across models - const comboModels = await getComboModels(modelStr); - if (comboModels) { + const combo = await getCombo(modelStr, ownerId); + if (combo) { + const comboModels = combo.models; const comboStrategies = settings.comboStrategies || {}; - const comboStrategy = comboStrategies[modelStr]?.fallbackStrategy || settings.comboStrategy || "fallback"; + const comboStrategy = comboStrategies[combo.id]?.fallbackStrategy || settings.comboStrategy || "fallback"; const comboStickyLimit = settings.comboStickyRoundRobinLimit; log.info("IMAGE", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`); return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelImage(b, m, { wantsStream, binaryOutput, preferredConnectionId }), + handleSingleModel: (b, m) => handleSingleModelImage(b, m, { wantsStream, binaryOutput, preferredConnectionId, ownerId }), log, comboName: modelStr, + comboId: combo.id, comboStrategy, comboStickyLimit, }); } - return handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId }); + return handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId, ownerId }); } -async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId } = {}) { - const modelInfo = await getModelInfo(modelStr); +async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId, ownerId } = {}) { + const modelInfo = await getModelInfo(modelStr, ownerId); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; @@ -95,7 +99,7 @@ async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutpu let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId }); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId, ownerId }); if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { diff --git a/src/sse/handlers/search.js b/src/sse/handlers/search.js index d8ee6b74..d8adf828 100644 --- a/src/sse/handlers/search.js +++ b/src/sse/handlers/search.js @@ -3,6 +3,7 @@ import { markAccountUnavailable, clearAccountError, extractApiKey, + getApiKeyOwnerId, isValidApiKey, } from "../services/auth.js"; import { getSettings, getCombos } from "@/lib/localDb"; @@ -38,6 +39,7 @@ export async function handleSearch(request) { // Log API key (masked) const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { @@ -69,11 +71,12 @@ export async function handleSearch(request) { } // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers - const combos = await getCombos(); - const comboModels = getComboModelsFromData(providerInput, combos); + const combos = await getCombos(ownerId); + const combo = combos.find((entry) => entry.name === providerInput && entry.models?.length > 0); + const comboModels = getComboModelsFromData(providerInput, combo ? [combo] : []); if (comboModels) { const comboStrategies = settings.comboStrategies || {}; - const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback"; + const comboStrategy = comboStrategies[combo.id]?.fallbackStrategy || settings.comboStrategy || "fallback"; const comboStickyLimit = settings.comboStickyRoundRobinLimit; log.info("SEARCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`); return handleComboChat({ @@ -82,6 +85,7 @@ export async function handleSearch(request) { handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings), log, comboName: providerInput, + comboId: combo.id, comboStrategy, comboStickyLimit }); @@ -149,7 +153,7 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey, let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(providerId, excludeConnectionIds); + const credentials = await getProviderCredentials(providerId, excludeConnectionIds, null, { ownerId }); if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { diff --git a/src/sse/handlers/stt.js b/src/sse/handlers/stt.js index ae314ecb..9add0015 100644 --- a/src/sse/handlers/stt.js +++ b/src/sse/handlers/stt.js @@ -1,5 +1,5 @@ import { - extractApiKey, isValidApiKey, + extractApiKey, getApiKeyOwnerId, isValidApiKey, getProviderCredentials, markAccountUnavailable, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; @@ -30,8 +30,9 @@ export async function handleStt(request) { log.request("POST", `/v1/audio/transcriptions | ${modelStr}`); const settings = await getSettings(); + const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); if (settings.requireApiKey) { - const apiKey = extractApiKey(request); if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); const valid = await isValidApiKey(apiKey); if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); @@ -40,7 +41,7 @@ export async function handleStt(request) { if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); if (!formData.get("file")) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); - const modelInfo = await getModelInfo(modelStr); + const modelInfo = await getModelInfo(modelStr, ownerId); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; @@ -63,7 +64,7 @@ export async function handleStt(request) { let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId }); if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js index 04ed57af..9a8a6c6b 100644 --- a/src/sse/handlers/tts.js +++ b/src/sse/handlers/tts.js @@ -1,9 +1,10 @@ import { extractApiKey, isValidApiKey, + getApiKeyOwnerId, getProviderCredentials, markAccountUnavailable, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; -import { getModelInfo, getComboModels } from "../services/model.js"; +import { getModelInfo, getCombo } from "../services/model.js"; import { handleTtsCore } from "open-sse/handlers/ttsCore.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; @@ -34,8 +35,9 @@ export async function handleTts(request) { log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}${language ? ` | lang=${language}` : ""}`); const settings = await getSettings(); + const apiKey = extractApiKey(request); + const ownerId = await getApiKeyOwnerId(apiKey); if (settings.requireApiKey) { - const apiKey = extractApiKey(request); if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); const valid = await isValidApiKey(apiKey); if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); @@ -45,28 +47,30 @@ export async function handleTts(request) { if (!body.input) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); // Combo expansion: model may be a combo name → run fallback/round-robin across models - const comboModels = await getComboModels(modelStr); - if (comboModels) { + const combo = await getCombo(modelStr, ownerId); + if (combo) { + const comboModels = combo.models; const comboStrategies = settings.comboStrategies || {}; - const comboStrategy = comboStrategies[modelStr]?.fallbackStrategy || settings.comboStrategy || "fallback"; + const comboStrategy = comboStrategies[combo.id]?.fallbackStrategy || settings.comboStrategy || "fallback"; const comboStickyLimit = settings.comboStickyRoundRobinLimit; log.info("TTS", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`); return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language), + handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language, ownerId), log, comboName: modelStr, + comboId: combo.id, comboStrategy, comboStickyLimit, }); } - return handleSingleModelTts(body, modelStr, responseFormat, language); + return handleSingleModelTts(body, modelStr, responseFormat, language, ownerId); } -async function handleSingleModelTts(body, modelStr, responseFormat, language) { - const modelInfo = await getModelInfo(modelStr); +async function handleSingleModelTts(body, modelStr, responseFormat, language, ownerId = undefined) { + const modelInfo = await getModelInfo(modelStr, ownerId); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; @@ -89,7 +93,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) { let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId }); if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index f931209b..32161818 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,4 +1,4 @@ -import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; +import { getProviderConnections, getApiKeyByKey, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy"; import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; @@ -14,6 +14,7 @@ let selectionMutex = Promise.resolve(); * @param {string} provider - Provider name * @param {Set|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account) * @param {string|null} model - Model name for per-model rate limit filtering + * @param {{ ownerId?: string|null }} options - API-key owner scope for credentials */ export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) { // Normalize to Set for consistent handling @@ -21,6 +22,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu ? excludeConnectionIds : (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set()); const preferredConnectionId = options?.preferredConnectionId || null; + const ownerId = options?.ownerId; // Acquire mutex to prevent race conditions const currentMutex = selectionMutex; let resolveMutex; @@ -59,7 +61,9 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu }; } - const connections = await getProviderConnections({ provider: providerId, isActive: true }); + const connectionFilter = { provider: providerId, isActive: true }; + if (ownerId !== undefined) connectionFilter.ownerId = ownerId; + const connections = await getProviderConnections(connectionFilter); log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`); if (connections.length === 0) { @@ -317,3 +321,15 @@ export async function isValidApiKey(apiKey) { if (!apiKey) return false; return await validateApiKey(apiKey); } + +/** + * Resolve the active dashboard user that owns an API key. + * Undefined deliberately represents local/no-key mode, which retains access + * to legacy global combos. + */ +export async function getApiKeyOwnerId(apiKey) { + if (!apiKey) return undefined; + const key = await getApiKeyByKey(apiKey); + if (!key?.isActive) return undefined; + return key.ownerId ?? null; +} diff --git a/src/sse/services/model.js b/src/sse/services/model.js index ba4cc6c2..b2ee4687 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -35,7 +35,7 @@ export async function resolveModelAlias(alias) { /** * Get full model info (parse or resolve) */ -export async function getModelInfo(modelStr) { +export async function getModelInfo(modelStr, ownerId = undefined) { const parsed = parseModel(modelStr); if (!parsed.isAlias) { @@ -68,7 +68,7 @@ export async function getModelInfo(modelStr) { // Check if this is a combo name before resolving as alias // This prevents combo names from being incorrectly routed to providers - const combo = await getComboByName(parsed.model); + const combo = await getComboByName(parsed.model, ownerId); if (combo) { // Return null provider to signal this should be handled as combo // The caller (handleChat) will detect this and handle it as combo @@ -82,13 +82,22 @@ export async function getModelInfo(modelStr) { * Check if model is a combo and get models list * @returns {Promise} Array of models or null if not a combo */ -export async function getComboModels(modelStr) { +export async function getComboModels(modelStr, ownerId = undefined) { // Only check if it's not in provider/model format if (modelStr.includes("/")) return null; - const combo = await getComboByName(modelStr); + const combo = await getComboByName(modelStr, ownerId); if (combo && combo.models && combo.models.length > 0) { return combo.models; } return null; } + +/** + * Resolve a complete combo record in the request owner's scope. + */ +export async function getCombo(modelStr, ownerId = undefined) { + if (modelStr.includes("/")) return null; + const combo = await getComboByName(modelStr, ownerId); + return combo?.models?.length ? combo : null; +} diff --git a/tests/unit/combo-ownership.test.js b/tests/unit/combo-ownership.test.js new file mode 100644 index 00000000..19d77736 --- /dev/null +++ b/tests/unit/combo-ownership.test.js @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let tempDir; +const originalDataDir = process.env.DATA_DIR; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-combo-owner-")); + process.env.DATA_DIR = tempDir; + delete global._dbAdapter; + vi.resetModules(); +}); + +afterEach(() => { + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describe("combo ownership", () => { + it("allows identical names in different user scopes and prevents cross-owner reads", async () => { + const { createUser } = await import("@/lib/db/index.js"); + const { + createCombo, + getComboByName, + getCombos, + updateCombo, + deleteCombo, + } = await import("@/lib/db/repos/combosRepo.js"); + + const userA = await createUser({ username: "combo-owner-a", password: "password", role: "user" }); + const userB = await createUser({ username: "combo-owner-b", password: "password", role: "user" }); + const comboA = await createCombo({ name: "fast", ownerId: userA.id, models: ["openai/gpt-a"] }); + const comboB = await createCombo({ name: "fast", ownerId: userB.id, models: ["anthropic/claude-b"] }); + + expect((await getComboByName("fast", userA.id)).id).toBe(comboA.id); + expect((await getComboByName("fast", userB.id)).id).toBe(comboB.id); + expect(await getCombos(userA.id)).toEqual([comboA]); + expect(await updateCombo(comboA.id, { models: ["openai/gpt-updated"] }, userB.id)).toBeNull(); + expect(await deleteCombo(comboA.id, userB.id)).toBe(false); + expect((await getComboByName("fast", userA.id)).models).toEqual(["openai/gpt-a"]); + }); +}); \ No newline at end of file diff --git a/tests/unit/combo-routing.test.js b/tests/unit/combo-routing.test.js index d6ef4d04..a5716daf 100644 --- a/tests/unit/combo-routing.test.js +++ b/tests/unit/combo-routing.test.js @@ -11,7 +11,7 @@ describe("combo round-robin routing", () => { const models = ["provider/model-a", "provider/model-b"]; const firstChoices = Array.from({ length: 4 }, () => ( - getRotatedModels(models, "code-xhigh", "round-robin")[0] + getRotatedModels(models, "combo-user-a", "round-robin")[0] )); expect(firstChoices).toEqual([ @@ -26,7 +26,7 @@ describe("combo round-robin routing", () => { const models = ["provider/model-a", "provider/model-b"]; const firstChoices = Array.from({ length: 6 }, () => ( - getRotatedModels(models, "code-xhigh", "round-robin", 2)[0] + getRotatedModels(models, "combo-user-a", "round-robin", 2)[0] )); expect(firstChoices).toEqual([ @@ -42,17 +42,27 @@ describe("combo round-robin routing", () => { it("tracks sticky rotation independently per combo", () => { const models = ["provider/model-a", "provider/model-b"]; - expect(getRotatedModels(models, "code-high", "round-robin", 2)[0]).toBe("provider/model-a"); - expect(getRotatedModels(models, "code-xhigh", "round-robin", 2)[0]).toBe("provider/model-a"); - expect(getRotatedModels(models, "code-high", "round-robin", 2)[0]).toBe("provider/model-a"); - expect(getRotatedModels(models, "code-high", "round-robin", 2)[0]).toBe("provider/model-b"); - expect(getRotatedModels(models, "code-xhigh", "round-robin", 2)[0]).toBe("provider/model-a"); + expect(getRotatedModels(models, "combo-user-a", "round-robin", 2)[0]).toBe("provider/model-a"); + expect(getRotatedModels(models, "combo-user-b", "round-robin", 2)[0]).toBe("provider/model-a"); + expect(getRotatedModels(models, "combo-user-a", "round-robin", 2)[0]).toBe("provider/model-a"); + expect(getRotatedModels(models, "combo-user-a", "round-robin", 2)[0]).toBe("provider/model-b"); + expect(getRotatedModels(models, "combo-user-b", "round-robin", 2)[0]).toBe("provider/model-a"); + }); + + it("isolates rotations for same-named combos owned by different users", () => { + const modelsA = ["provider/model-a", "provider/model-b"]; + const modelsB = ["provider/model-c", "provider/model-d"]; + + expect(getRotatedModels(modelsA, "combo-id-user-a-fast", "round-robin")[0]).toBe("provider/model-a"); + expect(getRotatedModels(modelsB, "combo-id-user-b-fast", "round-robin")[0]).toBe("provider/model-c"); + expect(getRotatedModels(modelsA, "combo-id-user-a-fast", "round-robin")[0]).toBe("provider/model-b"); + expect(getRotatedModels(modelsB, "combo-id-user-b-fast", "round-robin")[0]).toBe("provider/model-d"); }); it("does not rotate fallback combos", () => { const models = ["provider/model-a", "provider/model-b"]; - expect(getRotatedModels(models, "code-xhigh", "fallback", 2)).toEqual(models); + expect(getRotatedModels(models, "combo-user-a", "fallback", 2)).toEqual(models); expect(getRotatedModels(models, "code-xhigh", "fallback", 2)).toEqual(models); }); }); diff --git a/tests/unit/db-migration-chain.test.js b/tests/unit/db-migration-chain.test.js index b4393afa..e95486d3 100644 --- a/tests/unit/db-migration-chain.test.js +++ b/tests/unit/db-migration-chain.test.js @@ -39,6 +39,8 @@ describe("Schema migrations", () => { ])); expect(db.all(`PRAGMA table_info(providerConnections)`).map((column) => column.name)).toContain("ownerId"); expect(db.all(`PRAGMA index_list(providerConnections)`).map((index) => index.name)).toContain("idx_pc_owner"); + expect(db.all(`PRAGMA table_info(combos)`).map((column) => column.name)).toContain("ownerId"); + expect(db.all(`PRAGMA index_list(combos)`).map((index) => index.name)).toContain("idx_combo_owner_name"); }); it("existing DB at older schemaVersion → re-applies pending migrations on restart", async () => { diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js index 0ca45e16..fcb65928 100644 --- a/tests/unit/db-sqlite-vs-lowdb.test.js +++ b/tests/unit/db-sqlite-vs-lowdb.test.js @@ -141,6 +141,9 @@ describe("DB SQLite layer — public API parity", () => { const ownerOneConnections = await sqliteDb.getProviderConnections({ ownerId: ownerOne.id }); expect(ownerOneConnections.map((connection) => connection.id)).toContain(firstConnection.id); expect(ownerOneConnections.map((connection) => connection.id)).not.toContain(secondConnection.id); + const globalConnections = await sqliteDb.getProviderConnections({ ownerId: null }); + expect(globalConnections.map((connection) => connection.id)).not.toContain(firstConnection.id); + expect(globalConnections.map((connection) => connection.id)).not.toContain(secondConnection.id); await expect(sqliteDb.createProviderConnection({ provider: "owner-test-account", authType: "oauth",