mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
478
src/app/(dashboard)/dashboard/cloud-agents/page.tsx
Normal file
478
src/app/(dashboard)/dashboard/cloud-agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>) || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "Use upstream retry hints".
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
79
src/app/api/analytics/auto-routing/route.ts
Normal file
79
src/app/api/analytics/auto-routing/route.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
186
src/app/api/v1/agents/tasks/[id]/route.ts
Normal file
186
src/app/api/v1/agents/tasks/[id]/route.ts
Normal 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() }
|
||||
);
|
||||
}
|
||||
}
|
||||
173
src/app/api/v1/agents/tasks/route.ts
Normal file
173
src/app/api/v1/agents/tasks/route.ts
Normal 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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
148
src/lib/cloudAgent/agents/codex.ts
Normal file
148
src/lib/cloudAgent/agents/codex.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
137
src/lib/cloudAgent/agents/devin.ts
Normal file
137
src/lib/cloudAgent/agents/devin.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
170
src/lib/cloudAgent/agents/jules.ts
Normal file
170
src/lib/cloudAgent/agents/jules.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
95
src/lib/cloudAgent/baseAgent.ts
Normal file
95
src/lib/cloudAgent/baseAgent.ts
Normal 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
145
src/lib/cloudAgent/db.ts
Normal 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);
|
||||
}
|
||||
8
src/lib/cloudAgent/index.ts
Normal file
8
src/lib/cloudAgent/index.ts
Normal 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();
|
||||
25
src/lib/cloudAgent/registry.ts
Normal file
25
src/lib/cloudAgent/registry.ts
Normal 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
111
src/lib/cloudAgent/types.ts
Normal 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>;
|
||||
@@ -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);
|
||||
|
||||
11
src/lib/db/migrations/041_session_account_affinity.sql
Normal file
11
src/lib/db/migrations/041_session_account_affinity.sql
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
79
src/shared/components/AutoRoutingBanner.tsx
Normal file
79
src/shared/components/AutoRoutingBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
243
src/shared/utils/classify429.ts
Normal file
243
src/shared/utils/classify429.ts
Normal 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 });
|
||||
}
|
||||
73
src/shared/utils/providerHints.ts
Normal file
73
src/shared/utils/providerHints.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
Reference in New Issue
Block a user