mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat: complete Auto-Combo CRUD and fix missing translations
This commit is contained in:
@@ -173,35 +173,6 @@ export function selectProvider(
|
||||
};
|
||||
}
|
||||
|
||||
// ============ In-Memory Auto-Combo Registry ============
|
||||
|
||||
const autoCombos = new Map<string, AutoComboConfig>();
|
||||
|
||||
export function createAutoCombo(config: Omit<AutoComboConfig, "type">): AutoComboConfig {
|
||||
const full: AutoComboConfig = { ...config, type: "auto" };
|
||||
autoCombos.set(config.id, full);
|
||||
return full;
|
||||
}
|
||||
|
||||
export function getAutoCombo(id: string): AutoComboConfig | undefined {
|
||||
return autoCombos.get(id);
|
||||
}
|
||||
|
||||
export function updateAutoCombo(
|
||||
id: string,
|
||||
update: Partial<AutoComboConfig>
|
||||
): AutoComboConfig | undefined {
|
||||
const existing = autoCombos.get(id);
|
||||
if (!existing) return undefined;
|
||||
const updated = { ...existing, ...update, id, type: "auto" as const };
|
||||
autoCombos.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteAutoCombo(id: string): boolean {
|
||||
return autoCombos.delete(id);
|
||||
}
|
||||
|
||||
export function listAutoCombos(): AutoComboConfig[] {
|
||||
return [...autoCombos.values()];
|
||||
}
|
||||
// ============ Auto-Combo Config Schema Reference ============
|
||||
// Note: AutoCombos are now persisted natively in the SQLite DB via src/lib/db/combos.ts
|
||||
// using the combo.strategy = "auto" | "lkgp" type, with parameters nested inside combo.config
|
||||
|
||||
@@ -90,19 +90,27 @@ function getLatestPoints(points: ProviderUtilizationPoint[]) {
|
||||
|
||||
export default function ProviderUtilizationTab() {
|
||||
const [range, setRange] = useState<UtilizationTimeRange>("24h");
|
||||
const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider");
|
||||
const [data, setData] = useState<ProviderUtilizationResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchUtilization = useCallback(
|
||||
async (selectedRange: UtilizationTimeRange, signal?: AbortSignal) => {
|
||||
async (
|
||||
selectedRange: UtilizationTimeRange,
|
||||
selectedAggregate: "provider" | "connection",
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/usage/utilization?range=${selectedRange}`, {
|
||||
signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/usage/utilization?range=${selectedRange}&aggregateBy=${selectedAggregate}`,
|
||||
{
|
||||
signal,
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch utilization data");
|
||||
@@ -132,10 +140,10 @@ export default function ProviderUtilizationTab() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetchUtilization(range, controller.signal);
|
||||
fetchUtilization(range, aggregateBy, controller.signal);
|
||||
|
||||
return () => controller.abort();
|
||||
}, [fetchUtilization, range]);
|
||||
}, [fetchUtilization, range, aggregateBy]);
|
||||
|
||||
const providerColors = useMemo(() => {
|
||||
const colors = new Map<string, string>();
|
||||
@@ -177,8 +185,8 @@ export default function ProviderUtilizationTab() {
|
||||
const handleRetry = useCallback(() => {
|
||||
setRetrying(true);
|
||||
setError(null);
|
||||
fetchUtilization(range).finally(() => setRetrying(false));
|
||||
}, [range, fetchUtilization]);
|
||||
fetchUtilization(range, aggregateBy).finally(() => setRetrying(false));
|
||||
}, [range, aggregateBy, fetchUtilization]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -186,7 +194,35 @@ export default function ProviderUtilizationTab() {
|
||||
title="Provider utilization"
|
||||
subtitle={RANGE_LABELS[range]}
|
||||
icon="monitoring"
|
||||
action={<TimeRangeSelector value={range} onChange={setRange} />}
|
||||
action={
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex rounded-lg border border-border/50 bg-black/5 p-1 dark:bg-white/5">
|
||||
<button
|
||||
onClick={() => setAggregateBy("provider")}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
aggregateBy === "provider"
|
||||
? "bg-surface text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">dns</span>
|
||||
Global View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAggregateBy("connection")}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
aggregateBy === "connection"
|
||||
? "bg-surface text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">account_tree</span>
|
||||
Account Split
|
||||
</button>
|
||||
</div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{loading && !hasData ? (
|
||||
|
||||
161
src/app/(dashboard)/dashboard/auto-combo/AutoComboModal.tsx
Normal file
161
src/app/(dashboard)/dashboard/auto-combo/AutoComboModal.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Input, Button } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AutoComboModal({ isOpen, onClose, onSave, combo, activeProviders = [] }) {
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
strategy: "auto",
|
||||
candidatePool: [],
|
||||
explorationRate: 0.05,
|
||||
modePack: "ship-fast",
|
||||
budgetCap: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (combo) {
|
||||
// eslint-disable-next-line
|
||||
setFormData({
|
||||
name: combo.name || "",
|
||||
strategy: combo.strategy || "auto",
|
||||
candidatePool: combo.config?.candidatePool || [],
|
||||
explorationRate: combo.config?.explorationRate ?? 0.05,
|
||||
modePack: combo.config?.modePack || "ship-fast",
|
||||
budgetCap: combo.config?.budgetCap || "",
|
||||
});
|
||||
} else {
|
||||
|
||||
setFormData({
|
||||
name: "",
|
||||
strategy: "auto",
|
||||
candidatePool: [],
|
||||
explorationRate: 0.05,
|
||||
modePack: "ship-fast",
|
||||
budgetCap: "",
|
||||
});
|
||||
}
|
||||
}, [combo, isOpen]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name: formData.name,
|
||||
strategy: formData.strategy,
|
||||
config: {
|
||||
candidatePool: formData.candidatePool,
|
||||
explorationRate: Number(formData.explorationRate),
|
||||
modePack: formData.modePack,
|
||||
budgetCap: formData.budgetCap ? Number(formData.budgetCap) : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleProviderToggle = (providerId) => {
|
||||
setFormData((prev) => {
|
||||
const pool = prev.candidatePool.includes(providerId)
|
||||
? prev.candidatePool.filter((id) => id !== providerId)
|
||||
: [...prev.candidatePool, providerId];
|
||||
return { ...prev, candidatePool: pool };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={combo ? "Edit Auto-Combo" : "Create Auto-Combo"}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Combo Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
pattern="^[a-zA-Z0-9_\/\.\-]+$"
|
||||
disabled={!!combo} // Cannot change name if editing
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Strategy</label>
|
||||
<select
|
||||
className="w-full text-sm rounded-lg border border-border bg-surface px-3 py-2 text-text-main focus:border-primary focus:outline-none"
|
||||
value={formData.strategy}
|
||||
onChange={(e) => setFormData({ ...formData, strategy: e.target.value })}
|
||||
>
|
||||
<option value="auto">Smart Auto-Routing</option>
|
||||
<option value="lkgp">Last Known Good Provider (LKGP)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Candidate Pool</label>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
Select which providers this engine evaluates.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-2 border border-border rounded-lg">
|
||||
{activeProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => handleProviderToggle(p.id)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
formData.candidatePool.includes(p.id)
|
||||
? "bg-primary border-primary text-white"
|
||||
: "bg-surface border-border text-text-main"
|
||||
}`}
|
||||
>
|
||||
{p.name || p.id}
|
||||
</button>
|
||||
))}
|
||||
{activeProviders.length === 0 && (
|
||||
<span className="text-xs text-text-muted">No active APIs found</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Exploration Rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={formData.explorationRate}
|
||||
onChange={(e) => setFormData({ ...formData, explorationRate: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Mode Pack</label>
|
||||
<select
|
||||
className="w-full text-sm rounded-lg border border-border bg-surface px-3 py-2 text-text-main focus:border-primary focus:outline-none"
|
||||
value={formData.modePack}
|
||||
onChange={(e) => setFormData({ ...formData, modePack: e.target.value })}
|
||||
>
|
||||
<option value="ship-fast">Ship Fast</option>
|
||||
<option value="cost-saver">Cost Saver</option>
|
||||
<option value="quality-first">Quality First</option>
|
||||
<option value="offline-friendly">Offline Friendly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Budget Cap ($ USD / request limit)"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="Optional"
|
||||
value={formData.budgetCap}
|
||||
onChange={(e) => setFormData({ ...formData, budgetCap: e.target.value })}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import AutoComboModal from "./AutoComboModal";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
interface ProviderScore {
|
||||
provider: string;
|
||||
@@ -44,21 +46,25 @@ export default function AutoComboDashboard() {
|
||||
const [incidentMode, setIncidentMode] = useState(false);
|
||||
const [modePack, setModePack] = useState("ship-fast");
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [combosRes, healthRes] = await Promise.allSettled([
|
||||
fetch("/api/combos/auto"),
|
||||
fetch("/api/monitoring/health"),
|
||||
]);
|
||||
const notify = useNotificationStore();
|
||||
const [combos, setCombos] = useState<any[]>([]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingCombo, setEditingCombo] = useState<any | null>(null);
|
||||
const [activeProviders, setActiveProviders] = useState<any[]>([]);
|
||||
|
||||
if (combosRes.status === "fulfilled") {
|
||||
const comboPayload = await combosRes.value.json();
|
||||
const combos = Array.isArray(comboPayload?.combos)
|
||||
? (comboPayload.combos as AutoComboRecord[])
|
||||
: [];
|
||||
const firstCombo = combos[0] || null;
|
||||
const candidatePool = Array.isArray(firstCombo?.candidatePool)
|
||||
? firstCombo.candidatePool.filter((entry): entry is string => typeof entry === "string")
|
||||
const fetchCombos = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/combos");
|
||||
if (res.ok) {
|
||||
const payload = await res.json();
|
||||
const allCombos = Array.isArray(payload?.combos) ? payload.combos : [];
|
||||
const auto = allCombos.filter((c: any) => c.strategy === "auto" || c.strategy === "lkgp");
|
||||
setCombos(auto);
|
||||
|
||||
// Refresh scores based on first auto combo found
|
||||
const firstCombo = auto[0] || null;
|
||||
const candidatePool = Array.isArray(firstCombo?.config?.candidatePool)
|
||||
? firstCombo.config.candidatePool
|
||||
: [];
|
||||
const rawWeights =
|
||||
firstCombo?.weights &&
|
||||
@@ -81,9 +87,16 @@ export default function AutoComboDashboard() {
|
||||
} else {
|
||||
setScores([]);
|
||||
}
|
||||
} catch {
|
||||
setScores([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (healthRes.status === "fulfilled") {
|
||||
const health = (await healthRes.value.json()) as HealthRecord;
|
||||
const fetchHealth = useCallback(async () => {
|
||||
try {
|
||||
const healthRes = await fetch("/api/monitoring/health");
|
||||
if (healthRes.ok) {
|
||||
const health = (await healthRes.json()) as HealthRecord;
|
||||
const providerHealth =
|
||||
health?.providerHealth && typeof health.providerHealth === "object"
|
||||
? health.providerHealth
|
||||
@@ -126,6 +139,23 @@ export default function AutoComboDashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
await Promise.all([fetchCombos(), fetchHealth()]);
|
||||
|
||||
// Fetch active providers for the Modal
|
||||
try {
|
||||
const pRes = await fetch("/api/providers");
|
||||
if (pRes.ok) {
|
||||
const pData = await pRes.json();
|
||||
setActiveProviders(
|
||||
(pData.connections || []).filter(
|
||||
(c: any) => c.testStatus === "active" || c.testStatus === "success"
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}, [fetchCombos, fetchHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(fetchData, 0);
|
||||
const interval = setInterval(fetchData, 30_000);
|
||||
@@ -135,6 +165,59 @@ export default function AutoComboDashboard() {
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
try {
|
||||
const res = await fetch("/api/combos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchCombos();
|
||||
setShowCreateModal(false);
|
||||
notify.success("Auto-Combo created successfully");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
notify.error(err.error?.message || err.error || "Failed to create combo");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Error creating combo");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: any) => {
|
||||
try {
|
||||
const res = await fetch(`/api/combos/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchCombos();
|
||||
setEditingCombo(null);
|
||||
notify.success("Auto-Combo updated");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
notify.error("Failed to update: " + (err.error?.message || err.error));
|
||||
}
|
||||
} catch {
|
||||
notify.error("Error updating combo");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this auto-combo?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setCombos(combos.filter((c) => c.id !== id));
|
||||
notify.success("Auto-combo deleted");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Error deleting combo");
|
||||
}
|
||||
};
|
||||
|
||||
const FACTOR_LABELS: Record<string, string> = {
|
||||
quota: "📊 Quota",
|
||||
health: "💚 Health",
|
||||
@@ -154,15 +237,74 @@ export default function AutoComboDashboard() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">⚡ Auto-Combo Engine</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Smart routing automatically adapting to latency, health, and throughput
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)}>
|
||||
Create Auto-Combo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ──── CRUD Auto Combos List ──── */}
|
||||
{combos.length > 0 && (
|
||||
<Card className="mb-2">
|
||||
<h2 className="text-lg font-semibold mb-4">Configured Auto-Combos</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{combos.map((combo) => (
|
||||
<div
|
||||
key={combo.id}
|
||||
className="p-4 border rounded-lg bg-surface flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-main flex items-center gap-2">
|
||||
{combo.name}
|
||||
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500">
|
||||
{combo.strategy}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Pool: {combo.config?.candidatePool?.length || "All"} APIs | Pack:{" "}
|
||||
{combo.config?.modePack || "fast"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingCombo(combo)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(combo.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Forms */}
|
||||
{showCreateModal && (
|
||||
<AutoComboModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
activeProviders={activeProviders}
|
||||
combo={null}
|
||||
/>
|
||||
)}
|
||||
{editingCombo && (
|
||||
<AutoComboModal
|
||||
isOpen={!!editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data: any) => handleUpdate(editingCombo.id, data)}
|
||||
activeProviders={activeProviders}
|
||||
combo={editingCombo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
@@ -242,8 +384,7 @@ export default function AutoComboDashboard() {
|
||||
|
||||
{scores.length === 0 ? (
|
||||
<p className="text-sm text-text-muted py-4">
|
||||
No auto-combo configured or data loading... Create one via{" "}
|
||||
<code>POST /api/combos/auto</code>.
|
||||
No auto-combo configured... Create one to see live provider scores.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Auto-Combo REST API — `/api/combos/auto`
|
||||
*
|
||||
* POST — Create auto-combo
|
||||
* GET — List all auto-combos
|
||||
*
|
||||
* Note: Auto-combo state is managed in-memory by the engine module.
|
||||
* The open-sse/services/autoCombo module is outside Next.js src/,
|
||||
* so we use a lightweight in-memory store here that mirrors the engine API.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createAutoComboSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
// ── In-memory auto-combo store (mirrors open-sse/services/autoCombo/engine.ts) ──
|
||||
|
||||
interface ScoringWeights {
|
||||
quota: number;
|
||||
health: number;
|
||||
costInv: number;
|
||||
latencyInv: number;
|
||||
taskFit: number;
|
||||
stability: number;
|
||||
tierPriority: number;
|
||||
}
|
||||
|
||||
const DEFAULT_WEIGHTS: ScoringWeights = {
|
||||
quota: 0.2,
|
||||
health: 0.25,
|
||||
costInv: 0.2,
|
||||
latencyInv: 0.15,
|
||||
taskFit: 0.1,
|
||||
stability: 0.05,
|
||||
tierPriority: 0.05,
|
||||
};
|
||||
|
||||
interface AutoComboConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "auto";
|
||||
candidatePool: string[];
|
||||
weights: ScoringWeights;
|
||||
modePack?: string;
|
||||
budgetCap?: number;
|
||||
explorationRate: number;
|
||||
}
|
||||
|
||||
const autoCombos = new Map<string, AutoComboConfig>();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(createAutoComboSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { id, name, candidatePool, weights, modePack, budgetCap, explorationRate } =
|
||||
validation.data;
|
||||
|
||||
const config: AutoComboConfig = {
|
||||
id,
|
||||
name,
|
||||
type: "auto",
|
||||
candidatePool,
|
||||
weights: weights ?? DEFAULT_WEIGHTS,
|
||||
modePack,
|
||||
budgetCap,
|
||||
explorationRate,
|
||||
};
|
||||
autoCombos.set(id, config);
|
||||
|
||||
return NextResponse.json(config, { status: 201 });
|
||||
} catch (err) {
|
||||
console.log("Error creating auto-combo:", err);
|
||||
return NextResponse.json({ error: "Failed to create auto-combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ combos: [...autoCombos.values()] });
|
||||
}
|
||||
@@ -43,11 +43,14 @@ export async function GET(request: Request) {
|
||||
const range = rangeParam as UtilizationTimeRange;
|
||||
const since = getRangeStartIso(range);
|
||||
const bucketMinutes = BUCKET_SIZES[range];
|
||||
const aggregateByParam = searchParams.get("aggregateBy");
|
||||
const aggregateBy = aggregateByParam === "connection" ? "connection" : "provider";
|
||||
|
||||
const data = getAggregatedSnapshots({
|
||||
provider: providerParam || undefined,
|
||||
since,
|
||||
bucketMinutes,
|
||||
aggregateBy,
|
||||
});
|
||||
|
||||
const providers = Array.from(new Set(data.map((d) => d.provider)));
|
||||
|
||||
@@ -994,7 +994,11 @@
|
||||
}
|
||||
},
|
||||
"templateFreeStack": "Free Stack ($0)",
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding.",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Costs",
|
||||
@@ -1778,7 +1782,10 @@
|
||||
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
|
||||
"modelsPathLabel": "Models Endpoint Path",
|
||||
"modelsPathPlaceholder": "/models",
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)",
|
||||
"statusDeactivated": "Deactivated (Manual)",
|
||||
"statusBanned": "Banned / Sandbox Violation",
|
||||
"statusCreditsExhausted": "Insufficient Balance / Quota Exhausted"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -1918,7 +1925,6 @@
|
||||
"uploadFavicon": "Upload Favicon",
|
||||
"resetFavicon": "Reset Favicon",
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"promptCache": "Prompt Cache",
|
||||
"flushCache": "Flush Cache",
|
||||
"flushing": "Flushing…",
|
||||
"size": "Size",
|
||||
@@ -1940,8 +1946,8 @@
|
||||
"thinkingBudgetDesc": "Control AI reasoning token usage across all requests",
|
||||
"passthrough": "Passthrough",
|
||||
"passthroughDesc": "No changes — client controls thinking budget",
|
||||
"auto": "Auto",
|
||||
"autoDesc": "Strip all thinking config — let provider decide",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"custom": "Custom",
|
||||
"customDesc": "Set a fixed token budget for all requests",
|
||||
"adaptive": "Adaptive",
|
||||
@@ -2209,7 +2215,6 @@
|
||||
"unsaved": "unsaved",
|
||||
"resetDefaults": "Reset Defaults",
|
||||
"saveProvider": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"model": "Model",
|
||||
"models": "models",
|
||||
"moreProviders": "{count} more providers",
|
||||
@@ -2240,7 +2245,12 @@
|
||||
"customPricingNote": "You can override default pricing for specific models. Custom overrides take priority over auto-detected pricing.",
|
||||
"editPricing": "Edit Pricing",
|
||||
"viewFullDetails": "View Full Details",
|
||||
"themeCoral": "Coral"
|
||||
"themeCoral": "Coral",
|
||||
"adaptiveVolumeRouting": "Adaptive Volume Routing",
|
||||
"adaptiveVolumeRoutingDesc": "Scale connections dynamically based on payload volume and throughput pressure.",
|
||||
"days": "Days",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
},
|
||||
"translator": {
|
||||
"title": "Translator",
|
||||
@@ -3102,6 +3112,9 @@
|
||||
"model": "Model",
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"deduplicatedRequests": "Deduplicated Requests",
|
||||
"savedCalls": "Saved API Calls",
|
||||
"totalProcessed": "Total Requests Processed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,7 +994,11 @@
|
||||
}
|
||||
},
|
||||
"templateFreeStack": "Free Stack ($0)",
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding.",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
|
||||
"lkgp": "Modo LKGP",
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Custos",
|
||||
@@ -1776,7 +1780,10 @@
|
||||
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
|
||||
"modelsPathLabel": "Models Endpoint Path",
|
||||
"modelsPathPlaceholder": "/models",
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)",
|
||||
"statusDeactivated": "Desativado (Manual)",
|
||||
"statusBanned": "Banido / Sandbox Violation",
|
||||
"statusCreditsExhausted": "Saldo Insuficiente"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
@@ -1918,8 +1925,8 @@
|
||||
"thinkingBudgetDesc": "Controle o uso de tokens de raciocínio da IA em todas as requisições",
|
||||
"passthrough": "Passagem Direta",
|
||||
"passthroughDesc": "Sem alterações — cliente controla orçamento de raciocínio",
|
||||
"auto": "Automático",
|
||||
"autoDesc": "Remove toda configuração de raciocínio — provedor decide",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
|
||||
"custom": "Personalizado",
|
||||
"customDesc": "Define um orçamento fixo de tokens para todas as requisições",
|
||||
"adaptive": "Adaptativo",
|
||||
@@ -2217,7 +2224,31 @@
|
||||
"customPricingNote": "Você pode sobrescrever preços padrão para modelos específicos. Sobrescritas personalizadas têm prioridade sobre preços detectados automaticamente.",
|
||||
"editPricing": "Editar Preços",
|
||||
"viewFullDetails": "Ver Detalhes Completos",
|
||||
"themeCoral": "Coral"
|
||||
"themeCoral": "Coral",
|
||||
"adaptiveVolumeRouting": "Roteamento de Volume Adaptativo",
|
||||
"adaptiveVolumeRoutingDesc": "Escala conexões dinamicamente com base no volume e pressão na taxa de transferência.",
|
||||
"days": "Dias",
|
||||
"lkgp": "Modo LKGP",
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)",
|
||||
"memoryTitle": "Memória",
|
||||
"memoryDesc": "Memória conversacional persistente entre sessões",
|
||||
"memoryEnabled": "Ativar Memória",
|
||||
"memoryEnabledDesc": "Quando ativado, injetará o contexto passado relevante de forma dinâmica.",
|
||||
"maxTokens": "Tokens Máximos",
|
||||
"retentionDays": "Retenção",
|
||||
"recent": "Recente",
|
||||
"recentDesc": "Janela cronológica",
|
||||
"semantic": "Semântica",
|
||||
"semanticDesc": "Busca vetorial",
|
||||
"hybrid": "Híbrido",
|
||||
"hybridDesc": "Recente + Semântico",
|
||||
"skillsTitle": "Skills A2A",
|
||||
"skillsDesc": "Ferramentas auto-executáveis",
|
||||
"skillsEnabled": "Ativar Skills",
|
||||
"skillsEnabledDesc": "Permite aos agentes executar consultas e gerar arquivos.",
|
||||
"skillsComingSoon": "Marketplace em breve.",
|
||||
"memorySkillsTitle": "Memória e Skills",
|
||||
"memorySkillsDesc": "Contexto persistente e capacidades A2A"
|
||||
},
|
||||
"translator": {
|
||||
"title": "Tradutor",
|
||||
@@ -3078,6 +3109,9 @@
|
||||
"model": "Model",
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"deduplicatedRequests": "Requisições Desduplicadas",
|
||||
"savedCalls": "Chamadas API Poupadas",
|
||||
"totalProcessed": "Total Processado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,7 +994,11 @@
|
||||
}
|
||||
},
|
||||
"templateFreeStack": "Free Stack ($0)",
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding.",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
|
||||
"lkgp": "Modo LKGP",
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Custos",
|
||||
@@ -1776,7 +1780,10 @@
|
||||
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
|
||||
"modelsPathLabel": "Models Endpoint Path",
|
||||
"modelsPathPlaceholder": "/models",
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)",
|
||||
"statusDeactivated": "Desativado (Manual)",
|
||||
"statusBanned": "Banido / Sandbox Violation",
|
||||
"statusCreditsExhausted": "Saldo Insuficiente"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
@@ -1918,8 +1925,8 @@
|
||||
"thinkingBudgetDesc": "Controle o uso do token de raciocínio de IA em todas as solicitações",
|
||||
"passthrough": "Passagem",
|
||||
"passthroughDesc": "Sem alterações – o cliente controla o orçamento pensado",
|
||||
"auto": "Automático",
|
||||
"autoDesc": "Remova todas as configurações de pensamento – deixe o provedor decidir",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
|
||||
"custom": "Personalizado",
|
||||
"customDesc": "Defina um orçamento fixo de tokens para todas as solicitações",
|
||||
"adaptive": "Adaptativo",
|
||||
@@ -2217,7 +2224,31 @@
|
||||
"customPricingNote": "Você pode substituir o preço padrão de modelos específicos. As substituições personalizadas têm prioridade sobre os preços detectados automaticamente.",
|
||||
"editPricing": "Editar preços",
|
||||
"viewFullDetails": "Ver detalhes completos",
|
||||
"themeCoral": "Coral"
|
||||
"themeCoral": "Coral",
|
||||
"adaptiveVolumeRouting": "Roteamento de Volume Adaptativo",
|
||||
"adaptiveVolumeRoutingDesc": "Escala conexões dinamicamente com base no volume e pressão na taxa de transferência.",
|
||||
"days": "Dias",
|
||||
"lkgp": "Modo LKGP",
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)",
|
||||
"memoryTitle": "Memória",
|
||||
"memoryDesc": "Memória conversacional persistente entre sessões",
|
||||
"memoryEnabled": "Ativar Memória",
|
||||
"memoryEnabledDesc": "Quando ativado, injetará o contexto passado relevante de forma dinâmica.",
|
||||
"maxTokens": "Tokens Máximos",
|
||||
"retentionDays": "Retenção",
|
||||
"recent": "Recente",
|
||||
"recentDesc": "Janela cronológica",
|
||||
"semantic": "Semântica",
|
||||
"semanticDesc": "Busca vetorial",
|
||||
"hybrid": "Híbrido",
|
||||
"hybridDesc": "Recente + Semântico",
|
||||
"skillsTitle": "Skills A2A",
|
||||
"skillsDesc": "Ferramentas auto-executáveis",
|
||||
"skillsEnabled": "Ativar Skills",
|
||||
"skillsEnabledDesc": "Permite aos agentes executar consultas e gerar arquivos.",
|
||||
"skillsComingSoon": "Marketplace em breve.",
|
||||
"memorySkillsTitle": "Memória e Skills",
|
||||
"memorySkillsDesc": "Contexto persistente e capacidades A2A"
|
||||
},
|
||||
"translator": {
|
||||
"title": "Tradutor",
|
||||
@@ -3078,6 +3109,9 @@
|
||||
"model": "Model",
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"deduplicatedRequests": "Requisições Desduplicadas",
|
||||
"savedCalls": "Chamadas API Poupadas",
|
||||
"totalProcessed": "Total Processado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export function getAggregatedSnapshots(opts: {
|
||||
since: string;
|
||||
until?: string;
|
||||
bucketMinutes: number;
|
||||
aggregateBy?: "provider" | "connection";
|
||||
}): ProviderUtilizationPoint[] {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const conditions: string[] = ["created_at >= ?"];
|
||||
@@ -92,16 +93,23 @@ export function getAggregatedSnapshots(opts: {
|
||||
throw new Error("Invalid bucket size");
|
||||
}
|
||||
|
||||
const groupFields =
|
||||
opts.aggregateBy === "connection"
|
||||
? "bucket, provider, connection_id, window_key"
|
||||
: "bucket, provider, window_key";
|
||||
const selectKey =
|
||||
opts.aggregateBy === "connection" ? "provider || ':' || connection_id as provider" : "provider";
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
datetime((strftime('%s', created_at) / ${bucketSeconds}) * ${bucketSeconds}, 'unixepoch') as bucket,
|
||||
provider,
|
||||
${selectKey},
|
||||
AVG(remaining_percentage) as remainingPct,
|
||||
MAX(is_exhausted) as isExhausted,
|
||||
window_key
|
||||
FROM quota_snapshots
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
GROUP BY bucket, provider, window_key
|
||||
GROUP BY ${groupFields}
|
||||
ORDER BY bucket ASC
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ export type RoutingStrategyValue =
|
||||
| "random"
|
||||
| "least-used"
|
||||
| "cost-optimized"
|
||||
| "strict-random";
|
||||
| "strict-random"
|
||||
| "auto"
|
||||
| "lkgp";
|
||||
|
||||
type RoutingStrategyOption = {
|
||||
value: RoutingStrategyValue;
|
||||
@@ -81,6 +83,20 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [
|
||||
settingsDescKey: "strictRandomDesc",
|
||||
icon: "casino",
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
labelKey: "auto",
|
||||
combosDescKey: "autoDesc",
|
||||
settingsDescKey: "autoDesc",
|
||||
icon: "auto_awesome",
|
||||
},
|
||||
{
|
||||
value: "lkgp",
|
||||
labelKey: "lkgp",
|
||||
combosDescKey: "lkgpDesc",
|
||||
settingsDescKey: "lkgpDesc",
|
||||
icon: "verified",
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_FALLBACK_STRATEGY_VALUES: RoutingStrategyValue[] = [
|
||||
@@ -93,4 +109,6 @@ export const SETTINGS_FALLBACK_STRATEGY_VALUES: RoutingStrategyValue[] = [
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"strict-random",
|
||||
"auto",
|
||||
"lkgp",
|
||||
];
|
||||
|
||||
@@ -57,6 +57,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
|
||||
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
|
||||
{ id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" },
|
||||
{ id: "media", href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
|
||||
];
|
||||
|
||||
const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
@@ -69,7 +70,6 @@ const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
{ id: "translator", href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
|
||||
{ id: "playground", href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
|
||||
{ id: "media", href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
|
||||
{
|
||||
id: "search-tools",
|
||||
href: "/dashboard/search-tools",
|
||||
|
||||
@@ -82,8 +82,22 @@ const comboStrategySchema = z.enum([
|
||||
"fill-first",
|
||||
// #729 schema fixes for combo edit/save
|
||||
"p2c",
|
||||
"auto",
|
||||
"lkgp",
|
||||
]);
|
||||
|
||||
const scoringWeightsSchema = z
|
||||
.object({
|
||||
quota: z.number().min(0).max(1),
|
||||
health: z.number().min(0).max(1),
|
||||
costInv: z.number().min(0).max(1),
|
||||
latencyInv: z.number().min(0).max(1),
|
||||
taskFit: z.number().min(0).max(1),
|
||||
stability: z.number().min(0).max(1),
|
||||
tierPriority: z.number().min(0).max(1).optional().default(0.05),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const comboRuntimeConfigSchema = z
|
||||
.object({
|
||||
strategy: comboStrategySchema.optional(),
|
||||
@@ -96,6 +110,13 @@ const comboRuntimeConfigSchema = z
|
||||
healthCheckTimeoutMs: z.coerce.number().int().min(100).max(30000).optional(),
|
||||
maxComboDepth: z.coerce.number().int().min(1).max(10).optional(),
|
||||
trackMetrics: z.boolean().optional(),
|
||||
// Auto-Combo / LKGP Extensions
|
||||
candidatePool: z.array(z.string().min(1)).optional(),
|
||||
weights: scoringWeightsSchema.optional(),
|
||||
modePack: z.string().max(100).optional(),
|
||||
budgetCap: z.number().positive().optional(),
|
||||
explorationRate: z.number().min(0).max(1).optional(),
|
||||
routerStrategy: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -117,18 +138,6 @@ export const createComboSchema = z.object({
|
||||
|
||||
// ──── Auto-Combo Schemas ────
|
||||
|
||||
const scoringWeightsSchema = z
|
||||
.object({
|
||||
quota: z.number().min(0).max(1),
|
||||
health: z.number().min(0).max(1),
|
||||
costInv: z.number().min(0).max(1),
|
||||
latencyInv: z.number().min(0).max(1),
|
||||
taskFit: z.number().min(0).max(1),
|
||||
stability: z.number().min(0).max(1),
|
||||
tierPriority: z.number().min(0).max(1).optional().default(0.05),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const createAutoComboSchema = z.object({
|
||||
id: z.string().trim().min(1, "id is required").max(100),
|
||||
name: z.string().trim().min(1, "name is required").max(200),
|
||||
|
||||
Reference in New Issue
Block a user