@sixb/cli-core 0.1.8 → 0.1.9
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/dist/api-client.d.ts +2 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/arguments.d.ts +4 -2
- package/dist/arguments.d.ts.map +1 -1
- package/dist/commands/index.d.ts +1 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/metadata.d.ts +5 -2
- package/dist/commands/metadata.d.ts.map +1 -1
- package/dist/commands/models.d.ts +79 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/objects.d.ts.map +1 -1
- package/dist/commands/shared.d.ts +2 -0
- package/dist/commands/shared.d.ts.map +1 -1
- package/dist/commands/users.d.ts +3 -0
- package/dist/commands/users.d.ts.map +1 -0
- package/dist/commands/workflows.d.ts.map +1 -1
- package/dist/index.js +529 -23
- package/dist/index.js.map +15 -13
- package/dist/output.d.ts +12 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/policies.d.ts +5 -0
- package/dist/policies.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api-client.ts +13 -2
- package/src/arguments.ts +17 -6
- package/src/commands/actions.ts +2 -2
- package/src/commands/index.ts +8 -0
- package/src/commands/metadata.ts +76 -5
- package/src/commands/models.ts +508 -0
- package/src/commands/objects.ts +47 -2
- package/src/commands/shared.ts +11 -7
- package/src/commands/users.ts +67 -0
- package/src/commands/workflows.ts +10 -4
- package/src/output.ts +21 -0
- package/src/policies.ts +2 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { access, mkdir, stat } from "node:fs/promises"
|
|
2
|
+
import { dirname, extname, join, parse } from "node:path"
|
|
3
|
+
import type { ApiClient } from "../api-client"
|
|
4
|
+
import { enumValue, integerInRange, isHelp, parseCommandArgs, requestsHelp } from "../arguments"
|
|
5
|
+
import { fail, writeJson, writeText } from "../output"
|
|
6
|
+
import { GROUP_HELP } from "./metadata"
|
|
7
|
+
import { readText } from "./shared"
|
|
8
|
+
|
|
9
|
+
export const MODEL_KINDS = ["language", "image", "speech", "transcription"] as const
|
|
10
|
+
export type ModelKind = (typeof MODEL_KINDS)[number]
|
|
11
|
+
|
|
12
|
+
type TokenPrice =
|
|
13
|
+
| string
|
|
14
|
+
| { readonly default: string; readonly tiers: readonly { readonly price: string }[] }
|
|
15
|
+
type VariantPrice =
|
|
16
|
+
| string
|
|
17
|
+
| {
|
|
18
|
+
readonly default?: string
|
|
19
|
+
readonly variants: readonly (Readonly<Record<string, string>> & { readonly price: string })[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The prices a binding bills by, as GET /api/models returns them. */
|
|
23
|
+
export interface RateCard {
|
|
24
|
+
readonly input?: TokenPrice
|
|
25
|
+
readonly output?: TokenPrice
|
|
26
|
+
readonly cacheReadInput?: TokenPrice
|
|
27
|
+
readonly audioOutput?: TokenPrice
|
|
28
|
+
readonly images?: VariantPrice
|
|
29
|
+
readonly characters?: VariantPrice
|
|
30
|
+
readonly audioInputSeconds?: VariantPrice
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CatalogEntry {
|
|
34
|
+
readonly provider: string
|
|
35
|
+
readonly modelId: string
|
|
36
|
+
readonly isDefault: boolean
|
|
37
|
+
readonly tier?: string
|
|
38
|
+
readonly name?: string
|
|
39
|
+
readonly description?: string
|
|
40
|
+
readonly rateCard?: RateCard
|
|
41
|
+
readonly [option: string]: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Catalog {
|
|
45
|
+
readonly language: readonly CatalogEntry[]
|
|
46
|
+
readonly image: readonly CatalogEntry[]
|
|
47
|
+
readonly audio: {
|
|
48
|
+
readonly speech: readonly CatalogEntry[]
|
|
49
|
+
readonly transcription: readonly CatalogEntry[]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function models(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
54
|
+
const [sub, ...rest] = args
|
|
55
|
+
if (!sub || isHelp(sub) || requestsHelp(rest)) return writeText(GROUP_HELP.models)
|
|
56
|
+
if (sub === "list") {
|
|
57
|
+
const { options } = parseCommandArgs(rest, { "--kind": "string" }, "models list", 0)
|
|
58
|
+
const kind = options["--kind"] === undefined ? undefined : modelKind(options["--kind"])
|
|
59
|
+
const entries = catalogEntries((await api.get("/api/models")) as Catalog)
|
|
60
|
+
return writeJson(
|
|
61
|
+
entries
|
|
62
|
+
.filter((entry) => kind === undefined || entry.kind === kind)
|
|
63
|
+
.map(({ kind, entry }) => ({
|
|
64
|
+
kind,
|
|
65
|
+
model: entry.modelId,
|
|
66
|
+
provider: entry.provider,
|
|
67
|
+
...(entry.tier ? { tier: entry.tier } : {}),
|
|
68
|
+
...(entry.isDefault ? { default: true } : {}),
|
|
69
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
70
|
+
price: describePrice(entry.rateCard),
|
|
71
|
+
}))
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
if (sub === "get") {
|
|
75
|
+
const { positionals, options } = parseCommandArgs(rest, { "--kind": "string" }, "models get", 1)
|
|
76
|
+
const kind = options["--kind"] === undefined ? undefined : modelKind(options["--kind"])
|
|
77
|
+
const entries = catalogEntries((await api.get("/api/models")) as Catalog)
|
|
78
|
+
const { kind: found, entry } = findModel(entries, positionals[0] ?? "", kind)
|
|
79
|
+
const { provider, modelId, isDefault, rateCard, ...details } = entry
|
|
80
|
+
return writeJson({
|
|
81
|
+
kind: found,
|
|
82
|
+
model: modelId,
|
|
83
|
+
provider,
|
|
84
|
+
default: isDefault,
|
|
85
|
+
...details,
|
|
86
|
+
price: describePrice(rateCard),
|
|
87
|
+
...(rateCard ? { rateCard } : {}),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
if (sub === "text") return generateText(api, rest)
|
|
91
|
+
if (sub === "image") return generateImages(api, rest)
|
|
92
|
+
if (sub === "speech") return generateSpeech(api, rest)
|
|
93
|
+
if (sub === "transcribe") return transcribe(api, rest)
|
|
94
|
+
if (sub === "calls") return calls(api, rest)
|
|
95
|
+
fail(`Unknown models command '${sub}'.`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface FileRef {
|
|
99
|
+
readonly blobId: string
|
|
100
|
+
readonly mediaType?: string
|
|
101
|
+
readonly sizeBytes: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface CallResult {
|
|
105
|
+
readonly callId: string
|
|
106
|
+
readonly model: { readonly provider: string; readonly modelId: string }
|
|
107
|
+
readonly cost: { readonly status: string; readonly money?: { readonly amountNanos: string } }
|
|
108
|
+
readonly text?: string
|
|
109
|
+
readonly finishReason?: string
|
|
110
|
+
readonly files?: readonly FileRef[]
|
|
111
|
+
readonly warnings?: readonly string[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const SPEECH_FORMATS = {
|
|
115
|
+
mp3: "audio/mpeg",
|
|
116
|
+
wav: "audio/wav",
|
|
117
|
+
ogg: "audio/ogg",
|
|
118
|
+
aac: "audio/aac",
|
|
119
|
+
flac: "audio/flac",
|
|
120
|
+
} as const
|
|
121
|
+
const SPEECH_FORMAT_NAMES = Object.keys(SPEECH_FORMATS) as (keyof typeof SPEECH_FORMATS)[]
|
|
122
|
+
|
|
123
|
+
// Files the commands upload, and the extension a downloaded file takes from its media type.
|
|
124
|
+
const MEDIA_TYPES: Readonly<Record<string, string>> = {
|
|
125
|
+
".png": "image/png",
|
|
126
|
+
".jpg": "image/jpeg",
|
|
127
|
+
".jpeg": "image/jpeg",
|
|
128
|
+
".webp": "image/webp",
|
|
129
|
+
".gif": "image/gif",
|
|
130
|
+
".mp3": "audio/mpeg",
|
|
131
|
+
".wav": "audio/wav",
|
|
132
|
+
".ogg": "audio/ogg",
|
|
133
|
+
".aac": "audio/aac",
|
|
134
|
+
".flac": "audio/flac",
|
|
135
|
+
".opus": "audio/opus",
|
|
136
|
+
".m4a": "audio/mp4",
|
|
137
|
+
".webm": "audio/webm",
|
|
138
|
+
}
|
|
139
|
+
const EXTENSIONS: Readonly<Record<string, string>> = Object.fromEntries(
|
|
140
|
+
Object.entries(MEDIA_TYPES)
|
|
141
|
+
.filter(([extension]) => extension !== ".jpeg")
|
|
142
|
+
.map(([extension, mediaType]) => [mediaType, extension])
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async function generateText(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
146
|
+
const { positionals, options } = parseCommandArgs(
|
|
147
|
+
args,
|
|
148
|
+
{
|
|
149
|
+
"--model": "string",
|
|
150
|
+
"--instructions": "string",
|
|
151
|
+
"--max-output-tokens": "string",
|
|
152
|
+
"--reasoning": "string",
|
|
153
|
+
"--file": "string",
|
|
154
|
+
},
|
|
155
|
+
"models text",
|
|
156
|
+
[0, 1]
|
|
157
|
+
)
|
|
158
|
+
const prompt = await readPrompt(positionals[0], options["--file"])
|
|
159
|
+
const maxOutputTokens = options["--max-output-tokens"]
|
|
160
|
+
const result = (await api.post("/api/models/language/generations", {
|
|
161
|
+
...(await languageModelRef(api, options["--model"])),
|
|
162
|
+
prompt,
|
|
163
|
+
...(options["--instructions"] === undefined ? {} : { instructions: options["--instructions"] }),
|
|
164
|
+
...(maxOutputTokens === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { maxOutputTokens: integerInRange("--max-output-tokens", maxOutputTokens, 1, 1_000_000) }),
|
|
167
|
+
...(options["--reasoning"] === undefined ? {} : { reasoning: options["--reasoning"] }),
|
|
168
|
+
})) as CallResult
|
|
169
|
+
writeJson({
|
|
170
|
+
callId: result.callId,
|
|
171
|
+
model: result.model.modelId,
|
|
172
|
+
text: result.text,
|
|
173
|
+
finishReason: result.finishReason,
|
|
174
|
+
cost: describeCost(result.cost),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function generateImages(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
179
|
+
const { positionals, options } = parseCommandArgs(
|
|
180
|
+
args,
|
|
181
|
+
{
|
|
182
|
+
"--output": "string",
|
|
183
|
+
"--model": "string",
|
|
184
|
+
"--n": "string",
|
|
185
|
+
"--size": "string",
|
|
186
|
+
"--aspect-ratio": "string",
|
|
187
|
+
"--quality": "string",
|
|
188
|
+
"--style": "string",
|
|
189
|
+
"--seed": "string",
|
|
190
|
+
"--image": "strings",
|
|
191
|
+
},
|
|
192
|
+
"models image",
|
|
193
|
+
1
|
|
194
|
+
)
|
|
195
|
+
const output = requireOutput(options["--output"], "models image")
|
|
196
|
+
const seed = options["--seed"]
|
|
197
|
+
if (seed !== undefined && !/^-?[0-9]+$/.test(seed)) fail("--seed must be an integer.")
|
|
198
|
+
const model = await modelRef(api, options["--model"], "image")
|
|
199
|
+
const images = []
|
|
200
|
+
for (const path of options["--image"] ?? []) images.push(await uploadInput(api, path, "image"))
|
|
201
|
+
const result = (await api.post("/api/models/image/generations", {
|
|
202
|
+
...model,
|
|
203
|
+
prompt: positionals[0],
|
|
204
|
+
...(options["--n"] === undefined ? {} : { n: integerInRange("--n", options["--n"], 1, 100) }),
|
|
205
|
+
...optional("size", options["--size"]),
|
|
206
|
+
...optional("aspectRatio", options["--aspect-ratio"]),
|
|
207
|
+
...optional("quality", options["--quality"]),
|
|
208
|
+
...optional("style", options["--style"]),
|
|
209
|
+
...(seed === undefined ? {} : { seed: Number(seed) }),
|
|
210
|
+
...(images.length > 0 ? { images } : {}),
|
|
211
|
+
})) as CallResult
|
|
212
|
+
writeJson(await savedResult(api, result, output))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function generateSpeech(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
216
|
+
const { positionals, options } = parseCommandArgs(
|
|
217
|
+
args,
|
|
218
|
+
{
|
|
219
|
+
"--output": "string",
|
|
220
|
+
"--model": "string",
|
|
221
|
+
"--voice": "string",
|
|
222
|
+
"--format": "string",
|
|
223
|
+
"--instructions": "string",
|
|
224
|
+
"--speed": "string",
|
|
225
|
+
"--language": "string",
|
|
226
|
+
},
|
|
227
|
+
"models speech",
|
|
228
|
+
1
|
|
229
|
+
)
|
|
230
|
+
const output = requireOutput(options["--output"], "models speech")
|
|
231
|
+
const format = options["--format"]
|
|
232
|
+
const speed = options["--speed"]
|
|
233
|
+
if (speed !== undefined && !(Number(speed) > 0)) fail("--speed must be a positive number.")
|
|
234
|
+
const result = (await api.post("/api/models/audio/speech", {
|
|
235
|
+
...(await modelRef(api, options["--model"], "speech")),
|
|
236
|
+
text: positionals[0],
|
|
237
|
+
...optional("voice", options["--voice"]),
|
|
238
|
+
...(format === undefined
|
|
239
|
+
? {}
|
|
240
|
+
: { mediaType: SPEECH_FORMATS[enumValue("--format", format, SPEECH_FORMAT_NAMES)] }),
|
|
241
|
+
...optional("instructions", options["--instructions"]),
|
|
242
|
+
...(speed === undefined ? {} : { speed: Number(speed) }),
|
|
243
|
+
...optional("language", options["--language"]),
|
|
244
|
+
})) as CallResult
|
|
245
|
+
writeJson(await savedResult(api, result, output))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function transcribe(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
249
|
+
const { positionals, options } = parseCommandArgs(
|
|
250
|
+
args,
|
|
251
|
+
{ "--model": "string" },
|
|
252
|
+
"models transcribe",
|
|
253
|
+
1
|
|
254
|
+
)
|
|
255
|
+
const model = await modelRef(api, options["--model"], "transcription")
|
|
256
|
+
const audio = await uploadInput(api, positionals[0] ?? "", "audio")
|
|
257
|
+
const result = (await api.post("/api/models/audio/transcriptions", {
|
|
258
|
+
...model,
|
|
259
|
+
audio,
|
|
260
|
+
})) as CallResult
|
|
261
|
+
writeJson({
|
|
262
|
+
callId: result.callId,
|
|
263
|
+
model: result.model.modelId,
|
|
264
|
+
text: result.text,
|
|
265
|
+
cost: describeCost(result.cost),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function calls(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
270
|
+
const [sub, ...rest] = args
|
|
271
|
+
if (sub !== "files")
|
|
272
|
+
fail(`Unknown models calls command '${sub ?? ""}'. Use 'models calls files'.`)
|
|
273
|
+
const { positionals, options } = parseCommandArgs(
|
|
274
|
+
rest,
|
|
275
|
+
{ "--output": "string" },
|
|
276
|
+
"models calls files",
|
|
277
|
+
1
|
|
278
|
+
)
|
|
279
|
+
const callId = positionals[0] ?? ""
|
|
280
|
+
const call = (await api.get(`/api/ai/model-calls/${encodeURIComponent(callId)}/files`)) as {
|
|
281
|
+
readonly callId: string
|
|
282
|
+
readonly files: readonly FileRef[]
|
|
283
|
+
}
|
|
284
|
+
const output = options["--output"]
|
|
285
|
+
if (output === undefined) return writeJson(call)
|
|
286
|
+
writeJson({ callId: call.callId, files: await saveFiles(api, call.callId, call.files, output) })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A --model reference as the request body names it; absent, the kind's default answers. */
|
|
290
|
+
async function modelRef(
|
|
291
|
+
api: ApiClient,
|
|
292
|
+
reference: string | undefined,
|
|
293
|
+
kind: ModelKind
|
|
294
|
+
): Promise<{ model?: { provider: string; modelId: string } }> {
|
|
295
|
+
if (reference === undefined) return {}
|
|
296
|
+
const { entry } = findModel(
|
|
297
|
+
catalogEntries((await api.get("/api/models")) as Catalog),
|
|
298
|
+
reference,
|
|
299
|
+
kind
|
|
300
|
+
)
|
|
301
|
+
return { model: { provider: entry.provider, modelId: entry.modelId } }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The tiers of `models.language`, restated: this package depends on no other Sixb package.
|
|
305
|
+
const LANGUAGE_TIERS = ["light", "standard", "heavy"] as const
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A text request's --model: a tier goes to the server as is, which answers with that tier's model,
|
|
309
|
+
* or the default when the project configures no such tier. Anything else names a model.
|
|
310
|
+
*/
|
|
311
|
+
async function languageModelRef(
|
|
312
|
+
api: ApiClient,
|
|
313
|
+
reference: string | undefined
|
|
314
|
+
): Promise<{ model?: string | { provider: string; modelId: string } }> {
|
|
315
|
+
const tier = LANGUAGE_TIERS.find((candidate) => candidate === reference)
|
|
316
|
+
if (tier !== undefined) return { model: tier }
|
|
317
|
+
return modelRef(api, reference, "language")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The prompt given as an argument, or read from --file: a path, or - for standard input. */
|
|
321
|
+
async function readPrompt(
|
|
322
|
+
argument: string | undefined,
|
|
323
|
+
source: string | undefined
|
|
324
|
+
): Promise<string> {
|
|
325
|
+
if (argument !== undefined && source !== undefined) {
|
|
326
|
+
fail("models text takes its prompt as an argument or from --file, not both.")
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const prompt = source === undefined ? argument : await readText(source, "Prompt file")
|
|
330
|
+
if (prompt === undefined) {
|
|
331
|
+
fail('models text requires a prompt: "<prompt>", or --file <path|-> to read it.')
|
|
332
|
+
}
|
|
333
|
+
if (!prompt.trim()) fail("The prompt is empty.")
|
|
334
|
+
return prompt
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function requireOutput(output: string | undefined, command: string): string {
|
|
338
|
+
if (!output) fail(`${command} requires --output <path>: a file, or a directory ending in /.`)
|
|
339
|
+
return output
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function optional(field: string, value: string | undefined): Record<string, string> {
|
|
343
|
+
return value === undefined ? {} : { [field]: value }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function uploadInput(api: ApiClient, path: string, kind: "image" | "audio") {
|
|
347
|
+
try {
|
|
348
|
+
await access(path)
|
|
349
|
+
} catch {
|
|
350
|
+
fail(`Input file '${path}' does not exist.`)
|
|
351
|
+
}
|
|
352
|
+
const mediaType = MEDIA_TYPES[extname(path).toLowerCase()]
|
|
353
|
+
if (!mediaType?.startsWith(`${kind}/`)) {
|
|
354
|
+
const accepted = Object.entries(MEDIA_TYPES)
|
|
355
|
+
.filter(([, type]) => type.startsWith(`${kind}/`))
|
|
356
|
+
.map(([extension]) => extension)
|
|
357
|
+
fail(`'${path}' is not an ${kind} file. Use ${accepted.join(", ")}.`)
|
|
358
|
+
}
|
|
359
|
+
return api.upload("/api/files", path, undefined, mediaType)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function savedResult(api: ApiClient, result: CallResult, output: string) {
|
|
363
|
+
return {
|
|
364
|
+
callId: result.callId,
|
|
365
|
+
model: result.model.modelId,
|
|
366
|
+
files: await saveFiles(api, result.callId, result.files ?? [], output),
|
|
367
|
+
...(result.warnings?.length ? { warnings: result.warnings } : {}),
|
|
368
|
+
cost: describeCost(result.cost),
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Download a call's files. A directory (an existing one, or a path ending in /) receives
|
|
374
|
+
* `<call-id>.<ext>`; a file path is used as given, numbered when there are several. Either way the
|
|
375
|
+
* extension follows the file's media type, so the paths written are the ones returned.
|
|
376
|
+
*/
|
|
377
|
+
async function saveFiles(
|
|
378
|
+
api: ApiClient,
|
|
379
|
+
callId: string,
|
|
380
|
+
files: readonly FileRef[],
|
|
381
|
+
output: string
|
|
382
|
+
): Promise<string[]> {
|
|
383
|
+
const directory = output.endsWith("/") || (await stat(output).catch(() => null))?.isDirectory()
|
|
384
|
+
const paths = files.map((file, index) => {
|
|
385
|
+
const number = files.length > 1 ? `-${index + 1}` : ""
|
|
386
|
+
const extension = (file.mediaType && EXTENSIONS[file.mediaType]) ?? ""
|
|
387
|
+
if (directory) return join(output, `${callId}${number}${extension || ".bin"}`)
|
|
388
|
+
const { dir, name, ext } = parse(output)
|
|
389
|
+
return join(dir, `${name}${number}${extension || ext}`)
|
|
390
|
+
})
|
|
391
|
+
for (const [index, path] of paths.entries()) {
|
|
392
|
+
await mkdir(dirname(path), { recursive: true })
|
|
393
|
+
await api.download(`/api/ai/model-calls/${encodeURIComponent(callId)}/files/content`, path, {
|
|
394
|
+
path: `/files/${index}`,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
return paths
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** What a call cost, in USD, as the provider reported it or the binding's card rated it. */
|
|
401
|
+
export function describeCost(cost: CallResult["cost"]): string {
|
|
402
|
+
if (!cost.money) return "unknown"
|
|
403
|
+
const nanos = BigInt(cost.money.amountNanos)
|
|
404
|
+
const whole = nanos / 1_000_000_000n
|
|
405
|
+
const fraction = (nanos % 1_000_000_000n).toString().padStart(9, "0").replace(/0+$/, "")
|
|
406
|
+
return `$${whole}${fraction ? `.${fraction}` : ""}`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function modelKind(value: string): ModelKind {
|
|
410
|
+
const kind = MODEL_KINDS.find((candidate) => candidate === value)
|
|
411
|
+
if (!kind) fail(`Unknown model kind '${value}'. Use one of: ${MODEL_KINDS.join(", ")}.`)
|
|
412
|
+
return kind
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function catalogEntries(catalog: Catalog): { kind: ModelKind; entry: CatalogEntry }[] {
|
|
416
|
+
return [
|
|
417
|
+
...catalog.language.map((entry) => ({ kind: "language" as const, entry })),
|
|
418
|
+
...catalog.image.map((entry) => ({ kind: "image" as const, entry })),
|
|
419
|
+
...catalog.audio.speech.map((entry) => ({ kind: "speech" as const, entry })),
|
|
420
|
+
...catalog.audio.transcription.map((entry) => ({ kind: "transcription" as const, entry })),
|
|
421
|
+
]
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* A model by id, or provider:modelId when two providers share an id. An id may itself hold a
|
|
426
|
+
* colon, so the whole reference is tried as an id first. An id that names models of several
|
|
427
|
+
* kinds needs --kind.
|
|
428
|
+
*/
|
|
429
|
+
export function findModel(
|
|
430
|
+
entries: readonly { readonly kind: ModelKind; readonly entry: CatalogEntry }[],
|
|
431
|
+
reference: string,
|
|
432
|
+
kind: ModelKind | undefined
|
|
433
|
+
): { kind: ModelKind; entry: CatalogEntry } {
|
|
434
|
+
const ofKind = entries.filter((candidate) => kind === undefined || candidate.kind === kind)
|
|
435
|
+
let matches = ofKind.filter((candidate) => candidate.entry.modelId === reference)
|
|
436
|
+
const separator = reference.indexOf(":")
|
|
437
|
+
if (matches.length === 0 && separator > 0) {
|
|
438
|
+
const provider = reference.slice(0, separator)
|
|
439
|
+
const modelId = reference.slice(separator + 1)
|
|
440
|
+
matches = ofKind.filter(
|
|
441
|
+
(candidate) => candidate.entry.provider === provider && candidate.entry.modelId === modelId
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
const [match] = matches
|
|
445
|
+
if (!match) {
|
|
446
|
+
fail(
|
|
447
|
+
`No ${kind === undefined ? "" : `${kind} `}model '${reference}' is configured. Run 'sixb models list'.`
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
if (matches.length > 1) {
|
|
451
|
+
const kinds = new Set(matches.map((candidate) => candidate.kind))
|
|
452
|
+
fail(
|
|
453
|
+
kinds.size > 1
|
|
454
|
+
? `'${reference}' is configured as ${[...kinds].join(" and ")}. Add --kind <kind>.`
|
|
455
|
+
: `'${reference}' is configured by ${matches.map((candidate) => candidate.entry.provider).join(" and ")}. Use provider:${reference}.`
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
return match
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A rate card as people read it: "$0.04 per image", "per image: $0.041 at 768x768, otherwise
|
|
463
|
+
* $0.048", "$3 input · $15 output per 1M tokens".
|
|
464
|
+
*/
|
|
465
|
+
export function describePrice(card: RateCard | undefined): string {
|
|
466
|
+
if (!card) return "price unknown"
|
|
467
|
+
const parts = [
|
|
468
|
+
variantPrice(card.images, "per image", "at"),
|
|
469
|
+
variantPrice(card.characters, "per 1M characters", "for"),
|
|
470
|
+
variantPrice(card.audioInputSeconds, "per second of audio", "for"),
|
|
471
|
+
card.input !== undefined && card.output !== undefined
|
|
472
|
+
? `${dollars(tokenPrice(card.input))} input · ${dollars(tokenPrice(card.output))} output per 1M tokens`
|
|
473
|
+
: undefined,
|
|
474
|
+
card.audioOutput === undefined
|
|
475
|
+
? undefined
|
|
476
|
+
: `${dollars(tokenPrice(card.audioOutput))} per 1M audio tokens`,
|
|
477
|
+
].filter((part): part is string => part !== undefined)
|
|
478
|
+
return parts.length === 0 ? "price unknown" : parts.join(" · ")
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function variantPrice(
|
|
482
|
+
price: VariantPrice | undefined,
|
|
483
|
+
unit: string,
|
|
484
|
+
joiner: string
|
|
485
|
+
): string | undefined {
|
|
486
|
+
if (price === undefined) return undefined
|
|
487
|
+
if (typeof price === "string") return `${dollars(price)} ${unit}`
|
|
488
|
+
// Variants that all cost the default say nothing a single price doesn't.
|
|
489
|
+
const only = price.default
|
|
490
|
+
if (only !== undefined && price.variants.every((variant) => variant.price === only)) {
|
|
491
|
+
return `${dollars(only)} ${unit}`
|
|
492
|
+
}
|
|
493
|
+
const variants = price.variants.map(({ price: amount, ...dimensions }) => {
|
|
494
|
+
const label = Object.values(dimensions).join(" ")
|
|
495
|
+
return `${dollars(amount)} ${joiner} ${label}`
|
|
496
|
+
})
|
|
497
|
+
const otherwise = price.default === undefined ? [] : [`otherwise ${dollars(price.default)}`]
|
|
498
|
+
return `${unit}: ${[...variants, ...otherwise].join(", ")}`
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// A tiered price reads as its starting rate; `sixb models get` shows the tiers.
|
|
502
|
+
function tokenPrice(price: TokenPrice): string {
|
|
503
|
+
return typeof price === "string" ? price : price.default
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function dollars(amount: string): string {
|
|
507
|
+
return `$${amount}`
|
|
508
|
+
}
|
package/src/commands/objects.ts
CHANGED
|
@@ -9,9 +9,16 @@ import {
|
|
|
9
9
|
rfc3339Value,
|
|
10
10
|
} from "../arguments"
|
|
11
11
|
import { inspectGraph } from "../graph"
|
|
12
|
-
import { fail, writeJson, writeText } from "../output"
|
|
12
|
+
import { CliError, fail, writeJson, writeText } from "../output"
|
|
13
13
|
import { CLI_LIMITS, DEFAULT_LIST_ORDER, DEFAULT_OBJECT_ORDER_BY } from "../policies"
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
FACETS_EXAMPLE,
|
|
16
|
+
GET_BY_HELP,
|
|
17
|
+
OBJECTS_HELP,
|
|
18
|
+
QUERY_EXAMPLES,
|
|
19
|
+
QUERY_HELP,
|
|
20
|
+
SEARCH_HELP,
|
|
21
|
+
} from "./metadata"
|
|
15
22
|
import {
|
|
16
23
|
asRecord,
|
|
17
24
|
normalizeWindowOptions,
|
|
@@ -31,6 +38,8 @@ export async function objects(api: ApiClient, args: readonly string[]): Promise<
|
|
|
31
38
|
return objectsList(api, rest)
|
|
32
39
|
case "get":
|
|
33
40
|
return objectsGet(api, rest)
|
|
41
|
+
case "get-by":
|
|
42
|
+
return objectsGetBy(api, rest)
|
|
34
43
|
case "search":
|
|
35
44
|
return objectsSearch(api, rest)
|
|
36
45
|
case "query":
|
|
@@ -155,6 +164,42 @@ async function objectsGet(api: ApiClient, args: readonly string[]): Promise<void
|
|
|
155
164
|
)
|
|
156
165
|
}
|
|
157
166
|
|
|
167
|
+
/** Prints the object holding the value, or `null` when none does or its type is not visible. */
|
|
168
|
+
async function objectsGetBy(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
169
|
+
if (requestsHelp(args)) return writeText(GET_BY_HELP)
|
|
170
|
+
const { positionals, options } = parseCommandArgs(
|
|
171
|
+
args,
|
|
172
|
+
{ "--json": "boolean" },
|
|
173
|
+
"objects get-by",
|
|
174
|
+
3
|
|
175
|
+
)
|
|
176
|
+
const [objectTypeId = "", propertyId = "", text = ""] = positionals
|
|
177
|
+
const value = options["--json"] ? parseJsonValue(text) : text
|
|
178
|
+
|
|
179
|
+
const path = `/api/objects/${encodeURIComponent(objectTypeId)}/by/${encodeURIComponent(propertyId)}`
|
|
180
|
+
try {
|
|
181
|
+
writeJson(await api.post(path, { value }))
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!isObjectNotFound(error)) throw error
|
|
184
|
+
writeJson(null)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseJsonValue(text: string): unknown {
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(text)
|
|
191
|
+
} catch {
|
|
192
|
+
fail('--json needs the value as JSON, such as 42 or \'{"type":"user","id":"usr_1"}\'.')
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The route's own answer when no visible object holds the value. */
|
|
197
|
+
function isObjectNotFound(error: unknown): boolean {
|
|
198
|
+
return (
|
|
199
|
+
error instanceof CliError && error.body.status === 404 && error.body.code === "object.not_found"
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
158
203
|
async function objectsSearch(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
159
204
|
if (requestsHelp(args)) return writeText(SEARCH_HELP)
|
|
160
205
|
const { positionals, options } = parseCommandArgs(
|
package/src/commands/shared.ts
CHANGED
|
@@ -55,13 +55,7 @@ export function singleFileOption(args: readonly string[], command: string): stri
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export async function readJson(source: string): Promise<unknown> {
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
text = source === "-" ? await readStdin() : await readFile(source, "utf8")
|
|
61
|
-
} catch (error) {
|
|
62
|
-
if (isFileError(error, "ENOENT")) fail(`JSON file '${source}' does not exist.`)
|
|
63
|
-
throw error
|
|
64
|
-
}
|
|
58
|
+
const text = await readText(source, "JSON file")
|
|
65
59
|
try {
|
|
66
60
|
return JSON.parse(text)
|
|
67
61
|
} catch {
|
|
@@ -74,6 +68,16 @@ export async function readJson(source: string): Promise<unknown> {
|
|
|
74
68
|
}
|
|
75
69
|
}
|
|
76
70
|
|
|
71
|
+
/** A file's text, or standard input's when the source is `-`. */
|
|
72
|
+
export async function readText(source: string, noun: string): Promise<string> {
|
|
73
|
+
try {
|
|
74
|
+
return source === "-" ? await readStdin() : await readFile(source, "utf8")
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (isFileError(error, "ENOENT")) fail(`${noun} '${source}' does not exist.`)
|
|
77
|
+
throw error
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
export function asRecord(value: unknown): Record<string, unknown> {
|
|
78
82
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
79
83
|
? (value as Record<string, unknown>)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ApiClient } from "../api-client"
|
|
2
|
+
import {
|
|
3
|
+
enumValue,
|
|
4
|
+
integerInRange,
|
|
5
|
+
isHelp,
|
|
6
|
+
nonNegativeInteger,
|
|
7
|
+
parseCommandArgs,
|
|
8
|
+
requestsHelp,
|
|
9
|
+
} from "../arguments"
|
|
10
|
+
import { fail, writeJson, writeText } from "../output"
|
|
11
|
+
import { CLI_LIMITS } from "../policies"
|
|
12
|
+
import { GROUP_HELP } from "./metadata"
|
|
13
|
+
import type { QueryOptions } from "./shared"
|
|
14
|
+
|
|
15
|
+
const USER_STATUSES = ["active", "suspended", "all"] as const
|
|
16
|
+
|
|
17
|
+
const LIST_FLAGS = {
|
|
18
|
+
"--status": "string",
|
|
19
|
+
"--limit": "string",
|
|
20
|
+
"--offset": "string",
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export async function users(api: ApiClient, args: readonly string[]): Promise<void> {
|
|
24
|
+
const [sub, ...rest] = args
|
|
25
|
+
if (!sub || isHelp(sub) || requestsHelp(rest)) return writeText(GROUP_HELP.users)
|
|
26
|
+
|
|
27
|
+
if (sub === "get") {
|
|
28
|
+
const { positionals } = parseCommandArgs(rest, {}, "users get", 1)
|
|
29
|
+
return writeJson(await api.get(`/api/users/${encodeURIComponent(positionals[0] ?? "")}`))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (sub === "list") {
|
|
33
|
+
const { options } = parseCommandArgs(rest, LIST_FLAGS, "users list")
|
|
34
|
+
return writeJson(await api.get("/api/users", listQuery(options)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (sub === "search") {
|
|
38
|
+
const { positionals, options } = parseCommandArgs(rest, LIST_FLAGS, "users search", 1)
|
|
39
|
+
const search = positionals[0]?.trim()
|
|
40
|
+
if (!search) fail("users search requires the text to find in a name or email.")
|
|
41
|
+
|
|
42
|
+
return writeJson(await api.get("/api/users", { ...listQuery(options), search }))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fail(`Unknown users command '${sub}'.`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listQuery(options: {
|
|
49
|
+
readonly "--status"?: string
|
|
50
|
+
readonly "--limit"?: string
|
|
51
|
+
readonly "--offset"?: string
|
|
52
|
+
}): QueryOptions {
|
|
53
|
+
const limit = integerInRange(
|
|
54
|
+
"--limit",
|
|
55
|
+
options["--limit"] ?? String(CLI_LIMITS.users.default),
|
|
56
|
+
1,
|
|
57
|
+
CLI_LIMITS.users.maximum
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
status: enumValue("--status", options["--status"] ?? "active", USER_STATUSES),
|
|
62
|
+
limit: String(limit),
|
|
63
|
+
...(options["--offset"] === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { offset: String(nonNegativeInteger("--offset", options["--offset"])) }),
|
|
66
|
+
}
|
|
67
|
+
}
|