@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/index.ts +28 -0
- package/models.json +2954 -0
- package/package.json +61 -0
- package/plugin.ts +75 -0
- package/src/auth.ts +42 -0
- package/src/convert.ts +308 -0
- package/src/model-sync.ts +345 -0
- package/src/model.ts +159 -0
- package/src/stream.ts +188 -0
|
@@ -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
|
+
}
|