ai-cli 0.0.12 → 0.1.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,192 @@
1
+ import {
2
+ supportsKittyGraphics,
3
+ displayImage,
4
+ displayVideoFrame,
5
+ } from "./kitty.js";
6
+ import type { OutputFormat } from "./output.js";
7
+ import { writeOutput } from "./output.js";
8
+ import { pMap } from "./p-map.js";
9
+ import { Progress, MultiProgress, formatElapsed } from "./progress.js";
10
+
11
+ export interface Job {
12
+ modelId: string;
13
+ label: string;
14
+ index: number;
15
+ }
16
+
17
+ export interface RunJobsOptions {
18
+ noun: string;
19
+ format: OutputFormat;
20
+ outputPath?: string;
21
+ quiet?: boolean;
22
+ json?: boolean;
23
+ concurrency: number;
24
+ display?: boolean;
25
+ }
26
+
27
+ export function buildJobs(models: string[], countPerModel: number): Job[] {
28
+ let jobIndex = 0;
29
+ return models.flatMap((modelId) =>
30
+ Array.from({ length: countPerModel }, (_, i) => ({
31
+ modelId,
32
+ label: models.length > 1 ? `${modelId} #${i + 1}` : `#${i + 1}`,
33
+ index: jobIndex++,
34
+ }))
35
+ );
36
+ }
37
+
38
+ export interface RunJobsResult {
39
+ total: number;
40
+ failed: number;
41
+ }
42
+
43
+ export async function runJobs(
44
+ jobs: Job[],
45
+ generate: (modelId: string) => Promise<Buffer | string>,
46
+ opts: RunJobsOptions
47
+ ): Promise<RunJobsResult> {
48
+ const { noun, format, outputPath, quiet, json, concurrency, display } = opts;
49
+
50
+ if (jobs.length === 1) {
51
+ const { modelId } = jobs[0];
52
+ const progress = new Progress(quiet);
53
+ const start = Date.now();
54
+ progress.start(`Generating ${noun} with ${modelId}`);
55
+
56
+ try {
57
+ const data = await generate(modelId);
58
+ const elapsed = Date.now() - start;
59
+ progress.stop(`Generated ${noun} with ${modelId}`);
60
+
61
+ if (json) {
62
+ const path = await writeOutput({
63
+ data,
64
+ format,
65
+ outputPath,
66
+ quiet: true,
67
+ display,
68
+ });
69
+ const meta = {
70
+ elapsed_ms: elapsed,
71
+ count: 1,
72
+ results: [
73
+ {
74
+ index: 1,
75
+ model: modelId,
76
+ elapsed_ms: elapsed,
77
+ success: true,
78
+ file: path,
79
+ },
80
+ ],
81
+ };
82
+ process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
83
+ } else {
84
+ await writeOutput({ data, format, outputPath, quiet, display });
85
+ }
86
+ } catch (err) {
87
+ progress.stop();
88
+ throw err;
89
+ }
90
+ return { total: 1, failed: 0 };
91
+ }
92
+
93
+ const multi = new MultiProgress(quiet);
94
+ const start = Date.now();
95
+ const shouldDisplay =
96
+ display !== false &&
97
+ (format === "image" || format === "video") &&
98
+ process.stdout.isTTY &&
99
+ supportsKittyGraphics();
100
+
101
+ const lineIdxs = jobs.map((j) =>
102
+ multi.addLine(`Generating ${noun} ${j.label} with ${j.modelId}`)
103
+ );
104
+
105
+ const results: {
106
+ index: number;
107
+ model: string;
108
+ success: boolean;
109
+ elapsed_ms: number;
110
+ file: string | null;
111
+ }[] = [];
112
+ const pendingDisplayBuffers: Buffer[] = [];
113
+
114
+ await pMap(
115
+ jobs,
116
+ async (job, i) => {
117
+ multi.startLine(lineIdxs[i]);
118
+ const genStart = Date.now();
119
+ try {
120
+ const data = await generate(job.modelId);
121
+ const genElapsed = Date.now() - genStart;
122
+ const suffix = `${i + 1}`;
123
+ const path = await writeOutput({
124
+ data,
125
+ format,
126
+ outputPath,
127
+ suffix,
128
+ quiet: true,
129
+ display: false,
130
+ });
131
+ if (shouldDisplay && Buffer.isBuffer(data))
132
+ pendingDisplayBuffers.push(data);
133
+ const savedMsg = path
134
+ ? `Saved to ${path}`
135
+ : `${noun[0].toUpperCase()}${noun.slice(1)} ${job.label} written to stdout`;
136
+ multi.completeLine(
137
+ lineIdxs[i],
138
+ `${savedMsg} (${formatElapsed(genElapsed)})`
139
+ );
140
+ results.push({
141
+ index: i,
142
+ model: job.modelId,
143
+ success: true,
144
+ elapsed_ms: genElapsed,
145
+ file: path,
146
+ });
147
+ } catch (err: unknown) {
148
+ const genElapsed = Date.now() - genStart;
149
+ const msg = err instanceof Error ? err.message : String(err);
150
+ multi.completeLine(
151
+ lineIdxs[i],
152
+ `${noun[0].toUpperCase()}${noun.slice(1)} ${job.label} failed: ${msg} (${formatElapsed(genElapsed)})`
153
+ );
154
+ results.push({
155
+ index: i,
156
+ model: job.modelId,
157
+ success: false,
158
+ elapsed_ms: genElapsed,
159
+ file: null,
160
+ });
161
+ }
162
+ },
163
+ concurrency
164
+ );
165
+
166
+ if (json) {
167
+ const totalElapsed = Date.now() - start;
168
+ const meta = {
169
+ elapsed_ms: totalElapsed,
170
+ count: results.filter((r) => r.success).length,
171
+ results: results.map((r) => ({
172
+ index: r.index + 1,
173
+ model: r.model,
174
+ elapsed_ms: r.elapsed_ms,
175
+ success: r.success,
176
+ file: r.file,
177
+ })),
178
+ };
179
+ process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
180
+ }
181
+
182
+ for (const buf of pendingDisplayBuffers) {
183
+ if (format === "video") {
184
+ await displayVideoFrame(buf);
185
+ } else {
186
+ displayImage(buf);
187
+ }
188
+ }
189
+
190
+ const failCount = results.filter((r) => !r.success).length;
191
+ return { total: results.length, failed: failCount };
192
+ }
@@ -0,0 +1,55 @@
1
+ const SUPPORTED_TERMS = new Set(["xterm-kitty"]);
2
+
3
+ const SUPPORTED_TERM_PROGRAMS = new Set([
4
+ "kitty",
5
+ "ghostty",
6
+ "wezterm",
7
+ "warpterminal",
8
+ ]);
9
+
10
+ export function supportsKittyGraphics(): boolean {
11
+ if (process.env.AI_CLI_PREVIEW === "0") return false;
12
+ if (process.env.AI_CLI_PREVIEW === "1") return true;
13
+
14
+ const term = process.env.TERM ?? "";
15
+ if (SUPPORTED_TERMS.has(term)) return true;
16
+
17
+ const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase();
18
+ if (SUPPORTED_TERM_PROGRAMS.has(termProgram)) return true;
19
+
20
+ const lcTerminal = (process.env.LC_TERMINAL ?? "").toLowerCase();
21
+ if (lcTerminal === "iterm2") return true;
22
+
23
+ return false;
24
+ }
25
+
26
+ import { decodeIDR } from "./h264-wasm.js";
27
+ import { extractKeyframe } from "./mp4.js";
28
+ import { encodePNG } from "./png.js";
29
+
30
+ const CHUNK_SIZE = 4096;
31
+
32
+ export async function displayVideoFrame(buf: Buffer): Promise<void> {
33
+ try {
34
+ const kf = extractKeyframe(new Uint8Array(buf));
35
+ if (!kf) return;
36
+ const frame = await decodeIDR(kf.sps, kf.pps, kf.sliceData);
37
+ if (!frame) return;
38
+ const png = encodePNG(frame.yuv, frame.width, frame.height);
39
+ displayImage(png);
40
+ } catch {
41
+ // Preview is best-effort; skip silently on any failure
42
+ }
43
+ }
44
+
45
+ export function displayImage(buf: Buffer): void {
46
+ const encoded = buf.toString("base64");
47
+ for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
48
+ const chunk = encoded.slice(i, i + CHUNK_SIZE);
49
+ const isLast = i + CHUNK_SIZE >= encoded.length;
50
+ const control =
51
+ i === 0 ? `a=T,f=100,m=${isLast ? 0 : 1}` : `m=${isLast ? 0 : 1}`;
52
+ process.stderr.write(`\x1b_G${control};${chunk}\x1b\\`);
53
+ }
54
+ process.stderr.write("\n");
55
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ resolveModels,
5
+ fetchGatewayModels,
6
+ FALLBACK_TEXT_MODELS,
7
+ FALLBACK_IMAGE_MODELS,
8
+ FALLBACK_VIDEO_MODELS,
9
+ } from "./models.js";
10
+
11
+ describe("resolveModels", () => {
12
+ test("returns default when no user model", () => {
13
+ expect(resolveModels("text")[0]).toContain("/");
14
+ expect(resolveModels("image")[0]).toContain("/");
15
+ expect(resolveModels("video")[0]).toContain("/");
16
+ });
17
+
18
+ test("returns fully-qualified model as-is", () => {
19
+ expect(resolveModels("text", "openai/gpt-4")).toEqual(["openai/gpt-4"]);
20
+ expect(resolveModels("image", "openai/gpt-image-1")).toEqual([
21
+ "openai/gpt-image-1",
22
+ ]);
23
+ });
24
+
25
+ test("expands short image model names", () => {
26
+ expect(resolveModels("image", "gpt-image-1")).toEqual([
27
+ "openai/gpt-image-1",
28
+ ]);
29
+ expect(resolveModels("image", "flux-2-pro")).toEqual(["bfl/flux-2-pro"]);
30
+ });
31
+
32
+ test("expands short video model names", () => {
33
+ expect(resolveModels("video", "seedance-2.0")).toEqual([
34
+ "bytedance/seedance-2.0",
35
+ ]);
36
+ });
37
+
38
+ test("expands short text model names", () => {
39
+ expect(resolveModels("text", "gpt-5.5")).toEqual(["openai/gpt-5.5"]);
40
+ expect(resolveModels("text", "o3")).toEqual(["openai/o3"]);
41
+ });
42
+
43
+ test("returns unknown short names as-is for text", () => {
44
+ expect(resolveModels("text", "gpt-image-1")).toEqual(["gpt-image-1"]);
45
+ expect(resolveModels("text", "my-model")).toEqual(["my-model"]);
46
+ });
47
+
48
+ test("returns unknown short names as-is for image/video", () => {
49
+ expect(resolveModels("image", "nonexistent-model")).toEqual([
50
+ "nonexistent-model",
51
+ ]);
52
+ });
53
+ });
54
+
55
+ describe("resolveModels multi", () => {
56
+ test("returns default when no user model", () => {
57
+ const result = resolveModels("text");
58
+ expect(result).toHaveLength(1);
59
+ expect(result[0]).toContain("/");
60
+ });
61
+
62
+ test("splits comma-separated models", () => {
63
+ const result = resolveModels("image", "openai/gpt-image-1,bfl/flux-2-pro");
64
+ expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
65
+ });
66
+
67
+ test("trims whitespace around model names", () => {
68
+ const result = resolveModels(
69
+ "image",
70
+ "openai/gpt-image-1 , bfl/flux-2-pro"
71
+ );
72
+ expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
73
+ });
74
+
75
+ test("expands short names in comma list", () => {
76
+ const result = resolveModels("image", "gpt-image-1,flux-2-pro");
77
+ expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
78
+ });
79
+
80
+ test("filters empty segments from trailing comma", () => {
81
+ const result = resolveModels("image", "openai/gpt-image-1,");
82
+ expect(result).toEqual(["openai/gpt-image-1"]);
83
+ });
84
+
85
+ test("falls back to default when all segments are empty", () => {
86
+ const result = resolveModels("image", ",,,");
87
+ expect(result).toHaveLength(1);
88
+ expect(result[0]).toContain("/");
89
+ });
90
+ });
91
+
92
+ describe("fetchGatewayModels", () => {
93
+ test("partitions models by modelType", async () => {
94
+ const { gateway } = await import("ai");
95
+ const original = gateway.getAvailableModels;
96
+ gateway.getAvailableModels = mock(() =>
97
+ Promise.resolve({
98
+ models: [
99
+ { id: "openai/gpt-5", name: "GPT 5", modelType: "language" },
100
+ {
101
+ id: "openai/gpt-image-2",
102
+ name: "GPT Image 2",
103
+ description: "Image gen",
104
+ modelType: "image",
105
+ },
106
+ { id: "google/veo-3.0", name: "Veo 3", modelType: "video" },
107
+ {
108
+ id: "openai/text-embedding-3",
109
+ name: "Embedding",
110
+ modelType: "embedding",
111
+ },
112
+ ],
113
+ })
114
+ ) as unknown as typeof gateway.getAvailableModels;
115
+
116
+ try {
117
+ const result = await fetchGatewayModels();
118
+
119
+ expect(result.text).toEqual([
120
+ { id: "openai/gpt-5", name: "GPT 5", description: undefined },
121
+ ]);
122
+ expect(result.image).toEqual([
123
+ {
124
+ id: "openai/gpt-image-2",
125
+ name: "GPT Image 2",
126
+ description: "Image gen",
127
+ },
128
+ ]);
129
+ expect(result.video).toEqual([
130
+ { id: "google/veo-3.0", name: "Veo 3", description: undefined },
131
+ ]);
132
+ } finally {
133
+ gateway.getAvailableModels = original;
134
+ }
135
+ });
136
+
137
+ test("falls back to static lists on gateway error", async () => {
138
+ const { gateway } = await import("ai");
139
+ const original = gateway.getAvailableModels;
140
+ gateway.getAvailableModels = mock(() =>
141
+ Promise.reject(new Error("network error"))
142
+ ) as typeof gateway.getAvailableModels;
143
+
144
+ try {
145
+ const result = await fetchGatewayModels();
146
+
147
+ expect(result.text.map((m) => m.id)).toEqual(FALLBACK_TEXT_MODELS);
148
+ expect(result.image.map((m) => m.id)).toEqual(FALLBACK_IMAGE_MODELS);
149
+ expect(result.video.map((m) => m.id)).toEqual(FALLBACK_VIDEO_MODELS);
150
+ } finally {
151
+ gateway.getAvailableModels = original;
152
+ }
153
+ });
154
+
155
+ test("uses fallbacks when gateway returns no image/video models", async () => {
156
+ const { gateway } = await import("ai");
157
+ const original = gateway.getAvailableModels;
158
+ gateway.getAvailableModels = mock(() =>
159
+ Promise.resolve({
160
+ models: [{ id: "openai/gpt-5", name: "GPT 5", modelType: "language" }],
161
+ })
162
+ ) as unknown as typeof gateway.getAvailableModels;
163
+
164
+ try {
165
+ const result = await fetchGatewayModels();
166
+
167
+ expect(result.text).toHaveLength(1);
168
+ expect(result.text[0].id).toBe("openai/gpt-5");
169
+ expect(result.image.map((m) => m.id)).toEqual(FALLBACK_IMAGE_MODELS);
170
+ expect(result.video.map((m) => m.id)).toEqual(FALLBACK_VIDEO_MODELS);
171
+ } finally {
172
+ gateway.getAvailableModels = original;
173
+ }
174
+ });
175
+
176
+ test("uses text fallbacks when gateway returns no text models", async () => {
177
+ const { gateway } = await import("ai");
178
+ const original = gateway.getAvailableModels;
179
+ gateway.getAvailableModels = mock(() =>
180
+ Promise.resolve({
181
+ models: [
182
+ { id: "openai/gpt-image-2", name: "GPT Image 2", modelType: "image" },
183
+ ],
184
+ })
185
+ ) as unknown as typeof gateway.getAvailableModels;
186
+
187
+ try {
188
+ const result = await fetchGatewayModels();
189
+
190
+ expect(result.text.map((m) => m.id)).toEqual(FALLBACK_TEXT_MODELS);
191
+ expect(result.image).toHaveLength(1);
192
+ expect(result.video.map((m) => m.id)).toEqual(FALLBACK_VIDEO_MODELS);
193
+ } finally {
194
+ gateway.getAvailableModels = original;
195
+ }
196
+ });
197
+ });
@@ -0,0 +1,163 @@
1
+ import { gateway } from "ai";
2
+
3
+ export type Modality = "text" | "image" | "video";
4
+
5
+ const DEFAULTS: Record<Modality, string> = {
6
+ text: process.env.AI_CLI_TEXT_MODEL ?? "openai/gpt-5.5",
7
+ image: process.env.AI_CLI_IMAGE_MODEL ?? "openai/gpt-image-2",
8
+ video: process.env.AI_CLI_VIDEO_MODEL ?? "bytedance/seedance-2.0",
9
+ };
10
+
11
+ export const FALLBACK_TEXT_MODELS = [
12
+ "anthropic/claude-sonnet-4",
13
+ "google/gemini-2.5-pro",
14
+ "meta/llama-4-maverick",
15
+ "openai/gpt-4.1",
16
+ "openai/gpt-4.1-mini",
17
+ "openai/gpt-4.1-nano",
18
+ "openai/gpt-5.5",
19
+ "openai/o3",
20
+ "openai/o4-mini",
21
+ "xai/grok-3",
22
+ ];
23
+
24
+ export const FALLBACK_IMAGE_MODELS = [
25
+ "bfl/flux-2-flex",
26
+ "bfl/flux-2-klein-4b",
27
+ "bfl/flux-2-klein-9b",
28
+ "bfl/flux-2-max",
29
+ "bfl/flux-2-pro",
30
+ "bfl/flux-kontext-max",
31
+ "bfl/flux-kontext-pro",
32
+ "bfl/flux-pro-1.0-fill",
33
+ "bfl/flux-pro-1.1",
34
+ "bfl/flux-pro-1.1-ultra",
35
+ "bytedance/seedream-4.0",
36
+ "bytedance/seedream-4.5",
37
+ "bytedance/seedream-5.0-lite",
38
+ "google/imagen-4.0-fast-generate-001",
39
+ "google/imagen-4.0-generate-001",
40
+ "google/imagen-4.0-ultra-generate-001",
41
+ "openai/gpt-image-1",
42
+ "openai/gpt-image-1-mini",
43
+ "openai/gpt-image-1.5",
44
+ "openai/gpt-image-2",
45
+ "prodia/flux-fast-schnell",
46
+ "recraft/recraft-v2",
47
+ "recraft/recraft-v3",
48
+ "recraft/recraft-v4",
49
+ "recraft/recraft-v4-pro",
50
+ "xai/grok-imagine-image",
51
+ "xai/grok-imagine-image-pro",
52
+ ];
53
+
54
+ export const FALLBACK_VIDEO_MODELS = [
55
+ "alibaba/wan-v2.5-t2v-preview",
56
+ "alibaba/wan-v2.6-i2v",
57
+ "alibaba/wan-v2.6-i2v-flash",
58
+ "alibaba/wan-v2.6-r2v",
59
+ "alibaba/wan-v2.6-r2v-flash",
60
+ "alibaba/wan-v2.6-t2v",
61
+ "bytedance/seedance-2.0",
62
+ "bytedance/seedance-2.0-fast",
63
+ "bytedance/seedance-v1.0-lite-i2v",
64
+ "bytedance/seedance-v1.0-lite-t2v",
65
+ "bytedance/seedance-v1.0-pro",
66
+ "bytedance/seedance-v1.0-pro-fast",
67
+ "bytedance/seedance-v1.5-pro",
68
+ "google/veo-3.0-fast-generate-001",
69
+ "google/veo-3.0-generate-001",
70
+ "google/veo-3.1-fast-generate-001",
71
+ "google/veo-3.1-generate-001",
72
+ "klingai/kling-v2.5-turbo-i2v",
73
+ "klingai/kling-v2.5-turbo-t2v",
74
+ "klingai/kling-v2.6-i2v",
75
+ "klingai/kling-v2.6-motion-control",
76
+ "klingai/kling-v2.6-t2v",
77
+ "klingai/kling-v3.0-i2v",
78
+ "klingai/kling-v3.0-t2v",
79
+ "xai/grok-imagine-video",
80
+ ];
81
+
82
+ export interface ModelEntry {
83
+ id: string;
84
+ name?: string;
85
+ description?: string;
86
+ }
87
+
88
+ export interface GatewayModels {
89
+ text: ModelEntry[];
90
+ image: ModelEntry[];
91
+ video: ModelEntry[];
92
+ }
93
+
94
+ const MODEL_TYPE_TO_MODALITY: Record<string, Modality> = {
95
+ language: "text",
96
+ image: "image",
97
+ video: "video",
98
+ };
99
+
100
+ export async function fetchGatewayModels(): Promise<GatewayModels> {
101
+ const result: GatewayModels = { text: [], image: [], video: [] };
102
+
103
+ try {
104
+ const { models } = await gateway.getAvailableModels();
105
+ for (const m of models) {
106
+ const modality =
107
+ MODEL_TYPE_TO_MODALITY[(m as { modelType?: string }).modelType ?? ""];
108
+ if (!modality) continue;
109
+ result[modality].push({
110
+ id: m.id,
111
+ name: m.name,
112
+ description: m.description ?? undefined,
113
+ });
114
+ }
115
+ } catch {
116
+ result.text = FALLBACK_TEXT_MODELS.map((id) => ({ id }));
117
+ result.image = FALLBACK_IMAGE_MODELS.map((id) => ({ id }));
118
+ result.video = FALLBACK_VIDEO_MODELS.map((id) => ({ id }));
119
+ }
120
+
121
+ if (result.text.length === 0) {
122
+ result.text = FALLBACK_TEXT_MODELS.map((id) => ({ id }));
123
+ }
124
+ if (result.image.length === 0) {
125
+ result.image = FALLBACK_IMAGE_MODELS.map((id) => ({ id }));
126
+ }
127
+ if (result.video.length === 0) {
128
+ result.video = FALLBACK_VIDEO_MODELS.map((id) => ({ id }));
129
+ }
130
+
131
+ return result;
132
+ }
133
+
134
+ function expandModelId(input: string, modality: Modality): string {
135
+ if (input.includes("/")) return input;
136
+
137
+ const knownLists: string[][] = [];
138
+ if (modality === "text") knownLists.push(FALLBACK_TEXT_MODELS);
139
+ else if (modality === "image") knownLists.push(FALLBACK_IMAGE_MODELS);
140
+ else if (modality === "video") knownLists.push(FALLBACK_VIDEO_MODELS);
141
+
142
+ for (const list of knownLists) {
143
+ for (const fullId of list) {
144
+ const name = fullId.slice(fullId.indexOf("/") + 1);
145
+ if (name === input) return fullId;
146
+ }
147
+ }
148
+
149
+ return input;
150
+ }
151
+
152
+ export function resolveModels(
153
+ modality: Modality,
154
+ userModel?: string
155
+ ): string[] {
156
+ if (!userModel) return [DEFAULTS[modality]];
157
+ const models = userModel
158
+ .split(",")
159
+ .map((m) => m.trim())
160
+ .filter(Boolean)
161
+ .map((m) => expandModelId(m, modality));
162
+ return models.length > 0 ? models : [DEFAULTS[modality]];
163
+ }