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
|
* Get rotated model list based on strategy
|
||||||
* @param {string[]} models - Array of model strings
|
* @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 {string} strategy - "fallback" or "round-robin"
|
||||||
* @param {number|string} [stickyLimit=1] - Requests per combo model before switching
|
* @param {number|string} [stickyLimit=1] - Requests per combo model before switching
|
||||||
* @returns {string[]} Rotated models array
|
* @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") {
|
if (!models || models.length <= 1 || strategy !== "round-robin") {
|
||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rotationKey = comboName || "__default__";
|
const rotationKey = comboKey || "__default__";
|
||||||
const normalizedStickyLimit = normalizeStickyLimit(stickyLimit);
|
const normalizedStickyLimit = normalizeStickyLimit(stickyLimit);
|
||||||
const existingState = comboRotationState.get(rotationKey);
|
const existingState = comboRotationState.get(rotationKey);
|
||||||
const state = typeof existingState === "number"
|
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
|
* 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) {
|
export function resetComboRotation(comboKey) {
|
||||||
if (comboName) comboRotationState.delete(comboName);
|
if (comboKey) comboRotationState.delete(comboKey);
|
||||||
else comboRotationState.clear();
|
else comboRotationState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +221,15 @@ export function getComboModelsFromData(modelStr, combosData) {
|
|||||||
* @param {string[]} options.models - Array of model strings to try
|
* @param {string[]} options.models - Array of model strings to try
|
||||||
* @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
|
* @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
|
||||||
* @param {Object} options.log - Logger object
|
* @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 {string} [options.comboStrategy] - Strategy: "fallback" or "round-robin"
|
||||||
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
|
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
|
||||||
* @returns {Promise<Response>}
|
* @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
|
// 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.
|
// Auto-switch: float models that satisfy the request's required capabilities to the front.
|
||||||
if (autoSwitch) {
|
if (autoSwitch) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
@@ -27,7 +27,7 @@ export default function CombosPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const fetchData = async () => {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
|
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
|
||||||
fetch("/api/combos"),
|
fetch("/api/combos"),
|
||||||
@@ -57,7 +57,7 @@ export default function CombosPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleCreate = async (data) => {
|
const handleCreate = async (data) => {
|
||||||
try {
|
try {
|
||||||
@@ -115,26 +115,26 @@ export default function CombosPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty
|
// Update only this combo's strategy through an owner-authorized endpoint.
|
||||||
// patch (strategy back to default "fallback") drops the entry entirely.
|
const handleSetComboStrategy = async (comboId, patch) => {
|
||||||
const handleSetComboStrategy = async (comboName, patch) => {
|
|
||||||
try {
|
try {
|
||||||
const updated = { ...comboStrategies };
|
const next = { ...(comboStrategies[comboId] || {}), ...patch };
|
||||||
const next = { ...(updated[comboName] || {}), ...patch };
|
|
||||||
// Prune to keep settings clean: default fallback with no extras = no entry.
|
// Prune to keep settings clean: default fallback with no extras = no entry.
|
||||||
if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") {
|
const strategy = !next.fallbackStrategy || next.fallbackStrategy === "fallback" ? {} : next;
|
||||||
delete updated[comboName];
|
|
||||||
} else {
|
|
||||||
updated[comboName] = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetch("/api/settings", {
|
const res = await fetch(`/api/combos/${comboId}/strategy`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
} catch (error) {
|
||||||
console.log("Error updating combo strategy:", error);
|
console.log("Error updating combo strategy:", error);
|
||||||
}
|
}
|
||||||
@@ -195,8 +195,8 @@ export default function CombosPage() {
|
|||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
onEdit={() => setEditingCombo(combo)}
|
onEdit={() => setEditingCombo(combo)}
|
||||||
onDelete={() => handleDelete(combo.id)}
|
onDelete={() => handleDelete(combo.id)}
|
||||||
strategy={comboStrategies[combo.name] || {}}
|
strategy={comboStrategies[combo.id] || {}}
|
||||||
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
onSetStrategy={(patch) => handleSetComboStrategy(combo.id, patch)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function ComboDetailPage() {
|
|||||||
setName(c.name);
|
setName(c.name);
|
||||||
setProviders(c.models || []);
|
setProviders(c.models || []);
|
||||||
const s = settingsRes.ok ? await settingsRes.json() : {};
|
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() : [];
|
const allLogs = logsRes.ok ? await logsRes.json() : [];
|
||||||
setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50));
|
setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50));
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
@@ -150,17 +150,12 @@ export default function ComboDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleRoundRobin = async (enabled) => {
|
const handleToggleRoundRobin = async (enabled) => {
|
||||||
setRoundRobin(enabled);
|
const res = await fetch(`/api/combos/${id}/strategy`, {
|
||||||
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", {
|
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 () => {
|
const handleDelete = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb";
|
import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb";
|
||||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||||
const VALID_NAME_REGEX = /^[a-zA-Z0-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
|
// GET /api/combos/[id] - Get combo by ID
|
||||||
export async function GET(request, { params }) {
|
export async function GET(request, { params }) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const combo = await getComboById(id);
|
const combo = await getComboById(id, user.role === "admin" ? undefined : user.id);
|
||||||
|
|
||||||
if (!combo) {
|
if (!combo) {
|
||||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||||
@@ -17,6 +19,9 @@ export async function GET(request, { params }) {
|
|||||||
|
|
||||||
return NextResponse.json(combo);
|
return NextResponse.json(combo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.message === "Unauthorized") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
console.log("Error fetching combo:", error);
|
console.log("Error fetching combo:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch combo" }, { status: 500 });
|
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
|
// PUT /api/combos/[id] - Update combo
|
||||||
export async function PUT(request, { params }) {
|
export async function PUT(request, { params }) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
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
|
// Validate name format if provided
|
||||||
if (body.name) {
|
if (body.name) {
|
||||||
@@ -35,26 +48,31 @@ export async function PUT(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if name already exists (exclude current combo)
|
// 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) {
|
if (existing && existing.id !== id) {
|
||||||
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
|
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture previous name to invalidate rotation state on rename
|
// Capture previous name to invalidate rotation state on rename
|
||||||
const prev = await getComboById(id);
|
const combo = await updateCombo(id, body, ownerId);
|
||||||
const combo = await updateCombo(id, body);
|
|
||||||
|
|
||||||
if (!combo) {
|
if (!combo) {
|
||||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate rotation state (models/strategy/name may have changed)
|
// Invalidate rotation state (models/strategy/name may have changed)
|
||||||
if (prev?.name) resetComboRotation(prev.name);
|
if (prev?.id) resetComboRotation(prev.id);
|
||||||
if (combo.name && combo.name !== prev?.name) resetComboRotation(combo.name);
|
if (combo.id && combo.id !== prev?.id) resetComboRotation(combo.id);
|
||||||
|
|
||||||
return NextResponse.json(combo);
|
return NextResponse.json(combo);
|
||||||
} catch (error) {
|
} 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);
|
console.log("Error updating combo:", error);
|
||||||
return NextResponse.json({ error: "Failed to update combo" }, { status: 500 });
|
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
|
// DELETE /api/combos/[id] - Delete combo
|
||||||
export async function DELETE(request, { params }) {
|
export async function DELETE(request, { params }) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const prev = await getComboById(id);
|
const ownerId = user.role === "admin" ? undefined : user.id;
|
||||||
const success = await deleteCombo(id);
|
const prev = await getComboById(id, ownerId);
|
||||||
|
const success = await deleteCombo(id, ownerId);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.message === "Unauthorized") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
console.log("Error deleting combo:", error);
|
console.log("Error deleting combo:", error);
|
||||||
return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 });
|
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 { NextResponse } from "next/server";
|
||||||
import { getCombos, createCombo, getComboByName } from "@/lib/localDb";
|
import { getCombos, createCombo, getComboByName } from "@/lib/localDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -9,9 +10,13 @@ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
|
|||||||
// GET /api/combos - Get all combos
|
// GET /api/combos - Get all combos
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const combos = await getCombos();
|
const user = await requireUsageDashboardUser();
|
||||||
|
const combos = await getCombos(user.role === "admin" ? undefined : user.id);
|
||||||
return NextResponse.json({ combos });
|
return NextResponse.json({ combos });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.message === "Unauthorized") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
console.log("Error fetching combos:", error);
|
console.log("Error fetching combos:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch combos" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch combos" }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -20,6 +25,7 @@ export async function GET() {
|
|||||||
// POST /api/combos - Create new combo
|
// POST /api/combos - Create new combo
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, models, kind } = body;
|
const { name, models, kind } = body;
|
||||||
|
|
||||||
@@ -33,15 +39,21 @@ export async function POST(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if name already exists
|
// Check if name already exists
|
||||||
const existing = await getComboByName(name);
|
const existing = await getComboByName(name, user.id || null);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
|
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 });
|
return NextResponse.json(combo, { status: 201 });
|
||||||
} catch (error) {
|
} 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);
|
console.log("Error creating combo:", error);
|
||||||
return NextResponse.json({ error: "Failed to create combo" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to create combo" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||||
import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing";
|
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";
|
import { updateUser, verifyUserPassword } from "@/lib/db";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -20,6 +20,13 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const { password, oidcClientSecret, ...safeSettings } = settings;
|
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);
|
safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret);
|
||||||
|
|
||||||
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
|
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
|
||||||
@@ -43,7 +50,8 @@ export async function PATCH(request) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(body, "requireApiKey") ||
|
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;
|
let user;
|
||||||
try {
|
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.
|
// URL slug → service kind(s). `web` covers both webSearch and webFetch.
|
||||||
const KIND_SLUG_MAP = {
|
const KIND_SLUG_MAP = {
|
||||||
@@ -24,7 +24,7 @@ export async function OPTIONS() {
|
|||||||
* GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability.
|
* GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability.
|
||||||
* Supported kinds: image, tts, stt, embedding, image-to-text, web.
|
* Supported kinds: image, tts, stt, embedding, image-to-text, web.
|
||||||
*/
|
*/
|
||||||
export async function GET(_request, { params }) {
|
export async function GET(request, { params }) {
|
||||||
try {
|
try {
|
||||||
const { kind } = await params;
|
const { kind } = await params;
|
||||||
const kindFilter = KIND_SLUG_MAP[kind];
|
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 }, {
|
return Response.json({ object: "list", data }, {
|
||||||
headers: { "Access-Control-Allow-Origin": "*" },
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
isAnthropicCompatibleProvider,
|
isAnthropicCompatibleProvider,
|
||||||
isOpenAICompatibleProvider,
|
isOpenAICompatibleProvider,
|
||||||
} from "@/shared/constants/providers";
|
} 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 { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||||
import { resolveKimchiModels } from "open-sse/services/kimchiModels.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.
|
* Build OpenAI-format models list filtered by service kinds.
|
||||||
* @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]).
|
* @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 = [];
|
let connections = [];
|
||||||
try {
|
try {
|
||||||
connections = await getProviderConnections();
|
connections = await getProviderConnections();
|
||||||
@@ -200,7 +200,7 @@ export async function buildModelsList(kindFilter) {
|
|||||||
|
|
||||||
let combos = [];
|
let combos = [];
|
||||||
try {
|
try {
|
||||||
combos = await getCombos();
|
combos = await getCombos(ownerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Could not fetch combos");
|
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).
|
* 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).
|
* 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 {
|
try {
|
||||||
const data = await buildModelsList([LLM_KIND]);
|
const data = await buildModelsList([LLM_KIND], await getApiKeyOwnerId(request));
|
||||||
return Response.json({ object: "list", data }, {
|
return Response.json({ object: "list", data }, {
|
||||||
headers: { "Access-Control-Allow-Origin": "*" },
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ const ALWAYS_PROTECTED = [
|
|||||||
|
|
||||||
// User administration is never exposed to normal users, even if dashboard login
|
// User administration is never exposed to normal users, even if dashboard login
|
||||||
// is disabled for local single-user deployments.
|
// 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
|
// Dashboard paths requiring an administrator. Combo access is handled by its
|
||||||
// may view or change them. The Models page is available read-only to all users.
|
// owner-scoped API routes and is available to authenticated users.
|
||||||
const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/combos"];
|
const ADMIN_ONLY_DASHBOARD_PATHS = [];
|
||||||
|
|
||||||
// Require auth, but allow through if requireLogin is disabled
|
// Require auth, but allow through if requireLogin is disabled
|
||||||
const PROTECTED_API_PATHS = [
|
const PROTECTED_API_PATHS = [
|
||||||
|
|||||||
+5
-5
@@ -4,7 +4,7 @@ import { stringifyJson, parseJson } from "./helpers/jsonCol.js";
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export {
|
export {
|
||||||
getSettings, updateSettings, isCloudEnabled, getCloudUrl, exportSettings,
|
getSettings, updateSettings, updateComboStrategy, isCloudEnabled, getCloudUrl, exportSettings,
|
||||||
} from "./repos/settingsRepo.js";
|
} from "./repos/settingsRepo.js";
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@@ -36,7 +36,7 @@ export {
|
|||||||
// API keys
|
// API keys
|
||||||
export {
|
export {
|
||||||
getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId,
|
getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId,
|
||||||
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
getApiKeyByKey, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||||
} from "./repos/apiKeysRepo.js";
|
} from "./repos/apiKeysRepo.js";
|
||||||
|
|
||||||
// Combos
|
// 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 })),
|
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 })),
|
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 })),
|
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: {},
|
modelAliases: {},
|
||||||
customModels: [],
|
customModels: [],
|
||||||
mitmAlias: {},
|
mitmAlias: {},
|
||||||
@@ -177,8 +177,8 @@ export async function importDb(payload) {
|
|||||||
}
|
}
|
||||||
for (const c of payload.combos || []) {
|
for (const c of payload.combos || []) {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
`INSERT OR REPLACE INTO combos(id, name, ownerId, 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()]
|
[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 || {})) {
|
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 m003 from "./003-api-key-owners.js";
|
||||||
import m004 from "./004-provider-connection-owners.js";
|
import m004 from "./004-provider-connection-owners.js";
|
||||||
import m005 from "./005-usage-user-attribution.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() {
|
export function latestVersion() {
|
||||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export async function getApiKeyById(id) {
|
|||||||
return rowToKey(row);
|
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) {
|
export async function getApiKeyByIdAndOwnerId(id, ownerId) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT * FROM apiKeys WHERE id = ? AND ownerId = ?`, [id, ownerId]);
|
const row = db.get(`SELECT * FROM apiKeys WHERE id = ? AND ownerId = ?`, [id, ownerId]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ function rowToCombo(row) {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
ownerId: row.ownerId,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
models: parseJson(row.models, []),
|
models: parseJson(row.models, []),
|
||||||
createdAt: row.createdAt,
|
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 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);
|
return rows.map(rowToCombo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getComboById(id) {
|
export async function getComboById(id, ownerId = undefined) {
|
||||||
const db = await getAdapter();
|
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);
|
return rowToCombo(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getComboByName(name) {
|
export async function getComboByName(name, ownerId = undefined) {
|
||||||
const db = await getAdapter();
|
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);
|
return rowToCombo(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,36 +48,45 @@ export async function createCombo(data) {
|
|||||||
const combo = {
|
const combo = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
ownerId: data.ownerId ?? null,
|
||||||
kind: data.kind || null,
|
kind: data.kind || null,
|
||||||
models: data.models || [],
|
models: data.models || [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO combos(id, name, ownerId, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt]
|
[combo.id, combo.name, combo.ownerId, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt]
|
||||||
);
|
);
|
||||||
return combo;
|
return combo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCombo(id, data) {
|
export async function updateCombo(id, data, ownerId = undefined) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
let result = null;
|
let result = null;
|
||||||
db.transaction(() => {
|
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;
|
if (!row) return;
|
||||||
const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() };
|
const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() };
|
||||||
db.run(
|
db.run(
|
||||||
`UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`,
|
`UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?${hasOwnerScope ? " AND ownerId IS ?" : ""}`,
|
||||||
[merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id]
|
hasOwnerScope
|
||||||
|
? [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id, ownerId]
|
||||||
|
: [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id]
|
||||||
);
|
);
|
||||||
result = merged;
|
result = merged;
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCombo(id) {
|
export async function deleteCombo(id, ownerId = undefined) {
|
||||||
const db = await getAdapter();
|
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;
|
return (res?.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,12 @@ export async function getProviderConnections(filter = {}) {
|
|||||||
const where = [];
|
const where = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (filter.provider) { where.push("provider = ?"); params.push(filter.provider); }
|
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); }
|
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 sql = `SELECT * FROM providerConnections${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`;
|
||||||
const rows = db.all(sql, params);
|
const rows = db.all(sql, params);
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ export async function updateSettings(updates) {
|
|||||||
return mergeWithDefaults(next);
|
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() {
|
export async function isCloudEnabled() {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
return settings.cloudEnabled === true;
|
return settings.cloudEnabled === true;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
// 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.
|
// 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 = `
|
export const PRAGMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@@ -110,13 +110,18 @@ export const TABLES = {
|
|||||||
combos: {
|
combos: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
name: "TEXT UNIQUE NOT NULL",
|
name: "TEXT NOT NULL",
|
||||||
|
ownerId: "TEXT",
|
||||||
kind: "TEXT",
|
kind: "TEXT",
|
||||||
models: "TEXT NOT NULL",
|
models: "TEXT NOT NULL",
|
||||||
createdAt: "TEXT NOT NULL",
|
createdAt: "TEXT NOT NULL",
|
||||||
updatedAt: "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: {
|
kv: {
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
||||||
// Kept for backward compatibility with existing imports.
|
// Kept for backward compatibility with existing imports.
|
||||||
export {
|
export {
|
||||||
getSettings, updateSettings, isCloudEnabled, getCloudUrl,
|
getSettings, updateSettings, updateComboStrategy, isCloudEnabled, getCloudUrl,
|
||||||
getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser,
|
getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser,
|
||||||
countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword,
|
countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword,
|
||||||
getProviderConnections, getProviderConnectionById,
|
getProviderConnections, getProviderConnectionById,
|
||||||
@@ -12,7 +12,7 @@ export {
|
|||||||
createProviderNode, updateProviderNode, deleteProviderNode,
|
createProviderNode, updateProviderNode, deleteProviderNode,
|
||||||
getProxyPools, getProxyPoolById,
|
getProxyPools, getProxyPoolById,
|
||||||
createProxyPool, updateProxyPool, deleteProxyPool,
|
createProxyPool, updateProxyPool, deleteProxyPool,
|
||||||
getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId,
|
getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByKey, getApiKeyByIdAndOwnerId,
|
||||||
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||||
getCombos, getComboById, getComboByName,
|
getCombos, getComboById, getComboByName,
|
||||||
createCombo, updateCombo, deleteCombo,
|
createCombo, updateCombo, deleteCombo,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
getMitmAlias,
|
getMitmAlias,
|
||||||
setMitmAliasAll,
|
setMitmAliasAll,
|
||||||
getApiKeys,
|
getApiKeys,
|
||||||
|
getApiKeyByKey,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
deleteApiKey,
|
deleteApiKey,
|
||||||
validateApiKey,
|
validateApiKey,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const navItems = [
|
|||||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||||
{ href: "/dashboard/models", label: "Models", icon: "view_list" },
|
{ href: "/dashboard/models", label: "Models", icon: "view_list" },
|
||||||
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
|
// { 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/usage", label: "Usage", icon: "bar_chart" },
|
||||||
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
||||||
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
||||||
|
|||||||
+27
-19
@@ -5,11 +5,12 @@ import {
|
|||||||
markAccountUnavailable,
|
markAccountUnavailable,
|
||||||
clearAccountError,
|
clearAccountError,
|
||||||
extractApiKey,
|
extractApiKey,
|
||||||
|
getApiKeyOwnerId,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
|
import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
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 { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||||
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
||||||
import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js";
|
import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js";
|
||||||
@@ -56,6 +57,7 @@ export async function handleChat(request, clientRawRequest = null) {
|
|||||||
// Log API key (masked)
|
// Log API key (masked)
|
||||||
const authHeader = request.headers.get("Authorization");
|
const authHeader = request.headers.get("Authorization");
|
||||||
const apiKey = extractApiKey(request);
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
if (authHeader && apiKey) {
|
if (authHeader && apiKey) {
|
||||||
const masked = log.maskKey(apiKey);
|
const masked = log.maskKey(apiKey);
|
||||||
log.debug("AUTH", `API Key: ${masked}`);
|
log.debug("AUTH", `API Key: ${masked}`);
|
||||||
@@ -88,11 +90,12 @@ export async function handleChat(request, clientRawRequest = null) {
|
|||||||
if (bypassResponse) return bypassResponse.response || bypassResponse;
|
if (bypassResponse) return bypassResponse.response || bypassResponse;
|
||||||
|
|
||||||
// Check if model is a combo (has multiple models with fallback)
|
// Check if model is a combo (has multiple models with fallback)
|
||||||
const comboModels = await getComboModels(modelStr);
|
const combo = await getCombo(modelStr, ownerId);
|
||||||
if (comboModels) {
|
if (combo) {
|
||||||
|
const comboModels = combo.models;
|
||||||
// Check for combo-specific strategy first, fallback to global
|
// Check for combo-specific strategy first, fallback to global
|
||||||
const comboStrategies = settings.comboStrategies || {};
|
const comboStrategies = settings.comboStrategies || {};
|
||||||
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
|
const comboSpecificStrategy = comboStrategies[combo.id]?.fallbackStrategy;
|
||||||
const comboStrategy = comboSpecificStrategy || settings.comboStrategy || "fallback";
|
const comboStrategy = comboSpecificStrategy || settings.comboStrategy || "fallback";
|
||||||
|
|
||||||
if (comboStrategy === "fusion") {
|
if (comboStrategy === "fusion") {
|
||||||
@@ -106,12 +109,13 @@ export async function handleChat(request, clientRawRequest = null) {
|
|||||||
const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {};
|
const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {};
|
||||||
cleanRawReq = { ...clientRawRequest, body: cleanBody };
|
cleanRawReq = { ...clientRawRequest, body: cleanBody };
|
||||||
}
|
}
|
||||||
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey);
|
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey, ownerId);
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
judgeModel: comboStrategies[modelStr]?.judgeModel,
|
comboId: combo.id,
|
||||||
tuning: comboStrategies[modelStr]?.fusionTuning,
|
judgeModel: comboStrategies[combo.id]?.judgeModel,
|
||||||
|
tuning: comboStrategies[combo.id]?.fusionTuning,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,32 +124,34 @@ export async function handleChat(request, clientRawRequest = null) {
|
|||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, ownerId),
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
|
comboId: combo.id,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit
|
comboStickyLimit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single model request
|
// Single model request
|
||||||
return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey);
|
return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey, ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle single model chat request
|
* Handle single model chat request
|
||||||
*/
|
*/
|
||||||
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) {
|
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, ownerId = undefined) {
|
||||||
const modelInfo = await getModelInfo(modelStr);
|
const modelInfo = await getModelInfo(modelStr, ownerId);
|
||||||
|
|
||||||
// If provider is null, this might be a combo name - check and handle
|
// If provider is null, this might be a combo name - check and handle
|
||||||
if (!modelInfo.provider) {
|
if (!modelInfo.provider) {
|
||||||
const comboModels = await getComboModels(modelStr);
|
const combo = await getCombo(modelStr, ownerId);
|
||||||
if (comboModels) {
|
if (combo) {
|
||||||
|
const comboModels = combo.models;
|
||||||
const chatSettings = await getSettings();
|
const chatSettings = await getSettings();
|
||||||
// Check for combo-specific strategy first, fallback to global
|
// Check for combo-specific strategy first, fallback to global
|
||||||
const comboStrategies = chatSettings.comboStrategies || {};
|
const comboStrategies = chatSettings.comboStrategies || {};
|
||||||
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
|
const comboSpecificStrategy = comboStrategies[combo.id]?.fallbackStrategy;
|
||||||
const comboStrategy = comboSpecificStrategy || chatSettings.comboStrategy || "fallback";
|
const comboStrategy = comboSpecificStrategy || chatSettings.comboStrategy || "fallback";
|
||||||
|
|
||||||
if (comboStrategy === "fusion") {
|
if (comboStrategy === "fusion") {
|
||||||
@@ -159,12 +165,13 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {};
|
const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {};
|
||||||
cleanRawReq = { ...clientRawRequest, body: cleanBody };
|
cleanRawReq = { ...clientRawRequest, body: cleanBody };
|
||||||
}
|
}
|
||||||
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey);
|
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey, ownerId);
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
judgeModel: comboStrategies[modelStr]?.judgeModel,
|
comboId: combo.id,
|
||||||
tuning: comboStrategies[modelStr]?.fusionTuning,
|
judgeModel: comboStrategies[combo.id]?.judgeModel,
|
||||||
|
tuning: comboStrategies[combo.id]?.fusionTuning,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,9 +180,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, ownerId),
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
|
comboId: combo.id,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit
|
comboStickyLimit
|
||||||
});
|
});
|
||||||
@@ -200,7 +208,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId });
|
||||||
|
|
||||||
// All accounts unavailable
|
// All accounts unavailable
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
markAccountUnavailable,
|
markAccountUnavailable,
|
||||||
clearAccountError,
|
clearAccountError,
|
||||||
extractApiKey,
|
extractApiKey,
|
||||||
|
getApiKeyOwnerId,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
@@ -36,6 +37,7 @@ export async function handleEmbeddings(request) {
|
|||||||
|
|
||||||
// Log API key (masked)
|
// Log API key (masked)
|
||||||
const apiKey = extractApiKey(request);
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -66,7 +68,7 @@ export async function handleEmbeddings(request) {
|
|||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelInfo = await getModelInfo(modelStr);
|
const modelInfo = await getModelInfo(modelStr, ownerId);
|
||||||
if (!modelInfo.provider) {
|
if (!modelInfo.provider) {
|
||||||
log.warn("EMBEDDINGS", "Invalid model format", { model: modelStr });
|
log.warn("EMBEDDINGS", "Invalid model format", { model: modelStr });
|
||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||||
@@ -89,7 +91,7 @@ export async function handleEmbeddings(request) {
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId });
|
||||||
|
|
||||||
// All accounts unavailable
|
// All accounts unavailable
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
markAccountUnavailable,
|
markAccountUnavailable,
|
||||||
clearAccountError,
|
clearAccountError,
|
||||||
extractApiKey,
|
extractApiKey,
|
||||||
|
getApiKeyOwnerId,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings, getCombos } from "@/lib/localDb";
|
import { getSettings, getCombos } from "@/lib/localDb";
|
||||||
@@ -41,6 +42,7 @@ export async function handleFetch(request) {
|
|||||||
|
|
||||||
// Log API key (masked)
|
// Log API key (masked)
|
||||||
const apiKey = extractApiKey(request);
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -88,11 +90,12 @@ export async function handleFetch(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
||||||
const combos = await getCombos();
|
const combos = await getCombos(ownerId);
|
||||||
const comboModels = getComboModelsFromData(providerInput, combos);
|
const combo = combos.find((entry) => entry.name === providerInput && entry.models?.length > 0);
|
||||||
|
const comboModels = getComboModelsFromData(providerInput, combo ? [combo] : []);
|
||||||
if (comboModels) {
|
if (comboModels) {
|
||||||
const comboStrategies = settings.comboStrategies || {};
|
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;
|
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
|
||||||
log.info("FETCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
log.info("FETCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
@@ -101,6 +104,7 @@ export async function handleFetch(request) {
|
|||||||
handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings),
|
handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings),
|
||||||
log,
|
log,
|
||||||
comboName: providerInput,
|
comboName: providerInput,
|
||||||
|
comboId: combo.id,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit
|
comboStickyLimit
|
||||||
});
|
});
|
||||||
@@ -159,7 +163,7 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
|
const credentials = await getProviderCredentials(providerId, excludeConnectionIds, null, { ownerId });
|
||||||
|
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
if (credentials?.allRateLimited) {
|
if (credentials?.allRateLimited) {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import {
|
|||||||
markAccountUnavailable,
|
markAccountUnavailable,
|
||||||
clearAccountError,
|
clearAccountError,
|
||||||
extractApiKey,
|
extractApiKey,
|
||||||
|
getApiKeyOwnerId,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
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 { handleImageGenerationCore } from "open-sse/handlers/imageGenerationCore.js";
|
||||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
@@ -37,6 +38,7 @@ export async function handleImageGeneration(request) {
|
|||||||
const modelStr = body.model;
|
const modelStr = body.model;
|
||||||
|
|
||||||
const apiKey = extractApiKey(request);
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (settings.requireApiKey) {
|
if (settings.requireApiKey) {
|
||||||
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
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");
|
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
|
// Combo expansion: model may be a combo name → run fallback/round-robin across models
|
||||||
const comboModels = await getComboModels(modelStr);
|
const combo = await getCombo(modelStr, ownerId);
|
||||||
if (comboModels) {
|
if (combo) {
|
||||||
|
const comboModels = combo.models;
|
||||||
const comboStrategies = settings.comboStrategies || {};
|
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;
|
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
|
||||||
log.info("IMAGE", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
log.info("IMAGE", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelImage(b, m, { wantsStream, binaryOutput, preferredConnectionId }),
|
handleSingleModel: (b, m) => handleSingleModelImage(b, m, { wantsStream, binaryOutput, preferredConnectionId, ownerId }),
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
|
comboId: combo.id,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit,
|
comboStickyLimit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId });
|
return handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId, ownerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId } = {}) {
|
async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId, ownerId } = {}) {
|
||||||
const modelInfo = await getModelInfo(modelStr);
|
const modelInfo = await getModelInfo(modelStr, ownerId);
|
||||||
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||||
|
|
||||||
const { provider, model } = modelInfo;
|
const { provider, model } = modelInfo;
|
||||||
@@ -95,7 +99,7 @@ async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutpu
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
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 || credentials.allRateLimited) {
|
||||||
if (credentials?.allRateLimited) {
|
if (credentials?.allRateLimited) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
markAccountUnavailable,
|
markAccountUnavailable,
|
||||||
clearAccountError,
|
clearAccountError,
|
||||||
extractApiKey,
|
extractApiKey,
|
||||||
|
getApiKeyOwnerId,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings, getCombos } from "@/lib/localDb";
|
import { getSettings, getCombos } from "@/lib/localDb";
|
||||||
@@ -38,6 +39,7 @@ export async function handleSearch(request) {
|
|||||||
|
|
||||||
// Log API key (masked)
|
// Log API key (masked)
|
||||||
const apiKey = extractApiKey(request);
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -69,11 +71,12 @@ export async function handleSearch(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
||||||
const combos = await getCombos();
|
const combos = await getCombos(ownerId);
|
||||||
const comboModels = getComboModelsFromData(providerInput, combos);
|
const combo = combos.find((entry) => entry.name === providerInput && entry.models?.length > 0);
|
||||||
|
const comboModels = getComboModelsFromData(providerInput, combo ? [combo] : []);
|
||||||
if (comboModels) {
|
if (comboModels) {
|
||||||
const comboStrategies = settings.comboStrategies || {};
|
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;
|
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
|
||||||
log.info("SEARCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
log.info("SEARCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
@@ -82,6 +85,7 @@ export async function handleSearch(request) {
|
|||||||
handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings),
|
handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings),
|
||||||
log,
|
log,
|
||||||
comboName: providerInput,
|
comboName: providerInput,
|
||||||
|
comboId: combo.id,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit
|
comboStickyLimit
|
||||||
});
|
});
|
||||||
@@ -149,7 +153,7 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey,
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
|
const credentials = await getProviderCredentials(providerId, excludeConnectionIds, null, { ownerId });
|
||||||
|
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
if (credentials?.allRateLimited) {
|
if (credentials?.allRateLimited) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
extractApiKey, isValidApiKey,
|
extractApiKey, getApiKeyOwnerId, isValidApiKey,
|
||||||
getProviderCredentials, markAccountUnavailable,
|
getProviderCredentials, markAccountUnavailable,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
@@ -30,8 +30,9 @@ export async function handleStt(request) {
|
|||||||
log.request("POST", `/v1/audio/transcriptions | ${modelStr}`);
|
log.request("POST", `/v1/audio/transcriptions | ${modelStr}`);
|
||||||
|
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
if (settings.requireApiKey) {
|
if (settings.requireApiKey) {
|
||||||
const apiKey = extractApiKey(request);
|
|
||||||
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||||
const valid = await isValidApiKey(apiKey);
|
const valid = await isValidApiKey(apiKey);
|
||||||
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
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 (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||||
if (!formData.get("file")) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: file");
|
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");
|
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||||
|
|
||||||
const { provider, model } = modelInfo;
|
const { provider, model } = modelInfo;
|
||||||
@@ -63,7 +64,7 @@ export async function handleStt(request) {
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId });
|
||||||
|
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
if (credentials?.allRateLimited) {
|
if (credentials?.allRateLimited) {
|
||||||
|
|||||||
+14
-10
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
extractApiKey, isValidApiKey,
|
extractApiKey, isValidApiKey,
|
||||||
|
getApiKeyOwnerId,
|
||||||
getProviderCredentials, markAccountUnavailable,
|
getProviderCredentials, markAccountUnavailable,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
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 { handleTtsCore } from "open-sse/handlers/ttsCore.js";
|
||||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.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}` : ""}`);
|
log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}${language ? ` | lang=${language}` : ""}`);
|
||||||
|
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
const apiKey = extractApiKey(request);
|
||||||
|
const ownerId = await getApiKeyOwnerId(apiKey);
|
||||||
if (settings.requireApiKey) {
|
if (settings.requireApiKey) {
|
||||||
const apiKey = extractApiKey(request);
|
|
||||||
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||||
const valid = await isValidApiKey(apiKey);
|
const valid = await isValidApiKey(apiKey);
|
||||||
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
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");
|
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
|
// Combo expansion: model may be a combo name → run fallback/round-robin across models
|
||||||
const comboModels = await getComboModels(modelStr);
|
const combo = await getCombo(modelStr, ownerId);
|
||||||
if (comboModels) {
|
if (combo) {
|
||||||
|
const comboModels = combo.models;
|
||||||
const comboStrategies = settings.comboStrategies || {};
|
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;
|
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
|
||||||
log.info("TTS", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
log.info("TTS", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language),
|
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language, ownerId),
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
|
comboId: combo.id,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit,
|
comboStickyLimit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleSingleModelTts(body, modelStr, responseFormat, language);
|
return handleSingleModelTts(body, modelStr, responseFormat, language, ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSingleModelTts(body, modelStr, responseFormat, language) {
|
async function handleSingleModelTts(body, modelStr, responseFormat, language, ownerId = undefined) {
|
||||||
const modelInfo = await getModelInfo(modelStr);
|
const modelInfo = await getModelInfo(modelStr, ownerId);
|
||||||
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||||
|
|
||||||
const { provider, model } = modelInfo;
|
const { provider, model } = modelInfo;
|
||||||
@@ -89,7 +93,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) {
|
|||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { ownerId });
|
||||||
|
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
if (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 { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
|
||||||
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
|
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
|
||||||
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.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 {string} provider - Provider name
|
||||||
* @param {Set<string>|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
|
* @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 {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 = {}) {
|
export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) {
|
||||||
// Normalize to Set for consistent handling
|
// Normalize to Set for consistent handling
|
||||||
@@ -21,6 +22,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
|||||||
? excludeConnectionIds
|
? excludeConnectionIds
|
||||||
: (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
|
: (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
|
||||||
const preferredConnectionId = options?.preferredConnectionId || null;
|
const preferredConnectionId = options?.preferredConnectionId || null;
|
||||||
|
const ownerId = options?.ownerId;
|
||||||
// Acquire mutex to prevent race conditions
|
// Acquire mutex to prevent race conditions
|
||||||
const currentMutex = selectionMutex;
|
const currentMutex = selectionMutex;
|
||||||
let resolveMutex;
|
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"}`);
|
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
|
||||||
|
|
||||||
if (connections.length === 0) {
|
if (connections.length === 0) {
|
||||||
@@ -317,3 +321,15 @@ export async function isValidApiKey(apiKey) {
|
|||||||
if (!apiKey) return false;
|
if (!apiKey) return false;
|
||||||
return await validateApiKey(apiKey);
|
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)
|
* Get full model info (parse or resolve)
|
||||||
*/
|
*/
|
||||||
export async function getModelInfo(modelStr) {
|
export async function getModelInfo(modelStr, ownerId = undefined) {
|
||||||
const parsed = parseModel(modelStr);
|
const parsed = parseModel(modelStr);
|
||||||
|
|
||||||
if (!parsed.isAlias) {
|
if (!parsed.isAlias) {
|
||||||
@@ -68,7 +68,7 @@ export async function getModelInfo(modelStr) {
|
|||||||
|
|
||||||
// Check if this is a combo name before resolving as alias
|
// Check if this is a combo name before resolving as alias
|
||||||
// This prevents combo names from being incorrectly routed to providers
|
// 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) {
|
if (combo) {
|
||||||
// Return null provider to signal this should be handled as combo
|
// Return null provider to signal this should be handled as combo
|
||||||
// The caller (handleChat) will detect this and handle it 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
|
* Check if model is a combo and get models list
|
||||||
* @returns {Promise<string[]|null>} Array of models or null if not a combo
|
* @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
|
// Only check if it's not in provider/model format
|
||||||
if (modelStr.includes("/")) return null;
|
if (modelStr.includes("/")) return null;
|
||||||
|
|
||||||
const combo = await getComboByName(modelStr);
|
const combo = await getComboByName(modelStr, ownerId);
|
||||||
if (combo && combo.models && combo.models.length > 0) {
|
if (combo && combo.models && combo.models.length > 0) {
|
||||||
return combo.models;
|
return combo.models;
|
||||||
}
|
}
|
||||||
return null;
|
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 models = ["provider/model-a", "provider/model-b"];
|
||||||
|
|
||||||
const firstChoices = Array.from({ length: 4 }, () => (
|
const firstChoices = Array.from({ length: 4 }, () => (
|
||||||
getRotatedModels(models, "code-xhigh", "round-robin")[0]
|
getRotatedModels(models, "combo-user-a", "round-robin")[0]
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(firstChoices).toEqual([
|
expect(firstChoices).toEqual([
|
||||||
@@ -26,7 +26,7 @@ describe("combo round-robin routing", () => {
|
|||||||
const models = ["provider/model-a", "provider/model-b"];
|
const models = ["provider/model-a", "provider/model-b"];
|
||||||
|
|
||||||
const firstChoices = Array.from({ length: 6 }, () => (
|
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([
|
expect(firstChoices).toEqual([
|
||||||
@@ -42,17 +42,27 @@ describe("combo round-robin routing", () => {
|
|||||||
it("tracks sticky rotation independently per combo", () => {
|
it("tracks sticky rotation independently per combo", () => {
|
||||||
const models = ["provider/model-a", "provider/model-b"];
|
const models = ["provider/model-a", "provider/model-b"];
|
||||||
|
|
||||||
expect(getRotatedModels(models, "code-high", "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, "code-xhigh", "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, "code-high", "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, "code-high", "round-robin", 2)[0]).toBe("provider/model-b");
|
expect(getRotatedModels(models, "combo-user-a", "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-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", () => {
|
it("does not rotate fallback combos", () => {
|
||||||
const models = ["provider/model-a", "provider/model-b"];
|
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);
|
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 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 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 () => {
|
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 });
|
const ownerOneConnections = await sqliteDb.getProviderConnections({ ownerId: ownerOne.id });
|
||||||
expect(ownerOneConnections.map((connection) => connection.id)).toContain(firstConnection.id);
|
expect(ownerOneConnections.map((connection) => connection.id)).toContain(firstConnection.id);
|
||||||
expect(ownerOneConnections.map((connection) => connection.id)).not.toContain(secondConnection.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({
|
await expect(sqliteDb.createProviderConnection({
|
||||||
provider: "owner-test-account",
|
provider: "owner-test-account",
|
||||||
authType: "oauth",
|
authType: "oauth",
|
||||||
|
|||||||
Reference in New Issue
Block a user