@bprp/flockcode 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ // Wrapper around the opencode SDK that maps responses to our types.
2
+
3
+ import {
4
+ createOpencodeClient,
5
+ type Event as OpencodeEvent,
6
+ type PermissionRequest,
7
+ } from "@opencode-ai/sdk/v2"
8
+ import type {
9
+ Message,
10
+ MessagePart,
11
+ } from "./types"
12
+
13
+ export type { OpencodeEvent }
14
+
15
+ export type OpencodeClient = ReturnType<typeof createOpencodeClient>
16
+
17
+ export function createClient(baseUrl: string): OpencodeClient {
18
+ const client = createOpencodeClient({ baseUrl })
19
+ return client
20
+ }
21
+
22
+ /** Map SDK event parts into our app message types. */
23
+
24
+ export function mapPart(p: any): MessagePart {
25
+ switch (p.type) {
26
+ case "text":
27
+ return { type: "text" as const, id: p.id, text: p.text }
28
+ case "tool":
29
+ return {
30
+ type: "tool" as const,
31
+ id: p.id,
32
+ tool: p.tool,
33
+ state: {
34
+ status: p.state?.status ?? "pending",
35
+ input: p.state?.input,
36
+ output: p.state?.output,
37
+ title: p.state?.title,
38
+ error: p.state?.error,
39
+ metadata: p.state?.metadata,
40
+ time: p.state?.time,
41
+ },
42
+ }
43
+ case "step-start":
44
+ return { type: "step-start" as const, id: p.id }
45
+ case "step-finish":
46
+ return { type: "step-finish" as const, id: p.id }
47
+ case "reasoning":
48
+ return { type: "reasoning" as const, id: p.id, text: p.text }
49
+ default:
50
+ return { type: "text" as const, id: p.id, text: `[${p.type}]` }
51
+ }
52
+ }
53
+
54
+ export function mapMessage(raw: any): Message {
55
+ const info = raw.info
56
+ const parts: MessagePart[] = (raw.parts ?? []).map(mapPart)
57
+
58
+ // Extract model info — user messages nest it under info.model,
59
+ // assistant messages have it flat on info
60
+ const modelID = info.role === "user"
61
+ ? info.model?.modelID
62
+ : info.modelID
63
+ const providerID = info.role === "user"
64
+ ? info.model?.providerID
65
+ : info.providerID
66
+
67
+ const agent = info.agent as string | undefined
68
+
69
+ const msg: Message = {
70
+ id: info.id,
71
+ sessionId: info.sessionID,
72
+ role: info.role,
73
+ parts,
74
+ createdAt: info.time?.created ?? 0,
75
+ ...(modelID ? { modelID } : {}),
76
+ ...(providerID ? { providerID } : {}),
77
+ ...(agent ? { agent } : {}),
78
+ }
79
+
80
+ if (info.role === "assistant") {
81
+ msg.cost = info.cost
82
+ msg.tokens = info.tokens
83
+ ? {
84
+ input: info.tokens.input,
85
+ output: info.tokens.output,
86
+ reasoning: info.tokens.reasoning,
87
+ }
88
+ : undefined
89
+ msg.finish = info.finish
90
+ }
91
+
92
+ return msg
93
+ }
94
+
95
+ /** Callback signature for receiving Opencode events. */
96
+ export type OpencodeEventCallback = (event: OpencodeEvent) => void
97
+
98
+ export class Opencode {
99
+ #client: OpencodeClient
100
+
101
+ constructor(baseUrl: string) {
102
+ this.#client = createOpencodeClient({ baseUrl })
103
+ }
104
+
105
+ async spawnListener(callback: OpencodeEventCallback, baseUrl: string) {
106
+ const url = `${baseUrl}/global/event`
107
+ const res = await fetch(url)
108
+ if (!res.ok || !res.body) throw new Error(`Failed to connect to ${url}: ${res.status}`)
109
+ const reader = res.body.getReader()
110
+ const decoder = new TextDecoder()
111
+ let buffer = ""
112
+ const processStream = async () => {
113
+ while (true) {
114
+ const { done, value } = await reader.read()
115
+ if (done) break
116
+ buffer += decoder.decode(value, { stream: true })
117
+ const lines = buffer.split("\n")
118
+ buffer = lines.pop() ?? ""
119
+ for (const line of lines) {
120
+ if (!line.startsWith("data:")) continue
121
+ const json = line.slice("data:".length).trim()
122
+ if (!json) continue
123
+ try {
124
+ const parsed = JSON.parse(json)
125
+ // Global events wrap the payload: { directory, payload: <event> }
126
+ const event = parsed.payload ?? parsed
127
+ if (event.type === "server.heartbeat" || event.type === "server.connected") continue
128
+ callback(event)
129
+ } catch {}
130
+ }
131
+ }
132
+ }
133
+ processStream()
134
+ }
135
+
136
+ async listProjects() {
137
+ const res = await this.#client.project.list()
138
+ if (res.error) throw new Error("Failed to list projects")
139
+ if (!res.data) throw new Error('No projects found')
140
+ return res.data
141
+ }
142
+
143
+ async listSessions() {
144
+ const res = await this.#client.session.list()
145
+ if (res.error) throw new Error("Failed to list sessions")
146
+ if (!res.data) throw new Error('No sessions found')
147
+ return (res.data)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Exhaustive event handler contract.
153
+ * Translates every OpencodeEvent into the appropriate StateStream calls.
154
+ */
155
+ export type StateStreamSink = {
156
+ sessionCreated(info: any): void
157
+ sessionUpdated(info: any): void
158
+ sessionDeleted(info: any): void
159
+ sessionStatus(sessionId: string, status: { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string; next: number }): void
160
+ sessionIdle(sessionId: string): void
161
+ sessionCompacted(sessionId: string): void
162
+ sessionDiff(sessionId: string, diff: any[]): void
163
+ sessionError(sessionId: string | undefined, error: any): void
164
+ messageUpdated(info: any): void
165
+ messageRemoved(sessionId: string, messageId: string): void
166
+ messagePartUpdated(part: any): void
167
+ messagePartDelta(messageId: string, partId: string, field: string, delta: string): void
168
+ messagePartRemoved(sessionId: string, messageId: string, partId: string): void
169
+ permissionAsked(permission: PermissionRequest): void
170
+ permissionReplied(sessionId: string, requestId: string, reply: string): void
171
+ todoUpdated(sessionId: string, todos: any[]): void
172
+ commandExecuted(sessionId: string, name: string, args: string, messageId: string): void
173
+ }
174
+
175
+ export function handleOpencodeEvent(
176
+ event: OpencodeEvent,
177
+ sink: StateStreamSink,
178
+ ): void {
179
+ switch (event.type) {
180
+ // --- Session lifecycle ---
181
+ case "session.created":
182
+ return sink.sessionCreated(event.properties.info)
183
+ case "session.updated":
184
+ return sink.sessionUpdated(event.properties.info)
185
+ case "session.deleted":
186
+ return sink.sessionDeleted(event.properties.info)
187
+ case "session.status":
188
+ return sink.sessionStatus(event.properties.sessionID, (event.properties as any).status ?? { type: "busy" })
189
+ case "session.idle":
190
+ return sink.sessionIdle(event.properties.sessionID)
191
+ case "session.compacted":
192
+ return sink.sessionCompacted(event.properties.sessionID)
193
+ case "session.diff":
194
+ return sink.sessionDiff(event.properties.sessionID, event.properties.diff)
195
+ case "session.error":
196
+ return sink.sessionError(event.properties.sessionID, event.properties.error)
197
+
198
+ // --- Messages ---
199
+ case "message.updated":
200
+ return sink.messageUpdated(event.properties.info)
201
+ case "message.removed":
202
+ return sink.messageRemoved(event.properties.sessionID, event.properties.messageID)
203
+
204
+ // --- Message parts ---
205
+ case "message.part.updated":
206
+ return sink.messagePartUpdated(event.properties.part)
207
+ case "message.part.delta":
208
+ return sink.messagePartDelta(
209
+ event.properties.messageID,
210
+ event.properties.partID,
211
+ event.properties.field,
212
+ event.properties.delta,
213
+ )
214
+ case "message.part.removed":
215
+ return sink.messagePartRemoved(
216
+ event.properties.sessionID,
217
+ event.properties.messageID,
218
+ event.properties.partID,
219
+ )
220
+
221
+ // --- Permissions ---
222
+ case "permission.asked":
223
+ return sink.permissionAsked(event.properties)
224
+ case "permission.replied":
225
+ return sink.permissionReplied(
226
+ event.properties.sessionID,
227
+ event.properties.requestID,
228
+ event.properties.reply,
229
+ )
230
+
231
+ // --- Todos & commands ---
232
+ case "todo.updated":
233
+ return sink.todoUpdated(event.properties.sessionID, event.properties.todos)
234
+ case "command.executed":
235
+ return sink.commandExecuted(
236
+ event.properties.sessionID,
237
+ event.properties.name,
238
+ event.properties.arguments,
239
+ event.properties.messageID,
240
+ )
241
+
242
+ // --- Non-session events (no-ops for state stream) ---
243
+ case "server.instance.disposed":
244
+ case "server.connected":
245
+ case "global.disposed":
246
+ case "installation.updated":
247
+ case "installation.update-available":
248
+ case "project.updated":
249
+ case "lsp.client.diagnostics":
250
+ case "lsp.updated":
251
+ case "file.edited":
252
+ case "file.watcher.updated":
253
+ case "vcs.branch.updated":
254
+ case "tui.prompt.append":
255
+ case "tui.command.execute":
256
+ case "tui.toast.show":
257
+ case "tui.session.select":
258
+ case "mcp.tools.changed":
259
+ case "mcp.browser.open.failed":
260
+ case "pty.created":
261
+ case "pty.updated":
262
+ case "pty.exited":
263
+ case "pty.deleted":
264
+ case "question.asked":
265
+ case "question.replied":
266
+ case "question.rejected":
267
+ case "worktree.ready":
268
+ case "worktree.failed":
269
+ return
270
+
271
+ default: {
272
+ // Exhaustive check: if TypeScript complains here, a new event type
273
+ // was added to the SDK and needs to be handled above.
274
+ const _exhaustive: never = event
275
+ console.warn("Unhandled opencode event type:", (event as any).type)
276
+ }
277
+ }
278
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,127 @@
1
+ // Extracted prompt logic: resolves audio parts via transcription, then forwards to OpenCode.
2
+
3
+ import type { OpencodeClient } from "./opencode"
4
+ import { mapMessage } from "./opencode"
5
+ import { transcribeAudio } from "./transcribe"
6
+ import type { Message, PromptPartInput } from "./types"
7
+ import type { StateStream } from "./state-stream"
8
+
9
+ /** Line reference from the diff viewer — the user selected these lines before sending. */
10
+ interface LineReference {
11
+ file: string
12
+ startLine: number
13
+ endLine: number
14
+ side?: "additions" | "deletions"
15
+ }
16
+
17
+ export async function sendPrompt(
18
+ client: OpencodeClient,
19
+ sessionId: string,
20
+ parts: PromptPartInput[],
21
+ directory?: string,
22
+ model?: { providerID: string; modelID: string },
23
+ agent?: string,
24
+ lineReference?: LineReference,
25
+ stateStream?: StateStream,
26
+ /** Client-generated message ID. When provided, the real OpenCode user message
27
+ * will be created with this ID, enabling seamless client-side dedup. */
28
+ clientMessageId?: string,
29
+ ): Promise<void> {
30
+ // Fetch conversation context for audio transcription
31
+ let conversationContext: Message[] | undefined
32
+ const hasAudio = parts.some((p) => p.type === "audio")
33
+ if (hasAudio && clientMessageId) {
34
+ // Notify client that the server has the audio and transcription is starting
35
+ stateStream?.emitPendingTranscription(clientMessageId, sessionId, "transcribing")
36
+
37
+ try {
38
+ const res = await client.session.messages({ sessionID: sessionId, directory })
39
+ if (!res.error && res.data) {
40
+ conversationContext = (res.data as any[]).map(mapMessage)
41
+ }
42
+ } catch {}
43
+ }
44
+
45
+ const partSummary = parts.map((p) => p.type === "audio" ? "audio" : `text(${p.text.length})`).join(", ")
46
+ console.log(`[prompt] session=${sessionId} received ${parts.length} part(s): ${partSummary}`)
47
+
48
+ // Resolve all parts to text, transcribing audio via Gemini (in parallel)
49
+ const textParts = await Promise.all(
50
+ parts.map(async (p) => {
51
+ if (p.type === "audio") {
52
+ try {
53
+ const transcription = await transcribeAudio(
54
+ p.audioData,
55
+ p.mimeType ?? "audio/mp4",
56
+ conversationContext,
57
+ )
58
+ let text = transcription || "[inaudible]"
59
+
60
+ // Prepend per-chunk line reference if the audio part carries one
61
+ if (p.lineReference) {
62
+ const lines = p.lineReference.startLine === p.lineReference.endLine
63
+ ? `${p.lineReference.startLine}`
64
+ : `${p.lineReference.startLine}-${p.lineReference.endLine}`
65
+ const sideAttr = p.lineReference.side ? ` side="${p.lineReference.side}"` : ""
66
+ text = `<reference lines="${lines}" file="${p.lineReference.file}"${sideAttr}>\n${text}\n</reference>`
67
+ }
68
+
69
+ return { type: "text" as const, text }
70
+ } catch (err) {
71
+ console.error(`[prompt] transcription error:`, err)
72
+ return { type: "text" as const, text: "[transcription error]" }
73
+ }
74
+ }
75
+ return { type: "text" as const, text: p.text }
76
+ }),
77
+ )
78
+
79
+ // Notify client that transcription is complete with the resolved text
80
+ if (hasAudio && clientMessageId) {
81
+ const resolvedText = textParts.map((p) => p.text).join("\n")
82
+ stateStream?.emitPendingTranscription(clientMessageId, sessionId, "completed", resolvedText)
83
+ }
84
+
85
+ // Prepend line reference context if the user selected lines in the diff viewer
86
+ if (lineReference && textParts.length > 0) {
87
+ const lines = lineReference.startLine === lineReference.endLine
88
+ ? `${lineReference.startLine}`
89
+ : `${lineReference.startLine}-${lineReference.endLine}`
90
+ const sideAttr = lineReference.side ? ` side="${lineReference.side}"` : ""
91
+ const prefix = `<reference lines="${lines}" file="${lineReference.file}"${sideAttr}>\n`
92
+ const suffix = `\n</reference>`
93
+ textParts[0] = { type: "text", text: prefix + textParts[0].text + suffix }
94
+ }
95
+
96
+ const resolvedText = textParts.map((p) => p.text.slice(0, 100)).join(" | ")
97
+ console.log(`[prompt] session=${sessionId} forwarding to opencode: ${textParts.length} part(s), text preview: "${resolvedText.slice(0, 200)}"`)
98
+
99
+ const res = await client.session.promptAsync({
100
+ sessionID: sessionId,
101
+ directory,
102
+ parts: textParts,
103
+ // Disable the question tool — our mobile client doesn't support answering
104
+ // questions yet, and unanswered questions cause the session to hang.
105
+ // See: https://github.com/ben-pr-p/flockcode/issues/2
106
+ tools: { question: false },
107
+ ...(model ? { model } : {}),
108
+ ...(agent ? { agent } : {}),
109
+ // Use the client-generated message ID so the real user message arrives with
110
+ // the same ID, enabling seamless client-side dedup with the pending placeholder.
111
+ ...(clientMessageId ? { messageID: clientMessageId } : {}),
112
+ })
113
+ if (res.error) {
114
+ console.error(`[prompt] session=${sessionId} opencode error:`, res.error)
115
+ throw new Error(`Prompt failed: ${JSON.stringify(res.error)}`)
116
+ }
117
+
118
+ // Notify client the prompt has been forwarded to OpenCode.
119
+ // The real user message will arrive via the instance stream with the same ID,
120
+ // and the client's allMessages merge deduplicates by ID (server wins) —
121
+ // no explicit delete needed.
122
+ if (hasAudio && clientMessageId) {
123
+ stateStream?.emitPendingTranscription(clientMessageId, sessionId, "forwarded", textParts.map((p) => p.text).join("\n"))
124
+ }
125
+
126
+ console.log(`[prompt] session=${sessionId} prompt accepted by opencode (async)`)
127
+ }
@@ -0,0 +1,57 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { base } from "./base"
3
+
4
+ export const agents = {
5
+ /** List available agents, pre-shaped for the client. */
6
+ list: base
7
+ .handler(async ({ context }) => {
8
+ try {
9
+ const res = await (context.client.app as any).agents()
10
+ if (res.error) {
11
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
12
+ message: "Failed to list agents",
13
+ })
14
+ }
15
+
16
+ const data = res.data as any
17
+
18
+ const agents: {
19
+ name: string
20
+ description?: string
21
+ mode: string
22
+ color?: string
23
+ }[] = []
24
+
25
+ if (data && typeof data === "object") {
26
+ if (Array.isArray(data)) {
27
+ for (const agent of data) {
28
+ agents.push({
29
+ name: agent.name ?? "",
30
+ description: agent.description,
31
+ mode: agent.mode ?? "primary",
32
+ color: agent.color,
33
+ })
34
+ }
35
+ } else {
36
+ for (const [key, value] of Object.entries(data)) {
37
+ const agent = value as any
38
+ agents.push({
39
+ name: agent.name ?? key,
40
+ description: agent.description,
41
+ mode: agent.mode ?? "primary",
42
+ color: agent.color,
43
+ })
44
+ }
45
+ }
46
+ }
47
+
48
+ return agents
49
+ } catch (err: any) {
50
+ if (err instanceof ORPCError) throw err
51
+ console.error("[agents.list]", err)
52
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
53
+ message: err.message ?? "Failed to list agents",
54
+ })
55
+ }
56
+ }),
57
+ }
@@ -0,0 +1,10 @@
1
+ import { os } from "@orpc/server"
2
+ import type { RouterContext } from "./context"
3
+
4
+ /**
5
+ * Base procedure builder with the shared RouterContext.
6
+ *
7
+ * All router procedures should be built from this base so they
8
+ * automatically receive the injected context (client, appDs, etc.).
9
+ */
10
+ export const base = os.$context<RouterContext>()
@@ -0,0 +1,57 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { base } from "./base"
3
+
4
+ export const commands = {
5
+ /** List available commands, pre-shaped for the client. */
6
+ list: base
7
+ .handler(async ({ context }) => {
8
+ try {
9
+ const res = await (context.client.command as any).list()
10
+ if (res.error) {
11
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
12
+ message: "Failed to list commands",
13
+ })
14
+ }
15
+
16
+ const data = res.data as any
17
+
18
+ const commands: {
19
+ name: string
20
+ description?: string
21
+ agent?: string
22
+ template: string
23
+ }[] = []
24
+
25
+ if (data && typeof data === "object") {
26
+ if (Array.isArray(data)) {
27
+ for (const cmd of data) {
28
+ commands.push({
29
+ name: cmd.name ?? "",
30
+ description: cmd.description,
31
+ agent: cmd.agent,
32
+ template: cmd.template ?? "",
33
+ })
34
+ }
35
+ } else {
36
+ for (const [key, value] of Object.entries(data)) {
37
+ const cmd = value as any
38
+ commands.push({
39
+ name: cmd.name ?? key,
40
+ description: cmd.description,
41
+ agent: cmd.agent,
42
+ template: cmd.template ?? "",
43
+ })
44
+ }
45
+ }
46
+ }
47
+
48
+ return commands
49
+ } catch (err: any) {
50
+ if (err instanceof ORPCError) throw err
51
+ console.error("[commands.list]", err)
52
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
53
+ message: err.message ?? "Failed to list commands",
54
+ })
55
+ }
56
+ }),
57
+ }
@@ -0,0 +1,22 @@
1
+ import type { DurableStreamServer } from "durable-streams-web-standard"
2
+ import type { OpencodeClient } from "../opencode"
3
+ import type { StateStream } from "../state-stream"
4
+
5
+ /**
6
+ * Shared context provided to every oRPC procedure.
7
+ *
8
+ * Injected at handler creation time in app.ts — procedures receive this
9
+ * via oRPC's context mechanism, making them independently testable.
10
+ */
11
+ export interface RouterContext {
12
+ /** OpenCode SDK client for talking to the opencode backend. */
13
+ client: OpencodeClient
14
+ /** Persistent app-level durable stream (survives server restarts). */
15
+ appDs: DurableStreamServer
16
+ /** In-memory ephemeral durable stream (resets on server restart). */
17
+ ephemeralDs: DurableStreamServer
18
+ /** In-memory index of session → worktree mappings. */
19
+ sessionWorktrees: Map<string, { worktreePath: string; projectWorktree: string }>
20
+ /** Real-time state stream for pushing updates to connected clients. */
21
+ stateStream: StateStream
22
+ }
@@ -0,0 +1,46 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { z } from "zod/v4"
3
+ import { base } from "./base"
4
+
5
+ export const diffs = {
6
+ /** Returns { file, before, after } for a single file in a session. */
7
+ get: base
8
+ .input(z.object({ session: z.string(), file: z.string() }))
9
+ .handler(async ({ input, context }) => {
10
+ const { session: sessionId, file } = input
11
+ const sessionRes = await context.client.session.get({ sessionID: sessionId })
12
+ const directory = sessionRes.data?.directory
13
+ const res = await context.client.session.diff({ sessionID: sessionId, directory })
14
+ if (res.error) {
15
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
16
+ message: "Failed to fetch diffs",
17
+ })
18
+ }
19
+ const match = (res.data ?? []).find((d: any) => d.file === file)
20
+ if (!match) {
21
+ throw new ORPCError("NOT_FOUND", {
22
+ message: `File not found: ${file}`,
23
+ })
24
+ }
25
+ return { file: match.file as string, before: match.before as string, after: match.after as string }
26
+ }),
27
+
28
+ /** Returns all file diffs for a session. */
29
+ list: base
30
+ .input(z.object({ session: z.string() }))
31
+ .handler(async ({ input, context }) => {
32
+ const sessionRes = await context.client.session.get({ sessionID: input.session })
33
+ const directory = sessionRes.data?.directory
34
+ const res = await context.client.session.diff({ sessionID: input.session, directory })
35
+ if (res.error) {
36
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
37
+ message: "Failed to fetch diffs",
38
+ })
39
+ }
40
+ return (res.data ?? []).map((d: any) => ({
41
+ file: d.file as string,
42
+ before: d.before as string,
43
+ after: d.after as string,
44
+ }))
45
+ }),
46
+ }
@@ -0,0 +1,24 @@
1
+ import { sessions } from "./sessions"
2
+ import { projects } from "./projects"
3
+ import { diffs } from "./diffs"
4
+ import { models } from "./models"
5
+ import { agents } from "./agents"
6
+ import { commands } from "./commands"
7
+ import { permissions } from "./permissions"
8
+ import { snapshot } from "./snapshot"
9
+
10
+ export type { RouterContext } from "./context"
11
+
12
+ /** The complete oRPC router — mounted at /api/* in the Hono app. */
13
+ export const router = {
14
+ sessions,
15
+ projects,
16
+ diffs,
17
+ models,
18
+ agents,
19
+ commands,
20
+ permissions,
21
+ snapshot,
22
+ }
23
+
24
+ export type Router = typeof router
@@ -0,0 +1,55 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { base } from "./base"
3
+
4
+ export const models = {
5
+ /** List available providers and models, pre-shaped for the client. */
6
+ list: base
7
+ .handler(async ({ context }) => {
8
+ try {
9
+ const res = await context.client.provider.list()
10
+ if (res.error) {
11
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
12
+ message: "Failed to list providers",
13
+ })
14
+ }
15
+
16
+ const data = res.data as any
17
+
18
+ // Extract connected provider IDs
19
+ const connectedSet = new Set<string>(data.connected ?? [])
20
+
21
+ // Flatten providers → models, filtering to connected providers only
22
+ const models: {
23
+ id: string
24
+ name: string
25
+ providerID: string
26
+ providerName: string
27
+ status?: string
28
+ }[] = []
29
+
30
+ for (const provider of data.all ?? []) {
31
+ if (!connectedSet.has(provider.id)) continue
32
+ for (const [modelId, model] of Object.entries(provider.models ?? {})) {
33
+ const m = model as any
34
+ models.push({
35
+ id: modelId,
36
+ name: m.name ?? modelId,
37
+ providerID: provider.id,
38
+ providerName: provider.name ?? provider.id,
39
+ status: m.status,
40
+ })
41
+ }
42
+ }
43
+
44
+ const defaults: Record<string, string> = data.default ?? {}
45
+
46
+ return { models, defaults }
47
+ } catch (err: any) {
48
+ if (err instanceof ORPCError) throw err
49
+ console.error("[models.list]", err)
50
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
51
+ message: err.message ?? "Failed to list models",
52
+ })
53
+ }
54
+ }),
55
+ }