mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: update the permission access for combo page
This commit is contained in:
@@ -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<Response>
|
||||
* @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<Response>}
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "*" },
|
||||
});
|
||||
|
||||
@@ -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": "*" },
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+5
-5
@@ -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 || {})) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
getMitmAlias,
|
||||
setMitmAliasAll,
|
||||
getApiKeys,
|
||||
getApiKeyByKey,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
validateApiKey,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+27
-19
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+14
-10
@@ -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) {
|
||||
|
||||
@@ -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>|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;
|
||||
}
|
||||
|
||||
@@ -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<string[]|null>} 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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user