// Turns T3 state into the shapes in contract.ts. Thread shells carry status but not text, so the // last message and the pending request's details come from each thread's bounded snapshot. Those // are large (hundreds of KB), so they are reduced to a small summary and cached per thread until // the thread's `updatedAt` moves. import { resolvePhase, threadPhase, type Phase } from "./awareness.ts"; import type { Activity, AgentProvider, ChangedFile, PendingFile, Feed, FeedItem, HostStatusDetail, HostSummary, Message, PendingRequest, RecentResponse, RouteKind, Summary, ThreadDetail, ThreadRow, HistoryResponse, SearchResponse, } from "./contract.ts"; import type { T3Host } from "./t3/host.ts"; import { isApprovalRequest, isUserInputRequest, type BoundedThread, type ConversationMessage, type ThreadShell, type TurnItem, } from "./t3/types.ts"; const LAST_MESSAGE_CHARS = 280; const MESSAGE_CHARS = 2000; const MESSAGES_PER_THREAD = 20; const ACTIVITY_CHARS = 80; /** How long a running thread's summary stays fresh. Its steps change without moving `updatedAt`. */ const RUNNING_SUMMARY_MS = 5_000; const THREAD_LIST_LIMIT = 50; const RECENT_LIMIT = 10; /** How long a feed request waits for summaries it does not have yet. */ const SUMMARY_WAIT_MS = 2_000; export interface ThreadSummary { messages: Message[]; lastAssistantMessage: string | null; request: PendingRequest | null; activity: Activity | null; /** The files the latest run in the window changed. */ changedFiles: ChangedFile[]; /** The history route's cursor for messages before `messages`. */ earlierCursor: string | null; } /** Trims text for a small screen: collapses whitespace and cuts at a word near `max`. */ export function trimText(text: string, max: number): string { const flat = text.replace(/\s+/g, " ").trim(); if (flat.length <= max) return flat; const cut = flat.slice(0, max); const space = cut.lastIndexOf(" "); return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`; } /** Reduces a bounded thread snapshot to what the watch shows. */ export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSummary { const { messages, turnItems } = bounded.projection; const visible = visibleMessages(messages); const lastAssistant = visible.findLast((message) => message.role === "assistant"); const start = Math.max(0, visible.length - MESSAGES_PER_THREAD); return { messages: visible.slice(start).map(toMessage), lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null, request: pendingRequest(thread, turnItems), activity: currentActivity(turnItems), changedFiles: latestChanges(turnItems), earlierCursor: windowCursor(start, bounded), }; } const visibleMessages = (messages: ConversationMessage[]) => messages.filter((message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant")); const toMessage = (message: Pick): Message => ({ id: message.id, role: message.role as Message["role"], text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text, createdAt: message.createdAt, }); // History cursors are the gateway's own. `w` means the bounded window's messages before index n, // which the thread detail leaves out past its 20. `h` is T3's cursor for older pages. /** The cursor for what comes before the window's message `start`. */ function windowCursor(start: number, bounded: BoundedThread): string | null { if (start > 0) return `w${start}`; return bounded.hasMoreHistory && bounded.historyCursor ? `h${bounded.historyCursor}` : null; } /** * The messages before `cursor`: 20 from the bounded window, or one T3 page, which can hold more. * T3 pages without messages are skipped, three at most. */ export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: string): Promise { if (cursor.startsWith("w")) { const end = Number(cursor.slice(1)); const bounded = await host.bounded(thread.id); const visible = visibleMessages(bounded.projection.messages); const start = Math.max(0, Math.min(end, visible.length) - MESSAGES_PER_THREAD); return { messages: visible.slice(start, end).map(toMessage), earlierCursor: windowCursor(start, bounded) }; } let next: string | null = cursor.slice(1); for (let pages = 0; next && pages < 3; pages++) { const page = await host.history(thread.id, next); next = page.hasMoreHistory ? page.nextCursor : null; const messages = page.items .map(({ item }) => item) .filter( (item) => (item.type === "user_message" || item.type === "assistant_message") && (item.text ?? "").trim().length > 0, ) .map((item) => toMessage({ id: item.messageId ?? "", role: item.type === "user_message" ? "user" : "assistant", text: item.text ?? "", createdAt: item.startedAt ?? item.updatedAt ?? "", }), ); if (messages.length > 0) return { messages, earlierCursor: next && `h${next}` }; } return { messages: [], earlierCursor: next && `h${next}` }; } /** The files the latest run changed, summed per file, most lines changed first. */ export function latestChanges(turnItems: TurnItem[]): ChangedFile[] { const runId = turnItems.findLast((item) => item.runId)?.runId; if (!runId) return []; const files = new Map(); for (const item of turnItems) { if (item.type !== "file_change" || item.runId !== runId || !item.fileName) continue; const file = files.get(item.fileName) ?? { path: item.fileName, name: item.fileName.split("/").pop()!, additions: 0, deletions: 0 }; file.additions += item.additions ?? 0; file.deletions += item.deletions ?? 0; files.set(item.fileName, file); } return [...files.values()].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions)); } /** The newest step still in progress, said briefly. Monitors are left out, as they run for hours. */ export function currentActivity(turnItems: TurnItem[]): Activity | null { for (let i = turnItems.length - 1; i >= 0; i--) { const item = turnItems[i]!; if (item.status !== "running" && item.status !== "pending") continue; const step = describeStep(item); if (step) return { ...step, text: trimText(step.text, ACTIVITY_CHARS), startedAt: item.startedAt ?? null }; } return null; } function describeStep(item: TurnItem): Omit | null { switch (item.type) { case "command_execution": return item.monitor || typeof item.input !== "string" ? null : { kind: "command", text: item.input.split("\n").find((line) => line.trim()) ?? item.input }; case "file_change": return item.fileName ? { kind: "edit", text: item.fileName.split("/").pop()! } : null; case "file_search": return { kind: "search", text: item.pattern ?? item.title ?? "files" }; case "web_search": return { kind: "web", text: item.title ?? "the web" }; case "subagent": return { kind: "subagent", text: item.title ?? "Subagent" }; case "reasoning": return { kind: "thinking", text: "Thinking" }; case "dynamic_tool": return item.toolName || item.title ? { kind: "tool", text: (item.toolName ?? item.title)! } : null; default: return null; } } function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null { const pending = thread.pendingRuntimeRequest; if (!pending || pending.kind === "auth_refresh") return null; const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], files: [], questions: [] }; const item = turnItems.findLast( (candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id, ); if (item && isApprovalRequest(item)) { const options = item.options?.length ? item.options.map(({ decision, label, warning }) => ({ decision, label, ...(warning ? { warning } : {}) })) : [ { decision: "accept" as const, label: "Allow" }, { decision: "decline" as const, label: "Deny" }, ]; const prompt = item.prompt ?? item.title ?? null; const files = pending.kind === "file-change" ? pendingFiles(turnItems, item.runId, prompt) : []; return { ...base, prompt, reason: item.reason ?? null, options, files }; } if (item && isUserInputRequest(item)) { return { ...base, questions: item.questions.map((question) => ({ id: question.id, header: question.header, question: question.question, options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })), allowCustomAnswer: question.allowCustomAnswer ?? false, multiSelect: question.multiSelect ?? false, })), }; } // The turn item may not be in the bounded window yet. Offer the plain choices for approvals. return pending.kind === "user_input" ? base : { ...base, options: [ { decision: "accept", label: "Allow" }, { decision: "decline", label: "Deny" }, ], }; } const PREVIEW_LINES = 4; const PREVIEW_CHARS = 60; /** * The files a file-change approval would touch: the run's unfinished file changes, or failing * those, the path in the prompt, which Claude words as `Edit: `. Most providers send the * diff only after the edit runs, so counts and preview are usually empty. */ export function pendingFiles(turnItems: TurnItem[], runId: string | null | undefined, prompt: string | null): PendingFile[] { const files = turnItems .filter( (item) => item.type === "file_change" && item.fileName && (!runId || item.runId === runId) && item.status !== "completed" && item.status !== "failed" && item.status !== "cancelled", ) .map((item) => { const preview = (item.diffStr ?? "") .split("\n") .filter((line) => /^[+-]/.test(line) && !/^(\+\+\+|---)( |$)/.test(line)) .slice(0, PREVIEW_LINES) .map((line) => (line.length > PREVIEW_CHARS ? `${line.slice(0, PREVIEW_CHARS)}…` : line)); return { path: item.fileName!, name: item.fileName!.split("/").pop()!, additions: item.additions ?? null, deletions: item.deletions ?? null, preview, }; }); if (files.length > 0) return files; const path = prompt?.match(/^\w+: (\S.*)$/)?.[1]?.trim(); return path ? [{ path, name: path.split("/").pop()!, additions: null, deletions: null, preview: [] }] : []; } /** Per-thread summaries, refetched when a thread's `updatedAt` changes. */ export class SummaryCache { private readonly entries = new Map(); private readonly inflight = new Map>(); constructor(private readonly log: (message: string) => void = () => {}) {} /** The cached summary, if it matches the thread's current version and, while running, is recent. */ peek(host: T3Host, thread: ThreadShell): ThreadSummary | undefined { const entry = this.entries.get(key(host, thread.id)); if (entry?.updatedAt !== thread.updatedAt) return undefined; if (thread.activeRunId !== null && Date.now() - entry.fetchedAt > RUNNING_SUMMARY_MS) return undefined; return entry.summary; } get(host: T3Host, thread: ThreadShell): Promise { const cached = this.peek(host, thread); if (cached) return Promise.resolve(cached); const k = `${key(host, thread.id)}@${thread.updatedAt}`; const fetchedAt = Date.now(); const running = this.inflight.get(k); if (running) return running; const promise = host .bounded(thread.id) .then((bounded) => { const summary = summarize(bounded, thread); this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt, summary }); return summary; }) .finally(() => this.inflight.delete(k)); this.inflight.set(k, promise); return promise; } forget(host: T3Host, threadId: string) { this.entries.delete(key(host, threadId)); } } const key = (host: T3Host, threadId: string) => `${host.id}:${threadId}`; export const hostSummary = (host: T3Host): HostSummary => ({ id: host.id, label: host.label, status: host.status, error: host.lastError, }); /** A host with its routes, for the status screens. `tokenExpiresAt` comes from the hosts table. */ export function hostStatusDetail(host: T3Host, tokenExpiresAt: string | null): HostStatusDetail { return { ...hostSummary(host), activeUrl: host.status === "online" ? host.url : null, routes: host.config.urls.map((url) => { const attempt = host.routeAttempts.get(url); return { url, kind: routeKind(url), result: attempt ? (attempt.ok ? "ok" : "failed") : "untried", error: attempt?.error ?? null, checkedAt: attempt?.at ?? null, }; }), onlineSince: host.onlineSince, lastOnlineAt: host.lastOnlineAt, nextRetryAt: host.nextRetryAt, tokenExpiresAt, serverVersion: host.serverVersion, }; } export function routeKind(url: string): RouteKind { const { protocol, hostname } = new URL(url); if (protocol === "https:") return "public"; return hostname.includes("vpn") ? "vpn" : "direct"; } /** A thread on the feed. With `via`, the card shows that subagent's request on this thread. */ interface Candidate { host: T3Host; thread: ThreadShell; phase: Phase; via?: { host: T3Host; thread: ThreadShell; phase: Phase }; } const isWaiting = (phase: Phase | null) => phase === "waiting_for_approval" || phase === "waiting_for_input"; /** The thread that delegated `child`, if the gateway is connected to its host. */ function delegator(hosts: T3Host[], child: ThreadShell): { host: T3Host; thread: ThreadShell } | null { const from = child.delegatedFrom; if (!from) return null; const host = hosts.find((candidate) => candidate.config.environmentId === from.environmentId); const thread = host?.threads.get(from.threadId); return host && thread ? { host, thread } : null; } /** * Moves each waiting subagent's request onto the card of the thread that delegated it, the oldest * request first. A subagent keeps its own card when its parent is unknown, hidden, archived, or * already showing a request. */ function attachDelegates(candidates: Candidate[], hosts: T3Host[]): Candidate[] { const result = [...candidates]; const delegates = candidates .filter(({ thread, phase }) => thread.delegatedFrom && isWaiting(phase)) .sort((a, b) => requestAge(a.thread).localeCompare(requestAge(b.thread))); for (const child of delegates) { const parent = delegator(hosts, child.thread); const index = parent ? result.findIndex(({ host, thread }) => host === parent.host && thread.id === parent.thread.id) : -1; const card = result[index]; const own = result.indexOf(child); if (!card || own === -1 || card.via || isWaiting(card.phase)) continue; result[index] = { ...card, phase: child.phase, via: { host: child.host, thread: child.thread, phase: child.phase } }; result.splice(own, 1); } return result; } const requestAge = (thread: ThreadShell) => thread.pendingRuntimeRequest?.createdAt ?? ""; function feedItem( host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined, via?: { host: T3Host; thread: ThreadShell; summary: ThreadSummary | undefined }, ): FeedItem { return { hostId: host.id, hostLabel: host.label, threadId: thread.id, projectId: thread.projectId, projectTitle: host.projects.get(thread.projectId)?.title ?? "", title: thread.title, phase, updatedAt: thread.updatedAt, lastMessage: summary?.lastAssistantMessage ?? null, activity: summary?.activity ?? null, snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : null, changes: summary?.changedFiles.length ? { files: summary.changedFiles.length, additions: summary.changedFiles.reduce((sum, file) => sum + file.additions, 0), deletions: summary.changedFiles.reduce((sum, file) => sum + file.deletions, 0), } : null, request: (via ? via.summary : summary)?.request ?? null, via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null, }; } /** The longest waiting subagent that `parent` delegated to, if any. */ function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined { let oldest: Candidate | undefined; for (const host of hosts) { for (const thread of host.threads.values()) { const from = thread.delegatedFrom; if (from?.threadId !== parent.id || from.environmentId !== parentHost.config.environmentId) continue; const phase = threadPhase(thread); if (!isWaiting(phase) || thread.archivedAt || thread.deletedAt) continue; if (!oldest || requestAge(thread) < requestAge(oldest.thread)) oldest = { host, thread, phase: phase! }; } } return oldest; } /** Counts for the complication, from thread shells alone. */ /** * Counts for the complication, plus the longest waiting request and the busiest working thread. * Reads at most those two threads, waiting briefly as the feed does. */ export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promise { let needsYou = 0; let working = 0; let next: { host: T3Host; thread: ThreadShell } | null = null; let current: { host: T3Host; thread: ThreadShell } | null = null; for (const host of hosts) { for (const thread of host.threads.values()) { if (thread.archivedAt || thread.deletedAt) continue; const phase = threadPhase(thread); if (phase === "starting" || phase === "running") { working++; if (!current || thread.updatedAt > current.thread.updatedAt) current = { host, thread }; } if (phase !== "waiting_for_approval" && phase !== "waiting_for_input") continue; needsYou++; const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? ""; if (!next || waitingSince < (next.thread.pendingRuntimeRequest?.createdAt ?? "")) next = { host, thread }; } } const read = [next, current].filter((pick) => pick !== null); const missing = read.filter(({ host, thread }) => !cache.peek(host, thread)); if (missing.length > 0) { await Promise.race([ Promise.all(missing.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined))), Bun.sleep(SUMMARY_WAIT_MS), ]); } const request = next && cache.peek(next.host, next.thread)?.request; const asked = request?.prompt ?? request?.questions[0]?.question; return { needsYou, working, next: next && { hostId: next.host.id, threadId: next.thread.id, title: next.thread.title, hostLabel: next.host.label, request: asked ? trimText(asked, ACTIVITY_CHARS) : null, }, current: current && { hostId: current.host.id, threadId: current.thread.id, title: current.thread.title, activity: cache.peek(current.host, current.thread)?.activity ?? null, }, hostsOffline: hosts.filter((host) => host.status !== "online").length, }; } /** True while a snooze is set and its time has not come. */ const isSnoozed = (thread: ThreadShell, now: number) => !!thread.snoozedUntil && Date.parse(thread.snoozedUntil) > now; /** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */ export function feedCandidates(hosts: T3Host[], now = Date.now()) { const found: Candidate[] = []; for (const host of hosts) { for (const thread of host.threads.values()) { const phase = threadPhase(thread); if (phase === null || thread.archivedAt || thread.deletedAt) continue; found.push({ host, thread, phase }); } } const candidates = attachDelegates(found, hosts); const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) => b.thread.updatedAt.localeCompare(a.thread.updatedAt); const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread; const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed"); return { needsYou: candidates .filter(({ phase }) => isWaiting(phase)) .sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))), working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest), /** Finished and failed threads not yet settled or snoozed, however old. */ recent: finished.filter(({ thread }) => !isSnoozed(thread, now)).sort(byNewest), /** Finished threads snoozed for later, the soonest to come back first. */ snoozed: finished .filter(({ thread }) => isSnoozed(thread, now)) .sort((a, b) => a.thread.snoozedUntil!.localeCompare(b.thread.snoozedUntil!)), }; } const toItem = (cache: SummaryCache) => ({ host, thread, phase, via }: Candidate): FeedItem => feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) }); /** Builds the feed across every host, waiting briefly for summaries it has not read yet. */ export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise { const { needsYou, working, recent: allRecent, snoozed } = feedCandidates(hosts); const recent = allRecent.slice(0, RECENT_LIMIT); const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate])); const missing = shown .filter(({ host, thread }) => !cache.peek(host, thread)) .map(({ host, thread }) => cache.get(host, thread).catch(() => undefined)); if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]); return { needsYou: needsYou.map(toItem(cache)), working: working.map(toItem(cache)), recent: recent.map(toItem(cache)), recentTotal: allRecent.length, snoozedTotal: snoozed.length, hosts: hosts.map(hostSummary), }; } /** * Every unsettled finished thread, for the full Recent list. Uses only summaries already cached, * since there can be many, so `lastMessage` is null for threads the gateway has not read. */ export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentResponse { return { items: feedCandidates(hosts).recent.map(toItem(cache)) }; } /** Snoozed finished threads, the soonest to come back first. Cached summaries only, like Recent. */ export function buildSnoozed(hosts: T3Host[], cache: SummaryCache): RecentResponse { return { items: feedCandidates(hosts).snoozed.map(toItem(cache)) }; } /** * One thread with its recent messages. Fetches the summary if it is not cached. When the thread * has no request of its own but a subagent it delegated to is waiting, shows that request. */ export async function buildThreadDetail( host: T3Host, thread: ThreadShell, cache: SummaryCache, hosts: T3Host[], ): Promise { const summary = await cache.get(host, thread); const own = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed"); const child = isWaiting(own) ? undefined : waitingDelegate(hosts, host, thread); const via = child && { host: child.host, thread: child.thread, summary: await cache.get(child.host, child.thread).catch(() => undefined) }; return { item: feedItem(host, thread, child?.phase ?? own, summary, via), messages: summary.messages, canStop: thread.activeRunId !== null, askFirst: thread.runtimeMode === ASK_FIRST, earlierCursor: summary.earlierCursor, subagents: subagents(hosts, host, thread), changedFiles: summary.changedFiles, }; } const threadRow = (host: T3Host, thread: ThreadShell): ThreadRow => ({ hostId: host.id, threadId: thread.id, title: thread.title, phase: resolvePhase(thread), settled: thread.settledOverride === "settled", updatedAt: thread.updatedAt, }); const newestFirst = (a: ThreadRow, b: ThreadRow) => b.updatedAt.localeCompare(a.updatedAt); const isLive = (thread: ThreadShell) => !thread.archivedAt && !thread.deletedAt; /** The subagents `parent` started, its own and the ones it delegated to on any host. */ function subagents(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): ThreadRow[] { const rows: ThreadRow[] = []; for (const host of hosts) { for (const thread of host.threads.values()) { if (!isLive(thread)) continue; const native = host === parentHost && thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId === parent.id; const from = thread.delegatedFrom; const delegated = from?.threadId === parent.id && from.environmentId === parentHost.config.environmentId; if (native || delegated) rows.push(threadRow(host, thread)); } } return rows.sort(newestFirst); } /** * Searches every online host at once and merges the hits, newest match first. Hosts that are * offline or fail are named in `unsearched`. */ export async function searchThreads(hosts: T3Host[], query: string): Promise { const results = await Promise.all( hosts.map(async (host) => { if (host.status !== "online") return null; try { return { host, matches: await host.search(query) }; } catch { return null; } }), ); const hits = results.flatMap((result) => (result?.matches ?? []).flatMap((match) => { const thread = result!.host.threads.get(match.threadId); if (!thread || !isLive(thread)) return []; const snippet = match.snippet.trim(); return [ { thread: threadRow(result!.host, thread), projectTitle: result!.host.projects.get(thread.projectId)?.title ?? "", hostLabel: result!.host.label, snippet: snippet && snippet !== thread.title ? trimText(snippet, 160) : null, at: match.messageCreatedAt ?? thread.updatedAt, }, ]; }), ); return { hits: hits.sort((a, b) => b.at.localeCompare(a.at)).map(({ at: _, ...hit }) => hit), unsearched: hosts.filter((host, i) => results[i] === null).map((host) => host.label), }; } /** A project's newest 50 threads, settled ones included. Subagents are listed under their parents. */ export function projectThreads(host: T3Host, projectId: string): ThreadRow[] { return [...host.threads.values()] .filter( (thread) => thread.projectId === projectId && isLive(thread) && thread.lineage.relationshipToParent !== "subagent" && !thread.delegatedFrom, ) .map((thread) => threadRow(host, thread)) .sort(newestFirst) .slice(0, THREAD_LIST_LIMIT); } /** T3's runtime mode that asks before each action. */ export const ASK_FIRST = "approval-required"; /** The providers on a host that can start a thread now, leaving out legacy models. */ export function agentProviders(host: T3Host): AgentProvider[] { return (host.serverConfig?.providers ?? []) .filter( (provider) => provider.enabled && provider.installed && provider.availability !== "unavailable" && (provider.status === "ready" || provider.status === "warning"), ) .map((provider) => ({ instanceId: provider.instanceId, name: provider.displayName ?? provider.driver, canAskFirst: provider.supportedRuntimeModes?.includes(ASK_FIRST) ?? true, models: provider.models.filter((model) => !model.isLegacy).map((model) => ({ slug: model.slug, name: model.name })), })) .filter((provider) => provider.models.length > 0); }