feat: complete Auto-Combo CRUD and fix missing translations

This commit is contained in:
diegosouzapw
2026-04-03 13:06:05 -03:00
parent 2610a286ca
commit f5161404cb
13 changed files with 526 additions and 194 deletions

View File

@@ -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

View File

@@ -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 ? (

View 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>
);
}

View File

@@ -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">

View File

@@ -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()] });
}

View File

@@ -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)));

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
`;

View File

@@ -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",
];

View File

@@ -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",

View File

@@ -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),