@darthph0enix/commandcode-go-opencode-provider 0.5.0

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,345 @@
1
+ import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs"
2
+ import { dirname, join } from "path"
3
+ import { fileURLToPath } from "url"
4
+ import { resolveApiKey } from "./auth.js"
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+ const PACKAGE_ROOT = join(__dirname, "..")
8
+ const BUNDLED_MODELS = join(PACKAGE_ROOT, "models.json")
9
+ const CACHE_MODELS = join(PACKAGE_ROOT, "models.cache.json")
10
+
11
+ export interface ModelEntry {
12
+ id: string
13
+ name: string
14
+ tier: "premium" | "open-source"
15
+ reasoning: boolean
16
+ tool_call: boolean
17
+ attachment?: boolean
18
+ modalities?: {
19
+ input: string[]
20
+ output: string[]
21
+ }
22
+ reasoningEfforts?: string[]
23
+ variants?: Record<string, Record<string, unknown>>
24
+ cost: { input: number; output: number; cache_read?: number; cache_write?: number }
25
+ limit: { context: number; output: number }
26
+ }
27
+
28
+ interface ApiModel {
29
+ id: string
30
+ name?: string
31
+ context_length?: number
32
+ }
33
+
34
+ export interface SyncResult {
35
+ models: ModelEntry[]
36
+ source: string
37
+ }
38
+
39
+ export function loadCachedModels(file: string): ModelEntry[] | null {
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(file, "utf-8"))
42
+ if (Array.isArray(parsed) && parsed.length > 0) return parsed as ModelEntry[]
43
+ } catch {
44
+ // intentionally silent: unreadable or malformed cache is ignored
45
+ }
46
+ return null
47
+ }
48
+
49
+ function writeAtomic(file: string, data: string) {
50
+ const tmp = `${file}.${process.pid}.tmp`
51
+ writeFileSync(tmp, data)
52
+ renameSync(tmp, file)
53
+ }
54
+
55
+ // Locate the model catalog bundled with the installed Command Code CLI.
56
+ // The catalog is the vendor-maintained list for the same API endpoint this
57
+ // provider talks to, so it is the most accurate metadata source available.
58
+ function findModelsMd(): string | null {
59
+ const explicit = process.env.COMMANDCODE_MODELS_MD
60
+ if (explicit && existsSync(explicit)) return explicit
61
+
62
+ const dirs = (process.env.PATH ?? "").split(":").filter(Boolean)
63
+ for (const dir of dirs) {
64
+ for (const name of ["cmd", "command-code"]) {
65
+ const bin = join(dir, name)
66
+ if (!existsSync(bin)) continue
67
+ let target = bin
68
+ try {
69
+ target = realpathSync(bin)
70
+ } catch {
71
+ // keep unresolved path
72
+ }
73
+ const candidates = [
74
+ join(dirname(target), "bundled", "command-code-knowledge", "reference", "models.md"),
75
+ join(dirname(target), "..", "dist", "bundled", "command-code-knowledge", "reference", "models.md"),
76
+ join(dir, "..", "lib", "node_modules", "command-code", "dist", "bundled", "command-code-knowledge", "reference", "models.md"),
77
+ ]
78
+ for (const candidate of candidates) {
79
+ if (existsSync(candidate)) return candidate
80
+ }
81
+ }
82
+ }
83
+ return null
84
+ }
85
+
86
+ function findCliMjs(): string | null {
87
+ const dirs = (process.env.PATH ?? "").split(":").filter(Boolean)
88
+ for (const dir of dirs) {
89
+ for (const name of ["cmd", "command-code", "commandcode"]) {
90
+ const bin = join(dir, name)
91
+ if (!existsSync(bin)) continue
92
+ let target = bin
93
+ try {
94
+ target = realpathSync(bin)
95
+ } catch {}
96
+ const candidates = [
97
+ join(dirname(target), "..", "dist", "cli.mjs"),
98
+ join(dir, "..", "lib", "node_modules", "command-code", "dist", "cli.mjs"),
99
+ join(dirname(target), "cli.mjs"),
100
+ ]
101
+ for (const c of candidates) {
102
+ if (existsSync(c)) return c
103
+ }
104
+ }
105
+ }
106
+ return null
107
+ }
108
+
109
+ function extractCliMetadata(): { modalities: Map<string, string[]>; efforts: Map<string, string[]> } {
110
+ const modalities = new Map<string, string[]>()
111
+ const efforts = new Map<string, string[]>()
112
+ try {
113
+ const cliPath = findCliMjs()
114
+ if (cliPath && existsSync(cliPath)) {
115
+ const content = readFileSync(cliPath, "utf-8")
116
+ const m = content.match(/(\$L=\{[^;]+);/)
117
+ if (m) {
118
+ const text = m[1].replace(/^\$L=/, "")
119
+ const regex = /id:\s*"([^"]+)"/g
120
+ let match: RegExpExecArray | null
121
+ while ((match = regex.exec(text)) !== null) {
122
+ const id = match[1].toLowerCase()
123
+ const start = match.index
124
+ const slice = text.slice(start, start + 350)
125
+ const modMatch = slice.match(/inputModalities:\s*(\[[^\]]*\])/)
126
+ if (modMatch) {
127
+ try {
128
+ modalities.set(id, JSON.parse(modMatch[1].replace(/'/g, '"')))
129
+ } catch {}
130
+ }
131
+ const effMatch = slice.match(/reasoningEfforts:\s*(\[[^\]]*\])/)
132
+ if (effMatch) {
133
+ try {
134
+ efforts.set(id, JSON.parse(effMatch[1].replace(/'/g, '"')))
135
+ } catch {}
136
+ }
137
+ }
138
+ }
139
+ }
140
+ } catch {}
141
+ return { modalities, efforts }
142
+ }
143
+
144
+ function isVisionModelFallback(id: string): boolean {
145
+ const lower = id.toLowerCase()
146
+ return (
147
+ lower.includes("v4.1") ||
148
+ lower.includes("vision") ||
149
+ lower.includes("27b") ||
150
+ lower.includes("kimi-k2") ||
151
+ lower.includes("minimax-m3") ||
152
+ lower.includes("step-3.7") ||
153
+ lower.includes("inkling") ||
154
+ lower.includes("sonnet") ||
155
+ lower.includes("opus") ||
156
+ lower.includes("haiku") ||
157
+ lower.includes("fable") ||
158
+ lower.includes("gpt-5") ||
159
+ lower.includes("gpt-6") ||
160
+ lower.includes("gemini")
161
+ )
162
+ }
163
+
164
+ function parseContext(raw: string): number | null {
165
+ const match = /^([\d.]+)\s*([MK])$/i.exec(raw.trim())
166
+ if (!match) return null
167
+ const value = Number(match[1]) * (match[2].toUpperCase() === "M" ? 1_000_000 : 1_000)
168
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : null
169
+ }
170
+
171
+ function parseCost(raw: string): ModelEntry["cost"] | null {
172
+ const match = /^\$([\d.]+)\s*\/\s*\$([\d.]+)(?:\s*·\s*cache\s*\$([\d.]+))?(?:\s*\(write\s*\$([\d.]+)\))?/i.exec(
173
+ raw.trim(),
174
+ )
175
+ if (!match) return null
176
+ const cost: ModelEntry["cost"] = { input: Number(match[1]), output: Number(match[2]) }
177
+ if (match[3] !== undefined) cost.cache_read = Number(match[3])
178
+ if (match[4] !== undefined) cost.cache_write = Number(match[4])
179
+ return cost
180
+ }
181
+
182
+ export function parseModelsMd(text: string, hints: Map<string, ModelEntry>): ModelEntry[] {
183
+ const entries: ModelEntry[] = []
184
+ const { modalities: modalitiesMap, efforts: effortsMap } = extractCliMetadata()
185
+ let section = ""
186
+ for (const line of text.split(/\r?\n/)) {
187
+ const heading = /^##\s+(.+?)\s*$/.exec(line)
188
+ if (heading) {
189
+ section = heading[1]
190
+ continue
191
+ }
192
+ if (!line.startsWith("| `")) continue
193
+
194
+ const cells = line.split("|").slice(1, -1).map((cell) => cell.trim())
195
+ if (cells.length < 5) continue
196
+
197
+ const id = cells[0].replace(/`/g, "").trim()
198
+ if (!id) continue
199
+ const hint = hints.get(id.toLowerCase())
200
+ const context = parseContext(cells[2]) ?? (hint?.limit.context || 200000)
201
+ const cost = parseCost(cells[4]) ?? hint?.cost ?? { input: 0, output: 0 }
202
+ const minPlan = cells[5] ? cells[5].trim() : ""
203
+ const planBadge = minPlan ? ` [${minPlan.replace(" and above", "+")}]` : ""
204
+ const displayName = `${cells[1]}${planBadge}`
205
+
206
+ // Dynamically resolve reasoning efforts from vendor CLI catalog ($L in cli.mjs) or models.md
207
+ const rawEfforts = cells[3]?.trim()
208
+ let effortsList: string[] = []
209
+ if (rawEfforts && rawEfforts !== "—" && rawEfforts !== "-") {
210
+ effortsList = rawEfforts.split(",").map((s) => s.trim()).filter(Boolean)
211
+ }
212
+ const cliEfforts = effortsMap.get(id.toLowerCase())
213
+ if (cliEfforts && cliEfforts.length > 0) {
214
+ effortsList = cliEfforts
215
+ }
216
+
217
+ const variants: Record<string, Record<string, unknown>> = {}
218
+ for (const effort of effortsList) {
219
+ variants[effort] = {
220
+ reasoning_effort: effort,
221
+ reasoningEffort: effort,
222
+ effort: effort,
223
+ }
224
+ }
225
+
226
+ const cliMods = modalitiesMap.get(id.toLowerCase())
227
+ const hasVision = cliMods ? cliMods.includes("image") : isVisionModelFallback(id)
228
+ const inputMods = cliMods ?? (hasVision ? ["text", "image"] : ["text"])
229
+
230
+ entries.push({
231
+ id,
232
+ name: displayName,
233
+ tier: section.toLowerCase() === "open source" ? "open-source" : "premium",
234
+ reasoning: effortsList.length > 0,
235
+ reasoningEfforts: effortsList.length > 0 ? effortsList : undefined,
236
+ variants: Object.keys(variants).length > 0 ? variants : undefined,
237
+ tool_call: true,
238
+ attachment: inputMods.some((m) => m !== "text"),
239
+ modalities: {
240
+ input: inputMods,
241
+ output: ["text"],
242
+ },
243
+ cost,
244
+ limit: { context, output: hint?.limit.output ?? 65536 },
245
+ })
246
+ }
247
+ return entries
248
+ }
249
+
250
+ async function fetchApiModels(): Promise<ApiModel[] | null> {
251
+ const apiKey = resolveApiKey({})
252
+ const controller = new AbortController()
253
+ const timer = setTimeout(() => controller.abort(), 4000)
254
+ try {
255
+ const response = await fetch("https://api.commandcode.ai/provider/v1/models", {
256
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
257
+ signal: controller.signal,
258
+ })
259
+ if (!response.ok) return null
260
+ const payload = (await response.json()) as { data?: ApiModel[] }
261
+ return Array.isArray(payload?.data) ? payload.data : null
262
+ } catch {
263
+ return null
264
+ } finally {
265
+ clearTimeout(timer)
266
+ }
267
+ }
268
+
269
+ function mergeApiModels(entries: ModelEntry[], api: ApiModel[]): ModelEntry[] {
270
+ const seen = new Map(entries.map((entry) => [entry.id.toLowerCase(), entry]))
271
+ for (const model of api) {
272
+ if (!model?.id) continue
273
+ const key = model.id.toLowerCase()
274
+ const existing = seen.get(key)
275
+ if (existing) {
276
+ if (model.context_length && (!existing.limit.context || existing.limit.context <= 0)) {
277
+ existing.limit.context = model.context_length
278
+ }
279
+ continue
280
+ }
281
+ const entry: ModelEntry = {
282
+ id: model.id,
283
+ name: model.name?.trim() || model.id.split("/").pop() || model.id,
284
+ tier: "open-source",
285
+ reasoning: true,
286
+ tool_call: true,
287
+ cost: { input: 0, output: 0 },
288
+ limit: { context: model.context_length && model.context_length > 0 ? model.context_length : 200000, output: 65536 },
289
+ }
290
+ entries.push(entry)
291
+ seen.set(key, entry)
292
+ }
293
+ return entries
294
+ }
295
+
296
+ // Build the model list fresh on every opencode instance start:
297
+ // 1. CLI-bundled catalog (vendor-maintained, refreshed by `cmd update`)
298
+ // 2. Command Code's public model endpoint (picks up brand-new models)
299
+ // 3. last synced cache, then the bundled snapshot as an offline fallback.
300
+ export async function loadSyncedModels(): Promise<SyncResult> {
301
+ const bundled = loadCachedModels(BUNDLED_MODELS)
302
+ const cached = loadCachedModels(CACHE_MODELS)
303
+ const hints = new Map<string, ModelEntry>()
304
+ for (const entry of [...(cached ?? []), ...(bundled ?? [])]) hints.set(entry.id.toLowerCase(), entry)
305
+
306
+ let docModels: ModelEntry[] | null = null
307
+ try {
308
+ const modelsMd = findModelsMd()
309
+ if (modelsMd) {
310
+ const parsed = parseModelsMd(readFileSync(modelsMd, "utf-8"), hints)
311
+ if (parsed.length >= 5) docModels = parsed
312
+ }
313
+ } catch {
314
+ // intentionally silent: fall through to the API or cache
315
+ }
316
+
317
+ const apiModels = await fetchApiModels()
318
+
319
+ let models: ModelEntry[] | null = null
320
+ let source = ""
321
+ if (docModels?.length) {
322
+ models = mergeApiModels(docModels, apiModels ?? [])
323
+ source = apiModels?.length ? "cli-catalog+api" : "cli-catalog"
324
+ } else if (apiModels?.length) {
325
+ models = mergeApiModels([], apiModels)
326
+ source = "api"
327
+ }
328
+
329
+ if (!models?.length) {
330
+ models = cached ?? bundled ?? []
331
+ source = cached ? "cache" : "bundled"
332
+ }
333
+
334
+ if (!models.length) throw new Error("Command Code model catalog unavailable (no CLI, API, cache, or bundled list)")
335
+
336
+ if (source !== "cache" && source !== "bundled") {
337
+ try {
338
+ writeAtomic(CACHE_MODELS, JSON.stringify(models, null, 2) + "\n")
339
+ } catch {
340
+ // intentionally silent: cache is best-effort
341
+ }
342
+ }
343
+
344
+ return { models, source }
345
+ }
package/src/model.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type {
2
+ LanguageModelV3,
3
+ LanguageModelV3CallOptions,
4
+ LanguageModelV3StreamResult,
5
+ LanguageModelV3GenerateResult,
6
+ LanguageModelV3Content,
7
+ LanguageModelV3Usage,
8
+ LanguageModelV3FinishReason,
9
+ } from "@ai-sdk/provider"
10
+ import { buildRequest } from "./convert.js"
11
+ import { parseStreamEvents } from "./stream.js"
12
+
13
+ const DEFAULT_BASE_URL = "https://api.commandcode.ai"
14
+ // x-command-code-version must match the Command Code CLI version for API compatibility
15
+ const CC_VERSION = "1.54.0"
16
+
17
+ export interface CommandCodeModelOptions {
18
+ apiKey: string
19
+ baseURL?: string
20
+ headers?: Record<string, string>
21
+ }
22
+
23
+ export class CommandCodeLanguageModel implements LanguageModelV3 {
24
+ readonly specificationVersion = "v3" as const
25
+ readonly provider = "commandcode"
26
+ readonly modelId: string
27
+ supportedUrls: Record<string, RegExp[]> = {}
28
+
29
+ private opts: CommandCodeModelOptions
30
+
31
+ constructor(modelId: string, opts: CommandCodeModelOptions) {
32
+ this.modelId = modelId
33
+ this.opts = opts
34
+ }
35
+
36
+ private get baseURL(): string {
37
+ return this.opts.baseURL ?? DEFAULT_BASE_URL
38
+ }
39
+
40
+ private buildHeaders(): Record<string, string> {
41
+ return {
42
+ "Content-Type": "application/json",
43
+ Authorization: `Bearer ${this.opts.apiKey}`,
44
+ "x-command-code-version": CC_VERSION,
45
+ "x-cli-environment": "production",
46
+ "x-project-slug": "opencode",
47
+ ...this.opts.headers,
48
+ }
49
+ }
50
+
51
+ async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
52
+ const body = buildRequest(this.modelId, options)
53
+ const requestBody = JSON.stringify(body)
54
+
55
+ const controller = new AbortController()
56
+ const timeout = setTimeout(() => controller.abort(new Error("Request timed out after 5 minutes")), 300_000)
57
+ const userSignal = options.abortSignal
58
+ if (userSignal) {
59
+ const onAbort = () => controller.abort(userSignal.reason)
60
+ userSignal.addEventListener("abort", onAbort, { once: true })
61
+ }
62
+
63
+ try {
64
+ const response = await fetch(`${this.baseURL}/alpha/generate`, {
65
+ method: "POST",
66
+ headers: this.buildHeaders(),
67
+ body: requestBody,
68
+ signal: controller.signal,
69
+ })
70
+
71
+ if (!response.ok) {
72
+ const errorBody = await response.text().catch(() => "")
73
+ let errorMessage = `Command Code API error: ${response.status} ${response.statusText}`
74
+ try {
75
+ const parsed = JSON.parse(errorBody)
76
+ if (parsed.error?.message) errorMessage = parsed.error.message
77
+ else if (parsed.message) errorMessage = parsed.message
78
+ } catch {
79
+ // intentionally silent: error body is not JSON
80
+ } throw new Error(`${errorMessage} [model=${this.modelId}]`)
81
+ }
82
+
83
+ if (!response.body) {
84
+ throw new Error(`Command Code API returned no body [model=${this.modelId}]`)
85
+ }
86
+
87
+ const responseHeaders: Record<string, string> = {}
88
+ response.headers.forEach((v, k) => {
89
+ responseHeaders[k] = v
90
+ })
91
+
92
+ return {
93
+ stream: parseStreamEvents(response.body as ReadableStream<Uint8Array>),
94
+ request: { body: requestBody },
95
+ response: { headers: responseHeaders },
96
+ }
97
+ } finally {
98
+ clearTimeout(timeout)
99
+ }
100
+ }
101
+
102
+ async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
103
+ const { stream } = await this.doStream(options)
104
+
105
+ const textParts: string[] = []
106
+ const reasoningParts: string[] = []
107
+ const content: LanguageModelV3Content[] = []
108
+ let finishReason: LanguageModelV3FinishReason = { unified: "stop", raw: "stop" }
109
+ let usage: LanguageModelV3Usage = {
110
+ inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
111
+ outputTokens: { total: undefined, text: undefined, reasoning: undefined },
112
+ }
113
+
114
+ const reader = stream.getReader()
115
+ try {
116
+ while (true) {
117
+ const { done, value } = await reader.read()
118
+ if (done) break
119
+
120
+ switch (value.type) {
121
+ case "text-delta":
122
+ textParts.push(value.delta)
123
+ break
124
+ case "reasoning-delta":
125
+ reasoningParts.push(value.delta)
126
+ break
127
+ case "tool-call":
128
+ content.push({
129
+ type: "tool-call",
130
+ toolCallId: value.toolCallId,
131
+ toolName: value.toolName,
132
+ input: value.input,
133
+ })
134
+ break
135
+ case "finish":
136
+ finishReason = value.finishReason
137
+ usage = value.usage
138
+ break
139
+ }
140
+ }
141
+ } finally {
142
+ reader.releaseLock()
143
+ stream.cancel()
144
+ }
145
+
146
+ const text = textParts.join("")
147
+ if (text) content.unshift({ type: "text", text })
148
+
149
+ const reasoning = reasoningParts.join("")
150
+ if (reasoning) content.unshift({ type: "reasoning", text: reasoning })
151
+
152
+ return {
153
+ content,
154
+ finishReason,
155
+ usage,
156
+ warnings: [],
157
+ }
158
+ }
159
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,188 @@
1
+ import type { LanguageModelV3StreamPart, LanguageModelV3Usage, LanguageModelV3FinishReason } from "@ai-sdk/provider"
2
+
3
+ type RawEvent = Record<string, unknown> & { type: string }
4
+
5
+ function mapFinishReason(raw: string): LanguageModelV3FinishReason["unified"] {
6
+ switch (raw) {
7
+ case "stop":
8
+ case "end_turn":
9
+ return "stop"
10
+ case "tool_calls":
11
+ case "tool-calls":
12
+ return "tool-calls"
13
+ case "length":
14
+ case "max_tokens":
15
+ case "max-tokens":
16
+ case "max_output_tokens":
17
+ return "length"
18
+ case "content_filter":
19
+ return "content-filter"
20
+ default:
21
+ return "other"
22
+ }
23
+ }
24
+
25
+ function mapUsage(raw: Record<string, unknown>): LanguageModelV3Usage {
26
+ const inputDetails = (raw.inputTokenDetails ?? raw.input_token_details ?? {}) as Record<string, unknown>
27
+ const outputDetails = (raw.outputTokenDetails ?? raw.output_token_details ?? {}) as Record<string, unknown>
28
+ return {
29
+ inputTokens: {
30
+ total: typeof raw.inputTokens === "number" ? raw.inputTokens : typeof raw.prompt_tokens === "number" ? raw.prompt_tokens : undefined,
31
+ noCache: typeof inputDetails.noCacheTokens === "number" ? inputDetails.noCacheTokens : undefined,
32
+ cacheRead: typeof inputDetails.cacheReadTokens === "number" ? inputDetails.cacheReadTokens : undefined,
33
+ cacheWrite: typeof inputDetails.cacheWriteTokens === "number" ? inputDetails.cacheWriteTokens : undefined,
34
+ },
35
+ outputTokens: {
36
+ total: typeof raw.outputTokens === "number" ? raw.outputTokens : typeof raw.completion_tokens === "number" ? raw.completion_tokens : undefined,
37
+ text: typeof outputDetails.textTokens === "number" ? outputDetails.textTokens : undefined,
38
+ reasoning: typeof outputDetails.reasoningTokens === "number" ? outputDetails.reasoningTokens : undefined,
39
+ },
40
+ }
41
+ }
42
+
43
+ function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null {
44
+ switch (event.type) {
45
+ case "start":
46
+ return { type: "stream-start", warnings: [] }
47
+
48
+ case "text-start":
49
+ return { type: "text-start", id: event.id as string }
50
+ case "text-delta":
51
+ return { type: "text-delta", id: event.id as string, delta: (event.text ?? event.delta ?? "") as string }
52
+ case "text-end":
53
+ return { type: "text-end", id: event.id as string }
54
+
55
+ case "reasoning-start":
56
+ return { type: "reasoning-start", id: event.id as string }
57
+ case "reasoning-delta":
58
+ return { type: "reasoning-delta", id: event.id as string, delta: (event.text ?? event.delta ?? "") as string }
59
+ case "reasoning-end":
60
+ return { type: "reasoning-end", id: event.id as string }
61
+
62
+ case "tool-input-start":
63
+ return {
64
+ type: "tool-input-start",
65
+ id: event.id as string,
66
+ toolName: event.toolName as string,
67
+ dynamic: event.dynamic as boolean | undefined,
68
+ }
69
+ case "tool-input-delta":
70
+ return { type: "tool-input-delta", id: event.id as string, delta: (event.delta ?? "") as string }
71
+ case "tool-input-end":
72
+ return { type: "tool-input-end", id: event.id as string }
73
+
74
+ case "tool-call": {
75
+ const input = event.input ?? event.args ?? event.arguments
76
+ return {
77
+ type: "tool-call",
78
+ toolCallId: (event.toolCallId ?? event.id ?? "") as string,
79
+ toolName: event.toolName as string,
80
+ input: typeof input === "string" ? input : JSON.stringify(input ?? {}),
81
+ }
82
+ }
83
+
84
+ case "finish-step": {
85
+ const usage = event.usage ?? event.totalUsage ?? {}
86
+ const rawReason = (event.finishReason ?? event.rawFinishReason ?? "stop") as string
87
+ return {
88
+ type: "finish",
89
+ finishReason: { unified: mapFinishReason(rawReason), raw: rawReason },
90
+ usage: mapUsage(typeof usage === "object" && usage !== null ? (usage as Record<string, unknown>) : {}),
91
+ }
92
+ }
93
+
94
+ case "finish":
95
+ return null
96
+
97
+ case "response-metadata":
98
+ return {
99
+ type: "response-metadata",
100
+ id: event.id as string | undefined,
101
+ modelId: event.modelId as string | undefined,
102
+ }
103
+
104
+ case "error":
105
+ return { type: "error", error: event.error ?? event.message ?? "Unknown error" }
106
+
107
+ default:
108
+ return null
109
+ }
110
+ }
111
+
112
+ // Assumes line-delimited JSON over SSE: one JSON object per `data:` line.
113
+ // Multi-line `data:` fields are not supported — the buffer splits on `\n`.
114
+ export function parseStreamEvents(body: ReadableStream<Uint8Array>): ReadableStream<LanguageModelV3StreamPart> {
115
+ const reader = body.getReader()
116
+ const decoder = new TextDecoder()
117
+ let buffer = ""
118
+
119
+ return new ReadableStream<LanguageModelV3StreamPart>({
120
+ async pull(controller) {
121
+ try {
122
+ while (true) {
123
+ const lines = buffer.split("\n")
124
+ // Strip trailing \r from Windows-style \r\n line endings
125
+ for (let i = 0; i < lines.length; i++) {
126
+ const line = lines[i]
127
+ if (line !== undefined && line.endsWith("\r")) lines[i] = line.slice(0, -1)
128
+ }
129
+ buffer = lines.pop() ?? ""
130
+
131
+ for (const line of lines) {
132
+ const trimmed = line.trim()
133
+ if (!trimmed || trimmed.startsWith(":") || trimmed === "[DONE]") continue
134
+
135
+ let jsonStr = trimmed
136
+ if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6)
137
+ if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5)
138
+ if (!jsonStr || jsonStr === "[DONE]") continue
139
+
140
+ let parsed: RawEvent
141
+ try {
142
+ parsed = JSON.parse(jsonStr)
143
+ } catch {
144
+ // intentionally silent: skip malformed SSE lines
145
+ continue
146
+ }
147
+
148
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") continue
149
+
150
+ const part = toStreamPart(parsed)
151
+ if (part) controller.enqueue(part)
152
+ }
153
+
154
+ const { done, value } = await reader.read()
155
+ if (done) {
156
+ if (buffer.trim()) {
157
+ const trimmed = buffer.trim()
158
+ if (trimmed && trimmed !== "[DONE]" && !trimmed.startsWith(":")) {
159
+ let jsonStr = trimmed
160
+ if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6)
161
+ if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5)
162
+ try {
163
+ const parsed = JSON.parse(jsonStr)
164
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.type === "string") {
165
+ const part = toStreamPart(parsed)
166
+ if (part) controller.enqueue(part)
167
+ }
168
+ } catch {
169
+ // intentionally silent: skip malformed final buffer
170
+ }
171
+ }
172
+ }
173
+ controller.close()
174
+ return
175
+ }
176
+
177
+ buffer += decoder.decode(value, { stream: true })
178
+ }
179
+ } catch (err) {
180
+ controller.enqueue({ type: "error", error: err })
181
+ controller.close()
182
+ }
183
+ },
184
+ cancel() {
185
+ reader.cancel()
186
+ },
187
+ })
188
+ }