diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index bec36bab..f3b21b69 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -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": diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js index 27808963..51fab54e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -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 } ) : ( + + + + + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js index f059e6a9..e3d248bc 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -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(); diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 81daa124..85a0aca4 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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); + setTsEnabled(tsEn); - 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); - } - } catch { - pingTunnelHealth(tPublicUrl || tUrl); - } - } else { - setTunnelEnabled(tEnabled); + fetch(healthUrl, { cache: "no-store" }) + .then((r) => { if (!r.ok) setTunnelStatus({ type: "warning", message: "Tunnel reconnecting..." }); }) + .catch(() => setTunnelStatus({ type: "warning", message: "Tunnel reconnecting..." })); + } + 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) { diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js index 42c6d152..30e1ddbb 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js @@ -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 ( +
+ {combos.map((combo) => ( + + +
+ layers + {combo.name} +
+ {combo.models.slice(0, 6).map((entry, i) => { + const pid = typeof entry === "string" ? entry.split("/")[0] : ""; + const p = AI_PROVIDERS[pid]; + return ( +
+ +
+ ); + })} + {combo.models.length > 6 && ( + +{combo.models.length - 6} + )} +
+ {combo.models.length} + chevron_right +
+
+ + ))} +
+ ); +} + 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 (
- {isEmbedding && ( -
- + {(isEmbedding || supportsCombo) && ( +
+ {supportsCombo && ( + + )} + {isEmbedding && ( + + )}
)} + {supportsCombo && kindCombos.length > 0 && ( + + )} + {allProviders.length === 0 ? (
No providers support {kindConfig.label} yet. diff --git a/src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js similarity index 59% rename from src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js rename to src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index 1c8d1e3e..e4f6aad7 100644 --- a/src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -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 ( - - {providers.length === 0 ? ( -
- No connected providers available. Add a connection first in the {KIND_LABELS[kind]} section. -
- ) : ( -
- {providers.map((p) => { - const already = currentIds.includes(p.id); - return ( - - ); - })} -
- )} -
- ); +// 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
Loading...
; 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 (
{/* Header */}
- + arrow_back
@@ -269,20 +285,24 @@ export default function ComboDetailPage() {
) : (
- {providers.map((pid, idx) => { - const p = AI_PROVIDERS[pid]; + {providers.map((entry, idx) => { + const { providerId, model } = parseModelEntry(entry); + const p = AI_PROVIDERS[providerId]; return ( -
+
{idx + 1} - {p?.name || pid} +
+
{p?.name || providerId}
+ {model && {model}} +
- - +
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index e34da0de..f1e2cbc8 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -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,18 +136,27 @@ export default function QuotaTable({ quotas = [], compact = false }) { {/* Reset Time */} {countdown !== "-" || resetDisplay ? ( -
- {countdown !== "-" && ( -
- in {countdown} -
- )} - {resetDisplay && ( -
- {resetDisplay} -
- )} -
+ compact ? ( +
+ {countdown !== "-" ? `in ${countdown}` : resetDisplay} +
+ ) : ( +
+ {countdown !== "-" && ( +
+ in {countdown} +
+ )} + {resetDisplay && ( +
+ {resetDisplay} +
+ )} +
+ ) ) : (
N/A
)} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index cd178703..9df549e9 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -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() {

Provider Limits

- - Last updated: {formatLastUpdated()} -
-
+
{providerMenuOpen && ( @@ -516,42 +559,66 @@ export default function ProviderLimits() { + + {/* Bulk: disable depleted */} + + + {/* Bulk: enable available */} + + {/* Auto-refresh toggle */} {/* Refresh all button */} - + refresh +
@@ -572,7 +639,7 @@ export default function ProviderLimits() { padding="none" className={`min-w-0 ${isInactive ? "opacity-60" : ""}`} > -
+
@@ -662,7 +729,7 @@ export default function ProviderLimits() {
-
+
{isLoading ? (
diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js index 658c314c..7d505714 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.js +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -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 }); } } diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js index 95bb3780..ebf7b8ea 100644 --- a/src/lib/tunnel/tunnelManager.js +++ b/src/lib/tunnel/tunnelManager.js @@ -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 }; diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 3f6c4648..03e1ce22 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -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( diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index a50e91df..065cff92 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -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] = { diff --git a/src/sse/handlers/imageGeneration.js b/src/sse/handlers/imageGeneration.js index 3db18b53..78e738d0 100644 --- a/src/sse/handlers/imageGeneration.js +++ b/src/sse/handlers/imageGeneration.js @@ -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"); diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js index c8a6c737..443a74f3 100644 --- a/src/sse/handlers/tts.js +++ b/src/sse/handlers/tts.js @@ -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");