Merge remote-tracking branch 'origin/release/v3.8.0' into feat/dynamic-linux-cert-paths

# Conflicts:
#	src/mitm/cert/install.ts
This commit is contained in:
diegosouzapw
2026-05-10 18:35:52 -03:00
125 changed files with 12012 additions and 2159 deletions

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
interface AutoRoutingStats {
totalRequests: number;
variantBreakdown: Record<string, number>;
avgSelectionScore: number;
topProviders: Array<{ provider: string; count: number }>;
explorationRate: number;
lkgpHitRate: number;
}
export default function AutoRoutingAnalyticsTab() {
const [stats, setStats] = useState<AutoRoutingStats | null>(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("analytics");
useEffect(() => {
fetch("/api/analytics/auto-routing")
.then((res) => res.json())
.then((data) => {
setStats(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) {
return (
<Card>
<div className="animate-pulse space-y-4">
<div className="h-4 bg-border rounded w-1/4"></div>
<div className="h-20 bg-border rounded"></div>
</div>
</Card>
);
}
if (!stats) {
return (
<Card>
<div className="text-center py-8 text-text-muted">
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
</div>
<div>
<p className="text-sm text-text-muted">Total Auto Requests</p>
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">target</span>
</div>
<div>
<p className="text-sm text-text-muted">Avg Selection Score</p>
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">explore</span>
</div>
<div>
<p className="text-sm text-text-muted">Exploration Rate</p>
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">history</span>
</div>
<div>
<p className="text-sm text-text-muted">LKGP Hit Rate</p>
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
</div>
{/* Variant Breakdown */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Requests by Variant</h3>
<div className="space-y-3">
{Object.entries(stats.variantBreakdown).map(([variant, count]) => {
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
return (
<div key={variant} className="flex items-center gap-3">
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="w-20 text-sm text-text-muted text-right">
{count.toLocaleString()} ({percentage.toFixed(1)}%)
</div>
</div>
);
})}
</div>
</Card>
{/* Top Providers */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Top Routed Providers</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 font-medium">Provider</th>
<th className="text-right py-2 px-3 font-medium">Requests</th>
<th className="text-right py-2 px-3 font-medium">Share</th>
</tr>
</thead>
<tbody>
{stats.topProviders.map((provider, index) => {
const percentage =
stats.totalRequests > 0 ? (provider.count / stats.totalRequests) * 100 : 0;
return (
<tr key={provider.provider} className="border-b border-border/50">
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<span className="text-text-muted">#{index + 1}</span>
<span className="font-medium">{provider.provider}</span>
</div>
</td>
<td className="text-right py-2 px-3">{provider.count.toLocaleString()}</td>
<td className="text-right py-2 px-3 text-text-muted">
{percentage.toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import CompressionAnalyticsTab from "./CompressionAnalyticsTab";
import DiversityScoreCard from "./components/DiversityScoreCard";
import ProviderUtilizationTab from "./ProviderUtilizationTab";
import ComboHealthTab from "./ComboHealthTab";
import AutoRoutingAnalyticsTab from "./AutoRoutingAnalyticsTab";
import { useTranslations } from "next-intl";
export default function AnalyticsPage() {
@@ -21,6 +22,8 @@ export default function AnalyticsPage() {
utilization: t("utilizationDescription"),
comboHealth: t("comboHealthDescription"),
compression: t("compressionAnalyticsDescription"),
autoRouting:
"Auto-routing analytics — variant usage, provider selection, and LKGP performance.",
};
return (
@@ -42,6 +45,7 @@ export default function AnalyticsPage() {
{ value: "utilization", label: t("utilization") },
{ value: "comboHealth", label: t("comboHealth") },
{ value: "compression", label: t("compressionAnalyticsTitle") },
{ value: "autoRouting", label: "Auto-Routing" },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -23,6 +23,7 @@ type MediaModelConfig = { id: string; name: string };
type MediaProviderConfig = {
id: string;
authType: string;
supportedFormats?: string[];
models: MediaModelConfig[];
};
type ProviderModelGroup = {
@@ -172,8 +173,147 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
{ id: "aura-orion-en", label: "Orion (EN)" },
],
inworld: [
{ id: "Eva", label: "Eva (EN)" },
{ id: "Abby", label: "Abby (EN)" },
{ id: "Alex", label: "Alex (EN)" },
{ id: "Amina", label: "Amina (EN)" },
{ id: "Anjali", label: "Anjali (EN)" },
{ id: "Arjun", label: "Arjun (EN)" },
{ id: "Ashley", label: "Ashley (EN)" },
{ id: "Avery", label: "Avery (EN)" },
{ id: "Bianca", label: "Bianca (EN)" },
{ id: "Blake", label: "Blake (EN)" },
{ id: "Brandon", label: "Brandon (EN)" },
{ id: "Brian", label: "Brian (EN)" },
{ id: "Callum", label: "Callum (EN)" },
{ id: "Carter", label: "Carter (EN)" },
{ id: "Cedric", label: "Cedric (EN)" },
{ id: "Celeste", label: "Celeste (EN)" },
{ id: "Chloe", label: "Chloe (EN)" },
{ id: "Claire", label: "Claire (EN)" },
{ id: "Clive", label: "Clive (EN)" },
{ id: "Conrad", label: "Conrad (EN)" },
{ id: "Craig", label: "Craig (EN)" },
{ id: "Damon", label: "Damon (EN)" },
{ id: "Darlene", label: "Darlene (EN)" },
{ id: "Deborah", label: "Deborah (EN)" },
{ id: "Dennis", label: "Dennis (EN)" },
{ id: "Derek", label: "Derek (EN)" },
{ id: "Dominus", label: "Dominus (EN)" },
{ id: "Duncan", label: "Duncan (EN)" },
{ id: "Edward", label: "Edward (EN)" },
{ id: "Eleanor", label: "Eleanor (EN)" },
{ id: "Elliot", label: "Elliot (EN)" },
{ id: "Ethan", label: "Ethan (EN)" },
{ id: "Evan", label: "Evan (EN)" },
{ id: "Evelyn", label: "Evelyn (EN)" },
{ id: "Felix", label: "Felix (EN)" },
{ id: "Gareth", label: "Gareth (EN)" },
{ id: "Graham", label: "Graham (EN)" },
{ id: "Hades", label: "Hades (EN)" },
{ id: "Hamish", label: "Hamish (EN)" },
{ id: "Hana", label: "Hana (EN)" },
{ id: "Hank", label: "Hank (EN)" },
{ id: "James", label: "James (EN)" },
{ id: "Jason", label: "Jason (EN)" },
{ id: "Jessica", label: "Jessica (EN)" },
{ id: "Jonah", label: "Jonah (EN)" },
{ id: "Kelsey", label: "Kelsey (EN)" },
{ id: "Lauren", label: "Lauren (EN)" },
{ id: "Levi", label: "Levi (EN)" },
{ id: "Liam", label: "Liam (EN)" },
{ id: "Loretta", label: "Loretta (EN)" },
{ id: "Lucian", label: "Lucian (EN)" },
{ id: "Luna", label: "Luna (EN)" },
{ id: "Malcolm", label: "Malcolm (EN)" },
{ id: "Marcus", label: "Marcus (EN)" },
{ id: "Mark", label: "Mark (EN)" },
{ id: "Marlene", label: "Marlene (EN)" },
{ id: "Mia", label: "Mia (EN)" },
{ id: "Miranda", label: "Miranda (EN)" },
{ id: "Mortimer", label: "Mortimer (EN)" },
{ id: "Nadia", label: "Nadia (EN)" },
{ id: "Naomi", label: "Naomi (EN)" },
{ id: "Nate", label: "Nate (EN)" },
{ id: "Oliver", label: "Oliver (EN)" },
{ id: "Olivia", label: "Olivia (EN)" },
{ id: "Pippa", label: "Pippa (EN)" },
{ id: "Pixie", label: "Pixie (EN)" },
{ id: "Reed", label: "Reed (EN)" },
{ id: "Riley", label: "Riley (EN)" },
{ id: "Ronald", label: "Ronald (EN)" },
{ id: "Rupert", label: "Rupert (EN)" },
{ id: "Saanvi", label: "Saanvi (EN)" },
{ id: "Sarah", label: "Sarah (EN)" },
{ id: "Sebastian", label: "Sebastian (EN)" },
{ id: "Selene", label: "Selene (EN)" },
{ id: "Serena", label: "Serena (EN)" },
{ id: "Simon", label: "Simon (EN)" },
{ id: "Snik", label: "Snik (EN)" },
{ id: "Sophie", label: "Sophie (EN)" },
{ id: "Tessa", label: "Tessa (EN)" },
{ id: "Theodore", label: "Theodore (EN)" },
{ id: "Timothy", label: "Timothy (EN)" },
{ id: "Trevor", label: "Trevor (EN)" },
{ id: "Tristan", label: "Tristan (EN)" },
{ id: "Tyler", label: "Tyler (EN)" },
{ id: "Veronica", label: "Veronica (EN)" },
{ id: "Victor", label: "Victor (EN)" },
{ id: "Victoria", label: "Victoria (EN)" },
{ id: "Vinny", label: "Vinny (EN)" },
{ id: "Wendy", label: "Wendy (EN)" },
{ id: "Aanya", label: "Aanya (HI)" },
{ id: "Aarav", label: "Aarav (HI)" },
{ id: "Manoj", label: "Manoj (HI)" },
{ id: "Riya", label: "Riya (HI)" },
{ id: "Alain", label: "Alain (FR)" },
{ id: "Étienne", label: "Étienne (FR)" },
{ id: "Hélène", label: "Hélène (FR)" },
{ id: "Mathieu", label: "Mathieu (FR)" },
{ id: "Asuka", label: "Asuka (JP)" },
{ id: "Haruto", label: "Haruto (JP)" },
{ id: "Hina", label: "Hina (JP)" },
{ id: "Satoshi", label: "Satoshi (JP)" },
{ id: "Beatriz", label: "Beatriz (PT)" },
{ id: "Heitor", label: "Heitor (PT)" },
{ id: "Maitê", label: "Maitê (PT)" },
{ id: "Mariana", label: "Mariana (PT)" },
{ id: "Murilo", label: "Murilo (PT)" },
{ id: "Camila", label: "Camila (ES)" },
{ id: "Diego", label: "Diego (ES)" },
{ id: "Lupita", label: "Lupita (ES)" },
{ id: "Mateo", label: "Mateo (ES)" },
{ id: "Mauricio", label: "Mauricio (ES)" },
{ id: "Miguel", label: "Miguel (ES)" },
{ id: "Rafael", label: "Rafael (ES)" },
{ id: "Sofia", label: "Sofia (ES)" },
{ id: "Dmitry", label: "Dmitry (RU)" },
{ id: "Elena", label: "Elena (RU)" },
{ id: "Nikolai", label: "Nikolai (RU)" },
{ id: "Svetlana", label: "Svetlana (RU)" },
{ id: "Erik", label: "Erik (NL)" },
{ id: "Katrien", label: "Katrien (NL)" },
{ id: "Lennart", label: "Lennart (NL)" },
{ id: "Lore", label: "Lore (NL)" },
{ id: "Gianni", label: "Gianni (IT)" },
{ id: "Orietta", label: "Orietta (IT)" },
{ id: "Hyunwoo", label: "Hyunwoo (KO)" },
{ id: "Minji", label: "Minji (KO)" },
{ id: "Seojun", label: "Seojun (KO)" },
{ id: "Yoona", label: "Yoona (KO)" },
{ id: "Jing", label: "Jing (ZH)" },
{ id: "Mei", label: "Mei (ZH)" },
{ id: "Ming", label: "Ming (ZH)" },
{ id: "Xiaoyin", label: "Xiaoyin (ZH)" },
{ id: "Xinyi", label: "Xinyi (ZH)" },
{ id: "Yichen", label: "Yichen (ZH)" },
{ id: "Johanna", label: "Johanna (DE)" },
{ id: "Josef", label: "Josef (DE)" },
{ id: "Nour", label: "Nour (AR)" },
{ id: "Omar", label: "Omar (AR)" },
{ id: "Oren", label: "Oren (HE)" },
{ id: "Yael", label: "Yael (HE)" },
{ id: "Szymon", label: "Szymon (PL)" },
{ id: "Wojciech", label: "Wojciech (PL)" },
],
"xiaomi-mimo": [
{ id: "冰糖", label: "冰糖 (Chinese Female)" },
@@ -188,12 +328,10 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
};
const SPEECH_FORMATS = ["mp3", "wav", "opus", "flac", "pcm"];
const SPEECH_FORMATS_BY_PROVIDER: Record<string, string[]> = {
"xiaomi-mimo": ["mp3", "wav"],
};
function getSpeechFormats(providerId: string): string[] {
return SPEECH_FORMATS_BY_PROVIDER[providerId] || SPEECH_FORMATS;
const providerFormats = AUDIO_SPEECH_PROVIDERS[providerId]?.supportedFormats;
return providerFormats?.length ? providerFormats : SPEECH_FORMATS;
}
function getVoiceList(providerId: string) {

View File

@@ -0,0 +1,478 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Badge } from "@/shared/components";
import { useTranslations } from "next-intl";
interface CloudAgentTask {
id: string;
provider: string;
status: "pending" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled";
description: string;
createdAt: string;
updatedAt: string;
result?: string;
error?: string;
plan?: string;
messages: Array<{ role: string; content: string; timestamp: string }>;
}
const CLOUD_AGENTS = [
{
id: "jules",
name: "Jules",
provider: "Google",
description: "Google's autonomous coding agent",
icon: "🟡",
color: "bg-yellow-500/10 text-yellow-600",
},
{
id: "devin",
name: "Devin",
provider: "Cognition",
description: "Cognition's AI software engineer",
icon: "🔵",
color: "bg-blue-500/10 text-blue-600",
},
{
id: "codex-cloud",
name: "Codex Cloud",
provider: "OpenAI",
description: "OpenAI's cloud-based coding agent",
icon: "⚡",
color: "bg-emerald-500/10 text-emerald-600",
},
];
export default function CloudAgentsPage() {
const [tasks, setTasks] = useState<CloudAgentTask[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTask, setSelectedTask] = useState<CloudAgentTask | null>(null);
const [newTask, setNewTask] = useState({
provider: "jules",
description: "",
});
const [messageInput, setMessageInput] = useState("");
const t = useTranslations("cloudAgents");
const fetchTasks = useCallback(async () => {
try {
const res = await fetch("/api/v1/agents/tasks");
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch (err) {
console.error("Failed to fetch tasks:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleCreateTask = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
const res = await fetch("/api/v1/agents/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: newTask.provider,
description: newTask.description,
}),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => [data.task, ...prev]);
setNewTask({ provider: "jules", description: "" });
}
} catch (err) {
console.error("Failed to create task:", err);
} finally {
setCreating(false);
}
};
const handleSendMessage = async () => {
if (!selectedTask || !messageInput.trim()) return;
try {
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "message",
message: messageInput,
}),
});
if (res.ok) {
const data = await res.json();
setSelectedTask(data.task);
setTasks((prev) => prev.map((task) => (task.id === selectedTask.id ? data.task : task)));
setMessageInput("");
}
} catch (err) {
console.error("Failed to send message:", err);
}
};
const handleApprovePlan = async () => {
if (!selectedTask) return;
try {
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "approve" }),
});
if (res.ok) {
const data = await res.json();
setSelectedTask(data.task);
setTasks((prev) => prev.map((t) => (t.id === selectedTask.id ? data.task : t)));
}
} catch (err) {
console.error("Failed to approve plan:", err);
}
};
const handleCancelTask = async (taskId: string) => {
try {
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "cancel" }),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
if (selectedTask?.id === taskId) {
setSelectedTask(data.task);
}
}
} catch (err) {
console.error("Failed to cancel task:", err);
}
};
const handleDeleteTask = async (taskId: string) => {
try {
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
method: "DELETE",
});
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
if (selectedTask?.id === taskId) {
setSelectedTask(null);
}
}
} catch (err) {
console.error("Failed to delete task:", err);
}
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: "bg-zinc-500/10 text-zinc-500", label: t("statusPending") },
running: { color: "bg-blue-500/10 text-blue-500", label: t("statusRunning") },
waiting_approval: {
color: "bg-amber-500/10 text-amber-600",
label: t("statusWaitingApproval"),
},
completed: { color: "bg-emerald-500/10 text-emerald-600", label: t("statusCompleted") },
failed: { color: "bg-red-500/10 text-red-500", label: t("statusFailed") },
cancelled: { color: "bg-zinc-500/10 text-zinc-400", label: t("statusCancelled") },
};
const s = statusMap[status] || statusMap.pending;
return (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${s.color}`}
>
{status === "running" && <span className="animate-pulse"></span>}
{s.label}
</span>
);
};
const getAgentInfo = (providerId: string) => {
return CLOUD_AGENTS.find((a) => a.id === providerId) || CLOUD_AGENTS[0];
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">{t("loading")}</p>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-text-muted mt-1">{t("description")}</p>
</div>
</div>
<Card className="border-purple-500/20 bg-purple-500/5">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-sm font-semibold text-text-main">{t("aboutTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("aboutDescription")}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLOUD_AGENTS.map((agent) => (
<div
key={agent.id}
className="rounded-lg border border-purple-500/15 bg-purple-500/5 p-3"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{agent.icon}</span>
<p className="text-sm font-medium text-text-main">{agent.name}</p>
</div>
<p className="text-xs text-text-muted">{agent.description}</p>
<p className="text-[10px] text-purple-500 mt-1">{agent.provider}</p>
</div>
))}
</div>
<div className="rounded-lg border border-purple-500/15 bg-surface/40 p-3 text-sm text-text-muted">
<span className="font-medium text-text-main">{t("howItWorksTitle")}</span>
<span className="ml-1">{t("howItWorksDesc")}</span>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[20px]">add_task</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("newTaskTitle")}</h3>
<p className="text-sm text-text-muted">{t("newTaskDescription")}</p>
</div>
</div>
<form onSubmit={handleCreateTask} className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">
{t("selectAgent")}
</label>
<select
value={newTask.provider}
onChange={(e) => setNewTask({ ...newTask, provider: e.target.value })}
className="w-full rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{CLOUD_AGENTS.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.provider})
</option>
))}
</select>
</div>
</div>
<div>
<Input
label={t("taskDescription")}
placeholder={t("taskDescriptionPlaceholder")}
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
required
/>
</div>
<div className="flex justify-end">
<Button type="submit" variant="primary" loading={creating}>
<span className="material-symbols-outlined text-[16px] mr-1">rocket_launch</span>
{t("startTask")}
</Button>
</div>
</form>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{t("tasks")}</h2>
{tasks.length === 0 ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[40px] mb-2">assignment</span>
<p>{t("noTasks")}</p>
</div>
) : (
tasks.map((task) => {
const agent = getAgentInfo(task.provider);
return (
<Card
key={task.id}
className={`cursor-pointer transition-all hover:border-primary/30 ${
selectedTask?.id === task.id ? "border-primary ring-1 ring-primary/20" : ""
}`}
onClick={() => setSelectedTask(task)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-lg">{agent.icon}</span>
<div>
<p className="text-sm font-medium text-text-main line-clamp-1">
{task.description || t("untitledTask")}
</p>
<p className="text-xs text-text-muted">
{agent.name} {new Date(task.createdAt).toLocaleString()}
</p>
</div>
</div>
{getStatusBadge(task.status)}
</div>
</Card>
);
})
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{t("taskDetail")}</h2>
{selectedTask ? (
<Card className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getAgentInfo(selectedTask.provider).icon}</span>
<div>
<p className="font-medium">{getAgentInfo(selectedTask.provider).name}</p>
<p className="text-xs text-text-muted">
{t("created")}: {new Date(selectedTask.createdAt).toLocaleString()}
</p>
</div>
</div>
{getStatusBadge(selectedTask.status)}
</div>
{selectedTask.status === "waiting_approval" && selectedTask.plan && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-amber-600">
description
</span>
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t("planReady")}
</span>
</div>
<pre className="text-xs text-text-muted whitespace-pre-wrap bg-black/5 dark:bg-white/5 rounded p-2 max-h-32 overflow-auto">
{selectedTask.plan}
</pre>
<div className="flex gap-2 mt-2">
<Button variant="primary" size="sm" onClick={handleApprovePlan}>
<span className="material-symbols-outlined text-[14px] mr-1">check</span>
{t("approvePlan")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleCancelTask(selectedTask.id)}
>
{t("rejectPlan")}
</Button>
</div>
</div>
)}
{selectedTask.messages.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{t("conversation")}</p>
<div className="flex flex-col gap-2 max-h-64 overflow-auto">
{selectedTask.messages.map((msg, i) => (
<div
key={i}
className={`p-2 rounded-lg text-xs ${
msg.role === "assistant"
? "bg-purple-500/10 text-text-main"
: "bg-surface/40 text-text-main"
}`}
>
<span className="font-medium capitalize">{msg.role}: </span>
{msg.content}
</div>
))}
</div>
</div>
)}
{selectedTask.result && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-emerald-600">
check_circle
</span>
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
{t("result")}
</span>
</div>
<pre className="text-xs text-text-muted whitespace-pre-wrap">
{selectedTask.result}
</pre>
</div>
)}
{selectedTask.error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-red-500">
error
</span>
<span className="text-sm font-medium text-red-600">{t("error")}</span>
</div>
<p className="text-xs text-text-muted">{selectedTask.error}</p>
</div>
)}
{selectedTask.status === "running" && (
<div className="flex gap-2">
<Input
placeholder={t("sendMessagePlaceholder")}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSendMessage()}
className="flex-1"
/>
<Button variant="primary" onClick={handleSendMessage}>
<span className="material-symbols-outlined text-[16px]">send</span>
</Button>
</div>
)}
<div className="flex justify-between pt-3 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={() => handleCancelTask(selectedTask.id)}
disabled={["completed", "failed", "cancelled"].includes(selectedTask.status)}
>
<span className="material-symbols-outlined text-[14px] mr-1">cancel</span>
{t("cancel")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTask(selectedTask.id)}
className="text-red-500 hover:text-red-400"
>
<span className="material-symbols-outlined text-[14px] mr-1">delete</span>
{t("delete")}
</Button>
</div>
</Card>
) : (
<div className="text-center py-8 text-text-muted border border-dashed border-border/50 rounded-lg">
<span className="material-symbols-outlined text-[40px] mb-2">touch_app</span>
<p>{t("selectTaskPrompt")}</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -15,7 +15,7 @@ function getI18nOrFallback(t: any, key: string, fallback: string) {
return fallback;
}
function toProviderOptions(activeProviders: any[] = []) {
function toProviderOptions(activeProviders: any[] = [], candidatePool: string[] = []) {
const uniqueProviders = new Map<string, { id: string; label: string; connectionCount: number }>();
activeProviders.forEach((provider) => {
@@ -41,6 +41,16 @@ function toProviderOptions(activeProviders: any[] = []) {
});
});
candidatePool.forEach((poolId) => {
if (!uniqueProviders.has(poolId)) {
uniqueProviders.set(poolId, {
id: poolId,
label: `${poolId} (Offline/Deleted)`,
connectionCount: 0,
});
}
});
return [...uniqueProviders.values()].sort((a, b) => a.label.localeCompare(b.label));
}
@@ -56,7 +66,10 @@ export default function BuilderIntelligentStep({
activeProviders: any[];
}) {
const normalizedConfig = normalizeIntelligentRoutingConfig(config);
const providerOptions = useMemo(() => toProviderOptions(activeProviders), [activeProviders]);
const providerOptions = useMemo(
() => toProviderOptions(activeProviders, normalizedConfig.candidatePool),
[activeProviders, normalizedConfig.candidatePool]
);
const updateConfig = (patch: Record<string, unknown>) => {
onChange({
@@ -64,7 +77,7 @@ export default function BuilderIntelligentStep({
...patch,
weights: {
...normalizedConfig.weights,
...(patch.weights || {}),
...((patch.weights as Record<string, number>) || {}),
},
});
};

View File

@@ -509,6 +509,7 @@ interface ConnectionRowConnection {
expiresAt?: string;
tokenExpiresAt?: string;
maxConcurrent?: number | null;
authType?: string;
}
interface ConnectionRowProps {
@@ -3024,7 +3025,7 @@ export default function ProviderDetailPage() {
{selectedIds.size > 0 && (
<Button
variant="destructive"
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
@@ -3154,7 +3155,7 @@ export default function ProviderDetailPage() {
{selectedIds.size > 0 && (
<Button
variant="destructive"
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}

View File

@@ -11,6 +11,7 @@ import {
IMAGE_ONLY_PROVIDER_IDS,
VIDEO_PROVIDER_IDS,
isClaudeCodeCompatibleProvider,
CLOUD_AGENT_PROVIDERS,
} from "@/shared/constants/providers";
import { useRouter } from "next/navigation";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
@@ -488,6 +489,13 @@ export default function ProvidersPage() {
searchQuery
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
cloudAgentProviderEntriesAll,
showConfiguredOnly,
searchQuery
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
const upstreamProxyEntries = filterConfiguredProviderEntries(
upstreamProxyEntriesAll,
@@ -970,6 +978,51 @@ export default function ProvidersPage() {
</div>
)}
{/* Cloud Agent Providers */}
{cloudAgentProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("cloudAgentProviders")}{" "}
<span
className="size-2.5 rounded-full bg-violet-500"
title={t("cloudAgentProviders")}
/>
<ProviderCountBadge {...countConfigured(cloudAgentProviderEntriesAll)} />
</h2>
<button
onClick={() => handleBatchTest("cloud-agent")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "cloud-agent"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("testAll")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "cloud-agent" ? "sync" : "play_arrow"}
</span>
{testingMode === "cloud-agent" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{cloudAgentProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
key={providerId}
providerId={providerId}
provider={provider}
stats={stats}
authType={displayAuthType}
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
/>
)
)}
</div>
</div>
)}
{/* Local / Self-Hosted Providers */}
{localProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">

View File

@@ -16,6 +16,9 @@ type RequestQueueSettings = {
type ConnectionCooldownProfileSettings = {
baseCooldownMs: number;
useUpstreamRetryHints: boolean;
// Issue #2100 follow-up. Optional / undefined when unset; the per-provider
// default in src/shared/utils/providerHints.ts resolves at runtime.
useUpstream429BreakerHints?: boolean;
maxBackoffSteps: number;
};
@@ -360,6 +363,48 @@ function ConnectionCooldownCard({
}))
}
/>
<div className="flex flex-col gap-1">
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-text-muted">Use upstream 429 hints for breaker cooldown</span>
<select
className="rounded border border-border-default bg-surface-1 px-2 py-1 text-sm font-mono"
value={
current.useUpstream429BreakerHints === true
? "on"
: current.useUpstream429BreakerHints === false
? "off"
: "default"
}
onChange={(e) => {
const v = e.target.value;
const next: boolean | undefined =
v === "on" ? true : v === "off" ? false : undefined;
setDraft((prev) => {
const profile = { ...prev[key] };
if (next === undefined) {
delete (profile as { useUpstream429BreakerHints?: boolean })
.useUpstream429BreakerHints;
} else {
(
profile as { useUpstream429BreakerHints?: boolean }
).useUpstream429BreakerHints = next;
}
return { ...prev, [key]: profile };
});
}}
>
<option value="default">Default (per provider)</option>
<option value="on">Always on</option>
<option value="off">Always off</option>
</select>
</label>
<p className="text-xs text-text-muted">
Apply Retry-After / quota-exhausted signals from 429 responses to circuit-breaker
cooldown duration. Default uses a per-provider policy: direct cloud providers
default on; reverse-proxy / self-hosted / CLI-backed providers default off.
Independent of &quot;Use upstream retry hints&quot;.
</p>
</div>
<NumberField
label="Max backoff steps"
value={current.maxBackoffSteps}
@@ -381,6 +426,16 @@ function ConnectionCooldownCard({
{current.useUpstreamRetryHints ? "Yes" : "No"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Use upstream 429 hints (breaker)</span>
<span className="font-mono text-text-main">
{current.useUpstream429BreakerHints === true
? "Yes"
: current.useUpstream429BreakerHints === false
? "No"
: "Default"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Max backoff steps</span>
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
@@ -414,7 +469,26 @@ function ConnectionCooldownCard({
setEditing(false);
}}
onSave={async () => {
await onSave(draft);
// Build PATCH-ready payload: convert undefined useUpstream429BreakerHints
// to explicit null sentinel so the server treats it as unset (not as
// partial-merge "leave unchanged"). JSON.stringify drops undefined keys.
const payload = {
oauth: {
...draft.oauth,
useUpstream429BreakerHints:
draft.oauth.useUpstream429BreakerHints === undefined
? (null as unknown as boolean | undefined)
: draft.oauth.useUpstream429BreakerHints,
},
apikey: {
...draft.apikey,
useUpstream429BreakerHints:
draft.apikey.useUpstream429BreakerHints === undefined
? (null as unknown as boolean | undefined)
: draft.apikey.useUpstream429BreakerHints,
},
};
await onSave(payload as typeof draft);
setEditing(false);
}}
/>

View File

@@ -15,6 +15,8 @@ export default function RoutingTab() {
alwaysPreserveClientCache: "auto",
antigravitySignatureCacheMode: "enabled",
cliCompatProviders: [],
autoRoutingEnabled: true,
autoRoutingDefaultVariant: "lkgp",
});
const [loading, setLoading] = useState(true);
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
@@ -395,6 +397,81 @@ export default function RoutingTab() {
))}
</div>
</Card>
<Card>
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 h-fit">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
auto_awesome
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Zero-Config Auto-Routing</h3>
<p className="text-sm text-text-muted mt-1">
Enable automatic provider selection using the auto/ prefix. When enabled, requests
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
providers.
</p>
</div>
</div>
<div className="pt-1">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={settings.autoRoutingEnabled !== false}
onChange={(e) => updateSetting({ autoRoutingEnabled: e.target.checked })}
disabled={loading}
/>
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border/30">
<label className="block text-sm font-medium mb-2">Default Auto Variant</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
{ value: "coding", label: "Coding", desc: "Quality-first for code" },
{ value: "fast", label: "Fast", desc: "Low-latency routing" },
{ value: "cheap", label: "Cheap", desc: "Cost-optimized" },
{ value: "offline", label: "Offline", desc: "High availability" },
{ value: "smart", label: "Smart", desc: "Best discovery (10% explore)" },
].map((option) => (
<button
key={option.value}
onClick={() => updateSetting({ autoRoutingDefaultVariant: option.value })}
disabled={loading}
className={`p-2 rounded-lg border text-left transition-all ${
settings.autoRoutingDefaultVariant === option.value
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<div className="flex items-center gap-1">
<span
className={`material-symbols-outlined text-[14px] ${
settings.autoRoutingDefaultVariant === option.value
? "text-indigo-400"
: "text-text-muted"
}`}
>
{settings.autoRoutingDefaultVariant === option.value
? "check_circle"
: "radio_button_unchecked"}
</span>
<span
className={`text-xs font-medium ${settings.autoRoutingDefaultVariant === option.value ? "text-indigo-400" : ""}`}
>
{option.label}
</span>
</div>
</button>
))}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -26,6 +26,11 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
"search-prime": "Web Search",
"web-reader": "Web Reader",
zread: "Zread",
"5 Hours Quota": "5 Hours",
"Weekly Quota": "Weekly",
"Monthly Tools": "Monthly Tools",
tokens: "Tokens",
time_limit: "Time Limit",
};
const GLM_QUOTA_ORDER: Record<string, number> = {

View File

@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export const dynamic = "force-dynamic";
/**
* GET /api/analytics/auto-routing
* Returns auto-routing usage statistics and metrics.
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const db = getDbInstance();
// Query usage_logs for auto/ prefix requests
const totalRequests = db
.prepare(
`
SELECT COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
`
)
.get() as { count: number };
// Variant breakdown
const variantRows = db
.prepare(
`
SELECT
CASE
WHEN model = 'auto' THEN 'default'
WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6)
ELSE 'other'
END as variant,
COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
GROUP BY variant
ORDER BY count DESC
`
)
.all() as Array<{ variant: string; count: number }>;
const variantBreakdown: Record<string, number> = {};
variantRows.forEach((row) => {
variantBreakdown[row.variant] = row.count;
});
// Top providers (from LKGP cache or usage logs)
const topProviders = db
.prepare(
`
SELECT provider, COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
GROUP BY provider
ORDER BY count DESC
LIMIT 10
`
)
.all() as Array<{ provider: string; count: number }>;
return NextResponse.json({
totalRequests: totalRequests.count,
variantBreakdown,
topProviders,
});
} catch (error) {
console.error("Auto-routing analytics error:", error);
return NextResponse.json({
totalRequests: 0,
variantBreakdown: {},
topProviders: [],
});
}
}

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { deleteMemory, getMemory } from "@/lib/memory/store";
export async function DELETE(request: Request, props: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await props.params;
const success = await deleteMemory(id);
@@ -16,6 +20,9 @@ export async function DELETE(request: Request, props: { params: Promise<{ id: st
}
export async function GET(request: Request, props: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await props.params;
const memory = await getMemory(id);

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { listMemories, createMemory } from "@/lib/memory/store";
import { MemoryType } from "@/lib/memory/types";
import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination";
@@ -16,6 +17,9 @@ const createMemorySchema = z.object({
});
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const url = new URL(request.url);
const { searchParams } = url;
@@ -68,6 +72,9 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const rawBody = await request.json();
const validation = validateBody(createMemorySchema, rawBody);

View File

@@ -6,6 +6,7 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
updateModelComboMapping,
deleteModelComboMapping,
@@ -21,7 +22,10 @@ const updateMappingSchema = z.object({
description: z.string().max(1000).optional(),
});
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const mapping = await getModelComboMappingById(id);
@@ -35,6 +39,9 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const rawBody = await request.json();
@@ -58,7 +65,10 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
}
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const deleted = await deleteModelComboMapping(id);

View File

@@ -6,6 +6,7 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
@@ -17,7 +18,10 @@ const createMappingSchema = z.object({
description: z.string().max(1000).optional().default(""),
});
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const mappings = await getModelComboMappings();
return NextResponse.json({ mappings });
@@ -30,6 +34,9 @@ export async function GET() {
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const rawBody = await request.json();
const validation = validateBody(createMappingSchema, rawBody);

View File

@@ -58,22 +58,33 @@ export async function POST(request: Request) {
cursorService.validateImportToken(accessToken.trim(), machineId?.trim())
);
// Try to extract user info from token
const userInfo = cursorService.extractUserInfo(tokenData.accessToken);
// Try to extract user info from token (JWT decode, no API call)
const jwtInfo = cursorService.extractUserInfo(tokenData.accessToken);
// Save to database
// Best-effort fetch real profile (email + name) from cursor.com using the
// same WorkOS session cookie format we use for usage limits.
const profile = jwtInfo?.userId
? await runWithProxyContext(proxy, () =>
cursorService.fetchUserInfo(tokenData.accessToken, jwtInfo.userId)
)
: null;
const email = profile?.email || jwtInfo?.email || null;
// Save to database (no `name` — let the dashboard fall back to email so the
// privacy mask toggle applies, matching the codex/claude rendering).
const connection: any = await createProviderConnection({
provider: "cursor",
authType: "oauth",
accessToken: tokenData.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
email: userInfo?.email || null,
email,
providerSpecificData: {
machineId: tokenData.machineId,
authMethod: "imported",
provider: "Imported",
userId: userInfo?.userId,
userId: jwtInfo?.userId,
},
testStatus: "active",
});

View File

@@ -1,14 +1,22 @@
import { NextResponse } from "next/server";
import { readFile, readdir } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { createProviderConnection, isCloudEnabled, resolveProxyForProvider } from "@/models";
import { syncToCloud } from "@/lib/cloudSync";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { KiroService } from "@/lib/oauth/services/kiro";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
/**
* GET /api/oauth/kiro/auto-import
* Auto-detect and extract Kiro refresh token from AWS SSO cache.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
* Auto-import Kiro credentials from kiro-cli's SQLite database.
* Supports both personal Builder ID and enterprise SSO (IDC/profileArn).
*
* Falls back to ~/.aws/sso/cache if kiro-cli SQLite is not found.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
*/
export async function GET(request: Request) {
if (await isAuthRequired(request)) {
@@ -17,79 +25,249 @@ export async function GET(request: Request) {
}
}
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
// Try kiro-cli SQLite first
const sqliteResult = await tryKiroCliSqlite();
if (sqliteResult.found) {
return await saveAndRespond(sqliteResult, targetProvider, request);
}
// Fall back to ~/.aws/sso/cache (social auth / manual token)
const cacheResult = await tryAwsSsoCache(targetProvider);
if (cacheResult.found) {
return await saveAndRespond(cacheResult, targetProvider, request);
}
return NextResponse.json({
found: false,
error:
"Kiro credentials not found. " +
"Run `kiro-cli login --use-device-flow` then retry, " +
"or use the Import Token option in the dashboard.",
triedPaths: [sqliteResult.triedPath, cacheResult.triedPath].filter(Boolean),
});
}
// ── kiro-cli SQLite reader ────────────────────────────────────────────────────
async function tryKiroCliSqlite(): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
accessToken?: string;
expiresAt?: string;
clientId?: string;
clientSecret?: string;
region?: string;
profileArn?: string;
source?: string;
}> {
const dbPath = join(homedir(), ".local/share/kiro-cli/data.sqlite3");
let Database: any;
try {
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
const providerLabel = targetProvider === "amazon-q" ? "Amazon Q" : "Kiro";
const cachePath = join(homedir(), ".aws/sso/cache");
Database = (await import("better-sqlite3")).default;
} catch {
return { found: false, triedPath: dbPath };
}
// Try to read cache directory
let files;
try {
files = await readdir(cachePath);
} catch (error) {
return NextResponse.json({
found: false,
error: `AWS SSO cache not found. Please login to ${providerLabel} first.`,
});
}
// Look for kiro-auth-token.json or any .json file with refreshToken
let refreshToken = null;
let foundFile = null;
// First try kiro-auth-token.json
const preferredTokenFile =
targetProvider === "amazon-q" ? "amazon-q-auth-token.json" : "kiro-auth-token.json";
if (files.includes(preferredTokenFile)) {
try {
const content = await readFile(join(cachePath, preferredTokenFile), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
refreshToken = data.refreshToken;
foundFile = preferredTokenFile;
}
} catch (error) {
// Continue to search other files
}
}
// If not found, search all .json files
if (!refreshToken) {
for (const file of files) {
if (!file.endsWith(".json")) continue;
let db: any;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
} catch {
return { found: false, triedPath: dbPath };
}
try {
// Read OIDC token (access + refresh token)
const tokenKeys = ["kirocli:odic:token", "kirocli:oidc:token"];
let tokenData: any = null;
for (const key of tokenKeys) {
const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as
| { value: string }
| undefined;
if (row?.value) {
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
// Look for Kiro refresh token (starts with aorAAAAAG)
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
refreshToken = data.refreshToken;
foundFile = file;
break;
}
} catch (error) {
// Skip invalid JSON files
continue;
tokenData = JSON.parse(row.value);
break;
} catch {
// continue
}
}
}
if (!refreshToken) {
return NextResponse.json({
found: false,
error: `${providerLabel} token not found in AWS SSO cache. Please login to ${providerLabel} first.`,
});
if (!tokenData?.refresh_token) {
return { found: false, triedPath: dbPath };
}
// Read device registration (client_id + client_secret)
const regKeys = ["kirocli:odic:device-registration", "kirocli:oidc:device-registration"];
let regData: any = null;
for (const key of regKeys) {
const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as
| { value: string }
| undefined;
if (row?.value) {
try {
regData = JSON.parse(row.value);
break;
} catch {
// continue
}
}
}
// Read profileArn from state table (enterprise SSO / IDC)
let profileArn: string | undefined;
try {
const profileRow = db
.prepare("SELECT value FROM state WHERE key = 'api.codewhisperer.profile'")
.get() as { value: string } | undefined;
if (profileRow?.value) {
const profileData = JSON.parse(profileRow.value);
profileArn = profileData.arn || profileData.profileArn;
}
} catch {
// state table may not exist for personal Builder ID accounts
}
const region = tokenData.region || regData?.region || "us-east-1";
const expiresAt = tokenData.expires_at
? new Date(tokenData.expires_at).toISOString()
: new Date(Date.now() + 3600 * 1000).toISOString();
return {
found: true,
source: "kiro-cli-sqlite",
refreshToken: tokenData.refresh_token,
accessToken: tokenData.access_token,
expiresAt,
clientId: regData?.client_id,
clientSecret: regData?.client_secret,
region,
profileArn,
};
} finally {
db.close();
}
}
// ── ~/.aws/sso/cache fallback ─────────────────────────────────────────────────
async function tryAwsSsoCache(targetProvider: string): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
source?: string;
}> {
const { readFile, readdir } = await import("fs/promises");
const cachePath = join(homedir(), ".aws/sso/cache");
const preferredFile =
targetProvider === "amazon-q" ? "amazon-q-auth-token.json" : "kiro-auth-token.json";
let files: string[];
try {
files = await readdir(cachePath);
} catch {
return { found: false, triedPath: cachePath };
}
// Try preferred file first, then scan all
const ordered = [
preferredFile,
...files.filter((f) => f !== preferredFile && f.endsWith(".json")),
];
for (const file of ordered) {
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken?.startsWith("aorAAAAAG")) {
return { found: true, refreshToken: data.refreshToken, source: file };
}
} catch {
// skip
}
}
return { found: false, triedPath: cachePath };
}
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
async function saveAndRespond(
result: Awaited<ReturnType<typeof tryKiroCliSqlite>>,
targetProvider: string,
request: Request
) {
try {
const kiroService = new KiroService();
const proxy = await resolveProxyForProvider(targetProvider);
// If we have a refresh token but no valid access token, refresh now
let accessToken = result.accessToken;
let refreshToken = result.refreshToken!;
let expiresAt = result.expiresAt;
let profileArn = result.profileArn;
const providerSpecificData: Record<string, any> = {
authMethod: result.source === "kiro-cli-sqlite" ? "kiro-cli" : "imported",
provider: result.source === "kiro-cli-sqlite" ? "kiro-cli SQLite" : "AWS SSO Cache",
};
if (result.clientId) providerSpecificData.clientId = result.clientId;
if (result.clientSecret) providerSpecificData.clientSecret = result.clientSecret;
if (result.region) providerSpecificData.region = result.region;
if (profileArn) providerSpecificData.profileArn = profileArn;
// Refresh token to get a fresh access token and confirm it works
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken, providerSpecificData)
);
accessToken = refreshed.accessToken;
refreshToken = refreshed.refreshToken || refreshToken;
expiresAt = new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString();
// profileArn may come back from social auth refresh
if (refreshed.profileArn && !profileArn) {
profileArn = refreshed.profileArn;
providerSpecificData.profileArn = profileArn;
}
const email = kiroService.extractEmailFromJWT(accessToken);
await createProviderConnection({
provider: targetProvider,
authType: "oauth",
accessToken,
refreshToken,
expiresAt,
email: email || null,
providerSpecificData,
testStatus: "active",
} as any);
if (isCloudEnabled()) {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId).catch(() => {});
}
return NextResponse.json({
found: true,
refreshToken,
source: foundFile,
source: result.source,
email: email || null,
profileArn: profileArn || null,
region: result.region || null,
message: "Kiro credentials imported successfully.",
});
} catch (error) {
console.log("Kiro auto-import error:", error);
return NextResponse.json({ found: false, error: error.message }, { status: 500 });
} catch (error: any) {
console.error("[kiro auto-import] save error:", error);
return NextResponse.json(
{ found: false, error: `Import failed: ${error.message}` },
{ status: 500 }
);
}
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
getPricing,
getPricingWithSources,
@@ -14,6 +15,9 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
* Get current pricing configuration (merged user + defaults)
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const includeSources = new URL(request.url).searchParams.get("includeSources") === "1";
if (includeSources) {
@@ -34,6 +38,9 @@ export async function GET(request: Request) {
* Body: { provider: { model: { input: number, output: number, cached: number, ... } } }
*/
export async function PATCH(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
@@ -70,6 +77,9 @@ export async function PATCH(request) {
* Query params: ?provider=xxx&model=yyy (optional)
*/
export async function DELETE(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");

View File

@@ -7,10 +7,14 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { pricingSyncRequestSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
@@ -43,7 +47,10 @@ export async function POST(request: NextRequest) {
}
}
export async function GET() {
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { getSyncStatus } = await import("@/lib/pricingSync");
return NextResponse.json(getSyncStatus());
@@ -53,7 +60,10 @@ export async function GET() {
}
}
export async function DELETE() {
export async function DELETE(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { clearSyncedPricing } = await import("@/lib/pricingSync");
clearSyncedPricing();

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { validateClaudeCodeCompatibleProvider } from "@/lib/providers/validation";
import {
@@ -41,6 +42,9 @@ function sanitizeAuditBaseUrl(baseUrl: string) {
// POST /api/provider-nodes/validate - Validate API key against base URL
export async function POST(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let rawBody;
try {

View File

@@ -854,8 +854,10 @@ export async function GET(
return localCatalog.map((model) => ({
id: model.id,
name: model.name || model.id,
...(model.apiFormat ? { apiFormat: model.apiFormat } : {}),
...(model.supportedEndpoints ? { supportedEndpoints: model.supportedEndpoints } : {}),
...((model as any).apiFormat ? { apiFormat: (model as any).apiFormat } : {}),
...((model as any).supportedEndpoints
? { supportedEndpoints: (model as any).supportedEndpoints }
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
}));
};
@@ -902,7 +904,7 @@ export async function GET(
}
) => {
const status = getSafeOutboundFetchErrorStatus(error);
if (status === 400) return null;
if (status === 400 || status === 503 || status === 504) return null;
return buildDiscoveryFallbackResponse(warnings);
};
@@ -1852,8 +1854,10 @@ export async function GET(
models: localCatalog.map((m) => ({
id: m.id,
name: m.name || m.id,
...(m.apiFormat ? { apiFormat: m.apiFormat } : {}),
...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}),
...((m as any).apiFormat ? { apiFormat: (m as any).apiFormat } : {}),
...((m as any).supportedEndpoints
? { supportedEndpoints: (m as any).supportedEndpoints }
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
})),
source: "local_catalog",

View File

@@ -23,6 +23,7 @@ import {
isClaudeExtraUsageBlockEnabled,
} from "@/lib/providers/claudeExtraUsage";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
function normalizeCodexLimitPolicy(
incoming: unknown,
@@ -61,9 +62,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
// Hide sensitive fields
const revealKeys = isApiKeyRevealEnabled();
// Hide or mask sensitive fields
const result: Record<string, any> = { ...connection };
delete result.apiKey;
if (!revealKeys) {
result.apiKey = result.apiKey ? maskStoredApiKey(result.apiKey) : undefined;
}
delete result.accessToken;
delete result.refreshToken;
delete result.idToken;

View File

@@ -27,6 +27,7 @@ import {
} from "@/lib/providers/requestDefaults";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
// GET /api/providers - List all connections
export async function GET(request: Request) {
@@ -35,11 +36,12 @@ export async function GET(request: Request) {
try {
const connections = await getProviderConnections();
const revealKeys = isApiKeyRevealEnabled();
// Hide sensitive fields
// Hide or mask sensitive fields
const safeConnections = connections.map((c) => ({
...c,
apiKey: undefined,
apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderNodeById } from "@/models";
import {
@@ -24,6 +25,9 @@ function sanitizeAuditUrl(url: string | null | undefined) {
// POST /api/providers/validate - Validate API key with provider
export async function POST(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let rawBody;
try {

View File

@@ -9,6 +9,7 @@ import {
} from "@/lib/resilience/settings";
import { updateResilienceSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resetAllCircuitBreakers } from "@/shared/utils/circuitBreaker";
type JsonRecord = Record<string, unknown>;
@@ -196,6 +197,20 @@ export async function PATCH(request) {
});
await syncRuntimeSettings(nextResilience);
// Issue #2100 follow-up: detect transitions in useUpstream429BreakerHints
// and reset breakers so the registry stops serving cached options.
// Compared on STORED override transition (boolean | undefined) so that
// `null` (PATCH input) → undefined (stored) is correctly detected as
// "unset request" when the previous stored value was a boolean.
const breakerHintsChanged =
currentResilience.connectionCooldown.oauth.useUpstream429BreakerHints !==
nextResilience.connectionCooldown.oauth.useUpstream429BreakerHints ||
currentResilience.connectionCooldown.apikey.useUpstream429BreakerHints !==
nextResilience.connectionCooldown.apikey.useUpstream429BreakerHints;
if (breakerHintsChanged) {
resetAllCircuitBreakers();
}
return NextResponse.json({
ok: true,
requestQueue: nextResilience.requestQueue,

View File

@@ -21,6 +21,11 @@ export async function GET(request: Request) {
}
try {
const url = new URL(request.url);
// Telemetry/history tables grow indefinitely and inflate backups.
// Exclude them by default — opt-in with ?includeHistory=true (#2125).
const includeHistory = url.searchParams.get("includeHistory") === "true";
const rawSettings = await getSettings();
// REDACT sensitive security keys to maintain Zero-Trust posture
@@ -33,27 +38,30 @@ export async function GET(request: Request) {
const combos = await getCombos();
const apiKeys = await getApiKeys();
const db = getDbInstance();
const usageHistory = db.prepare("SELECT * FROM usage_history").all();
const domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all();
const domainBudgets = db.prepare("SELECT * FROM domain_budgets").all();
const exportData = {
const exportData: Record<string, unknown> = {
settings: safeSettings,
providerConnections,
providerNodes,
combos,
apiKeys,
usageHistory,
domainCostHistory,
domainBudgets,
// Metadata to identify export version
_meta: {
exportedAt: new Date().toISOString(),
version: "omniroute-v3-legacy-export",
includesHistory: includeHistory,
},
};
// Only include telemetry/history tables when explicitly requested.
// These tables (usage_history, domain_cost_history, domain_budgets) can contain
// thousands of rows and make the config backup grow to many MBs.
if (includeHistory) {
const db = getDbInstance();
exportData.usageHistory = db.prepare("SELECT * FROM usage_history").all();
exportData.domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all();
exportData.domainBudgets = db.prepare("SELECT * FROM domain_budgets").all();
}
return new NextResponse(JSON.stringify(exportData, null, 2), {
status: 200,
headers: {

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeys } from "@/lib/db/apiKeys";
import { getDbInstance } from "@/lib/db/core";
@@ -261,6 +262,9 @@ function computeActivityStreak(activityMap: Record<string, number>): number {
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const range = searchParams.get("range") || "30d";
@@ -555,6 +559,7 @@ export async function GET(request: Request) {
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
LOWER(provider) as provider,
LOWER(model) as model,
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
@@ -563,6 +568,7 @@ export async function GET(request: Request) {
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${whereClause}
GROUP BY serviceTier, LOWER(provider), LOWER(model)
`

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getUsageStats } from "@/lib/usageDb";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const stats = await getUsageStats();
return NextResponse.json(stats);

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getRecentLogs } from "@/lib/usageDb";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const logs = await getRecentLogs(200);
return NextResponse.json(logs);

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getRecentLogs } from "@/lib/usageDb";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const logs = await getRecentLogs(200);
return NextResponse.json(logs);

View File

@@ -0,0 +1,186 @@
import { NextRequest, NextResponse } from "next/server";
import { extractApiKey } from "@/sse/services/auth";
import { getAgent } from "@/lib/cloudAgent/registry";
import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db";
import { z } from "zod";
import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCorsHeaders() });
}
const ApproveSchema = z.object({
action: z.literal("approve"),
});
const MessageSchema = z.object({
action: z.literal("message"),
message: z.string().min(1),
});
const CancelSchema = z.object({
action: z.literal("cancel"),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCorsHeaders() }
);
}
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
const agent = getAgent(task.provider_id);
if (agent && task.external_id) {
try {
const statusResult = await agent.getStatus(task.external_id, { apiKey });
updateCloudAgentTask(id, {
status: statusResult.status,
result: statusResult.result ? JSON.stringify(statusResult.result) : null,
activities: JSON.stringify(statusResult.activities),
error: statusResult.error || null,
completed_at:
statusResult.status === "completed" || statusResult.status === "failed"
? new Date().toISOString()
: null,
});
} catch (err) {
console.error("Failed to sync task status:", err);
}
}
const updatedTask = getCloudAgentTaskById(id);
return NextResponse.json(
{
data: {
id: updatedTask!.id,
providerId: updatedTask!.provider_id,
externalId: updatedTask!.external_id,
status: updatedTask!.status,
prompt: updatedTask!.prompt,
source: JSON.parse(updatedTask!.source),
options: JSON.parse(updatedTask!.options),
result: updatedTask!.result ? JSON.parse(updatedTask!.result) : null,
activities: JSON.parse(updatedTask!.activities),
error: updatedTask!.error,
createdAt: updatedTask!.created_at,
updatedAt: updatedTask!.updated_at,
completedAt: updatedTask!.completed_at,
},
},
{ headers: getCorsHeaders() }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCorsHeaders() }
);
}
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
let validated;
if (body.action === "approve") {
validated = ApproveSchema.parse(body);
} else if (body.action === "message") {
validated = MessageSchema.parse(body);
} else if (body.action === "cancel") {
validated = CancelSchema.parse(body);
} else {
return NextResponse.json(
{ error: "Invalid action" },
{ status: 400, headers: getCorsHeaders() }
);
}
const agent = getAgent(task.provider_id);
if (!agent) {
return NextResponse.json(
{ error: "Agent not found" },
{ status: 500, headers: getCorsHeaders() }
);
}
if (validated.action === "approve") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to approve" },
{ status: 400, headers: getCorsHeaders() }
);
}
await agent.approvePlan(task.external_id, { apiKey });
updateCloudAgentTask(id, { status: "running" });
} else if (validated.action === "message") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to message" },
{ status: 400, headers: getCorsHeaders() }
);
}
const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey });
const activities = JSON.parse(task.activities);
activities.push(activity);
updateCloudAgentTask(id, { activities: JSON.stringify(activities) });
} else if (validated.action === "cancel") {
updateCloudAgentTask(id, { status: "cancelled" });
}
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400, headers: getCorsHeaders() }
);
}
logger.error({ err: error }, "Failed to process task action");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { extractApiKey } from "@/sse/services/auth";
import { getAgent } from "@/lib/cloudAgent/registry";
import {
insertCloudAgentTask,
getCloudAgentTaskById,
getAllCloudAgentTasks,
getCloudAgentTasksByProvider,
getCloudAgentTasksByStatus,
updateCloudAgentTask,
deleteCloudAgentTask,
} from "@/lib/cloudAgent/db";
import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types";
import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers";
import { z } from "zod";
import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCorsHeaders() });
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const providerId = searchParams.get("provider");
const status = searchParams.get("status");
const limit = parseInt(searchParams.get("limit") || "50", 10);
let tasks;
if (providerId) {
tasks = getCloudAgentTasksByProvider(providerId, limit);
} else if (status) {
tasks = getCloudAgentTasksByStatus(status, limit);
} else {
tasks = getAllCloudAgentTasks(limit);
}
return NextResponse.json(
{
data: tasks.map((t) => ({
id: t.id,
providerId: t.provider_id,
externalId: t.external_id,
status: t.status,
prompt: t.prompt,
source: JSON.parse(t.source),
options: JSON.parse(t.options),
result: t.result ? JSON.parse(t.result) : null,
activities: JSON.parse(t.activities),
error: t.error,
createdAt: t.created_at,
updatedAt: t.updated_at,
completedAt: t.completed_at,
})),
},
{ headers: getCorsHeaders() }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = CreateCloudAgentTaskSchema.parse(body);
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
const agent = getAgent(validated.providerId);
if (!agent) {
return NextResponse.json(
{ error: `Unknown provider: ${validated.providerId}` },
{ status: 400, headers: getCorsHeaders() }
);
}
const task = await agent.createTask(
{
prompt: validated.prompt,
source: validated.source,
options: validated.options || {},
},
{ apiKey }
);
insertCloudAgentTask({
id: task.id,
provider_id: task.providerId,
external_id: task.externalId || null,
status: task.status,
prompt: task.prompt,
source: JSON.stringify(task.source),
options: JSON.stringify(task.options),
result: null,
activities: JSON.stringify(task.activities),
error: null,
created_at: task.createdAt,
updated_at: task.updatedAt,
completed_at: null,
});
return NextResponse.json(
{
data: {
id: task.id,
providerId: task.providerId,
externalId: task.externalId,
status: task.status,
prompt: task.prompt,
source: task.source,
options: task.options,
createdAt: task.createdAt,
},
},
{ status: 201, headers: getCorsHeaders() }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400, headers: getCorsHeaders() }
);
}
logger.error({ err: error }, "Failed to create cloud agent task");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get("id");
if (!taskId) {
return NextResponse.json(
{ error: "Task ID required" },
{ status: 400, headers: getCorsHeaders() }
);
}
deleteCloudAgentTask(taskId);
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -158,6 +158,7 @@
"noLockouts": "No Lockouts",
"webSearchDesc": "Web Search Desc",
"audioProvidersHeading": "Audio Providers Heading",
"cloudAgentProviders": "Cloud Agent Providers",
"minutesAgo": "Minutes Ago",
"a": "A",
"liveAutoRefreshing": "Live Auto Refreshing",
@@ -668,6 +669,7 @@
"playground": "Playground",
"searchTools": "Search Tools",
"agents": "Agents",
"cloudAgents": "Cloud Agents",
"memory": "Memory",
"skills": "Skills",
"docs": "Docs",
@@ -4851,6 +4853,42 @@
"settingsRoutingLink": "Settings/Routing",
"openSettings": "Settings"
},
"cloudAgents": {
"title": "Cloud Agents",
"description": "Manage autonomous coding agents (Jules, Devin, Codex Cloud)",
"loading": "Loading tasks...",
"aboutTitle": "About Cloud Agents",
"aboutDescription": "Cloud agents are remote AI coding assistants that can execute tasks autonomously. They work differently from local CLI agents - you interact with them through OmniRoute's API.",
"howItWorksTitle": "How it works:",
"howItWorksDesc": "Create a task → Agent analyzes and proposes a plan → You approve → Agent executes → Results returned",
"newTaskTitle": "Create New Task",
"newTaskDescription": "Start a new task with a cloud agent",
"selectAgent": "Select Agent",
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the agent to do...",
"startTask": "Start Task",
"tasks": "Tasks",
"taskDetail": "Task Detail",
"noTasks": "No tasks yet. Create one to get started.",
"untitledTask": "Untitled Task",
"created": "Created",
"conversation": "Conversation",
"result": "Result",
"error": "Error",
"planReady": "Plan Ready for Approval",
"approvePlan": "Approve Plan",
"rejectPlan": "Reject & Cancel",
"sendMessagePlaceholder": "Send a message to the agent...",
"cancel": "Cancel",
"delete": "Delete",
"selectTaskPrompt": "Select a task to view details",
"statusPending": "Pending",
"statusRunning": "Running",
"statusWaitingApproval": "Waiting Approval",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"statusCancelled": "Cancelled"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",

File diff suppressed because it is too large Load Diff

View File

@@ -136,11 +136,15 @@ export async function registerNodejs(): Promise<void> {
}
try {
const [{ migrateCodexConnectionDefaultsFromLegacySettings }, { seedDefaultModelAliases }] =
await Promise.all([
import("@/lib/providers/codexConnectionDefaults"),
import("@/lib/modelAliasSeed"),
]);
const [
{ migrateCodexConnectionDefaultsFromLegacySettings },
{ startSessionAccountAffinityCleanup },
{ seedDefaultModelAliases },
] = await Promise.all([
import("@/lib/providers/codexConnectionDefaults"),
import("@/lib/db/sessionAccountAffinity"),
import("@/lib/modelAliasSeed"),
]);
let settings = await getSettings();
const passwordState = await ensurePersistentManagementPasswordHash({
logger: console,
@@ -161,6 +165,7 @@ export async function registerNodejs(): Promise<void> {
console.log(
`[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, failed=${seededModelAliases.failed.length}`
);
startSessionAccountAffinityCleanup();
const migration = await migrateCodexConnectionDefaultsFromLegacySettings();
if (migration.migrated) {

View File

@@ -22,7 +22,22 @@ function isOpenAiCompatiblePath(pathname: string): boolean {
return OPENAI_COMPAT_PATHS.some((pattern) => pattern.test(pathname));
}
function requestWantsStreaming(req: IncomingMessage): boolean {
const accept = String(req.headers.accept || "").toLowerCase();
if (accept.includes("text/event-stream")) return true;
const pathname = (req.url || "/").split("?")[0] || "/";
return /^\/(?:v1\/)?(?:responses|chat\/completions)(?:\/|$)/.test(pathname);
}
function getProxyTimeoutMs(req: IncomingMessage): number {
if (!requestWantsStreaming(req)) return API_BRIDGE_TIMEOUTS.proxyTimeoutMs;
return Math.max(API_BRIDGE_TIMEOUTS.proxyTimeoutMs, API_BRIDGE_TIMEOUTS.serverRequestTimeoutMs);
}
function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
const proxyTimeoutMs = getProxyTimeoutMs(req);
const targetReq = http.request(
{
hostname: "127.0.0.1",
@@ -33,9 +48,14 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort:
...req.headers,
host: `127.0.0.1:${dashboardPort}`,
},
timeout: API_BRIDGE_TIMEOUTS.proxyTimeoutMs,
timeout: proxyTimeoutMs,
},
(targetRes) => {
const contentType = String(targetRes.headers["content-type"] || "").toLowerCase();
if (contentType.includes("text/event-stream")) {
targetReq.setTimeout(0);
}
res.writeHead(targetRes.statusCode || 502, targetRes.headers);
targetRes.pipe(res);
}
@@ -48,7 +68,7 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort:
res.end(
JSON.stringify({
error: "api_bridge_timeout",
detail: `Proxy request timed out after ${API_BRIDGE_TIMEOUTS.proxyTimeoutMs}ms`,
detail: `Proxy request timed out after ${proxyTimeoutMs}ms`,
})
);
});

View File

@@ -0,0 +1,148 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class CodexCloudAgent extends CloudAgentBase {
readonly providerId = "codex-cloud";
readonly baseUrl = "https://api.openai.com/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repository_context: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
if (params.options.environment) {
body.environment = {
setup: params.options.environment,
};
}
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "pending");
const activities: CloudAgentActivity[] = [];
if (data.subagents) {
for (const subagent of data.subagents) {
activities.push({
id: this.generateActivityId(),
type: "command",
content: `Subagent: ${subagent.name} - ${subagent.status}`,
timestamp: new Date().toISOString(),
});
}
}
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && (data.result || data.pr_url)) {
result = {
prUrl: data.pr_url || data.result?.pr_url,
commitMessage: data.result?.commit_message,
summary: data.result?.summary,
duration: data.elapsed_time,
};
}
return {
status,
externalId,
result,
activities,
error: data.error || data.error_message,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Codex Cloud does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}/followup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}

View File

@@ -0,0 +1,137 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class DevinAgent extends CloudAgentBase {
readonly providerId = "devin";
readonly baseUrl = "https://api.devin.ai/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repo_url: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "created"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "created");
const activities: CloudAgentActivity[] = (data.messages || []).map(
(msg: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: "message" as const,
content: (msg.content as string) || "",
timestamp: (msg.created_at as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.output) {
result = {
prUrl: data.pr_url,
summary: data.output,
duration: data.duration,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Devin does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}

View File

@@ -0,0 +1,170 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class JulesAgent extends CloudAgentBase {
readonly providerId = "jules";
readonly baseUrl = "https://jules.googleapis.com/v1alpha";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
source: {
repository: {
owner: params.source.repoUrl.split("/").filter(Boolean).slice(-2, -1)[0] || "",
name: params.source.repoName,
},
branch: params.source.branch || "main",
},
};
if (params.options.autoCreatePr) {
body.automationMode = "AUTO_CREATE_PR";
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.name?.split("/").pop() || taskId,
status: this.mapStatus(data.state || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, _credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
"X-Goog-Api-Key": _credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.state || "pending");
const activities: CloudAgentActivity[] = (data.activities || []).map(
(act: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: act.type as CloudAgentActivity["type"],
content: (act.description as string) || "",
timestamp: (act.timestamp as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.outputs) {
result = {
prUrl: data.outputs.prUrl,
commitMessage: data.outputs.commitMessage,
summary: data.outputs.summary,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:approvePlan`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules approve plan failed: ${response.status} ${error}`);
}
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:sendMessage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
const response = await fetch(`${this.baseUrl}/sources`, {
headers: {
"X-Goog-Api-Key": credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules list sources failed: ${response.status} ${error}`);
}
const data = await response.json();
return (data.sources || []).map((source: Record<string, unknown>) => ({
name: source.name as string,
url: `https://github.com/${source.repoOwner}/${source.repoName}`,
branch: source.defaultBranch as string | undefined,
}));
}
}

View File

@@ -0,0 +1,95 @@
import type {
CloudAgentTask,
CloudAgentStatus,
CloudAgentSource,
CloudAgentResult,
CloudAgentActivity,
} from "./types.ts";
export interface AgentCredentials {
apiKey: string;
baseUrl?: string;
}
export interface CreateTaskParams {
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
}
export interface GetStatusResult {
status: CloudAgentStatus;
externalId?: string;
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
}
export abstract class CloudAgentBase {
abstract readonly providerId: string;
abstract readonly baseUrl: string;
abstract createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask>;
abstract getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult>;
abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise<void>;
abstract sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity>;
abstract listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]>;
protected mapStatus(status: string): CloudAgentStatus {
const statusLower = status.toLowerCase();
if (statusLower.includes("completed") || statusLower.includes("done")) {
return "completed";
}
if (statusLower.includes("failed") || statusLower.includes("error")) {
return "failed";
}
if (statusLower.includes("cancelled") || statusLower.includes("canceled")) {
return "cancelled";
}
if (
statusLower.includes("running") ||
statusLower.includes("active") ||
statusLower.includes("executing")
) {
return "running";
}
if (
statusLower.includes("pending") ||
statusLower.includes("queued") ||
statusLower.includes("waiting")
) {
return "queued";
}
if (statusLower.includes("approval") || statusLower.includes("plan")) {
return "awaiting_approval";
}
return "queued";
}
protected generateTaskId(): string {
return `task_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
protected generateActivityId(): string {
return `act_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
}

145
src/lib/cloudAgent/db.ts Normal file
View File

@@ -0,0 +1,145 @@
import { getDbInstance } from "@/lib/db/core.ts";
export interface CloudAgentTaskRow {
id: string;
provider_id: string;
external_id: string | null;
status: string;
prompt: string;
source: string;
options: string;
result: string | null;
activities: string;
error: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export function createCloudAgentTaskTable(): void {
const db = getDbInstance();
db.exec(`
CREATE TABLE IF NOT EXISTS cloud_agent_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
external_id TEXT,
status TEXT NOT NULL DEFAULT 'queued',
prompt TEXT NOT NULL,
source TEXT NOT NULL,
options TEXT DEFAULT '{}',
result TEXT,
activities TEXT DEFAULT '[]',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_provider
ON cloud_agent_tasks(provider_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_status
ON cloud_agent_tasks(status)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_created
ON cloud_agent_tasks(created_at DESC)
`);
}
export function insertCloudAgentTask(task: CloudAgentTaskRow): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO cloud_agent_tasks (
id, provider_id, external_id, status, prompt, source,
options, result, activities, error, created_at, updated_at, completed_at
) VALUES (
@id, @provider_id, @external_id, @status, @prompt, @source,
@options, @result, @activities, @error, @created_at, @updated_at, @completed_at
)
`
).run(task);
}
// Whitelist of allowed columns for update operations
const ALLOWED_UPDATE_COLUMNS = new Set([
"status",
"prompt",
"source",
"options",
"result",
"activities",
"error",
"completed_at",
]);
export function updateCloudAgentTask(
id: string,
updates: Partial<Omit<CloudAgentTaskRow, "id">>
): void {
const db = getDbInstance();
// Validate keys against whitelist to prevent SQL injection
const validUpdates: Partial<Omit<CloudAgentTaskRow, "id">> = {};
for (const [key, value] of Object.entries(updates)) {
if (ALLOWED_UPDATE_COLUMNS.has(key)) {
(validUpdates as Record<string, unknown>)[key] = value;
}
}
const fields = Object.keys(validUpdates)
.map((key) => `${key} = @${key}`)
.join(", ");
if (!fields) return; // No valid updates
db.prepare(
`
UPDATE cloud_agent_tasks
SET ${fields}, updated_at = datetime('now')
WHERE id = @id
`
).run({ id, ...validUpdates });
}
export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE id = ?")
.get(id) as CloudAgentTaskRow | null;
}
export function getCloudAgentTasksByProvider(providerId: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare(
"SELECT * FROM cloud_agent_tasks WHERE provider_id = ? ORDER BY created_at DESC LIMIT ?"
)
.all(providerId, limit) as CloudAgentTaskRow[];
}
export function getCloudAgentTasksByStatus(status: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?")
.all(status, limit) as CloudAgentTaskRow[];
}
export function getAllCloudAgentTasks(limit = 100): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks ORDER BY created_at DESC LIMIT ?")
.all(limit) as CloudAgentTaskRow[];
}
export function deleteCloudAgentTask(id: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM cloud_agent_tasks WHERE id = ?").run(id);
}

View File

@@ -0,0 +1,8 @@
export * from "./types.ts";
export * from "./baseAgent.ts";
export * from "./registry.ts";
export * from "./db.ts";
import { createCloudAgentTaskTable } from "./db.ts";
createCloudAgentTaskTable();

View File

@@ -0,0 +1,25 @@
import type { CloudAgentBase } from "./baseAgent.ts";
import { JulesAgent } from "./agents/jules.ts";
import { DevinAgent } from "./agents/devin.ts";
import { CodexCloudAgent } from "./agents/codex.ts";
const AGENTS: Record<string, CloudAgentBase> = {
jules: new JulesAgent(),
devin: new DevinAgent(),
"codex-cloud": new CodexCloudAgent(),
};
export function getAgent(providerId: string): CloudAgentBase | null {
return AGENTS[providerId] || null;
}
export function getAvailableAgents(): string[] {
return Object.keys(AGENTS);
}
export function isCloudAgentProvider(providerId: string): boolean {
return providerId in AGENTS;
}
export { JulesAgent, DevinAgent, CodexCloudAgent };
export type { CloudAgentBase } from "./baseAgent.ts";

111
src/lib/cloudAgent/types.ts Normal file
View File

@@ -0,0 +1,111 @@
import { z } from "zod";
export const CLOUD_AGENT_STATUS = {
QUEUED: "queued",
RUNNING: "running",
AWAITING_APPROVAL: "awaiting_approval",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type CloudAgentStatus = (typeof CLOUD_AGENT_STATUS)[keyof typeof CLOUD_AGENT_STATUS];
export const CloudAgentStatusSchema = z.enum([
"queued",
"running",
"awaiting_approval",
"completed",
"failed",
"cancelled",
]);
export interface CloudAgentSource {
repoName: string;
repoUrl: string;
branch?: string;
}
export interface CloudAgentResult {
prUrl?: string;
prNumber?: number;
commitMessage?: string;
diffUrl?: string;
summary?: string;
duration?: number;
cost?: number;
}
export interface CloudAgentActivity {
id: string;
type: "plan" | "command" | "code_change" | "message" | "error" | "completion";
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface CloudAgentTask {
id: string;
providerId: "jules" | "devin" | "codex-cloud";
externalId?: string;
status: CloudAgentStatus;
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export const CloudAgentSourceSchema = z.object({
repoName: z.string().min(1),
repoUrl: z.string().url(),
branch: z.string().optional(),
});
export const CloudAgentResultSchema = z.object({
prUrl: z.string().url().optional(),
prNumber: z.number().int().positive().optional(),
commitMessage: z.string().optional(),
diffUrl: z.string().url().optional(),
summary: z.string().optional(),
duration: z.number().int().positive().optional(),
cost: z.number().positive().optional(),
});
export const CloudAgentActivitySchema = z.object({
id: z.string(),
type: z.enum(["plan", "command", "code_change", "message", "error", "completion"]),
content: z.string(),
timestamp: z.string().datetime(),
metadata: z.record(z.unknown()).optional(),
});
export const CloudAgentTaskOptionsSchema = z.object({
autoCreatePr: z.boolean().optional(),
planApprovalRequired: z.boolean().optional(),
environment: z.record(z.string()).optional(),
});
export const CreateCloudAgentTaskSchema = z.object({
providerId: z.enum(["jules", "devin", "codex-cloud"]),
prompt: z.string().min(1).max(10000),
source: CloudAgentSourceSchema,
options: CloudAgentTaskOptionsSchema.optional(),
});
export const UpdateCloudAgentTaskSchema = z.object({
id: z.string().min(1),
action: z.enum(["approve", "reject", "cancel", "message"]),
message: z.string().optional(),
});
export type CreateCloudAgentTaskInput = z.infer<typeof CreateCloudAgentTaskSchema>;
export type UpdateCloudAgentTaskInput = z.infer<typeof UpdateCloudAgentTaskSchema>;

View File

@@ -133,6 +133,12 @@ const RENAMED_MIGRATION_COMPATIBILITY = [
toVersion: "039",
toName: "compression_cache_stats",
},
{
fromVersion: "041",
fromName: "session_account_affinity",
toVersion: "050",
toName: "session_account_affinity",
},
] as const;
const LEGACY_VERSION_SLOT_MIGRATIONS = [
@@ -144,6 +150,15 @@ const LEGACY_VERSION_SLOT_MIGRATIONS = [
{ version: "033", name: "provider_connections_block_extra_usage" },
] as const;
const SUPERSEDED_DUPLICATE_MIGRATIONS = [
{
version: "041",
name: "session_account_affinity",
supersededByVersion: "050",
supersededByName: "session_account_affinity",
},
] as const;
const PHYSICAL_SCHEMA_SENTINELS = [
{ version: "028", tableName: "batches", description: "batches table" },
{ version: "024", tableName: "sync_tokens", description: "sync_tokens table" },
@@ -198,6 +213,34 @@ function getMigrationFiles(): Array<{ version: string; name: string; path: strin
.filter(Boolean) as Array<{ version: string; name: string; path: string }>;
}
function filterSupersededDuplicateMigrations(
files: Array<{ version: string; name: string; path: string }>
): Array<{ version: string; name: string; path: string }> {
return files.filter((file) => {
const superseded = SUPERSEDED_DUPLICATE_MIGRATIONS.find(
(migration) => migration.version === file.version && migration.name === file.name
);
if (!superseded) {
return true;
}
const hasReplacement = files.some(
(candidate) =>
candidate.version === superseded.supersededByVersion &&
candidate.name === superseded.supersededByName
);
if (!hasReplacement) {
return true;
}
console.warn(
`[Migration] Ignoring superseded duplicate migration ${file.version}_${file.name}; ` +
`${superseded.supersededByVersion}_${superseded.supersededByName} is the canonical slot.`
);
return false;
});
}
/**
* Get list of already-applied migration versions.
*/
@@ -286,6 +329,9 @@ function isSchemaAlreadyApplied(
case "040":
return hasColumn(db, "proxy_registry", "source");
case "041":
if (migration.name === "session_account_affinity") {
return hasTable(db, "session_account_affinity");
}
return (
hasColumn(db, "compression_analytics", "actual_prompt_tokens") &&
hasColumn(db, "compression_analytics", "actual_completion_tokens") &&
@@ -667,7 +713,7 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
const isNewDb = options?.isNewDb === true;
ensureMigrationsTable(db);
const files = getMigrationFiles();
const files = filterSupersededDuplicateMigrations(getMigrationFiles());
rehomeLegacyVersionSlotMigrations(db, files);
reconcileRenumberedMigrations(db, files);
const applied = getAppliedVersions(db);
@@ -779,7 +825,7 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
);
} else if (migration.version === "032") {
applyApiKeyLifecycleMigration(db);
} else if (migration.version === "041") {
} else if (migration.version === "041" && migration.name === "compression_receipts") {
applyCompressionReceiptsMigration(db);
} else if (migration.version === "042") {
applyCompressionCombosMigration(db, migration.path);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS session_account_affinity (
session_key TEXT NOT NULL,
provider TEXT NOT NULL,
connection_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
PRIMARY KEY (session_key, provider)
);
CREATE INDEX IF NOT EXISTS idx_saa_provider ON session_account_affinity(provider);
CREATE INDEX IF NOT EXISTS idx_saa_last_seen ON session_account_affinity(last_seen_at);

View File

@@ -1,7 +1,49 @@
// Stubbed functions for session account affinity (PR 1887 pending)
import { getDbInstance } from "./core";
export function getSessionAccountAffinity(sessionKey: string, provider: string): any {
return null;
export interface SessionAccountAffinity {
sessionKey: string;
provider: string;
connectionId: string;
createdAt: number;
lastSeenAt: number;
}
interface SessionAccountAffinityRow {
session_key: string;
provider: string;
connection_id: string;
created_at: number;
last_seen_at: number;
}
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let cleanupTimer: NodeJS.Timeout | null = null;
function rowToAffinity(row: SessionAccountAffinityRow): SessionAccountAffinity {
return {
sessionKey: row.session_key,
provider: row.provider,
connectionId: row.connection_id,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
};
}
export function getSessionAccountAffinity(
sessionKey: string,
provider: string
): SessionAccountAffinity | null {
const db = getDbInstance();
const row = db
.prepare(
`SELECT session_key, provider, connection_id, created_at, last_seen_at
FROM session_account_affinity
WHERE session_key = ? AND provider = ?`
)
.get(sessionKey, provider) as SessionAccountAffinityRow | undefined;
return row ? rowToAffinity(row) : null;
}
export function upsertSessionAccountAffinity(
@@ -9,23 +51,72 @@ export function upsertSessionAccountAffinity(
provider: string,
connectionId: string,
now: number = Date.now()
): void {}
): void {
const db = getDbInstance();
db.prepare(
`INSERT INTO session_account_affinity
(session_key, provider, connection_id, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key, provider) DO UPDATE SET
connection_id = excluded.connection_id,
last_seen_at = excluded.last_seen_at`
).run(sessionKey, provider, connectionId, now, now);
}
export function touchSessionAccountAffinity(
sessionKey: string,
provider: string,
now: number = Date.now()
): void {}
export function deleteSessionAccountAffinity(sessionKey: string, provider: string): void {}
export function cleanupStaleSessionAccountAffinities(
ttlMs: number = 30 * 60 * 1000,
now: number = Date.now()
): number {
return 0;
): void {
const db = getDbInstance();
db.prepare(
`UPDATE session_account_affinity
SET last_seen_at = ?
WHERE session_key = ? AND provider = ?`
).run(now, sessionKey, provider);
}
export function startSessionAccountAffinityCleanup(): void {}
export function deleteSessionAccountAffinity(sessionKey: string, provider: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM session_account_affinity WHERE session_key = ? AND provider = ?").run(
sessionKey,
provider
);
}
export function stopSessionAccountAffinityCleanupForTests(): void {}
export function cleanupStaleSessionAccountAffinities(
ttlMs: number = DEFAULT_TTL_MS,
now: number = Date.now()
): number {
const db = getDbInstance();
const cutoff = now - ttlMs;
const result = db
.prepare("DELETE FROM session_account_affinity WHERE last_seen_at < ?")
.run(cutoff);
return Number(result.changes || 0);
}
export function startSessionAccountAffinityCleanup(): void {
if (cleanupTimer) return;
try {
cleanupStaleSessionAccountAffinities();
} catch (error) {
console.warn("[SESSION_AFFINITY] Startup cleanup failed:", error);
}
cleanupTimer = setInterval(() => {
try {
cleanupStaleSessionAccountAffinities();
} catch (error) {
console.warn("[SESSION_AFFINITY] Periodic cleanup failed:", error);
}
}, CLEANUP_INTERVAL_MS);
cleanupTimer.unref?.();
}
export function stopSessionAccountAffinityCleanupForTests(): void {
if (!cleanupTimer) return;
clearInterval(cleanupTimer);
cleanupTimer = null;
}

View File

@@ -271,24 +271,51 @@ export async function getPricingWithSources(): Promise<{
export async function getPricingForModel(provider: string, model: string) {
const pricing = await getPricing();
if (pricing[provider]?.[model]) return pricing[provider][model];
const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels");
// Check if provider is an ID -> map to ALIAS
const alias = PROVIDER_ID_TO_ALIAS[provider];
if (alias && pricing[alias]) return pricing[alias][model] || null;
const findKeyInsensitive = (obj: Record<string, any> | undefined | null, key: string) => {
if (!obj || !key) return undefined;
const lowerKey = key.toLowerCase();
for (const [k, v] of Object.entries(obj)) {
if (k.toLowerCase() === lowerKey) return v;
}
return undefined;
};
// Check if provider is an ALIAS -> map to ID (search values)
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (mappedAlias === provider && pricing[id]?.[model]) {
return pricing[id][model];
const pLower = (provider || "").toLowerCase();
let providerPricing = findKeyInsensitive(pricing, pLower);
if (!providerPricing) {
const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower);
if (alias) providerPricing = findKeyInsensitive(pricing, alias);
}
if (!providerPricing) {
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (typeof mappedAlias === "string" && mappedAlias.toLowerCase() === pLower) {
providerPricing = findKeyInsensitive(pricing, id);
if (providerPricing) break;
}
}
}
const np = provider?.replace(/-cn$/, "");
if (np && np !== provider && pricing[np]) return pricing[np][model] || null;
if (!providerPricing) {
const np = pLower.replace(/-cn$/, "");
if (np && np !== pLower) {
providerPricing = findKeyInsensitive(pricing, np);
}
}
return null;
if (!providerPricing) return null;
const mLower = (model || "").toLowerCase();
let modelPricing = findKeyInsensitive(providerPricing, mLower);
if (!modelPricing) {
const hyphenModel = mLower.replace(/\./g, "-");
modelPricing = findKeyInsensitive(providerPricing, hyphenModel);
}
return modelPricing || null;
}
export async function updatePricing(pricingData: PricingByProvider) {

View File

@@ -15,7 +15,7 @@ import { getProviderNodes } from "@/lib/localDb";
type ValidatedEmbeddingBody = Record<string, unknown> & { model: string };
interface EmbeddingHandlerOptions {
export interface EmbeddingHandlerOptions {
clientRawRequest?: {
endpoint: string;
body: Record<string, unknown>;

View File

@@ -372,3 +372,13 @@ export {
} from "./db/oneproxy";
export type { OneproxyProxyRecord, OneproxyStats } from "./db/oneproxy";
export {
getSessionAccountAffinity,
upsertSessionAccountAffinity,
touchSessionAccountAffinity,
deleteSessionAccountAffinity,
cleanupStaleSessionAccountAffinities,
startSessionAccountAffinityCleanup,
stopSessionAccountAffinityCleanupForTests,
} from "./db/sessionAccountAffinity";

View File

@@ -143,8 +143,10 @@ export class CursorService {
const decoded = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
);
const email =
typeof decoded.email === "string" && decoded.email.includes("@") ? decoded.email : null;
return {
email: decoded.email || decoded.sub,
email,
userId: decoded.sub || decoded.user_id,
};
}
@@ -155,6 +157,41 @@ export class CursorService {
return null;
}
/**
* Fetch real user profile from cursor.com using the same WorkOS-session cookie
* format that powers the dashboard. Returns null on any failure so the import
* flow can fall back to whatever it can extract from the JWT.
*/
async fetchUserInfo(
accessToken: string,
userId: string
): Promise<{ email: string | null; name: string | null; sub: string | null } | null> {
if (!accessToken || !userId) return null;
try {
const response = await fetch("https://cursor.com/api/auth/me", {
method: "GET",
redirect: "manual",
headers: {
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
Origin: "https://cursor.com",
Referer: "https://cursor.com/dashboard",
Accept: "application/json",
"User-Agent": getCursorUserAgent(this.config.clientVersion),
},
});
if (!response.ok) return null;
const data = (await response.json()) as Record<string, unknown>;
return {
email: typeof data.email === "string" ? data.email : null,
name: typeof data.name === "string" ? data.name : null,
sub: typeof data.sub === "string" ? data.sub : null,
};
} catch {
return null;
}
}
/**
* Get token storage path instructions for user
*/

View File

@@ -1,6 +1,7 @@
import {
APIKEY_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
CLOUD_AGENT_PROVIDERS,
FREE_PROVIDERS,
LOCAL_PROVIDERS,
OAUTH_PROVIDERS,
@@ -21,7 +22,8 @@ export type StaticProviderCatalogCategory =
| "search"
| "audio"
| "upstream-proxy"
| "apikey";
| "apikey"
| "cloud-agent";
export interface ProviderCatalogMetadata {
id: string;
@@ -133,6 +135,12 @@ export const STATIC_PROVIDER_CATALOG_GROUPS: Record<
displayAuthType: "apikey",
toggleAuthType: "apikey",
},
"cloud-agent": {
category: "cloud-agent",
providers: CLOUD_AGENT_PROVIDERS as ProviderRecord,
displayAuthType: "apikey",
toggleAuthType: "apikey",
},
};
export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCategory[] = [
@@ -143,6 +151,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate
"search",
"audio",
"upstream-proxy",
"cloud-agent",
"apikey",
];

View File

@@ -14,6 +14,17 @@ export interface RequestQueueSettings {
export interface ConnectionCooldownProfileSettings {
baseCooldownMs: number;
useUpstreamRetryHints: boolean;
/**
* Issue #2100 follow-up: opt-in toggle for upstream 429 hint trust at the
* circuit-breaker cooldown layer (independent of `useUpstreamRetryHints`
* which controls retry scheduling).
*
* Stored shape is intentionally optional / `boolean | undefined`: when
* unset, the per-provider default from `providerHints.ts` applies.
* Normalize/merge MUST preserve `undefined` — do not coerce via
* `toBoolean(value, fallback)`.
*/
useUpstream429BreakerHints?: boolean;
maxBackoffSteps: number;
}
@@ -155,7 +166,26 @@ function normalizeConnectionCooldownProfile(
fallback: ConnectionCooldownProfileSettings
): ConnectionCooldownProfileSettings {
const record = asRecord(next);
return {
// useUpstream429BreakerHints uses a 3-state input contract:
// - boolean → user override, store as-is
// - null → explicit unset sentinel, drop key so the per-provider
// default in `providerHints.ts` resolves at runtime
// - omitted → leave existing fallback value unchanged (partial-merge)
// Never coerce via `toBoolean(value, fallback)` because that would
// collapse the unset state.
const hasHintsKey = Object.prototype.hasOwnProperty.call(record, "useUpstream429BreakerHints");
const rawHints = record.useUpstream429BreakerHints;
let useUpstream429BreakerHints: boolean | undefined;
if (!hasHintsKey) {
useUpstream429BreakerHints = fallback.useUpstream429BreakerHints;
} else if (rawHints === null) {
useUpstream429BreakerHints = undefined;
} else if (typeof rawHints === "boolean") {
useUpstream429BreakerHints = rawHints;
} else {
useUpstream429BreakerHints = fallback.useUpstream429BreakerHints;
}
const out: ConnectionCooldownProfileSettings = {
baseCooldownMs: toInteger(record.baseCooldownMs, fallback.baseCooldownMs, {
min: 0,
max: 24 * 60 * 60 * 1000,
@@ -166,6 +196,11 @@ function normalizeConnectionCooldownProfile(
max: 32,
}),
};
// Only attach the key when defined — preserves omission across round-trips.
if (useUpstream429BreakerHints !== undefined) {
out.useUpstream429BreakerHints = useUpstream429BreakerHints;
}
return out;
}
function normalizeLegacyConnectionCooldownProfile(

View File

@@ -47,6 +47,7 @@ interface ProviderConnectionLike {
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
"glm",
"glm-cn",
"zai",
"glmt",
"minimax",
"minimax-cn",
@@ -297,7 +298,9 @@ async function fetchLiveProviderLimitsWithOptions(
connection: ProviderConnectionLike;
usage: JsonRecord;
}> {
let connection = (await getProviderConnectionById(connectionId)) as ProviderConnectionLike | null;
let connection = (await getProviderConnectionById(
connectionId
)) as unknown as ProviderConnectionLike | null;
if (!connection) {
throw withStatus(new Error("Connection not found"), 404);
}
@@ -435,7 +438,7 @@ export async function syncAllProviderLimits(
}> {
const { source = "manual", concurrency = 5 } = options;
const connections = (
(await getProviderConnections({ isActive: true })) as ProviderConnectionLike[]
(await getProviderConnections({ isActive: true })) as unknown as ProviderConnectionLike[]
).filter(isSupportedUsageConnection);
const cacheEntries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }> = [];
const caches: Record<string, ProviderLimitsCacheEntry> = {};

View File

@@ -53,6 +53,14 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
};
}
if (normalizedPath === "/dashboard/onboarding") {
return {
routeClass: "PUBLIC",
reason: "setup_wizard",
normalizedPath,
};
}
if (normalizedPath.startsWith("/dashboard")) {
return {
routeClass: "MANAGEMENT",

View File

@@ -28,7 +28,9 @@ export type ClassificationReason =
| "public_prefix"
| "public_readonly_prefix"
| "dashboard_prefix"
| "setup_wizard"
| "client_api_v1"
| "client_api_mcp"
| "client_api_alias"
| "client_api_codex_alias"
| "client_api_double_prefix"

View File

@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
const AUTO_ROUTING_DISMISSED_KEY = "auto-routing-banner-dismissed";
export default function AutoRoutingBanner() {
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
try {
const dismissed = localStorage.getItem(AUTO_ROUTING_DISMISSED_KEY);
if (dismissed === "true") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDismissed(true);
}
} catch {
// localStorage unavailable (SSR or private mode) — do nothing
}
}, []);
const handleDismiss = () => {
try {
localStorage.setItem(AUTO_ROUTING_DISMISSED_KEY, "true");
} catch {
// ignore localStorage errors (private mode, quotas)
}
setIsDismissed(true);
};
if (isDismissed) return null;
return (
<div
role="banner"
aria-label="Auto-routing mode active"
className="relative overflow-hidden rounded-lg border-l-4 border-blue-500 bg-blue-50/50 p-4 my-4 dark:bg-blue-950/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-blue-500" />
<span className="font-semibold text-sm text-blue-700 dark:text-blue-300">
Auto-Routing Active
</span>
</div>
<div className="text-sm leading-relaxed text-text-muted">
OmniRoute is automatically routing requests using combo-based strategies.
<span className="block sm:inline sm:ml-1">
View or change your routing configuration on the{" "}
<a
href="/dashboard/combos"
className="text-blue-600 hover:text-blue-800 underline dark:text-blue-400 dark:hover:text-blue-300"
>
Combos page
</a>
.
</span>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss auto-routing banner"
className="ml-auto flex-shrink-0 rounded-md p-1 text-text-muted hover:bg-blue-100 hover:text-blue-700 dark:hover:bg-blue-900/50 dark:hover:text-blue-300 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -65,8 +65,12 @@ export default function Card({
);
}
interface CardSectionProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
// Sub-component: Bordered section inside Card
Card.Section = function CardSection({ children, className, ...props }) {
Card.Section = function CardSection({ children, className, ...props }: CardSectionProps) {
return (
<div
className={cn(
@@ -82,8 +86,12 @@ Card.Section = function CardSection({ children, className, ...props }) {
);
};
interface CardRowProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
// Sub-component: Hoverable row inside Card
Card.Row = function CardRow({ children, className, ...props }) {
Card.Row = function CardRow({ children, className, ...props }: CardRowProps) {
return (
<div
className={cn(
@@ -99,8 +107,18 @@ Card.Row = function CardRow({ children, className, ...props }) {
);
};
interface CardListItemProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
actions?: React.ReactNode;
}
// Sub-component: List item with hover actions (macOS style)
Card.ListItem = function CardListItem({ children, actions, className, ...props }) {
Card.ListItem = function CardListItem({
children,
actions,
className,
...props
}: CardListItemProps) {
return (
<div
className={cn(

View File

@@ -7,6 +7,7 @@ import Breadcrumbs from "../Breadcrumbs";
import NotificationToast from "../NotificationToast";
import MaintenanceBanner from "../MaintenanceBanner";
import { useIsElectron } from "@/shared/hooks/useElectron";
import AutoRoutingBanner from "../AutoRoutingBanner";
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
@@ -77,6 +78,7 @@ export default function DashboardLayout({ children }) {
>
<Header onMenuClick={() => setSidebarOpen(true)} />
{!isE2EMode && <MaintenanceBanner />}
<AutoRoutingBanner />
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar p-4 sm:p-6 lg:p-10">
<div className="max-w-7xl mx-auto w-full">
<Breadcrumbs />

View File

@@ -1880,6 +1880,20 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
}
// ── System Providers (virtual, not user-connectable) ──────────────────────────
export const SYSTEM_PROVIDERS = {
auto: {
id: "auto",
alias: "auto",
name: "Auto (Zero-Config)",
icon: "auto_awesome",
color: "#6366F1",
textIcon: "Auto",
systemOnly: true,
description: "Zero-config auto-routing with LKGP across all connected providers",
},
};
// All providers (combined)
export const AI_PROVIDERS = {
...FREE_PROVIDERS,
@@ -1890,6 +1904,8 @@ export const AI_PROVIDERS = {
...SEARCH_PROVIDERS,
...AUDIO_ONLY_PROVIDERS,
...UPSTREAM_PROXY_PROVIDERS,
...CLOUD_AGENT_PROVIDERS,
...SYSTEM_PROVIDERS, // <-- system providers included
};
export type AiProviderId = keyof typeof AI_PROVIDERS;
@@ -1946,9 +1962,11 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"github",
"codex",
"claude",
"cursor",
"kimi-coding",
"glm",
"glm-cn",
"zai",
"glmt",
"minimax",
"minimax-cn",
@@ -1968,3 +1986,4 @@ validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");

View File

@@ -14,6 +14,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"limits",
"cli-tools",
"agents",
"cloud-agents",
"memory",
"skills",
"translator",
@@ -69,6 +70,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" },
{ id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" },
{ id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" },
{ id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" },
{ id: "skills", href: "/dashboard/skills", i18nKey: "skills", icon: "auto_fix_high" },
];

View File

@@ -19,6 +19,7 @@ import {
deleteCircuitBreakerState,
deleteAllCircuitBreakerStates,
} from "../../lib/db/domainState";
import type { FailureKind } from "./classify429";
const STATE = {
CLOSED: "CLOSED",
@@ -34,6 +35,25 @@ interface CircuitBreakerOptions {
halfOpenRequests?: number;
onStateChange?: ((name: string, oldState: string, newState: string) => void) | null;
isFailure?: (error: unknown) => boolean;
/**
* Per-failure-kind cooldown override (Issue #2100).
*
* When set, `_timeUntilReset()` and `_shouldAttemptReset()` use
* `cooldownByKind[lastFailureKind]` instead of `resetTimeout` whenever
* the last failure had a known kind. Use this to give a longer cooldown
* to `quota_exhausted` (period-end may be hours away) than to
* `rate_limit` (typically 60s).
*/
cooldownByKind?: Partial<Record<FailureKind, number>>;
/**
* Optional classifier called on `execute()` errors (Issue #2100).
* Returns the kind to record. When omitted, all failures are recorded
* as `lastFailureKind = null` (existing behavior preserved).
*
* Pair with `classify429()` from `./classify429.ts` for HTTP responses,
* or supply a custom classifier for non-HTTP errors.
*/
classifyError?: (error: unknown) => FailureKind | undefined;
}
export class CircuitBreaker {
@@ -48,6 +68,9 @@ export class CircuitBreaker {
successCount: number;
lastFailureTime: number | null;
halfOpenAllowed: number;
cooldownByKind: Partial<Record<FailureKind, number>>;
classifyError: ((error: unknown) => FailureKind | undefined) | null;
lastFailureKind: FailureKind | null;
constructor(name: string, options: CircuitBreakerOptions = {}) {
this.name = name;
@@ -62,6 +85,9 @@ export class CircuitBreaker {
this.successCount = 0;
this.lastFailureTime = null;
this.halfOpenAllowed = 0;
this.cooldownByKind = options.cooldownByKind ?? {};
this.classifyError = options.classifyError ?? null;
this.lastFailureKind = null;
// Try to restore state from DB
this._restoreFromDb();
@@ -151,7 +177,17 @@ export class CircuitBreaker {
return result;
} catch (error) {
if (this.isFailure(error)) {
this._onFailure();
let kind: FailureKind | undefined;
if (this.classifyError) {
try {
kind = this.classifyError(error);
} catch {
// A user-supplied classifier must not mask the original error
// or change failure-counting semantics; fall back to no kind.
kind = undefined;
}
}
this._onFailure(kind);
}
throw error;
}
@@ -205,6 +241,7 @@ export class CircuitBreaker {
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.lastFailureKind = null;
this._persistToDb();
}
@@ -218,10 +255,12 @@ export class CircuitBreaker {
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.lastFailureKind = null;
} else if (this.state === STATE.HALF_OPEN) {
this.successCount++;
this._transition(STATE.CLOSED);
this.failureCount = 0;
this.lastFailureKind = null;
} else {
// In CLOSED state, just reset failure count
this.failureCount = 0;
@@ -229,9 +268,10 @@ export class CircuitBreaker {
this._persistToDb();
}
_onFailure() {
_onFailure(kind?: FailureKind | null) {
this.failureCount++;
this.lastFailureTime = Date.now();
this.lastFailureKind = kind ?? null;
if (this.state === STATE.OPEN) {
// Already OPEN — just update persistence (re-tripped by combo path)
@@ -245,12 +285,31 @@ export class CircuitBreaker {
_shouldAttemptReset() {
if (!this.lastFailureTime) return true;
return Date.now() - this.lastFailureTime >= this.resetTimeout;
const cooldown = this._effectiveCooldown();
return Date.now() - this.lastFailureTime >= cooldown;
}
/**
* Resolve the cooldown for the current `lastFailureKind`. Falls back to
* `resetTimeout` when no kind was recorded, no override exists for it,
* or the override is not a finite non-negative number (NaN / Infinity /
* negative all silently fall through to `resetTimeout`).
* @private
*/
_effectiveCooldown() {
if (this.lastFailureKind !== null) {
const override = this.cooldownByKind[this.lastFailureKind];
if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
return override;
}
}
return this.resetTimeout;
}
_timeUntilReset() {
if (!this.lastFailureTime) return 0;
return Math.max(0, this.resetTimeout - (Date.now() - this.lastFailureTime));
const cooldown = this._effectiveCooldown();
return Math.max(0, cooldown - (Date.now() - this.lastFailureTime));
}
_refreshOpenState() {
@@ -315,6 +374,18 @@ export function getCircuitBreaker(name: string, options?: CircuitBreakerOptions)
if (typeof options.isFailure === "function") {
breaker.isFailure = options.isFailure;
}
if (options.cooldownByKind) {
// Merge keys, don't replace: callers that add different kinds
// (e.g. one sets `quota_exhausted`, another `rate_limit`) should
// not silently lose each other's overrides.
breaker.cooldownByKind = {
...breaker.cooldownByKind,
...options.cooldownByKind,
};
}
if (typeof options.classifyError === "function") {
breaker.classifyError = options.classifyError;
}
breaker._persistToDb();
}
return breaker;

View File

@@ -0,0 +1,243 @@
/**
* 429 response classifier — distinguish rate-limit from quota-exhausted.
*
* Most LLM providers return HTTP 429 for two semantically different reasons:
*
* 1. **Rate-limit**: short transient back-off ("too many requests in
* the last minute"). Fix: wait the Retry-After window and retry.
* 2. **Quota-exhausted**: long-period cap hit ("daily/monthly limit
* reached"). Fix: wait until the period rolls over (could be hours
* or days). Retrying every 60s wastes calls and burns alerts.
*
* The HTTP status alone cannot disambiguate. This helper inspects the
* response body and headers to return a `FailureKind` the circuit
* breaker can use to pick the right cooldown.
*
* Companion to OmniRoute issue #2100.
*
* @module shared/utils/classify429
*/
export type FailureKind = "rate_limit" | "quota_exhausted" | "transient";
/**
* Heuristic regexes for "explicit quota exhausted" vs "rate-limited"
* detection in 429 error bodies. A 429 alone never implies quota
* exhausted — only an explicit keyword does.
*
* Patterns observed across OpenAI, Anthropic, Groq, Cerebras, Mistral,
* Google Gemini, and OpenRouter free-tier responses.
*/
const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
/daily.*limit/i,
/daily.*quota/i,
/per.?day.*limit/i,
/monthly.*limit/i,
/monthly.*quota/i,
/per.?month.*limit/i,
/quota.*exceed/i,
/exceed.*quota/i,
/insufficient.*quota/i,
/billing.*cap/i,
/credit.*exhaust/i,
/out of credits/i,
/hard.?limit/i,
/plan.*limit/i,
];
/**
* Best-effort case-insensitive header lookup.
*/
function getHeader(headers: Record<string, string> | undefined, name: string): string | undefined {
if (!headers) return undefined;
const target = name.toLowerCase();
for (const [k, v] of Object.entries(headers)) {
if (k.toLowerCase() === target) return v;
}
return undefined;
}
/**
* Coerce a body of unknown shape to a string for keyword scanning.
* - string: returned as-is
* - object: JSON-stringified (so nested error.message gets scanned)
* - undefined/null: empty string
*/
function bodyToText(body: unknown): string {
if (typeof body === "string") return body;
if (body == null) return "";
try {
return JSON.stringify(body);
} catch {
return "";
}
}
/**
* Returns true if the body looks like an explicit quota-exhausted
* error — i.e. the upstream is telling us a long-period cap was hit.
*/
export function looksLikeQuotaExhausted(body: unknown): boolean {
const text = bodyToText(body);
if (!text) return false;
return QUOTA_PATTERNS.some((pat) => pat.test(text));
}
/**
* Classify a 429 (or any) response into a `FailureKind`.
*
* Decision order:
* 1. status !== 429 → `"transient"` (don't pretend to know more than
* the caller does about non-429 failures).
* 2. body matches a quota keyword → `"quota_exhausted"`.
* 3. otherwise → `"rate_limit"` (default for 429 — even without
* Retry-After, a 429 is per definition a rate-limit signal).
*
* @param response - the upstream response with status, optional headers,
* optional body. Headers are looked up
* case-insensitively.
*/
export function classify429(response: {
status: number;
headers?: Record<string, string>;
body?: unknown;
}): FailureKind {
if (response.status !== 429) return "transient";
if (looksLikeQuotaExhausted(response.body)) return "quota_exhausted";
return "rate_limit";
}
/**
* Parse a `Retry-After` header value into seconds.
*
* Accepts:
* - integer seconds: `"60"`
* - HTTP date: `"Wed, 08 May 2026 03:00:00 GMT"`
* - Groq-style relative: `"60s"`, `"5m"`, `"2h"`
*
* Returns `null` if unparseable.
*
* Note: integer seconds vs Groq relative units are easy to confuse —
* `parseInt("5m", 10)` returns `5` (parses leading digits and ignores
* trailing). This helper checks the relative-unit pattern FIRST.
*/
export function parseRetryAfter(headerValue: string | undefined): number | null {
if (!headerValue) return null;
const trimmed = headerValue.trim();
if (!trimmed) return null;
// Groq-style relative: must check BEFORE plain int parse.
const relMatch = trimmed.match(/^(\d+)([smh])$/i);
if (relMatch) {
const n = Number(relMatch[1]);
const unit = relMatch[2].toLowerCase();
if (Number.isFinite(n)) {
if (unit === "s") return n;
if (unit === "m") return n * 60;
if (unit === "h") return n * 3600;
}
}
// Pure integer seconds.
if (/^\d+$/.test(trimmed)) {
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
// HTTP date.
const ts = Date.parse(trimmed);
if (Number.isFinite(ts)) {
return Math.max(0, Math.floor((ts - Date.now()) / 1000));
}
return null;
}
/**
* Convenience wrapper: pull the Retry-After from a response's headers
* and parse it to seconds. Returns null if absent or unparseable.
*/
export function retryAfterFromResponse(response: {
headers?: Record<string, string>;
}): number | null {
return parseRetryAfter(getHeader(response.headers, "retry-after"));
}
/**
* Normalize an unknown headers-like value into a plain `Record<string, string>`.
* Native `Headers` (from `fetch`) does NOT respond to `Object.entries` — it
* exposes `.entries()` instead. Without this normalization, `getHeader` would
* silently miss every header on a Headers instance.
*/
function normalizeHeaders(raw: unknown): Record<string, string> | undefined {
if (raw === null || typeof raw !== "object") return undefined;
const maybeIter = (raw as { entries?: unknown }).entries;
if (typeof maybeIter === "function") {
try {
return Object.fromEntries((raw as { entries: () => Iterable<[string, string]> }).entries());
} catch {
// fall through to plain-object treatment
}
}
return raw as Record<string, string>;
}
/**
* Adapter that takes an error thrown by an HTTP client (fetch wrapper, axios,
* upstream SDK, etc.) and produces a {@link FailureKind} suitable for the
* `classifyError` option of the circuit breaker.
*
* Recognises the common error shapes:
* - `err.status` + `err.headers` + `err.body` (low-level fetch wrapper)
* - `err.response.status` + `err.response.headers` + `err.response.data` (axios-style)
* - `err.message` (last-resort body for keyword scan)
*
* Returns `undefined` when the error doesn't carry enough information to
* classify, so the breaker can decide what to do without a kind tag.
*
* Companion to issue #2100 follow-up.
*/
export function classify429FromError(err: unknown): FailureKind | undefined {
if (err === null || typeof err !== "object") return undefined;
const e = err as Record<string, unknown>;
let status: number | undefined;
let headers: Record<string, string> | undefined;
let body: unknown;
if (typeof e.status === "number") {
status = e.status;
}
if (typeof e.statusCode === "number" && status === undefined) {
status = e.statusCode;
}
if (e.response && typeof e.response === "object") {
const resp = e.response as Record<string, unknown>;
if (typeof resp.status === "number" && status === undefined) {
status = resp.status;
}
if (resp.headers && typeof resp.headers === "object") {
headers = normalizeHeaders(resp.headers);
}
if (resp.data !== undefined) {
body = resp.data;
} else if (typeof resp.body !== "undefined") {
body = resp.body;
}
}
if (headers === undefined && e.headers && typeof e.headers === "object") {
headers = normalizeHeaders(e.headers);
}
if (body === undefined) {
if (typeof e.body !== "undefined") {
body = e.body;
} else if (typeof e.message === "string") {
body = e.message;
}
}
if (typeof status !== "number") return undefined;
return classify429({ status, headers, body });
}

View File

@@ -0,0 +1,73 @@
/**
* Per-provider default policy for upstream 429 hint trust.
*
* @see Issue #2100 follow-up — surface a user-overridable per-profile toggle
* that decides whether the circuit breaker uses upstream 429 body / Retry-After
* hints (`classify429`, `cooldownByKind`) to differentiate rate-limit from
* quota-exhausted failure cooldowns.
*
* This helper returns the **default** answer for a given provider. The actual
* runtime decision is the user override (if any) OR this default. See
* `accountFallback.ts` / `chat.ts` / `chatHelpers.ts` for the resolution
* call sites:
*
* ```ts
* const userValue = providerProfile.useUpstream429BreakerHints; // boolean | undefined
* const useHints = userValue !== undefined
* ? userValue
* : defaultUseUpstream429BreakerHints(provider);
* ```
*
* Default policy: direct cloud providers default `true` because their 429
* bodies and `Retry-After` headers are authoritative. Reverse-proxy /
* self-hosted / CLI-backed providers default `false` because forwarded 429
* metadata is often unreliable or fabricated by the proxy.
*
* @module shared/utils/providerHints
*/
import {
UPSTREAM_PROXY_PROVIDERS,
SELF_HOSTED_CHAT_PROVIDER_IDS,
isLocalProvider,
isClaudeCodeCompatibleProvider,
} from "../constants/providers";
/**
* Conservative per-provider default for `useUpstream429BreakerHints`.
*
* Returns `false` for any provider whose 429 metadata may be forwarded by
* an intermediary (proxy, self-hosted runtime, CLI wrapper). Returns `true`
* for direct cloud providers where the upstream response is authoritative.
*/
export function defaultUseUpstream429BreakerHints(providerId: string): boolean {
if (Object.prototype.hasOwnProperty.call(UPSTREAM_PROXY_PROVIDERS, providerId)) {
return false;
}
if (isLocalProvider(providerId)) {
return false;
}
if (SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId)) {
return false;
}
if (isClaudeCodeCompatibleProvider(providerId)) {
return false;
}
return true;
}
/**
* Resolve the effective `useHints` decision: the user override wins if set,
* otherwise fall back to the per-provider default.
*
* `undefined` means "not user-set" and triggers the default lookup.
*/
export function resolveUseUpstream429BreakerHints(
providerId: string,
userValue: boolean | undefined
): boolean {
if (userValue !== undefined) {
return userValue;
}
return defaultUseUpstream429BreakerHints(providerId);
}

View File

@@ -11,7 +11,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 30_000;
export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000;
export const DEFAULT_FETCH_KEEPALIVE_TIMEOUT_MS = 4_000;
export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 30_000;
export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 600_000;
export const DEFAULT_API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS = 300_000;
export const DEFAULT_API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS = 60_000;
export const DEFAULT_API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS = 5_000;

View File

@@ -775,6 +775,11 @@ const connectionCooldownProfileSchema = z
.object({
baseCooldownMs: z.number().int().min(0).optional(),
useUpstreamRetryHints: z.boolean().optional(),
// Issue #2100 follow-up: per-profile toggle for upstream 429 hint trust.
// `null` is an explicit unset sentinel — PATCH handler deletes the key
// from stored settings so the per-provider default resolves at runtime.
// `undefined` (key omitted) means "leave existing value unchanged".
useUpstream429BreakerHints: z.boolean().nullable().optional(),
maxBackoffSteps: z.number().int().min(0).optional(),
})
.strict();

View File

@@ -104,6 +104,11 @@ export const updateSettingsSchema = z.object({
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
// Auto-routing settings
autoRoutingEnabled: z.boolean().optional(),
autoRoutingDefaultVariant: z
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
.optional(),
});
export const databaseSettingsSchema = z.object(

View File

@@ -3,6 +3,8 @@ import {
getProviderCredentialsWithQuotaPreflight,
markAccountUnavailable,
extractApiKey,
isValidApiKey,
extractSessionAffinityKey,
} from "../services/auth";
import {
getRuntimeProviderProfile,
@@ -23,6 +25,7 @@ import {
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
} from "@omniroute/open-sse/config/providerModels.ts";
import type { AutoVariant } from "@omniroute/open-sse/services/autoCombo/autoPrefix.ts";
import * as log from "../utils/logger";
import { checkAndRefreshToken } from "../services/tokenRefresh";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
@@ -43,6 +46,8 @@ import {
} from "./chatHelpers";
// Pipeline integration — wired modules
import { classify429FromError, type FailureKind } from "@/shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints";
import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
@@ -208,6 +213,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// T04: client-provided external session header has priority over generated fingerprint.
const externalSessionId = extractExternalSessionId(request.headers);
const sessionId = externalSessionId || generateStableSessionId(body);
const sessionAffinityKey = extractSessionAffinityKey(body, request.headers) || sessionId;
const requestedConnectionId = request.headers.get("x-omniroute-connection")?.trim() || null;
if (sessionId) {
touchSession(sessionId);
@@ -229,9 +235,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// Guardrail pre-call pipeline — prompt injection, PII masking, and future custom rules.
telemetry.startPhase("validate");
const preCallGuardrails = await guardrailRegistry.runPreCallHooks(body, {
apiKeyInfo,
apiKeyInfo: apiKeyInfo as any,
disabledGuardrails: resolveDisabledGuardrails({
apiKeyInfo: apiKeyInfo as Record<string, unknown> | null,
apiKeyInfo: (apiKeyInfo ?? null) as any,
body,
headers: request.headers,
}),
@@ -295,6 +301,44 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry.endPhase();
}
// ── Zero-Config Auto-Routing (auto and auto/ prefix) ────────────────────────
// If the model ID is "auto" or starts with "auto/", bypass DB combo lookup
// entirely and generate a virtual auto-combo on-the-fly from connected providers.
let autoVariant: AutoVariant | undefined;
let isAutoRouting = resolvedModelStr === "auto" || resolvedModelStr.startsWith("auto/");
if (isAutoRouting) {
// C2: Enforce autoRoutingEnabled setting
const settings = await getSettings();
if (settings?.autoRoutingEnabled === false) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
"Auto routing is disabled. Enable it in Settings > Routing."
);
}
try {
const { parseAutoPrefix } =
await import("@omniroute/open-sse/services/autoCombo/autoPrefix.ts");
const parsed = parseAutoPrefix(resolvedModelStr);
if (parsed.valid) {
autoVariant = parsed.variant;
// C3: Apply autoRoutingDefaultVariant from settings when bare "auto" is used
if (autoVariant === undefined && settings?.autoRoutingDefaultVariant) {
autoVariant = settings.autoRoutingDefaultVariant as AutoVariant;
}
log.info(
"AUTO",
`Zero-config routing variant: ${autoVariant || "default"} (model=${resolvedModelStr})`
);
} else {
log.warn("AUTO", `Invalid auto prefix format: ${resolvedModelStr}`);
}
} catch (err) {
log.error("AUTO", "Failed to load auto-prefix parser", { err });
}
}
// ────────────────────────────────────────────────────────────────────────────
// Check if model is a combo (has multiple models with fallback)
telemetry.startPhase("resolve");
let combo: any = await getComboForModel(resolvedModelStr);
@@ -312,6 +356,23 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
}
}
// Auto-prefix short-circuit: if auto/ prefix was detected, replace combo with virtual one
if (isAutoRouting && combo === null) {
try {
const { createVirtualAutoCombo } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
const virtualCombo = await createVirtualAutoCombo(autoVariant);
virtualCombo.name = resolvedModelStr;
virtualCombo.id = resolvedModelStr;
combo = virtualCombo;
log.info(
"AUTO",
`Virtual auto-combo created: ${combo.name} (${virtualCombo.candidatePool?.length || 0} candidates)`
);
} catch (err) {
log.error("AUTO", "Failed to create virtual auto-combo", { err });
}
}
if (combo) {
log.info(
"CHAT",
@@ -358,6 +419,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
allowedConnections,
resolvedModel,
{
sessionKey: sessionAffinityKey,
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
}
);
@@ -388,6 +450,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
connectionId?: string | null;
executionKey?: string | null;
stepId?: string | null;
allowedConnectionIds?: string[] | null;
}
) =>
handleSingleModelChat(
@@ -400,6 +463,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry,
{
sessionId,
sessionAffinityKey,
forceLiveComboTest: isComboLiveTest,
forcedConnectionId: target?.connectionId ?? null,
allowedConnectionIds: target?.allowedConnectionIds ?? null,
@@ -450,7 +514,12 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
combo.name,
apiKeyInfo,
telemetry,
{ sessionId, emergencyFallbackTried: true, forceLiveComboTest: isComboLiveTest },
{
sessionId,
sessionAffinityKey,
emergencyFallbackTried: true,
forceLiveComboTest: isComboLiveTest,
},
combo.strategy,
true
);
@@ -486,6 +555,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry,
{
sessionId,
sessionAffinityKey,
forceLiveComboTest: isComboLiveTest,
forcedConnectionId: requestedConnectionId,
},
@@ -524,6 +594,7 @@ async function handleSingleModelChat(
emergencyFallbackTried?: boolean;
forceLiveComboTest?: boolean;
sessionId?: string | null;
sessionAffinityKey?: string | null;
forcedConnectionId?: string | null;
allowedConnectionIds?: string[] | null;
comboStepId?: string | null;
@@ -615,11 +686,25 @@ async function handleSingleModelChat(
});
if (gate) return gate;
// Issue #2100 follow-up: opt-in upstream 429 hint trust per provider.
const useHints429 = resolveUseUpstream429BreakerHints(
provider,
(providerProfile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints
);
const breaker = getCircuitBreaker(provider, {
failureThreshold: providerProfile.failureThreshold,
resetTimeout: providerProfile.resetTimeoutMs,
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from}${to}`),
...(useHints429
? {
cooldownByKind: {
rate_limit: 60_000,
quota_exhausted: 3_600_000,
} satisfies Partial<Record<FailureKind, number>>,
classifyError: classify429FromError,
}
: {}),
});
const userAgent = request?.headers?.get("user-agent") || "";
@@ -670,6 +755,7 @@ async function handleSingleModelChat(
effectiveAllowedConnections,
model,
{
sessionKey: runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? null,
excludeConnectionIds: Array.from(excludedConnectionIds),
...(forceLiveComboTest
? {
@@ -861,6 +947,25 @@ async function handleSingleModelChat(
return result.response;
}
if (result.errorType === "account_semaphore_capacity") {
// Local concurrency pressure is not an upstream quota failure. Prefer another
// account when possible; pinned combo steps fall through to combo orchestration.
if (hasForcedConnection) {
return result.response;
}
log.warn(
"AUTH",
`Account ${accountId}... at local concurrency cap, trying fallback account`
);
excludedConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
requestRetryLastError = result.error;
requestRetryLastStatus = result.status;
continue;
}
// Emergency fallback for budget exhaustion (402 / billing / quota keywords):
// reroute to a free model (default provider/model: nvidia + openai/gpt-oss-120b) exactly once.
if (!runtimeOptions.emergencyFallbackTried) {

View File

@@ -25,6 +25,9 @@ import {
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
import { CircuitBreakerOpenError, getCircuitBreaker } from "../../shared/utils/circuitBreaker";
import { classify429FromError, type FailureKind } from "../../shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "../../shared/utils/providerHints";
import { logProxyEvent } from "../../lib/proxyLogger";
import { logTranslationEvent } from "../../lib/translatorEvents";
import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts";
@@ -238,11 +241,25 @@ export async function checkPipelineGates(
) {
const bypassReason = options.bypassReason || "pipeline override";
const providerProfile = options.providerProfile ?? (await getRuntimeProviderProfile(provider));
// Issue #2100 follow-up: opt-in upstream 429 hint trust per provider.
const useHints429 = resolveUseUpstream429BreakerHints(
provider,
(providerProfile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints
);
const breaker = getCircuitBreaker(provider, {
failureThreshold: providerProfile.failureThreshold ?? providerProfile.circuitBreakerThreshold,
resetTimeout: providerProfile.resetTimeoutMs ?? providerProfile.circuitBreakerReset,
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from}${to}`),
...(useHints429
? {
cooldownByKind: {
rate_limit: 60_000,
quota_exhausted: 3_600_000,
} satisfies Partial<Record<FailureKind, number>>,
classifyError: classify429FromError,
}
: {}),
});
if (options.ignoreCircuitBreaker && !breaker.canExecute()) {
log.info("CIRCUIT", `Bypassing OPEN circuit breaker for ${provider} (${bypassReason})`);