Enhance token refresh logic and improve MITM server handling

- Introduced a caching mechanism for in-flight token refresh requests to prevent race conditions and reduce unnecessary API calls.
- Added error handling for unrecoverable refresh errors, ensuring that the application can gracefully handle token reuse and invalidation scenarios.
- Updated the MITM server management to handle port 443 conflicts, allowing users to kill processes occupying the port before starting the server.
- Improved user feedback in the MitmServerCard component regarding port conflicts and admin privileges.
- Refactored the ComboList component to streamline the display of media provider combos.

This update aims to enhance the reliability and user experience of the token management and MITM functionalities.
This commit is contained in:
decolua
2026-05-03 22:10:03 +07:00
parent b8e3a46add
commit 4ba546afe7
17 changed files with 680 additions and 229 deletions

View File

@@ -5,6 +5,24 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
// Default token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
// In-flight refresh dedup: prevents race condition that triggers refresh_token_reused → Auth0 family revoke
const refreshPromiseCache = new Map();
function getRefreshCacheKey(provider, refreshToken) {
return `${provider}:${refreshToken}`;
}
// Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth)
export function isUnrecoverableRefreshError(result) {
return (
result &&
typeof result === "object" &&
(result.error === "unrecoverable_refresh_error" ||
result.error === "refresh_token_reused" ||
result.error === "invalid_request" ||
result.error === "invalid_grant")
);
}
// Get provider-specific refresh lead time, falls back to default buffer
export function getRefreshLeadMs(provider) {
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
@@ -193,7 +211,10 @@ export async function refreshQwenToken(refreshToken, log) {
}
/**
* Specialized refresh for Codex (OpenAI) OAuth tokens
* Specialized refresh for Codex (OpenAI) OAuth tokens.
* OpenAI uses rotating (one-time-use) refresh tokens.
* Returns { error: 'unrecoverable_refresh_error' } when token already consumed/invalid,
* so callers stop retrying and request re-authentication.
*/
export async function refreshCodexToken(refreshToken, log) {
try {
@@ -213,6 +234,27 @@ export async function refreshCodexToken(refreshToken, log) {
if (!response.ok) {
const errorText = await response.text();
// Detect unrecoverable errors (token reused/expired) — Auth0 revokes whole family on retry
let errorCode = null;
try {
const parsed = JSON.parse(errorText);
errorCode = parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
} catch {}
if (
errorCode === "refresh_token_reused" ||
errorCode === "invalid_grant" ||
errorCode === "token_expired" ||
errorCode === "invalid_token"
) {
log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", {
status: response.status,
errorCode,
});
return { error: "unrecoverable_refresh_error", code: errorCode };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
@@ -466,14 +508,32 @@ export async function refreshCopilotToken(githubAccessToken, log) {
}
/**
* Get access token for a specific provider
* Get access token for a specific provider (with in-flight dedup).
* If a refresh is already in-flight for same provider+token, share the promise
* to prevent parallel OAuth requests → Auth0 'refresh_token_reused' family revoke.
*/
export async function getAccessToken(provider, credentials, log) {
if (!credentials || !credentials.refreshToken) {
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
return null;
}
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
if (refreshPromiseCache.has(cacheKey)) {
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
return refreshPromiseCache.get(cacheKey);
}
const refreshPromise = _getAccessTokenInternal(provider, credentials, log).finally(() => {
refreshPromiseCache.delete(cacheKey);
});
refreshPromiseCache.set(cacheKey, refreshPromise);
return refreshPromise;
}
async function _getAccessTokenInternal(provider, credentials, log) {
switch (provider) {
case "gemini":
case "gemini-cli":

View File

@@ -19,6 +19,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
const [modalError, setModalError] = useState(null);
const [actionError, setActionError] = useState(null);
const [mitmRouterBaseUrl, setMitmRouterBaseUrl] = useState(DEFAULT_MITM_ROUTER_BASE);
const [port443Conflict, setPort443Conflict] = useState(null);
const serverIsWindows = status?.isWin === true;
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
@@ -50,6 +51,8 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
const handleAction = (action) => {
setActionError(null);
// Wait for status to load before deciding whether to show sudo modal
if (!status) return;
if (canRunWithoutPassword) {
doAction(action, "");
} else {
@@ -59,7 +62,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
}
};
const doAction = async (action, password) => {
const doAction = async (action, password, forceKillPort443 = false) => {
setLoading(true);
setActionError(null);
try {
@@ -81,6 +84,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
apiKey: keyToUse,
sudoPassword: password,
mitmRouterBaseUrl: mitmRouterBaseUrl.trim() || DEFAULT_MITM_ROUTER_BASE,
forceKillPort443,
}),
});
} else {
@@ -92,11 +96,17 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.code === "PORT_443_BUSY" && data.portOwner) {
setShowPasswordModal(false);
setPort443Conflict({ owner: data.portOwner, password });
return;
}
setActionError(data.error || `Failed to ${action} MITM server`);
return;
}
setShowPasswordModal(false);
setSudoPassword("");
setPort443Conflict(null);
await fetchStatus();
} catch (e) {
setActionError(e.message || "Network error");
@@ -106,6 +116,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
}
};
const handleKillAndStart = () => {
const pwd = port443Conflict?.password || "";
doAction("start", pwd, true);
};
const handleConfirmPassword = () => {
if (!sudoPassword.trim()) {
setModalError("Sudo password is required");
@@ -218,7 +233,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
) : (
<button
onClick={() => handleAction("start")}
disabled={loading || (serverIsWindows && !isAdmin)}
disabled={loading || !status || (serverIsWindows && !isAdmin)}
title={serverIsWindows && !isAdmin ? "Administrator required" : undefined}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50 sm:w-auto sm:py-1.5"
>
@@ -282,6 +297,33 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
</div>
</div>
)}
{/* Port 443 Conflict Modal */}
{port443Conflict && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="mx-4 flex w-full max-w-md flex-col gap-4 rounded-xl border border-border bg-surface p-5 shadow-xl sm:p-6">
<h3 className="font-semibold text-text-main">Port 443 Already In Use</h3>
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
<div className="flex flex-col gap-1 text-xs text-text-muted">
<p>Port 443 đang bị process khác chiếm:</p>
<p className="font-mono text-text-main" data-i18n-skip="true">
{port443Conflict.owner.name} (PID {port443Conflict.owner.pid})
</p>
<p>Kill process này để chạy MITM Server?</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => { setPort443Conflict(null); setLoading(false); }} disabled={loading}>
Cancel
</Button>
<Button variant="primary" size="sm" onClick={handleKillAndStart} loading={loading}>
Kill & Start
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -19,6 +19,7 @@ export default function MitmToolCard({
dnsActive,
hasCachedPassword,
needsSudoPassword,
isWin,
apiKeys,
activeProviders,
hasActiveProviders,
@@ -37,7 +38,7 @@ export default function MitmToolCard({
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
const mitmHosts = TOOL_HOSTS[tool.id] ?? [];
const canRunWithoutPassword = hasCachedPassword || needsSudoPassword === false;
const canRunWithoutPassword = isWin || hasCachedPassword || needsSudoPassword === false;
useEffect(() => {
if (isExpanded) loadSavedMappings();

View File

@@ -14,6 +14,7 @@ const TUNNEL_BENEFITS = [
const TUNNEL_PING_INTERVAL_MS = 2000;
const TUNNEL_PING_MAX_MS = 300000;
const STATUS_POLL_INTERVAL_MS = 5000;
const CAVEMAN_LEVELS = [
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
@@ -74,14 +75,42 @@ export default function APIPageClient({ machineId }) {
useEffect(() => {
fetchData();
loadSettings();
// Poll status periodically + on tab visible to sync after watchdog restarts
const interval = setInterval(() => { syncTunnelStatus(); }, STATUS_POLL_INTERVAL_MS);
const onVisible = () => { if (!document.hidden) syncTunnelStatus(); };
document.addEventListener("visibilitychange", onVisible);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
// Trust user intent (settingsEnabled): UI stays "enabled" while watchdog restarts process
const syncTunnelStatus = async () => {
try {
const statusRes = await fetch("/api/tunnel/status", { cache: "no-store" });
if (!statusRes.ok) return;
const data = await statusRes.json();
const tEnabled = data.tunnel?.settingsEnabled ?? data.tunnel?.enabled ?? false;
const tUrl = data.tunnel?.tunnelUrl || "";
const tPublicUrl = data.tunnel?.publicUrl || "";
setTunnelUrl(tUrl);
setTunnelPublicUrl(tPublicUrl);
setTunnelEnabled(tEnabled);
const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false;
const tsUrlVal = data.tailscale?.tunnelUrl || "";
setTsUrl(tsUrlVal);
setTsEnabled(tsEn);
} catch { /* ignore poll errors */ }
};
const loadSettings = async () => {
setTunnelChecking(true);
try {
const [settingsRes, statusRes] = await Promise.all([
fetch("/api/settings"),
fetch("/api/tunnel/status")
fetch("/api/tunnel/status", { cache: "no-store" })
]);
if (settingsRes.ok) {
const data = await settingsRes.json();
@@ -95,55 +124,30 @@ export default function APIPageClient({ machineId }) {
}
if (statusRes.ok) {
const data = await statusRes.json();
const tEnabled = data.tunnel?.enabled || false;
const tEnabled = data.tunnel?.settingsEnabled ?? data.tunnel?.enabled ?? false;
const tUrl = data.tunnel?.tunnelUrl || "";
const tPublicUrl = data.tunnel?.publicUrl || "";
setTunnelUrl(tUrl);
setTunnelPublicUrl(tPublicUrl);
const tsEn = data.tailscale?.enabled || false;
// Trust user intent: stays enabled while watchdog restores process
setTunnelEnabled(tEnabled);
const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false;
const tsUrlVal = data.tailscale?.tunnelUrl || "";
setTsUrl(tsUrlVal);
if (tsEn && tsUrlVal) {
setTsLoading(true);
setTsProgress("Checking Tailscale...");
const tsHealthUrl = `${tsUrlVal}/api/health`;
try {
const tsPing = await fetch(tsHealthUrl, { mode: "no-cors", cache: "no-store" });
if (tsPing.ok || tsPing.type === "opaque") {
setTsEnabled(true);
} else {
const ok = await pingTsHealth(tsUrlVal);
setTsEnabled(ok);
if (!ok) setTsStatus({ type: "warning", message: "Tailscale not reachable." });
}
} catch {
const ok = await pingTsHealth(tsUrlVal);
setTsEnabled(ok);
if (!ok) setTsStatus({ type: "warning", message: "Tailscale not reachable." });
} finally {
setTsLoading(false);
setTsProgress("");
}
} else {
setTsEnabled(tsEn);
}
// Background reachability probes (non-blocking, only show warning)
if (tEnabled && (tPublicUrl || tUrl)) {
// Ping once to verify reachable
const healthUrl = `${tPublicUrl || tUrl}/api/health`;
try {
const ping = await fetch(healthUrl, { cache: "no-store" });
if (ping.ok) {
setTunnelEnabled(true);
} else {
pingTunnelHealth(tPublicUrl || tUrl);
fetch(healthUrl, { cache: "no-store" })
.then((r) => { if (!r.ok) setTunnelStatus({ type: "warning", message: "Tunnel reconnecting..." }); })
.catch(() => setTunnelStatus({ type: "warning", message: "Tunnel reconnecting..." }));
}
} catch {
pingTunnelHealth(tPublicUrl || tUrl);
}
} else {
setTunnelEnabled(tEnabled);
if (tsEn && tsUrlVal) {
fetch(`${tsUrlVal}/api/health`, { mode: "no-cors", cache: "no-store" })
.then((r) => { if (!(r.ok || r.type === "opaque")) setTsStatus({ type: "warning", message: "Tailscale reconnecting..." }); })
.catch(() => setTsStatus({ type: "warning", message: "Tailscale reconnecting..." }));
}
}
} catch (error) {

View File

@@ -7,6 +7,11 @@ import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/component
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
// Kinds that support combos (currently disabled for image/tts — temporarily hidden).
// webSearch/webFetch handled by /web page.
const COMBO_KINDS = new Set([]);
const COMBO_BASE_NAMES = { image: "image-combo", tts: "tts-combo" };
function getEffectiveStatus(conn) {
const isCooldown = Object.entries(conn).some(
([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now()
@@ -70,11 +75,53 @@ function MediaProviderCard({ provider, kind, connections, isCustom }) {
);
}
function ComboList({ combos }) {
if (combos.length === 0) return null;
return (
<div className="flex flex-col gap-2">
{combos.map((combo) => (
<Link key={combo.id} href={`/dashboard/media-providers/combo/${combo.id}`}>
<Card padding="xs" className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors cursor-pointer">
<div className="flex min-w-0 items-center gap-3">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
<code className="text-sm font-mono font-medium flex-1 truncate">{combo.name}</code>
<div className="flex flex-wrap items-center gap-1 sm:shrink-0">
{combo.models.slice(0, 6).map((entry, i) => {
const pid = typeof entry === "string" ? entry.split("/")[0] : "";
const p = AI_PROVIDERS[pid];
return (
<div key={`${entry}-${i}`} title={p?.name || entry} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
size={18}
className="object-contain rounded max-w-[18px] max-h-[18px]"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
</div>
);
})}
{combo.models.length > 6 && (
<span className="text-[10px] text-text-muted ml-1">+{combo.models.length - 6}</span>
)}
</div>
<span className="text-[11px] text-text-muted shrink-0">{combo.models.length}</span>
<span className="material-symbols-outlined text-text-muted text-[16px]">chevron_right</span>
</div>
</Card>
</Link>
))}
</div>
);
}
export default function MediaProviderKindPage() {
const { kind } = useParams();
const router = useRouter();
const [connections, setConnections] = useState([]);
const [customNodes, setCustomNodes] = useState([]);
const [combos, setCombos] = useState([]);
const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false);
// webSearch/webFetch listing pages are merged into /web
@@ -86,6 +133,7 @@ export default function MediaProviderKindPage() {
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
const isEmbedding = kind === "embedding";
const supportsCombo = COMBO_KINDS.has(kind);
useEffect(() => {
if (!kindConfig) return;
@@ -99,11 +147,18 @@ export default function MediaProviderKindPage() {
.then((d) => setCustomNodes((d.nodes || []).filter((n) => n.type === "custom-embedding")))
.catch(() => {});
}
}, [isEmbedding, kindConfig]);
if (supportsCombo) {
fetch("/api/combos", { cache: "no-store" })
.then((r) => r.json())
.then((d) => setCombos(d.combos || []))
.catch(() => {});
}
}, [isEmbedding, supportsCombo, kindConfig]);
if (!kindConfig) return notFound();
const providers = getProvidersByKind(kind);
const kindCombos = combos.filter((c) => c.kind === kind);
// Map custom nodes to MediaProviderCard shape
const customProviders = customNodes.map((n) => ({
@@ -115,16 +170,45 @@ export default function MediaProviderKindPage() {
const allProviders = [...providers, ...customProviders];
const handleCreateCombo = async () => {
const base = COMBO_BASE_NAMES[kind] || `${kind}-combo`;
let name = base;
let i = 1;
const existing = new Set(combos.map((c) => c.name));
while (existing.has(name)) { name = `${base}-${i++}`; }
const res = await fetch("/api/combos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, models: [], kind }),
});
if (res.ok) {
const created = await res.json();
router.push(`/dashboard/media-providers/combo/${created.id}`);
} else {
const err = await res.json();
alert(err.error || "Failed to create combo");
}
};
return (
<div className="flex flex-col gap-6">
{(isEmbedding || supportsCombo) && (
<div className="flex items-center justify-end gap-2">
{supportsCombo && (
<Button size="sm" icon="add" onClick={handleCreateCombo}>Create Combo</Button>
)}
{isEmbedding && (
<div className="flex items-center justify-end">
<Button size="sm" icon="add" onClick={() => setShowAddCustomEmbedding(true)}>
Add Custom Embedding
</Button>
)}
</div>
)}
{supportsCombo && kindCombos.length > 0 && (
<ComboList combos={kindCombos} />
)}
{allProviders.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-xl text-text-muted text-sm">
No providers support <strong>{kindConfig.label}</strong> yet.

View File

@@ -3,78 +3,45 @@
import { useParams, notFound, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, Button, Input, Toggle, Modal } from "@/shared/components";
import { Card, Button, Input, Toggle, ModelSelectModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
import { AI_PROVIDERS, MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
// Parse "providerId/model" or just "providerId" → { providerId, model }
function parseModelEntry(entry) {
if (typeof entry !== "string") return { providerId: "", model: "" };
const idx = entry.indexOf("/");
if (idx < 0) return { providerId: entry, model: "" };
return { providerId: entry.slice(0, idx), model: entry.slice(idx + 1) };
}
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
const KIND_LABELS = {
webSearch: "Web Search",
webFetch: "Web Fetch",
image: "Text to Image",
tts: "Text To Speech",
};
const EXAMPLE_PATHS = {
webSearch: "/v1/search",
webFetch: "/v1/web/fetch",
image: "/v1/images/generations",
tts: "/v1/audio/speech",
};
const EXAMPLE_BODIES = {
webSearch: (comboName) => ({ model: comboName, query: "What is the latest news about AI?", search_type: "web", max_results: 5 }),
webFetch: (comboName) => ({ model: comboName, url: "https://example.com", format: "markdown" }),
webSearch: (n) => ({ model: n, query: "What is the latest news about AI?", search_type: "web", max_results: 5 }),
webFetch: (n) => ({ model: n, url: "https://example.com", format: "markdown" }),
image: (n) => ({ model: n, prompt: "A cute cat playing piano", n: 1, size: "1024x1024" }),
tts: (n) => ({ model: n, input: "Hello, this is a test.", voice: "alloy" }),
};
function ProviderPickerModal({ isOpen, onClose, onPick, kind, currentIds, connections }) {
// Only show providers with at least one usable connection (active/success) or noAuth
const usableIds = new Set(
(connections || [])
.filter((c) => {
if (c.isActive === false) return false;
const s = c.testStatus;
return s === "active" || s === "success" || s === "unavailable";
})
.map((c) => c.provider)
);
const all = kind ? getProvidersByKind(kind) : [];
const providers = all.filter((p) => p.noAuth || usableIds.has(p.id));
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Add ${KIND_LABELS[kind] || ""} Provider`} size="md">
{providers.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
No connected providers available. Add a connection first in the {KIND_LABELS[kind]} section.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 max-h-[400px] overflow-y-auto">
{providers.map((p) => {
const already = currentIds.includes(p.id);
return (
<button
key={p.id}
disabled={already}
onClick={() => { onPick(p.id); onClose(); }}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
already
? "border-border opacity-40 cursor-not-allowed"
: "border-border hover:border-primary/50 hover:bg-primary/5 cursor-pointer"
}`}
>
<ProviderIcon
src={`/providers/${p.id}.png`}
alt={p.name}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p.textIcon || p.id.slice(0, 2).toUpperCase()}
fallbackColor={p.color}
/>
<span className="text-xs font-medium truncate text-left">{p.name}</span>
{already && <span className="text-[9px] text-text-muted ml-auto">added</span>}
</button>
);
})}
</div>
)}
</Modal>
);
// Map combo.kind → listing route to go back to
function getListingHref(kind) {
if (kind === "webSearch" || kind === "webFetch") return "/dashboard/media-providers/web";
return `/dashboard/media-providers/${kind}`;
}
export default function ComboDetailPage() {
@@ -89,19 +56,23 @@ export default function ComboDetailPage() {
const [showPicker, setShowPicker] = useState(false);
const [logs, setLogs] = useState([]);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
const [testResult, setTestResult] = useState(null);
const [testError, setTestError] = useState("");
const [apiKey, setApiKey] = useState("");
const [connections, setConnections] = useState([]);
const [modelAliases, setModelAliases] = useState({});
const fetchAll = async () => {
try {
const [comboRes, settingsRes, logsRes, keysRes, connsRes] = await Promise.all([
const [comboRes, settingsRes, logsRes, keysRes, connsRes, aliasesRes] = await Promise.all([
fetch(`/api/combos/${id}`, { cache: "no-store" }),
fetch("/api/settings", { cache: "no-store" }),
fetch("/api/usage/logs", { cache: "no-store" }),
fetch("/api/keys", { cache: "no-store" }),
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/models/alias", { cache: "no-store" }),
]);
if (aliasesRes.ok) setModelAliases((await aliasesRes.json()).aliases || {});
if (keysRes.ok) {
const k = await keysRes.json();
setApiKey((k.keys || []).find((x) => x.isActive !== false)?.key || "");
@@ -147,8 +118,10 @@ export default function ComboDetailPage() {
if (ok) await fetchAll();
};
const handleAddProvider = async (providerId) => {
const next = [...providers, providerId];
const handleAddModel = async (model) => {
const value = model?.value || model;
if (!value || providers.includes(value)) return;
const next = [...providers, value];
setProviders(next);
await saveCombo({ models: next });
};
@@ -185,42 +158,85 @@ export default function ComboDetailPage() {
const handleDelete = async () => {
if (!confirm(`Delete combo "${combo.name}"?`)) return;
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) router.push("/dashboard/media-providers/web");
if (res.ok) router.push(getListingHref(combo.kind));
};
const handleTest = async () => {
setTesting(true);
setTestResult("");
setTestResult(null);
setTestError("");
if (testResult?.audioUrl) { try { URL.revokeObjectURL(testResult.audioUrl); } catch {} }
if (testResult?.imageUrl?.startsWith("blob:")) { try { URL.revokeObjectURL(testResult.imageUrl); } catch {} }
const start = Date.now();
try {
const path = EXAMPLE_PATHS[combo.kind];
const body = EXAMPLE_BODIES[combo.kind](combo.name);
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`/api${path}`, { method: "POST", headers, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
setTestResult(JSON.stringify(data, null, 2));
const latencyMs = Date.now() - start;
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setTestError(d?.error?.message || d?.error || `HTTP ${res.status}`);
setTestResult({ json: JSON.stringify(d, null, 2), latencyMs });
return;
}
const ctype = res.headers.get("content-type") || "";
// Binary image
if (ctype.startsWith("image/")) {
const blob = await res.blob();
setTestResult({ imageUrl: URL.createObjectURL(blob), latencyMs });
return;
}
// Binary audio
if (ctype.startsWith("audio/") || ctype === "application/octet-stream") {
const blob = await res.blob();
setTestResult({ audioUrl: URL.createObjectURL(blob), latencyMs });
return;
}
// JSON — could be image (data[0].b64_json/url) or generic
const data = await res.json();
const first = data?.data?.[0];
const imageUrl = first?.b64_json
? `data:image/png;base64,${first.b64_json}`
: (first?.url || "");
setTestResult({ json: JSON.stringify(maskB64(data), null, 2), imageUrl, latencyMs });
} catch (e) {
setTestResult(`Error: ${e.message}`);
setTestError(e.message || "Network error");
}
setTesting(false);
};
// Mask large b64_json strings to keep JSON view readable
function maskB64(obj) {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(maskB64);
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
? `<${v.length} chars base64>`
: maskB64(v);
}
return out;
}
if (loading) return <div className="text-text-muted text-sm">Loading...</div>;
if (!combo) return notFound();
const kindLabel = KIND_LABELS[combo.kind] || "Web";
const kindLabel = KIND_LABELS[combo.kind] || MEDIA_PROVIDER_KINDS.find((k) => k.id === combo.kind)?.label || "Combo";
const examplePath = EXAMPLE_PATHS[combo.kind];
const exampleBody = combo.kind ? EXAMPLE_BODIES[combo.kind](combo.name) : null;
const exampleBody = combo.kind && EXAMPLE_BODIES[combo.kind] ? EXAMPLE_BODIES[combo.kind](combo.name) : null;
const curlExample = examplePath
? `curl -X POST http://localhost:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'`
: "";
const backHref = getListingHref(combo.kind);
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<Link href="/dashboard/media-providers/web" className="text-text-muted hover:text-primary">
<Link href={backHref} className="text-text-muted hover:text-primary">
<span className="material-symbols-outlined">arrow_back</span>
</Link>
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
@@ -269,20 +285,24 @@ export default function ComboDetailPage() {
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((pid, idx) => {
const p = AI_PROVIDERS[pid];
{providers.map((entry, idx) => {
const { providerId, model } = parseModelEntry(entry);
const p = AI_PROVIDERS[providerId];
return (
<div key={`${pid}-${idx}`} className="flex items-center gap-3 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<div key={`${entry}-${idx}`} className="flex items-center gap-3 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<span className="text-xs text-text-muted w-5 text-center">{idx + 1}</span>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
src={`/providers/${providerId}.png`}
alt={p?.name || providerId}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackText={p?.textIcon || providerId.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
<span className="text-sm font-medium flex-1 truncate">{p?.name || pid}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{p?.name || providerId}</div>
{model && <code className="text-[10px] text-text-muted font-mono truncate block">{model}</code>}
</div>
<div className="flex items-center gap-0.5">
<button onClick={() => handleMove(idx, -1)} disabled={idx === 0} className={`p-1 rounded ${idx === 0 ? "text-text-muted/20" : "text-text-muted hover:text-primary hover:bg-black/5"}`} title="Move up">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
@@ -302,7 +322,7 @@ export default function ComboDetailPage() {
</Card>
{/* Test Example Card */}
{combo.kind && (
{combo.kind && examplePath && (
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-3">
<h2 className="text-lg font-semibold">Test Example</h2>
@@ -313,11 +333,43 @@ export default function ComboDetailPage() {
<pre className="text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-x-auto whitespace-pre-wrap break-all">
{curlExample}
</pre>
{testError && (
<p className="mt-3 text-xs text-red-500 break-words">{testError}</p>
)}
{testResult && (
<pre className="mt-3 text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[300px]">
{testResult}
<div className="mt-3 flex flex-col gap-3">
{testResult.latencyMs != null && (
<span className="text-[11px] text-text-muted"> {testResult.latencyMs}ms</span>
)}
{testResult.imageUrl && (
<div>
<div className="flex items-center justify-end mb-1.5">
<a href={testResult.imageUrl} download="image.png" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[14px]">download</span>
Download
</a>
</div>
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" />
</div>
)}
{testResult.audioUrl && (
<div>
<div className="flex items-center justify-end mb-1.5">
<a href={testResult.audioUrl} download="speech.mp3" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[14px]">download</span>
Download
</a>
</div>
<audio controls src={testResult.audioUrl} className="w-full" />
</div>
)}
{testResult.json && (
<pre className="text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[300px] whitespace-pre-wrap break-all">
{testResult.json}
</pre>
)}
</div>
)}
</Card>
)}
@@ -333,13 +385,14 @@ export default function ComboDetailPage() {
)}
</Card>
<ProviderPickerModal
<ModelSelectModal
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onPick={handleAddProvider}
kind={combo.kind}
currentIds={providers}
connections={connections}
onSelect={handleAddModel}
activeProviders={connections}
modelAliases={modelAliases}
title={`Add ${kindLabel} Model`}
kindFilter={combo.kind}
/>
</div>
);

View File

@@ -70,17 +70,18 @@ function ComboList({ combos }) {
return (
<div className="flex flex-col gap-2">
{combos.map((combo) => (
<Link key={combo.id} href={`/dashboard/media-providers/web/combo/${combo.id}`}>
<Link key={combo.id} href={`/dashboard/media-providers/combo/${combo.id}`}>
<Card padding="xs" className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors cursor-pointer">
<div className="flex min-w-0 items-center gap-3">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
<code className="text-sm font-mono font-medium flex-1 truncate">{combo.name}</code>
{/* Provider icons preview */}
<div className="flex flex-wrap items-center gap-1 sm:shrink-0">
{combo.models.slice(0, 6).map((pid, i) => {
{combo.models.slice(0, 6).map((entry, i) => {
const pid = typeof entry === "string" ? entry.split("/")[0] : "";
const p = AI_PROVIDERS[pid];
return (
<div key={`${pid}-${i}`} title={p?.name || pid} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<div key={`${entry}-${i}`} title={p?.name || entry} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
@@ -180,7 +181,7 @@ export default function WebProvidersPage() {
});
if (res.ok) {
const created = await res.json();
router.push(`/dashboard/media-providers/web/combo/${created.id}`);
router.push(`/dashboard/media-providers/combo/${created.id}`);
} else {
const err = await res.json();
alert(err.error || "Failed to create combo");

View File

@@ -95,6 +95,7 @@ export default function MitmPageClient() {
dnsActive={mitmStatus.dnsStatus?.[toolId] || false}
hasCachedPassword={mitmStatus.hasCachedPassword || false}
needsSudoPassword={mitmStatus.needsSudoPassword !== false}
isWin={mitmStatus.isWin === true}
apiKeys={apiKeys}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders()}

View File

@@ -343,13 +343,13 @@ export default function ProxyPoolsPage() {
</div>
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="secondary" icon="cloud_upload" onClick={openVercelModal}>
<Button size="sm" variant="secondary" icon="cloud_upload" onClick={openVercelModal}>
Vercel Relay
</Button>
<Button variant="secondary" icon="upload" onClick={openBatchImportModal}>
<Button size="sm" variant="secondary" icon="upload" onClick={openBatchImportModal}>
Batch Import
</Button>
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
<Button size="sm" icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
</div>
</div>

View File

@@ -75,9 +75,9 @@ export default function QuotaTable({ quotas = [], compact = false }) {
return null;
}
const cellPad = compact ? "py-1.5 px-2" : "py-2 px-3";
const nameText = compact ? "text-xs" : "text-sm";
const resetPrimary = compact ? "text-xs" : "text-sm";
const cellPad = compact ? "py-1 px-1.5" : "py-2 px-3";
const nameText = compact ? "text-[11px]" : "text-sm";
const resetPrimary = compact ? "text-[11px]" : "text-sm";
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
return (
@@ -136,6 +136,14 @@ export default function QuotaTable({ quotas = [], compact = false }) {
{/* Reset Time */}
<td className={`${cellPad} w-[25%]`}>
{countdown !== "-" || resetDisplay ? (
compact ? (
<div
className={`${resetPrimary} text-text-primary font-medium truncate`}
title={resetDisplay || ""}
>
{countdown !== "-" ? `in ${countdown}` : resetDisplay}
</div>
) : (
<div className="space-y-0.5">
{countdown !== "-" && (
<div className={`${resetPrimary} text-text-primary font-medium`}>
@@ -148,6 +156,7 @@ export default function QuotaTable({ quotas = [], compact = false }) {
</div>
)}
</div>
)
) : (
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}

View File

@@ -6,18 +6,23 @@ import QuotaTable from "./QuotaTable";
import Toggle from "@/shared/components/Toggle";
import { parseQuotaData, calculatePercentage } from "./utils";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import { EditConnectionModal } from "@/shared/components";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
export default function ProviderLimits() {
const [connections, setConnections] = useState([]);
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
const [errors, setErrors] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(() => {
if (typeof window === "undefined") return true;
const stored = window.localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
return stored === null ? true : stored === "true";
});
const [lastUpdated, setLastUpdated] = useState(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(60);
@@ -30,6 +35,7 @@ export default function ProviderLimits() {
const [providerFilter, setProviderFilter] = useState("all");
const [expiringFirst, setExpiringFirst] = useState(false);
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
const [bulkToggling, setBulkToggling] = useState(false);
const intervalRef = useRef(null);
const countdownRef = useRef(null);
@@ -282,6 +288,12 @@ export default function ProviderLimits() {
initializeData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Persist auto-refresh preference
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
}, [autoRefresh]);
// Auto-refresh interval
useEffect(() => {
if (!autoRefresh) {
@@ -342,22 +354,6 @@ export default function ProviderLimits() {
};
}, [autoRefresh, refreshAll]);
// Format last updated time
const formatLastUpdated = useCallback(() => {
if (!lastUpdated) return "Never";
const now = new Date();
const diffMs = now - lastUpdated;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMinutes > 0) return `${diffMinutes}m ago`;
return "Just now";
}, [lastUpdated]);
// Filter only supported providers
const filteredConnections = connections.filter(
(conn) =>
@@ -389,6 +385,56 @@ export default function ProviderLimits() {
return a.provider.localeCompare(b.provider);
});
// Connection is depleted when any quota entry hit the threshold
const isConnectionDepleted = (conn) => {
const quotas = quotaData[conn.id]?.quotas;
if (!quotas?.length) return false;
return quotas.some((q) => {
if (!q.total || q.total <= 0) return false;
return calculatePercentage(q.used, q.total) <= DEPLETED_QUOTA_THRESHOLD;
});
};
const bulkSetActive = useCallback(
async (targetIds, isActive) => {
if (!targetIds.length || bulkToggling) return;
setBulkToggling(true);
try {
await Promise.all(
targetIds.map((id) =>
fetch(`/api/providers/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive }),
}),
),
);
setConnections((prev) =>
prev.map((c) => (targetIds.includes(c.id) ? { ...c, isActive } : c)),
);
} catch (error) {
console.error("Error bulk toggling connections:", error);
} finally {
setBulkToggling(false);
}
},
[bulkToggling],
);
const handleDisableDepleted = () => {
const ids = sortedConnections
.filter((c) => (c.isActive ?? true) && isConnectionDepleted(c))
.map((c) => c.id);
bulkSetActive(ids, false);
};
const handleEnableAvailable = () => {
const ids = sortedConnections
.filter((c) => !(c.isActive ?? true) && !isConnectionDepleted(c))
.map((c) => c.id);
bulkSetActive(ids, true);
};
const providerOptions = Array.from(new Set(filteredConnections.map((conn) => conn.provider))).sort();
const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter;
@@ -438,36 +484,33 @@ export default function ProviderLimits() {
<h2 className="text-xl font-semibold text-text-primary">
Provider Limits
</h2>
<span className="text-sm text-text-muted">
Last updated: {formatLastUpdated()}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<div className="relative">
<button
type="button"
onClick={() => setProviderMenuOpen((prev) => !prev)}
className="flex h-10 min-w-[116px] items-center justify-between gap-2 rounded-xl border border-black/10 bg-black/[0.02] px-3 text-sm text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10 sm:min-w-[180px]"
className="flex h-8 items-center justify-between gap-1 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
aria-haspopup="menu"
aria-expanded={providerMenuOpen}
title="Filter quota providers"
>
<span className="flex min-w-0 items-center gap-2">
<span className="flex min-w-0 items-center gap-1.5">
{providerFilter === "all" ? (
<span className="material-symbols-outlined text-[20px] text-text-muted">apps</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">apps</span>
) : (
<ProviderIcon
src={`/providers/${providerFilter}.png`}
alt={providerFilter}
size={22}
className="size-[22px] rounded-md object-contain"
size={18}
className="size-[18px] rounded object-contain"
fallbackText={providerFilter.slice(0, 2).toUpperCase()}
/>
)}
<span className="truncate capitalize hidden sm:inline">{selectedProviderLabel}</span>
<span className="truncate capitalize hidden lg:inline">{selectedProviderLabel}</span>
</span>
<span className="material-symbols-outlined text-[18px] text-text-muted">expand_more</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">expand_more</span>
</button>
{providerMenuOpen && (
@@ -516,42 +559,66 @@ export default function ProviderLimits() {
<button
type="button"
onClick={() => setExpiringFirst((prev) => !prev)}
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors ${expiringFirst ? "border-amber-500/40 bg-amber-500/10 text-amber-500" : "border-black/10 text-text-primary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"}`}
className={`flex h-8 shrink-0 items-center gap-1 rounded-lg border px-2 text-xs transition-colors ${expiringFirst ? "border-amber-500/40 bg-amber-500/10 text-amber-500" : "border-black/10 text-text-primary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"}`}
title="Sort accounts by earliest quota reset time"
>
<span className="material-symbols-outlined text-[18px]">hourglass_top</span>
<span className="material-symbols-outlined text-[14px]">hourglass_top</span>
<span className="hidden sm:inline">Expiring first</span>
</button>
{/* Bulk: disable depleted */}
<button
type="button"
onClick={handleDisableDepleted}
disabled={bulkToggling}
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-red-500/30 px-2 text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
title="Disable connections with depleted quota (within current filter)"
>
<span className="material-symbols-outlined text-[14px]">block</span>
<span className="hidden sm:inline">Turn off Empty</span>
</button>
{/* Bulk: enable available */}
<button
type="button"
onClick={handleEnableAvailable}
disabled={bulkToggling}
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-emerald-500/30 px-2 text-xs text-emerald-500 transition-colors hover:bg-emerald-500/10 disabled:opacity-50"
title="Enable connections that still have quota (within current filter)"
>
<span className="material-symbols-outlined text-[14px]">check_circle</span>
<span className="hidden sm:inline">Turn on Available</span>
</button>
{/* Auto-refresh toggle */}
<button
onClick={() => setAutoRefresh((prev) => !prev)}
className="flex shrink-0 items-center gap-2 rounded-lg border border-black/10 px-3 py-2 transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"
title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"}
>
<span
className={`material-symbols-outlined text-[18px] ${
className={`material-symbols-outlined text-[14px] ${
autoRefresh ? "text-primary" : "text-text-muted"
}`}
>
{autoRefresh ? "toggle_on" : "toggle_off"}
</span>
<span className="hidden text-sm text-text-primary sm:inline">Auto-refresh</span>
<span className="hidden text-text-primary sm:inline">Auto-refresh</span>
{autoRefresh && (
<span className="text-xs text-text-muted">({countdown}s)</span>
<span className="text-[10px] text-text-muted tabular-nums">({countdown}s)</span>
)}
</button>
{/* Refresh all button */}
<Button
variant="secondary"
size="md"
icon="refresh"
<button
type="button"
onClick={refreshAll}
disabled={refreshingAll}
loading={refreshingAll}
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5 disabled:opacity-50"
title="Refresh all"
>
Refresh All
</Button>
<span className={`material-symbols-outlined text-[14px] ${refreshingAll ? "animate-spin" : ""}`}>refresh</span>
</button>
</div>
</div>
@@ -572,7 +639,7 @@ export default function ProviderLimits() {
padding="none"
className={`min-w-0 ${isInactive ? "opacity-60" : ""}`}
>
<div className="px-4 py-3 border-b border-black/10 dark:border-white/10">
<div className="px-3 py-2 border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 shrink-0 rounded-md flex items-center justify-center overflow-hidden">
@@ -662,7 +729,7 @@ export default function ProviderLimits() {
</div>
</div>
<div className="px-3 py-3">
<div className="px-2 py-1.5">
{isLoading ? (
<div className="text-center py-5 text-text-muted">
<span className="material-symbols-outlined text-[28px] animate-spin">

View File

@@ -93,7 +93,7 @@ export async function GET() {
// POST - Start MITM server (cert + server, no DNS)
export async function POST(request) {
try {
const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json();
const { apiKey, sudoPassword, mitmRouterBaseUrl, forceKillPort443 } = await request.json();
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!apiKey || requiresSudoPassword(pwd)) {
@@ -122,12 +122,18 @@ export async function POST(request) {
}
}
const result = await startServer(apiKey, pwd);
const result = await startServer(apiKey, pwd, !!forceKillPort443);
if (!isWin) setCachedPassword(pwd);
return NextResponse.json({ success: true, running: result.running, pid: result.pid });
} catch (error) {
console.log("Error starting MITM server:", error.message);
if (error.code === "PORT_443_BUSY") {
return NextResponse.json(
{ error: error.message, code: "PORT_443_BUSY", portOwner: error.portOwner },
{ status: 409 }
);
}
return NextResponse.json({ error: error.message || "Failed to start MITM server" }, { status: 500 });
}
}

View File

@@ -124,6 +124,7 @@ export async function getTunnelStatus() {
return {
enabled: settings.tunnelEnabled === true && running,
settingsEnabled: settings.tunnelEnabled === true,
tunnelUrl: state?.tunnelUrl || "",
shortId,
publicUrl,
@@ -190,6 +191,7 @@ export async function getTailscaleStatus() {
const running = isTailscaleRunning();
return {
enabled: settings.tailscaleEnabled === true && running,
settingsEnabled: settings.tailscaleEnabled === true,
tunnelUrl: settings.tailscaleUrl || "",
running
};

View File

@@ -444,7 +444,26 @@ async function scheduleMitmRestart(apiKey) {
/**
* Start MITM server only (cert + server, no DNS)
*/
async function startServer(apiKey, sudoPassword) {
async function killPort443Owner(owner, sudoPassword) {
if (!owner || !owner.pid) return;
if (IS_WIN) {
try {
execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${owner.pid} -Force -ErrorAction SilentlyContinue"`, { windowsHide: true });
} catch { /* best effort */ }
} else {
try {
const { execWithPassword } = require("./dns/dnsConfig");
if (sudoPassword || isSudoAvailable()) {
await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword || "");
} else {
execSync(`kill -9 ${owner.pid}`, { windowsHide: true });
}
} catch { /* best effort */ }
}
await new Promise(r => setTimeout(r, 800));
}
async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
if (!serverProcess || serverProcess.killed) {
try {
if (fs.existsSync(PID_FILE)) {
@@ -472,19 +491,19 @@ async function startServer(apiKey, sudoPassword) {
const portStatus = await checkPort443Free();
if (portStatus === "in-use" || portStatus === "no-permission") {
const owner = await getPort443Owner(sudoPassword);
const ownerIsNode = owner && (owner.name === "node" || owner.name.includes("node"));
if (ownerIsNode) {
log(`Killing orphan node process on port 443 (PID ${owner.pid}, name=${owner.name})...`);
try {
const { execWithPassword } = require("./dns/dnsConfig");
await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword);
await new Promise(r => setTimeout(r, 800));
} catch { /* best effort */ }
} else if (owner) {
if (owner) {
const shortName = owner.name.includes("/")
? owner.name.split("/").filter(Boolean).pop()
: owner.name;
throw new Error(`Port 443 is already in use by "${shortName}" (PID ${owner.pid}). Stop that process first.`);
if (forceKillPort443) {
log(`Killing process on port 443 (PID ${owner.pid}, name=${shortName})...`);
await killPort443Owner(owner, sudoPassword);
} else {
const e = new Error(`Port 443 is already in use by "${shortName}" (PID ${owner.pid}).`);
e.code = "PORT_443_BUSY";
e.portOwner = { pid: owner.pid, name: shortName };
throw e;
}
}
}
}
@@ -533,12 +552,19 @@ async function startServer(apiKey, sudoPassword) {
const mitmRouterBase = await resolveMitmRouterBaseUrl();
log(`🚀 Starting server... (router: ${mitmRouterBase})`);
if (IS_WIN) {
// Kill any process using port 443 before spawning
try {
const psKill = `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c -and $c.OwningProcess -gt 4) { Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue }`;
execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "${psKill}"`, { windowsHide: true });
await new Promise(r => setTimeout(r, 500));
} catch { /* best effort */ }
// Check port 443 — ask user before killing
const winOwner = await getPort443Owner(sudoPassword);
if (winOwner) {
if (forceKillPort443) {
log(`Killing process on port 443 (PID ${winOwner.pid}, name=${winOwner.name})...`);
await killPort443Owner(winOwner, sudoPassword);
} else {
const e = new Error(`Port 443 is already in use by "${winOwner.name}" (PID ${winOwner.pid}).`);
e.code = "PORT_443_BUSY";
e.portOwner = { pid: winOwner.pid, name: winOwner.name };
throw e;
}
}
// Spawn directly — process already has admin rights
serverProcess = spawn(

View File

@@ -95,6 +95,19 @@ export default function ModelSelectModal({
const groupedModels = useMemo(() => {
const groups = {};
// Kinds where the provider IS the model (no per-model selection needed)
const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]);
// Kinds that map directly to model.type field
const TYPED_KINDS = new Set(["image", "tts", "stt", "embedding", "imageToText"]);
// For these kinds, providers without hardcoded models can still be picked (provider-as-model fallback)
const ALLOW_PROVIDER_FALLBACK_KINDS = new Set(["tts", "image", "webFetch"]);
// Filter a models[] array by kindFilter (keep only matching m.type)
const filterByKind = (models) => {
if (!kindFilter || !TYPED_KINDS.has(kindFilter)) return models;
return models.filter((m) => m.isPlaceholder || m.type === kindFilter);
};
// Get all active provider IDs from connections (filtered by kindFilter if set)
const activeConnectionIds = filteredActiveProviders.map(p => p.provider);
@@ -121,6 +134,17 @@ export default function ModelSelectModal({
const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" };
const isCustomProvider = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
// For provider-as-model kinds (webSearch/webFetch): emit a single entry where value === providerId
if (kindFilter && PROVIDER_AS_MODEL_KINDS.has(kindFilter)) {
groups[providerId] = {
name: providerInfo.name,
alias,
color: providerInfo.color,
models: [{ id: providerId, name: providerInfo.name, value: providerId }],
};
return;
}
if (providerInfo.passthroughModels) {
const aliasModels = Object.entries(modelAliases)
.filter(([, fullModel]) => fullModel.startsWith(`${alias}/`))
@@ -130,7 +154,20 @@ export default function ModelSelectModal({
value: fullModel,
}));
if (aliasModels.length > 0) {
// For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info)
let combined = aliasModels;
if (kindFilter && TYPED_KINDS.has(kindFilter)) {
combined = getModelsByProviderId(providerId)
.filter((m) => m.type === kindFilter)
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, type: m.type }));
// Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only)
if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter);
if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }];
}
}
if (combined.length > 0) {
// Check for custom name from providerNodes (for compatible providers)
const matchedNode = providerNodes.find(node => node.id === providerId);
const displayName = matchedNode?.name || providerInfo.name;
@@ -139,10 +176,12 @@ export default function ModelSelectModal({
name: displayName,
alias: alias,
color: providerInfo.color,
models: aliasModels,
models: combined,
};
}
} else if (isCustomProvider) {
// Custom (openai/anthropic-compatible) providers are LLM-only — skip for typed media kinds
if (kindFilter && TYPED_KINDS.has(kindFilter)) return;
// Find connection object to get prefix synchronously without waiting for providerNodes fetch
const connection = activeProviders.find(p => p.provider === providerId);
const matchedNode = providerNodes.find(node => node.id === providerId);
@@ -200,11 +239,20 @@ export default function ModelSelectModal({
.filter((m) => m.providerAlias === alias && !hardcodedIds.has(m.id) && !customAliasIds.has(m.id))
.map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true }));
const allModels = [
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}` })),
let allModels = filterByKind([
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, type: m.type })),
...customAliasModels,
...customRegisteredModels,
];
]);
// Provider-as-model fallback: providers that support the kind but have no hardcoded models
// can still be picked (value = providerAlias). Skips embedding (always needs model).
if (allModels.length === 0 && kindFilter && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter);
if (supports) {
allModels = [{ id: providerId, name: providerInfo.name, value: alias }];
}
}
if (allModels.length > 0) {
groups[providerId] = {

View File

@@ -6,11 +6,13 @@ import {
isValidApiKey,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleImageGenerationCore } from "open-sse/handlers/imageGenerationCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { handleComboChat } from "open-sse/services/combo.js";
import * as log from "../utils/logger.js";
// Providers that don't require credentials (noAuth)
const NO_AUTH_PROVIDERS = new Set(["sdwebui", "comfyui"]);
@@ -44,6 +46,28 @@ export async function handleImageGeneration(request) {
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
if (!body.prompt) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt");
// Combo expansion: model may be a combo name → run fallback/round-robin across models
const comboModels = await getComboModels(modelStr);
if (comboModels) {
const comboStrategies = settings.comboStrategies || {};
const comboStrategy = comboStrategies[modelStr]?.fallbackStrategy || settings.comboStrategy || "fallback";
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
log.info("IMAGE", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleModelImage(b, m, { wantsStream, binaryOutput, preferredConnectionId }),
log,
comboName: modelStr,
comboStrategy,
comboStickyLimit,
});
}
return handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId });
}
async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutput, preferredConnectionId } = {}) {
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");

View File

@@ -3,11 +3,12 @@ import {
getProviderCredentials, markAccountUnavailable,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleTtsCore } from "open-sse/handlers/ttsCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { handleComboChat } from "open-sse/services/combo.js";
import * as log from "../utils/logger.js";
// Derived from providers.js: any TTS provider not noAuth requires stored credentials
@@ -41,6 +42,28 @@ export async function handleTts(request) {
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
if (!body.input) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
// Combo expansion: model may be a combo name → run fallback/round-robin across models
const comboModels = await getComboModels(modelStr);
if (comboModels) {
const comboStrategies = settings.comboStrategies || {};
const comboStrategy = comboStrategies[modelStr]?.fallbackStrategy || settings.comboStrategy || "fallback";
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
log.info("TTS", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat),
log,
comboName: modelStr,
comboStrategy,
comboStickyLimit,
});
}
return handleSingleModelTts(body, modelStr, responseFormat);
}
async function handleSingleModelTts(body, modelStr, responseFormat) {
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");