ai-cli 0.1.0 → 0.2.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.
@@ -1,13 +1,32 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  resolveModels,
5
5
  fetchGatewayModels,
6
- FALLBACK_TEXT_MODELS,
7
- FALLBACK_IMAGE_MODELS,
8
- FALLBACK_VIDEO_MODELS,
6
+ resetGatewayCache,
9
7
  } from "./models.js";
10
8
 
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ afterEach(() => {
12
+ globalThis.fetch = originalFetch;
13
+ resetGatewayCache();
14
+ });
15
+
16
+ function mockGateway(models: Record<string, unknown>[]) {
17
+ globalThis.fetch = mock(() =>
18
+ Promise.resolve(
19
+ new Response(JSON.stringify({ data: models }), { status: 200 })
20
+ )
21
+ ) as unknown as typeof fetch;
22
+ }
23
+
24
+ function mockGatewayError() {
25
+ globalThis.fetch = mock(() =>
26
+ Promise.reject(new Error("network error"))
27
+ ) as unknown as typeof fetch;
28
+ }
29
+
11
30
  describe("resolveModels", () => {
12
31
  test("returns default when no user model", () => {
13
32
  expect(resolveModels("text")[0]).toContain("/");
@@ -22,32 +41,24 @@ describe("resolveModels", () => {
22
41
  ]);
23
42
  });
24
43
 
25
- test("expands short image model names", () => {
26
- expect(resolveModels("image", "gpt-image-1")).toEqual([
44
+ test("expands short names when knownModels provided", () => {
45
+ const known = [{ id: "openai/gpt-image-1" }, { id: "bfl/flux-2-pro" }];
46
+ expect(resolveModels("image", "gpt-image-1", known)).toEqual([
27
47
  "openai/gpt-image-1",
28
48
  ]);
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",
49
+ expect(resolveModels("image", "flux-2-pro", known)).toEqual([
50
+ "bfl/flux-2-pro",
35
51
  ]);
36
52
  });
37
53
 
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"]);
54
+ test("returns unknown short names as-is when no knownModels", () => {
45
55
  expect(resolveModels("text", "my-model")).toEqual(["my-model"]);
46
56
  });
47
57
 
48
- test("returns unknown short names as-is for image/video", () => {
49
- expect(resolveModels("image", "nonexistent-model")).toEqual([
50
- "nonexistent-model",
58
+ test("returns unknown short names as-is when not in knownModels", () => {
59
+ const known = [{ id: "openai/gpt-5" }];
60
+ expect(resolveModels("text", "nonexistent", known)).toEqual([
61
+ "nonexistent",
51
62
  ]);
52
63
  });
53
64
  });
@@ -73,7 +84,8 @@ describe("resolveModels multi", () => {
73
84
  });
74
85
 
75
86
  test("expands short names in comma list", () => {
76
- const result = resolveModels("image", "gpt-image-1,flux-2-pro");
87
+ const known = [{ id: "openai/gpt-image-1" }, { id: "bfl/flux-2-pro" }];
88
+ const result = resolveModels("image", "gpt-image-1,flux-2-pro", known);
77
89
  expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
78
90
  });
79
91
 
@@ -90,108 +102,206 @@ describe("resolveModels multi", () => {
90
102
  });
91
103
 
92
104
  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
- }
105
+ test("partitions models by type with enriched fields", async () => {
106
+ mockGateway([
107
+ {
108
+ id: "openai/gpt-5",
109
+ name: "GPT 5",
110
+ owned_by: "openai",
111
+ type: "language",
112
+ tags: [],
113
+ pricing: { input: "0.000003", output: "0.000015" },
114
+ },
115
+ {
116
+ id: "openai/gpt-image-2",
117
+ name: "GPT Image 2",
118
+ description: "Image gen",
119
+ owned_by: "openai",
120
+ type: "image",
121
+ tags: ["image-generation"],
122
+ pricing: { image: "0.02" },
123
+ },
124
+ {
125
+ id: "google/veo-3.0",
126
+ name: "Veo 3",
127
+ owned_by: "google",
128
+ type: "video",
129
+ tags: [],
130
+ },
131
+ {
132
+ id: "openai/text-embedding-3",
133
+ name: "Embedding",
134
+ owned_by: "openai",
135
+ type: "embedding",
136
+ tags: [],
137
+ },
138
+ ]);
139
+
140
+ const result = await fetchGatewayModels();
141
+
142
+ expect(result.text).toHaveLength(1);
143
+ expect(result.text[0].id).toBe("openai/gpt-5");
144
+ expect(result.text[0].creator).toBe("openai");
145
+ expect(result.text[0].capabilities).toEqual(["text"]);
146
+ expect(result.text[0].pricing).toEqual({
147
+ input: "0.000003",
148
+ output: "0.000015",
149
+ });
150
+
151
+ expect(result.image).toHaveLength(1);
152
+ expect(result.image[0].id).toBe("openai/gpt-image-2");
153
+ expect(result.image[0].creator).toBe("openai");
154
+ expect(result.image[0].capabilities).toEqual(["image"]);
155
+ expect(result.image[0].description).toBe("Image gen");
156
+ expect(result.image[0].pricing).toEqual({ image: "0.02" });
157
+
158
+ expect(result.video).toHaveLength(1);
159
+ expect(result.video[0].id).toBe("google/veo-3.0");
160
+ expect(result.video[0].creator).toBe("google");
161
+ expect(result.video[0].capabilities).toEqual(["video"]);
162
+
163
+ // embedding type is excluded
164
+ expect(result.all).toHaveLength(3);
165
+ });
166
+
167
+ test("language models with image-generation tag appear in both text and image", async () => {
168
+ mockGateway([
169
+ {
170
+ id: "google/gemini-2.5-flash-image",
171
+ name: "Gemini Flash Image",
172
+ owned_by: "google",
173
+ type: "language",
174
+ tags: ["image-generation"],
175
+ },
176
+ {
177
+ id: "openai/gpt-image-2",
178
+ name: "GPT Image 2",
179
+ owned_by: "openai",
180
+ type: "image",
181
+ tags: ["image-generation"],
182
+ },
183
+ ]);
184
+
185
+ const result = await fetchGatewayModels();
186
+
187
+ expect(result.text.map((m) => m.id)).toContain(
188
+ "google/gemini-2.5-flash-image"
189
+ );
190
+ expect(result.image.map((m) => m.id)).toContain(
191
+ "google/gemini-2.5-flash-image"
192
+ );
193
+ expect(result.image.map((m) => m.id)).toContain("openai/gpt-image-2");
194
+
195
+ const gemini = result.all.find(
196
+ (m) => m.id === "google/gemini-2.5-flash-image"
197
+ )!;
198
+ expect(gemini.capabilities).toEqual(["text", "image"]);
199
+
200
+ expect(
201
+ result.languageImageModelIds.has("google/gemini-2.5-flash-image")
202
+ ).toBe(true);
203
+ expect(result.languageImageModelIds.has("openai/gpt-image-2")).toBe(false);
204
+ });
205
+
206
+ test("language models without image-generation tag stay in text only", async () => {
207
+ mockGateway([
208
+ {
209
+ id: "openai/gpt-5",
210
+ name: "GPT 5",
211
+ owned_by: "openai",
212
+ type: "language",
213
+ tags: ["tool-use"],
214
+ },
215
+ ]);
216
+
217
+ const result = await fetchGatewayModels();
218
+
219
+ expect(result.text).toHaveLength(1);
220
+ expect(result.image).toHaveLength(0);
221
+ expect(result.languageImageModelIds.size).toBe(0);
222
+ expect(result.all[0].capabilities).toEqual(["text"]);
223
+ });
224
+
225
+ test("returns empty lists on gateway error", async () => {
226
+ mockGatewayError();
227
+
228
+ const result = await fetchGatewayModels();
229
+
230
+ expect(result.text).toHaveLength(0);
231
+ expect(result.image).toHaveLength(0);
232
+ expect(result.video).toHaveLength(0);
233
+ expect(result.all).toHaveLength(0);
234
+ expect(result.languageImageModelIds.size).toBe(0);
235
+ });
236
+
237
+ test("returns empty lists on non-200 response", async () => {
238
+ globalThis.fetch = mock(() =>
239
+ Promise.resolve(new Response("Not Found", { status: 404 }))
240
+ ) as unknown as typeof fetch;
241
+
242
+ const result = await fetchGatewayModels();
243
+
244
+ expect(result.text).toHaveLength(0);
245
+ expect(result.image).toHaveLength(0);
246
+ expect(result.video).toHaveLength(0);
247
+ expect(result.all).toHaveLength(0);
248
+ });
249
+
250
+ test("caches result across multiple calls", async () => {
251
+ const fetchMock = mock(() =>
252
+ Promise.resolve(
253
+ new Response(
254
+ JSON.stringify({
255
+ data: [
256
+ {
257
+ id: "openai/gpt-5",
258
+ name: "GPT 5",
259
+ owned_by: "openai",
260
+ type: "language",
261
+ tags: [],
262
+ },
263
+ ],
264
+ }),
265
+ { status: 200 }
266
+ )
267
+ )
268
+ );
269
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
270
+
271
+ const r1 = await fetchGatewayModels();
272
+ const r2 = await fetchGatewayModels();
273
+
274
+ expect(r1).toBe(r2);
275
+ expect(fetchMock).toHaveBeenCalledTimes(1);
276
+ });
277
+
278
+ test("pricing is omitted when all pricing fields are empty", async () => {
279
+ mockGateway([
280
+ {
281
+ id: "openai/gpt-5",
282
+ name: "GPT 5",
283
+ owned_by: "openai",
284
+ type: "language",
285
+ tags: [],
286
+ pricing: {},
287
+ },
288
+ ]);
289
+
290
+ const result = await fetchGatewayModels();
291
+ expect(result.text[0].pricing).toBeUndefined();
292
+ });
293
+
294
+ test("falls back to parsing creator from id when owned_by is absent", async () => {
295
+ mockGateway([
296
+ {
297
+ id: "openai/gpt-5",
298
+ name: "GPT 5",
299
+ type: "language",
300
+ tags: [],
301
+ },
302
+ ]);
303
+
304
+ const result = await fetchGatewayModels();
305
+ expect(result.text[0].creator).toBe("openai");
196
306
  });
197
307
  });