/** Session commands whose activation policy is explicit at each Remote method. */ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError, admitEncodedFile, admitPromptContent } from '@deepseek-ai/dsh-attachment' import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' import { ApiSessionAgentController, ApiSessionCwdConflict, ApiSessionNotFound, ApiSessionPresetConflict, ApiSessionSubagentOwnership, apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, inspectApiSession, } from './agent.ts' import type { SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, SessionCancelValue, SessionCreateRequest, SessionCreateValue, SessionForkRequest, SessionForkValue, FileUploadReceiptId, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, SessionRenameValue, SessionSelectModelRequest, SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, SessionUploadFileRequest, SessionUploadFileValue, SessionRequestId, } from './types.ts' interface SessionReadState { readonly id: SessionId readonly header: SessionHeader readonly events: readonly SessionEvent[] } interface StagedFileUpload { readonly file: FileAttachmentRef /** Prompt that accepted this receipt; absent until successful admission. */ requestId?: SessionRequestId } /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { /** * Staged file uploads awaiting a prompt, keyed by Session. Entries are the * prompt-time authority for file references: a prompt may only cite a file * previously uploaded for the same Session in this process. */ private readonly stagedFiles = new Map>() /** * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. * @param agents - sole owner of create, resume, and Session-local model selection. * @param defaultCwd - project directory used when create names neither a Workspace nor a cwd. */ constructor( private readonly ctx: Context, private readonly agents: ApiSessionAgentController, private readonly defaultCwd: string, ) { ctx.inject(['commands'], (commandCtx) => { const resolve: CommandFileReceiptResolver = (agent, receiptId) => this.resolveStagedFile(agent.id, receiptId as FileUploadReceiptId) commandCtx.effect( () => commandCtx.commands.registerFileReceiptResolver(resolve), 'session-controller: command file receipt resolver', ) }) } /** * Persist one browser file upload verbatim and stage it for later prompts. * @param request - Session identity, base64 payload, and optional display name. * @returns an opaque per-upload receipt and the durable file reference. */ async uploadFile(request: SessionUploadFileRequest): Promise { const agent = await this.resolveAgent(request.sessionId) return this.commitFileUpload(agent, async () => admitEncodedFile(this.ctx.attachments, { data: request.data, ...(request.name === undefined ? {} : { name: request.name }), })) } /** * Persist raw upload chunks without collecting the complete file in memory. * @param request - Session identity, ordered exact bytes, cancellation, and optional display name. * @returns an opaque per-upload receipt and the durable file reference. */ async uploadFileStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise { const agent = await this.resolveAgent(request.sessionId) return this.commitFileUpload(agent, async () => this.ctx.attachments.saveFileStream({ data: request.data, ...(request.signal === undefined ? {} : { signal: request.signal }), ...(request.name === undefined ? {} : { name: request.name }), })) } private async commitFileUpload( agent: Agent, save: () => Promise, ): Promise { let file: FileAttachmentRef try { file = await save() } catch (error) { if (error instanceof AttachmentError) { throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } throw new RemoteError( 'gateway/internal', `failed to store file upload: ${String(error)}`, {}, { cause: error }, ) } if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', `session "${agent.id}" was disposed before its file upload completed`, { sessionId: agent.id }, ) } let staged = this.stagedFiles.get(agent.id) if (staged === undefined) { staged = new Map() this.stagedFiles.set(agent.id, staged) } const receiptId = randomUUID() as FileUploadReceiptId staged.set(receiptId, { file }) return { receiptId, file } } /** * Resolve one staged upload for the same Session without exposing the receipt table. * @param sessionId - receiving Session identity. * @param receiptId - Host-minted upload receipt. * @returns the durable file reference, or `undefined` when the receipt is absent or belongs elsewhere. */ resolveStagedFile(sessionId: SessionId, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { return this.stagedFiles.get(sessionId)?.get(receiptId)?.file } /** * Retire file receipts only after their accepted prompt becomes observable. * @param sessionId - Session whose log emitted the prompt. * @param requestId - browser prompt identity echoed by the event. */ retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void { const staged = this.stagedFiles.get(sessionId) if (staged === undefined) return for (const [receiptId, upload] of staged) { if (upload.requestId === requestId) staged.delete(receiptId) } if (staged.size === 0) this.stagedFiles.delete(sessionId) } /** * Drop one Session's staged uploads (the stored objects remain durable). * @param sessionId - Session leaving the live registry. */ releaseStagedFiles(sessionId: SessionId): void { this.stagedFiles.delete(sessionId) } /** * Create or idempotently adopt one ordinary Session. * @param request - requested identity, location, and Agent preset. * @returns the Session identity and resolved preset when configured. */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) if (workspace === undefined) { throw new RemoteError('workspace/not-found', `workspace "${request.workspaceId}" not found`, { workspaceId: request.workspaceId, }) } } const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd let adopted: Agent try { adopted = await this.agents.ensureSession( sessionId, cwd, request.sessionId !== undefined, request.agentPreset, ) } catch (error) { this.rejectCreation(sessionId, error) } if (workspace !== undefined) { try { await workspace.attachSession(sessionId) } catch (error) { throw new RemoteError( 'session/workspace-attach-failed', `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, { sessionId, workspaceId: workspace.id }, ) } } const agentPreset = this.agents.presetForSession(adopted.session) return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } } /** * Validate and install one Session-local model selection. * @param request - Session identity and requested model selection. * @returns the normalized selection installed for the Session. */ async selectModel(request: SessionSelectModelRequest): Promise { const agent = await this.resolveAgent(request.sessionId) return this.agents.serializeImageAdmission(agent, async () => { try { const resolved = await this.ctx.llm.resolveCallConfig({ provider: request.provider, model: request.model, ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }), }) const selected: AgentModelSelection = { provider: resolved.provider, model: resolved.model, ...(resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }), } this.agents.selectForNextRequest(agent, selected) try { await this.ctx.agentDefaultModel.saveSelection(selected) } catch (error) { this.ctx.logger.warn( `session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`, ) } return { selected: { ...selected } } } catch (error) { if (remoteErrorOf(error) !== undefined) throw error throw new RemoteError( 'session/model-unavailable', error instanceof Error ? error.message : String(error), { provider: request.provider, model: request.model }, ) } }) } /** * Normalize and append a user-owned Session title. * @param request - Session identity and proposed title. * @returns the accepted title and durable event sequence. */ async rename(request: SessionRenameRequest): Promise { const agent = await this.resolveAgent(request.sessionId) const titles = this.ctx.get('sessionTitle') if (titles === undefined) { throw new RemoteError('gateway/internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) } try { const accepted = titles.rename(agent.session, request.title) return { title: accepted.title, seq: accepted.eventSeq } } catch (error) { if (error instanceof SessionTitleInvalidError) { throw new RemoteError('session/title-invalid', error.message, { sessionId: request.sessionId }) } throw new RemoteError( 'gateway/internal', `failed to rename session "${request.sessionId}": ${String(error)}`, {}, ) } } /** * Create a new ordinary Session from one completed-turn prefix. * @param request - source Session and optional event anchor. * @returns the new Session identity. */ async fork(request: SessionForkRequest): Promise { let atSeq: ReturnType | undefined try { atSeq = request.atSeq === undefined ? undefined : SessionSeq(request.atSeq) } catch { throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative safe integer', {}) } let observed: SessionObservation try { observed = await this.ctx.sessionQuery.observeSession(request.sessionId) } catch (error) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { throw new RemoteError('session/not-found', `session "${request.sessionId}" not found`, { sessionId: request.sessionId, }) } throw new RemoteError( 'gateway/internal', `fork source unavailable for session "${request.sessionId}": ${String(error)}`, {}, ) } using source = observed const lastSeq = source.events.at(-1)?.seq ?? -1 const anchoredBoundary = atSeq === undefined ? undefined : source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq) const boundary = anchoredBoundary ?? (atSeq === undefined || atSeq > lastSeq ? source.events.findLast(event => event.type === 'turn/end') : undefined) if (boundary === undefined) { throw new RemoteError( 'session/fork-unavailable', atSeq !== undefined && atSeq <= lastSeq ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}` : `session "${request.sessionId}" has no completed turn to fork from`, { sessionId: request.sessionId }, ) } let cut = SessionLogOffset(boundary.seq + 1) while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') { cut = SessionLogOffset(cut + 1) } let workspace: Workspace | undefined try { workspace = await this.forkWorkspace(source.header) } catch (error) { throw new RemoteError( 'gateway/internal', `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, {}, ) } const childId = brandString(`session-${randomUUID()}`) const composition = await this.agents.composeAgent(this.agents.presetForObservation(source)) try { const { provider, model } = this.ctx.agentDefaultModel.currentSelection() await this.ctx.agents.create({ sessionId: childId, seed: source.events.slice(0, cut), inheritedEventCount: cut, meta: { ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), parentSession: source.header.id, isSeeded: true, ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }), }, agentOptions: { provider, model }, setup: composition.setup, }) } catch (error) { throw new RemoteError( 'gateway/internal', `failed to fork session "${request.sessionId}": ${String(error)}`, {}, ) } if (workspace !== undefined) { try { await workspace.attachSession(childId) } catch (error) { throw new RemoteError( 'session/workspace-attach-failed', `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, { sessionId: childId, workspaceId: workspace.id }, ) } } return { sessionId: childId } } /** * Admit one browser prompt after explicit Agent resume and image validation. * @param request - Session identity, prompt content, source metadata, and delivery mode. * @returns acknowledgement that the Agent accepted the prompt. */ async prompt(request: SessionPromptRequest): Promise { const clientTimeZone = request.clientTimeZone === undefined ? undefined : canonicalClientTimeZone(request.clientTimeZone) if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { throw new RemoteError( 'session/invalid-time-zone', 'clientTimeZone must be UTC or a valid IANA Area/Location name', { value: request.clientTimeZone }, ) } const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current if (!routeServed(this.ctx, selection.provider)) { throw new RemoteError( 'session/model-unavailable', `no adapter serves provider "${selection.provider}"; select a model for this session`, { provider: selection.provider, model: selection.model }, ) } const source: MessageSource = { kind: 'user', rpcId: request.requestId, ...(clientTimeZone === undefined ? {} : { clientTimeZone }), } const hasImage = request.content.some(part => part.type === 'image') const admit = async (): Promise => { try { if (hasImage) { const current = this.agents.selectionFor(agent).current const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { throw new RemoteError( 'session/attachment-invalid', `Model "${current.model}" does not support image input.`, { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, ) } } const staged = this.stagedFiles.get(request.sessionId) const durable = await durablePromptContent( this.ctx, request.content, receiptId => staged?.get(receiptId)?.file, ) const message: UserMessage = createUserMessage({ content: durable.content, source }) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', `session "${agent.id}" was disposed during prompt admission`, { sessionId: agent.id }, ) } const bound = durable.receiptIds.map((receiptId) => { const upload = staged?.get(receiptId) if (upload === undefined) { throw new RemoteError( 'session/attachment-invalid', 'File was not uploaded for this session.', { reason: 'FILE_NOT_STAGED' }, ) } return { upload, previous: upload.requestId } }) for (const { upload } of bound) upload.requestId = request.requestId try { if (request.mode === 'steer') agent.steer(message) else agent.followup(message) } catch (error) { for (const { upload, previous } of bound) { if (previous === undefined) delete upload.requestId else upload.requestId = previous } throw error } } catch (error) { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) }) } return { accepted: true } } return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } /** * Read one durable image after proving the Session log references it. * @param request - Session and attachment identities used for authorization. * @returns the durable attachment reference and base64-encoded bytes. */ async attachment(request: SessionAttachmentRequest): Promise { let source: SessionReadState try { source = await this.readSessionState(request.sessionId) } catch (error) { if (error instanceof ApiSessionNotFound) { throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }) } throw new RemoteError( 'gateway/internal', `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, {}, ) } const ref = referencedImage(source.events, String(request.attachmentId)) if (ref === undefined) { throw new RemoteError( 'session/attachment-invalid', 'Image is not referenced by this session.', { reason: 'ATTACHMENT_NOT_REFERENCED' }, ) } try { const stored = await this.ctx.attachments.readImage(ref) return { attachment: stored.ref, data: Buffer.from(stored.data).toString('base64'), } } catch (error) { if (error instanceof AttachmentError) { throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } throw new RemoteError('gateway/internal', 'Unable to read image attachment.', {}) } } /** * Mutate one still-pending queue occurrence without resuming a cold Agent. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { if (request.action.kind === 'edit' && request.action.content.some(block => block.type !== 'text')) { throw new RemoteError( 'session/attachment-invalid', 'queue edits accept text content only', { reason: 'QUEUE_EDIT_NON_TEXT' }, ) } const agent = this.ctx.agents.get(request.sessionId) if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { throw apiSessionSubagentOwnershipError(request.sessionId) } if (agent === undefined) { throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) const located = nextTurn === undefined ? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep } : { target: 'next-turn' as const, message: nextTurn } if (located === undefined) { throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const { target, message } = located if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) } if (request.action.kind === 'edit') { agent.inbox.replace(request.itemId, freezeMessage({ ...message, content: [...request.action.content], })) } else { agent.inbox.remove(request.itemId) if (request.action.kind === 'remove') { const source = message.source if (source.kind === 'user' && 'rpcId' in source) { this.retireObservedPrompt(request.sessionId, source.rpcId) } } if (request.action.kind === 'steer') agent.steer(message) } return { accepted: true } } /** * Cancel one live ordinary Agent while retaining pending inbox work. * @param request - Session whose active Agent turn is cancelled. * @returns acknowledgement that cancellation was requested. */ cancel(request: SessionCancelRequest): SessionCancelValue { const agent = this.ctx.agents.get(request.sessionId) if (agent === undefined) { throw new RemoteError( 'session/not-found', `session "${request.sessionId}" not found (not attached)`, { sessionId: request.sessionId }, ) } if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { throw apiSessionSubagentOwnershipError(request.sessionId) } agent.cancel({ kind: 'user' }, { keepInbox: true }) return { accepted: true } } private async resolveAgent(sessionId: SessionId): Promise { const found = await this.agents.resolveAgent(sessionId) if ('error' in found) throw found.error return found.agent } private rejectCreation(sessionId: SessionId, error: unknown): never { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof ApiSessionPresetConflict) { throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } if (error instanceof ApiSessionCwdConflict) { throw new RemoteError('session/conflict', error.message, { sessionId: error.sessionId, requestedCwd: error.requestedCwd, ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), }) } if (error instanceof ApiSessionSubagentOwnership) { throw apiSessionSubagentOwnershipError(error.sessionId) } throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } } const inspected = await inspectApiSession(this.ctx, sessionId) return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } } private async forkWorkspace(source: SessionHeader): Promise { const workspaces = this.ctx.workspaceRegistry.list() const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) if (direct !== undefined || source.origin !== 'subagent') return direct const lineage = await this.ctx.sessionQuery.traceSession(source.id) for (const ancestor of lineage.ancestors) { const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) if (workspace !== undefined) return workspace } return undefined } } async function durablePromptContent( ctx: Context, content: readonly SessionPromptRequest['content'][number][], stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, ): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> { const files = new Map() for (const part of content) { if (part.type !== 'file' || files.has(part.receiptId)) continue const file = stagedFile(part.receiptId) if (file === undefined) { throw new RemoteError( 'session/attachment-invalid', 'File was not uploaded for this session.', { reason: 'FILE_NOT_STAGED' }, ) } files.set(part.receiptId, file) } type NonFilePart = Exclude const admitted = await admitPromptContent( ctx.attachments, content.filter((part): part is NonFilePart => part.type !== 'file'), ) let next = 0 const durable = content.map((part) => { if (part.type === 'file') { return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef } } return admitted[next++] as ContentBlock }) return { content: durable, receiptIds: [...files.keys()] } } function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { const matches = (message: UserMessage): boolean => { const source = message.source return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId } if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true return agent.session.snapshotEvents().some((event) => { if (event.type !== 'user/message') return false const source = event.data.source return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId }) } function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, ): ImageAttachmentRef | undefined { if (!Array.isArray(content)) return undefined for (const value of content) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef if (match(ref)) return ref } if (block.type === 'tool-result') { const nested = imageBlockIn(block.content, match) if (nested !== undefined) return nested } } return undefined } function imageInEvent( event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean, ): ImageAttachmentRef | undefined { const data = event.data as { readonly content?: unknown readonly message?: { readonly content?: unknown } readonly inserted?: readonly { readonly content?: unknown }[] readonly chunk?: { readonly type?: unknown; readonly block?: unknown } } const direct = imageBlockIn(data.content, match) if (direct !== undefined) return direct const message = imageBlockIn(data.message?.content, match) if (message !== undefined) return message for (const inserted of data.inserted ?? []) { const found = imageBlockIn(inserted.content, match) if (found !== undefined) return found } return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' ? imageBlockIn([data.chunk.block], match) : undefined } function referencedImage( events: readonly SessionEvent[], attachmentId: string, ): ImageAttachmentRef | undefined { for (const event of events) { const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) if (found !== undefined) return found } return undefined } function routeServed(ctx: Context, provider: string): boolean { return ctx.llm.listProviders().some(entry => entry.id === provider) }