@flatkey-ai/cli 0.1.0 → 0.1.1
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/README.md +2 -0
- package/package.json +1 -1
- package/src/api.js +60 -16
- package/src/artifacts.js +40 -8
- package/src/cli.js +76 -8
- package/src/help.js +7 -4
- package/src/models.js +7 -0
- package/test/api.test.js +74 -0
- package/test/artifacts.test.js +60 -0
- package/test/cli.test.js +35 -0
- package/test/help.test.js +2 -0
- package/test/integration.test.js +80 -0
- package/test/live-flatkey.test.js +91 -0
- package/test/models.test.js +22 -0
package/README.md
CHANGED
|
@@ -30,8 +30,10 @@ export FLATKEY_API_KEY=<key>
|
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
32
|
flatkey image generate --prompt "magazine cover" --json
|
|
33
|
+
flatkey image generate --prompt "magazine cover" --output cover.png --json
|
|
33
34
|
flatkey video generate --prompt "product launch clip" --json
|
|
34
35
|
flatkey audio generate --prompt "30 second voiceover" --json
|
|
36
|
+
flatkey text generate --prompt "write a headline" --model gpt-5.5 --json
|
|
35
37
|
flatkey credits --json
|
|
36
38
|
flatkey status --json
|
|
37
39
|
flatkey models --json
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -9,9 +9,13 @@ export class FlatkeyError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export async function generateImage(options) {
|
|
12
|
+
return requestJsonFromPlan(options, planImageRequest(options));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function planImageRequest(options) {
|
|
12
16
|
const model = options.model ?? "nano-banana-pro-preview";
|
|
13
17
|
if (model.startsWith("gpt")) {
|
|
14
|
-
return
|
|
18
|
+
return planJsonPost(options, "/v1/images/generations", cleanObject({
|
|
15
19
|
model,
|
|
16
20
|
prompt: options.prompt,
|
|
17
21
|
size: options.size,
|
|
@@ -21,17 +25,21 @@ export async function generateImage(options) {
|
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
const path = `/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
24
|
-
return
|
|
28
|
+
return planRequest(options, path, {
|
|
25
29
|
method: "POST",
|
|
26
30
|
headers: { "content-type": "application/json" },
|
|
27
|
-
body:
|
|
31
|
+
body: {
|
|
28
32
|
contents: [{ parts: [{ text: options.prompt }] }],
|
|
29
|
-
}
|
|
33
|
+
},
|
|
30
34
|
});
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function generateVideo(options) {
|
|
34
|
-
return
|
|
38
|
+
return requestJsonFromPlan(options, planVideoRequest(options));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function planVideoRequest(options) {
|
|
42
|
+
return planJsonPost(options, "/v1/videos/generations", cleanObject({
|
|
35
43
|
model: options.model ?? "veo-3",
|
|
36
44
|
prompt: options.prompt,
|
|
37
45
|
duration: parseOptionalInteger(options.duration),
|
|
@@ -41,7 +49,11 @@ export function generateVideo(options) {
|
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export function generateAudio(options) {
|
|
44
|
-
return
|
|
52
|
+
return requestJsonFromPlan(options, planAudioRequest(options));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function planAudioRequest(options) {
|
|
56
|
+
return planJsonPost(options, "/v1/audio/generations", cleanObject({
|
|
45
57
|
model: options.model ?? "tts-1",
|
|
46
58
|
prompt: options.prompt,
|
|
47
59
|
voice: options.voice,
|
|
@@ -49,6 +61,17 @@ export function generateAudio(options) {
|
|
|
49
61
|
}));
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
export function generateText(options) {
|
|
65
|
+
return requestJsonFromPlan(options, planTextRequest(options));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planTextRequest(options) {
|
|
69
|
+
return planJsonPost(options, "/v1/chat/completions", {
|
|
70
|
+
model: options.model ?? "gpt-5.5",
|
|
71
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
export function getCredits(options) {
|
|
53
76
|
return requestJson(options, "/v1/credits");
|
|
54
77
|
}
|
|
@@ -62,22 +85,32 @@ export function getModels(options) {
|
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
async function postJson(options, path, payload) {
|
|
65
|
-
return
|
|
88
|
+
return requestJsonFromPlan(options, planJsonPost(options, path, payload));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function planJsonPost(options, path, payload) {
|
|
92
|
+
return planRequest(options, path, {
|
|
66
93
|
method: "POST",
|
|
67
94
|
headers: jsonHeaders(options.apiKey),
|
|
68
|
-
body:
|
|
95
|
+
body: payload,
|
|
69
96
|
});
|
|
70
97
|
}
|
|
71
98
|
|
|
72
|
-
|
|
99
|
+
function planRequest(options, path, init = {}) {
|
|
100
|
+
return {
|
|
101
|
+
url: buildUrl(options.baseUrl, path),
|
|
102
|
+
method: init.method ?? "GET",
|
|
103
|
+
headers: init.headers ?? authHeaders(options.apiKey),
|
|
104
|
+
body: init.body,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function requestJsonFromPlan(options, plan) {
|
|
73
109
|
const fetchImpl = options.fetch ?? fetch;
|
|
74
|
-
const response = await fetchImpl(
|
|
75
|
-
method:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
...authHeaders(options.apiKey),
|
|
79
|
-
...init.headers,
|
|
80
|
-
},
|
|
110
|
+
const response = await fetchImpl(plan.url, {
|
|
111
|
+
method: plan.method,
|
|
112
|
+
headers: plan.headers,
|
|
113
|
+
body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
|
|
81
114
|
});
|
|
82
115
|
const body = await readJson(response);
|
|
83
116
|
if (!response.ok) {
|
|
@@ -88,6 +121,17 @@ async function requestJson(options, path, init = {}) {
|
|
|
88
121
|
return body;
|
|
89
122
|
}
|
|
90
123
|
|
|
124
|
+
async function requestJson(options, path, init = {}) {
|
|
125
|
+
return requestJsonFromPlan(options, planRequest(options, path, {
|
|
126
|
+
method: init.method ?? "GET",
|
|
127
|
+
headers: {
|
|
128
|
+
...authHeaders(options.apiKey),
|
|
129
|
+
...init.headers,
|
|
130
|
+
},
|
|
131
|
+
body: init.body ? JSON.parse(init.body) : undefined,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
91
135
|
function buildUrl(baseUrl = DEFAULT_BASE_URL, path) {
|
|
92
136
|
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
|
93
137
|
}
|
package/src/artifacts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, extname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_EXTENSIONS = {
|
|
5
5
|
audio: "mp3",
|
|
@@ -18,34 +18,51 @@ const MIME_EXTENSIONS = {
|
|
|
18
18
|
"video/mp4": "mp4",
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
export async function persistArtifacts({
|
|
21
|
+
export async function persistArtifacts({
|
|
22
|
+
kind,
|
|
23
|
+
response,
|
|
24
|
+
outDir = "flatkey-output",
|
|
25
|
+
output,
|
|
26
|
+
fetch: fetchImpl = fetch,
|
|
27
|
+
}) {
|
|
22
28
|
const items = extractItems(response);
|
|
23
29
|
const artifacts = [];
|
|
24
|
-
await mkdir(outDir, { recursive: true });
|
|
30
|
+
await mkdir(output ? dirname(output) : outDir, { recursive: true });
|
|
25
31
|
|
|
26
32
|
for (const [index, item] of items.entries()) {
|
|
27
|
-
const artifact = await persistItem({ kind, item, outDir, index });
|
|
33
|
+
const artifact = await persistItem({ kind, item, outDir, output, index, fetchImpl });
|
|
28
34
|
if (artifact) artifacts.push(artifact);
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
return artifacts;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
async function persistItem({ kind, item, outDir, index }) {
|
|
40
|
+
async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
|
|
35
41
|
const dataUrl = getString(item, ["url", "data_url", "dataUrl"]);
|
|
36
42
|
if (dataUrl?.startsWith("data:")) {
|
|
37
43
|
const parsed = parseDataUrl(dataUrl);
|
|
38
|
-
const path = artifactPath({ kind, outDir, index, extension: parsed.extension });
|
|
44
|
+
const path = artifactPath({ kind, outDir, output, index, extension: parsed.extension });
|
|
39
45
|
await writeFile(path, parsed.buffer);
|
|
40
46
|
return { path };
|
|
41
47
|
}
|
|
42
48
|
if (dataUrl?.startsWith("http://") || dataUrl?.startsWith("https://")) {
|
|
49
|
+
if (output) {
|
|
50
|
+
const path = artifactPath({
|
|
51
|
+
kind,
|
|
52
|
+
outDir,
|
|
53
|
+
output,
|
|
54
|
+
index,
|
|
55
|
+
extension: DEFAULT_EXTENSIONS[kind] ?? "bin",
|
|
56
|
+
});
|
|
57
|
+
await downloadArtifact({ url: dataUrl, path, fetchImpl });
|
|
58
|
+
return { path };
|
|
59
|
+
}
|
|
43
60
|
return { url: dataUrl };
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
const base64 = getString(item, ["b64_json", "base64", "data"]);
|
|
47
64
|
if (base64) {
|
|
48
|
-
const path = artifactPath({ kind, outDir, index, extension: DEFAULT_EXTENSIONS[kind] });
|
|
65
|
+
const path = artifactPath({ kind, outDir, output, index, extension: DEFAULT_EXTENSIONS[kind] });
|
|
49
66
|
await writeFile(path, Buffer.from(base64, "base64"));
|
|
50
67
|
return { path };
|
|
51
68
|
}
|
|
@@ -53,6 +70,14 @@ async function persistItem({ kind, item, outDir, index }) {
|
|
|
53
70
|
return undefined;
|
|
54
71
|
}
|
|
55
72
|
|
|
73
|
+
async function downloadArtifact({ url, path, fetchImpl }) {
|
|
74
|
+
const response = await fetchImpl(url);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Failed to download artifact ${url}: HTTP ${response.status}`);
|
|
77
|
+
}
|
|
78
|
+
await writeFile(path, Buffer.from(await response.arrayBuffer()));
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
function extractItems(response) {
|
|
57
82
|
if (Array.isArray(response?.data)) return response.data;
|
|
58
83
|
if (Array.isArray(response?.artifacts)) return response.artifacts;
|
|
@@ -80,7 +105,14 @@ function parseDataUrl(value) {
|
|
|
80
105
|
};
|
|
81
106
|
}
|
|
82
107
|
|
|
83
|
-
function artifactPath({ kind, outDir, index, extension }) {
|
|
108
|
+
function artifactPath({ kind, outDir, output, index, extension }) {
|
|
109
|
+
if (output) {
|
|
110
|
+
if (index === 0) return output;
|
|
111
|
+
const existingExtension = extname(output);
|
|
112
|
+
const base = existingExtension ? output.slice(0, -existingExtension.length) : output;
|
|
113
|
+
const suffix = String(index + 1).padStart(2, "0");
|
|
114
|
+
return `${base}-${suffix}${existingExtension || `.${extension}`}`;
|
|
115
|
+
}
|
|
84
116
|
const number = String(index + 1).padStart(2, "0");
|
|
85
117
|
return join(outDir, `${kind}-${number}.${extension}`);
|
|
86
118
|
}
|
package/src/cli.js
CHANGED
|
@@ -6,11 +6,12 @@ const COMMANDS = new Set([
|
|
|
6
6
|
"models",
|
|
7
7
|
"onboard",
|
|
8
8
|
"status",
|
|
9
|
+
"text",
|
|
9
10
|
"version",
|
|
10
11
|
"video",
|
|
11
12
|
]);
|
|
12
13
|
|
|
13
|
-
const GROUP_ACTIONS = new Set(["audio", "image", "video"]);
|
|
14
|
+
const GROUP_ACTIONS = new Set(["audio", "image", "text", "video"]);
|
|
14
15
|
|
|
15
16
|
export function parseArgv(argv) {
|
|
16
17
|
const [group, maybeAction, ...rest] = argv;
|
|
@@ -36,6 +37,15 @@ function parseOptions(tokens) {
|
|
|
36
37
|
const options = {};
|
|
37
38
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
38
39
|
const token = tokens[index];
|
|
40
|
+
if (token === "-o") {
|
|
41
|
+
const output = tokens[index + 1];
|
|
42
|
+
if (!output || output.startsWith("-")) {
|
|
43
|
+
throw new Error("Missing value for -o.");
|
|
44
|
+
}
|
|
45
|
+
options.output = output;
|
|
46
|
+
index += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
39
49
|
if (!token.startsWith("--")) {
|
|
40
50
|
throw new Error(`Unexpected argument: ${token}`);
|
|
41
51
|
}
|
|
@@ -88,7 +98,7 @@ export async function runCommand(command, deps = {}) {
|
|
|
88
98
|
return handleUtility(command, deps);
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
if (["image", "video", "audio"].includes(command.group)) {
|
|
101
|
+
if (["image", "video", "audio", "text"].includes(command.group)) {
|
|
92
102
|
if (command.action !== "generate") {
|
|
93
103
|
throw new Error(`Unknown action for ${command.group}: ${command.action}`);
|
|
94
104
|
}
|
|
@@ -100,19 +110,40 @@ export async function runCommand(command, deps = {}) {
|
|
|
100
110
|
|
|
101
111
|
async function handleGenerate(command, deps) {
|
|
102
112
|
const { resolveApiKey } = await import("./config.js");
|
|
103
|
-
const {
|
|
113
|
+
const {
|
|
114
|
+
generateAudio,
|
|
115
|
+
generateImage,
|
|
116
|
+
generateText,
|
|
117
|
+
generateVideo,
|
|
118
|
+
planAudioRequest,
|
|
119
|
+
planImageRequest,
|
|
120
|
+
planTextRequest,
|
|
121
|
+
planVideoRequest,
|
|
122
|
+
} = await import("./api.js");
|
|
104
123
|
const { persistArtifacts } = await import("./artifacts.js");
|
|
105
124
|
const { createAnimation } = await import("./animation.js");
|
|
106
|
-
const apiKey =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
const apiKey = command.options.dry_run
|
|
126
|
+
? (command.options.api_key ?? "FLATKEY_API_KEY")
|
|
127
|
+
: await resolveApiKey({
|
|
128
|
+
apiKey: command.options.api_key,
|
|
129
|
+
env: deps.env ?? process.env,
|
|
130
|
+
});
|
|
110
131
|
const options = {
|
|
111
132
|
...command.options,
|
|
112
133
|
apiKey,
|
|
113
134
|
baseUrl: command.options.base_url,
|
|
114
135
|
fetch: deps.fetch,
|
|
115
136
|
};
|
|
137
|
+
if (command.options.dry_run) {
|
|
138
|
+
const request = command.group === "image"
|
|
139
|
+
? planImageRequest(options)
|
|
140
|
+
: command.group === "video"
|
|
141
|
+
? planVideoRequest(options)
|
|
142
|
+
: command.group === "audio"
|
|
143
|
+
? planAudioRequest(options)
|
|
144
|
+
: planTextRequest(options);
|
|
145
|
+
return { dryRun: true, kind: command.group, request: redactRequest(request) };
|
|
146
|
+
}
|
|
116
147
|
const animation = createAnimation({
|
|
117
148
|
json: Boolean(command.options.json),
|
|
118
149
|
stream: deps.stderr,
|
|
@@ -123,11 +154,20 @@ async function handleGenerate(command, deps) {
|
|
|
123
154
|
? await generateImage(options)
|
|
124
155
|
: command.group === "video"
|
|
125
156
|
? await generateVideo(options)
|
|
126
|
-
:
|
|
157
|
+
: command.group === "audio"
|
|
158
|
+
? await generateAudio(options)
|
|
159
|
+
: await generateText(options);
|
|
160
|
+
if (command.group === "text") {
|
|
161
|
+
const text = extractText(response);
|
|
162
|
+
const output = await writeTextOutput(text, command.options.output);
|
|
163
|
+
return { kind: command.group, text, output, response };
|
|
164
|
+
}
|
|
127
165
|
const artifacts = await persistArtifacts({
|
|
128
166
|
kind: command.group,
|
|
129
167
|
response,
|
|
130
168
|
outDir: command.options.out ?? "flatkey-output",
|
|
169
|
+
output: command.options.output,
|
|
170
|
+
fetch: deps.fetch,
|
|
131
171
|
});
|
|
132
172
|
return { kind: command.group, artifacts, response };
|
|
133
173
|
} finally {
|
|
@@ -135,6 +175,34 @@ async function handleGenerate(command, deps) {
|
|
|
135
175
|
}
|
|
136
176
|
}
|
|
137
177
|
|
|
178
|
+
function extractText(response) {
|
|
179
|
+
return response?.choices?.[0]?.message?.content
|
|
180
|
+
?? response?.output_text
|
|
181
|
+
?? response?.text
|
|
182
|
+
?? "";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function writeTextOutput(text, output) {
|
|
186
|
+
if (!output) return undefined;
|
|
187
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
188
|
+
const { dirname } = await import("node:path");
|
|
189
|
+
await mkdir(dirname(output), { recursive: true });
|
|
190
|
+
await writeFile(output, text);
|
|
191
|
+
return output;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function redactRequest(request) {
|
|
195
|
+
return {
|
|
196
|
+
...request,
|
|
197
|
+
headers: Object.fromEntries(
|
|
198
|
+
Object.entries(request.headers ?? {}).map(([key, value]) => [
|
|
199
|
+
key,
|
|
200
|
+
key.toLowerCase() === "authorization" ? "Bearer <redacted>" : value,
|
|
201
|
+
]),
|
|
202
|
+
),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
138
206
|
async function handleUtility(command, deps) {
|
|
139
207
|
const { resolveApiKey } = await import("./config.js");
|
|
140
208
|
const { getCredits, getStatus } = await import("./api.js");
|
package/src/help.js
CHANGED
|
@@ -12,12 +12,13 @@ JSON mode:
|
|
|
12
12
|
- ASCII animation and human logs are disabled.
|
|
13
13
|
|
|
14
14
|
Commands:
|
|
15
|
-
- flatkey image generate --prompt "<prompt>" --json [--model <model>] [--
|
|
16
|
-
- flatkey video generate --prompt "<prompt>" --json [--model
|
|
17
|
-
- flatkey audio generate --prompt "<prompt>" --json [--model tts-1] [--
|
|
15
|
+
- flatkey image generate --prompt "<prompt>" --json [--model <model>] [--output image.png]
|
|
16
|
+
- flatkey video generate --prompt "<prompt>" --json [--model seedance2] [--output video.mp4]
|
|
17
|
+
- flatkey audio generate --prompt "<prompt>" --json [--model tts-1] [--output audio.mp3]
|
|
18
|
+
- flatkey text generate --prompt "<prompt>" --json [--model gpt-5.5] [--output text.txt]
|
|
18
19
|
- flatkey credits --json
|
|
19
20
|
- flatkey status --json
|
|
20
|
-
- flatkey models --json [--type image|video|audio]
|
|
21
|
+
- flatkey models --json [--type image|video|audio|text]
|
|
21
22
|
- flatkey help --ai
|
|
22
23
|
|
|
23
24
|
Environment:
|
|
@@ -39,6 +40,7 @@ Commands:
|
|
|
39
40
|
image generate --prompt <txt> Generate image
|
|
40
41
|
video generate --prompt <txt> Generate video
|
|
41
42
|
audio generate --prompt <txt> Generate audio
|
|
43
|
+
text generate --prompt <txt> Generate text
|
|
42
44
|
credits Show remaining credits
|
|
43
45
|
status Show usage/status
|
|
44
46
|
models List available models
|
|
@@ -46,6 +48,7 @@ Commands:
|
|
|
46
48
|
|
|
47
49
|
Global options:
|
|
48
50
|
--json Print machine-readable JSON
|
|
51
|
+
--output, -o <file> Write generated output to a local file
|
|
49
52
|
--base-url <url> Override Flatkey router URL
|
|
50
53
|
`;
|
|
51
54
|
}
|
package/src/models.js
CHANGED
|
@@ -4,6 +4,8 @@ const BUNDLED_MODELS = [
|
|
|
4
4
|
{ id: "gpt-image-2", type: "image" },
|
|
5
5
|
{ id: "veo-3", type: "video" },
|
|
6
6
|
{ id: "veo-3-fast", type: "video" },
|
|
7
|
+
{ id: "seedance2", type: "video" },
|
|
8
|
+
{ id: "gpt-5.5", type: "text" },
|
|
7
9
|
{ id: "tts-1", type: "audio" },
|
|
8
10
|
{ id: "gpt-4o-mini-tts", type: "audio" },
|
|
9
11
|
];
|
|
@@ -35,5 +37,10 @@ function inferType(model) {
|
|
|
35
37
|
if (model.capabilities?.includes("video")) return "video";
|
|
36
38
|
if (model.capabilities?.includes("audio")) return "audio";
|
|
37
39
|
if (model.capabilities?.includes("image")) return "image";
|
|
40
|
+
if (model.capabilities?.includes("text")) return "text";
|
|
41
|
+
if (/image|nano-banana/i.test(model.id)) return "image";
|
|
42
|
+
if (/seedance|veo|sora|video/i.test(model.id)) return "video";
|
|
43
|
+
if (/tts|audio|voice|speech/i.test(model.id)) return "audio";
|
|
44
|
+
if (model.object === "model") return "text";
|
|
38
45
|
return undefined;
|
|
39
46
|
}
|
package/test/api.test.js
CHANGED
|
@@ -5,10 +5,14 @@ import {
|
|
|
5
5
|
FlatkeyError,
|
|
6
6
|
generateAudio,
|
|
7
7
|
generateImage,
|
|
8
|
+
generateText,
|
|
8
9
|
generateVideo,
|
|
9
10
|
getCredits,
|
|
10
11
|
getModels,
|
|
11
12
|
getStatus,
|
|
13
|
+
planImageRequest,
|
|
14
|
+
planTextRequest,
|
|
15
|
+
planVideoRequest,
|
|
12
16
|
} from "../src/api.js";
|
|
13
17
|
|
|
14
18
|
function fetchRecorder(responseBody = { ok: true }) {
|
|
@@ -93,6 +97,39 @@ test("builds video generation request", async () => {
|
|
|
93
97
|
});
|
|
94
98
|
});
|
|
95
99
|
|
|
100
|
+
test("builds seedance2 video generation request", async () => {
|
|
101
|
+
const { fetch, calls } = fetchRecorder({ data: [] });
|
|
102
|
+
|
|
103
|
+
await generateVideo({
|
|
104
|
+
apiKey: "key",
|
|
105
|
+
baseUrl: "https://router.test",
|
|
106
|
+
model: "seedance2",
|
|
107
|
+
prompt: "newsroom b-roll",
|
|
108
|
+
fetch,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(calls[0].url, "https://router.test/v1/videos/generations");
|
|
112
|
+
assert.equal(JSON.parse(calls[0].init.body).model, "seedance2");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("builds text generation request for gpt-5.5", async () => {
|
|
116
|
+
const { fetch, calls } = fetchRecorder({ choices: [{ message: { content: "copy" } }] });
|
|
117
|
+
|
|
118
|
+
await generateText({
|
|
119
|
+
apiKey: "key",
|
|
120
|
+
baseUrl: "https://router.test",
|
|
121
|
+
model: "gpt-5.5",
|
|
122
|
+
prompt: "write headline",
|
|
123
|
+
fetch,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(calls[0].url, "https://router.test/v1/chat/completions");
|
|
127
|
+
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
|
128
|
+
model: "gpt-5.5",
|
|
129
|
+
messages: [{ role: "user", content: "write headline" }],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
96
133
|
test("builds audio generation request", async () => {
|
|
97
134
|
const { fetch, calls } = fetchRecorder({ data: [] });
|
|
98
135
|
|
|
@@ -126,6 +163,43 @@ test("builds credits, status, and models requests", async () => {
|
|
|
126
163
|
assert.equal(calls[1].url, "https://router.test/v1/status");
|
|
127
164
|
assert.equal(calls[2].url, "https://router.test/v1/models");
|
|
128
165
|
assert.equal(calls[0].init.headers.Authorization, "Bearer key");
|
|
166
|
+
assert.equal(calls[2].init.headers.Authorization, "Bearer key");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("plans model list request from new-api relay route", async () => {
|
|
170
|
+
const { fetch, calls } = fetchRecorder({ object: "list", data: [{ id: "gpt-5.5" }] });
|
|
171
|
+
|
|
172
|
+
await getModels({ apiKey: "env-key", baseUrl: "https://router.flatkey.ai", fetch });
|
|
173
|
+
|
|
174
|
+
assert.equal(calls[0].url, "https://router.flatkey.ai/v1/models");
|
|
175
|
+
assert.equal(calls[0].init.method, "GET");
|
|
176
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer env-key");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("plans dry-run requests for target media models", () => {
|
|
180
|
+
assert.equal(planImageRequest({
|
|
181
|
+
apiKey: "key",
|
|
182
|
+
model: "gpt-image-2",
|
|
183
|
+
prompt: "poster",
|
|
184
|
+
}).url, "https://router.flatkey.ai/v1/images/generations");
|
|
185
|
+
|
|
186
|
+
assert.match(planImageRequest({
|
|
187
|
+
apiKey: "key",
|
|
188
|
+
model: "nano-banana-pro-preview",
|
|
189
|
+
prompt: "poster",
|
|
190
|
+
}).url, /\/v1beta\/models\/nano-banana-pro-preview:generateContent\?key=key$/);
|
|
191
|
+
|
|
192
|
+
assert.equal(planVideoRequest({
|
|
193
|
+
apiKey: "key",
|
|
194
|
+
model: "seedance2",
|
|
195
|
+
prompt: "clip",
|
|
196
|
+
}).body.model, "seedance2");
|
|
197
|
+
|
|
198
|
+
assert.equal(planTextRequest({
|
|
199
|
+
apiKey: "key",
|
|
200
|
+
model: "gpt-5.5",
|
|
201
|
+
prompt: "headline",
|
|
202
|
+
}).body.model, "gpt-5.5");
|
|
129
203
|
});
|
|
130
204
|
|
|
131
205
|
test("throws FlatkeyError with API message on HTTP failure", async () => {
|
package/test/artifacts.test.js
CHANGED
|
@@ -46,3 +46,63 @@ test("preserves remote url artifacts without downloading", async () => {
|
|
|
46
46
|
|
|
47
47
|
assert.deepEqual(artifacts, [{ url: "https://cdn.test/audio.mp3" }]);
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
test("saves first artifact to explicit output path", async () => {
|
|
51
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
|
|
52
|
+
const output = join(outDir, "custom.png");
|
|
53
|
+
|
|
54
|
+
const artifacts = await persistArtifacts({
|
|
55
|
+
kind: "image",
|
|
56
|
+
response: { data: [{ b64_json: Buffer.from("custom-bytes").toString("base64") }] },
|
|
57
|
+
output,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(artifacts, [{ path: output }]);
|
|
61
|
+
assert.equal(await readFile(output, "utf8"), "custom-bytes");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("adds numeric suffix for multiple artifacts with explicit output path", async () => {
|
|
65
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
|
|
66
|
+
const output = join(outDir, "custom.png");
|
|
67
|
+
|
|
68
|
+
const artifacts = await persistArtifacts({
|
|
69
|
+
kind: "image",
|
|
70
|
+
response: {
|
|
71
|
+
data: [
|
|
72
|
+
{ b64_json: Buffer.from("one").toString("base64") },
|
|
73
|
+
{ b64_json: Buffer.from("two").toString("base64") },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
output,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.match(artifacts[0].path, /custom\.png$/);
|
|
80
|
+
assert.match(artifacts[1].path, /custom-02\.png$/);
|
|
81
|
+
assert.equal(await readFile(artifacts[1].path, "utf8"), "two");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("downloads remote url artifact when explicit output path is set", async () => {
|
|
85
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
|
|
86
|
+
const output = join(outDir, "clip.mp4");
|
|
87
|
+
const calls = [];
|
|
88
|
+
|
|
89
|
+
const artifacts = await persistArtifacts({
|
|
90
|
+
kind: "video",
|
|
91
|
+
response: { data: [{ url: "https://cdn.test/clip.mp4" }] },
|
|
92
|
+
output,
|
|
93
|
+
fetch: async (url) => {
|
|
94
|
+
calls.push(url);
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 200,
|
|
98
|
+
async arrayBuffer() {
|
|
99
|
+
return Buffer.from("video-bytes");
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(calls, ["https://cdn.test/clip.mp4"]);
|
|
106
|
+
assert.deepEqual(artifacts, [{ path: output }]);
|
|
107
|
+
assert.equal(await readFile(output, "utf8"), "video-bytes");
|
|
108
|
+
});
|
package/test/cli.test.js
CHANGED
|
@@ -28,6 +28,41 @@ test("parses credits with json mode", () => {
|
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
test("parses text generation with dry-run", () => {
|
|
32
|
+
const command = parseArgv([
|
|
33
|
+
"text",
|
|
34
|
+
"generate",
|
|
35
|
+
"--prompt",
|
|
36
|
+
"write lead",
|
|
37
|
+
"--model",
|
|
38
|
+
"gpt-5.5",
|
|
39
|
+
"--dry-run",
|
|
40
|
+
"--json",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(command, {
|
|
44
|
+
group: "text",
|
|
45
|
+
action: "generate",
|
|
46
|
+
options: {
|
|
47
|
+
prompt: "write lead",
|
|
48
|
+
model: "gpt-5.5",
|
|
49
|
+
dry_run: true,
|
|
50
|
+
json: true,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("parses output aliases", () => {
|
|
56
|
+
assert.equal(
|
|
57
|
+
parseArgv(["image", "generate", "--prompt", "x", "--output", "out.png"]).options.output,
|
|
58
|
+
"out.png",
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
parseArgv(["video", "generate", "--prompt", "x", "-o", "clip.mp4"]).options.output,
|
|
62
|
+
"clip.mp4",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
31
66
|
test("parses ai help", () => {
|
|
32
67
|
const command = parseArgv(["help", "--ai"]);
|
|
33
68
|
|
package/test/help.test.js
CHANGED
|
@@ -11,6 +11,7 @@ test("ai help teaches agents setup and command usage", () => {
|
|
|
11
11
|
assert.match(help, /flatkey image generate/);
|
|
12
12
|
assert.match(help, /flatkey video generate/);
|
|
13
13
|
assert.match(help, /flatkey audio generate/);
|
|
14
|
+
assert.match(help, /flatkey text generate/);
|
|
14
15
|
assert.match(help, /flatkey credits --json/);
|
|
15
16
|
assert.match(help, /flatkey status --json/);
|
|
16
17
|
assert.match(help, /flatkey models --json/);
|
|
@@ -24,4 +25,5 @@ test("human help lists core commands", () => {
|
|
|
24
25
|
assert.match(help, /Usage: flatkey/);
|
|
25
26
|
assert.match(help, /image generate/);
|
|
26
27
|
assert.match(help, /models/);
|
|
28
|
+
assert.match(help, /text generate/);
|
|
27
29
|
});
|
package/test/integration.test.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
4
7
|
import { test } from "node:test";
|
|
5
8
|
|
|
6
9
|
const BIN = new URL("../bin/flatkey.js", import.meta.url).pathname;
|
|
@@ -21,6 +24,8 @@ test("runs generation and utility commands in json mode", async (t) => {
|
|
|
21
24
|
response.end(JSON.stringify({ data: [{ url: "https://cdn.test/video.mp4" }] }));
|
|
22
25
|
} else if (request.url === "/v1/audio/generations") {
|
|
23
26
|
response.end(JSON.stringify({ data: [{ url: "https://cdn.test/audio.mp3" }] }));
|
|
27
|
+
} else if (request.url === "/v1/chat/completions") {
|
|
28
|
+
response.end(JSON.stringify({ choices: [{ message: { content: "headline" } }] }));
|
|
24
29
|
} else if (request.url === "/v1/credits") {
|
|
25
30
|
response.end(JSON.stringify({ remaining: 42, used: 8 }));
|
|
26
31
|
} else if (request.url === "/v1/status") {
|
|
@@ -41,6 +46,7 @@ test("runs generation and utility commands in json mode", async (t) => {
|
|
|
41
46
|
const image = await runCli(["image", "generate", "--prompt", "poster", ...common]);
|
|
42
47
|
const video = await runCli(["video", "generate", "--prompt", "clip", ...common]);
|
|
43
48
|
const audio = await runCli(["audio", "generate", "--prompt", "voice", ...common]);
|
|
49
|
+
const text = await runCli(["text", "generate", "--prompt", "headline", ...common]);
|
|
44
50
|
const credits = await runCli(["credits", ...common]);
|
|
45
51
|
const status = await runCli(["status", ...common]);
|
|
46
52
|
const models = await runCli(["models", ...common]);
|
|
@@ -48,6 +54,7 @@ test("runs generation and utility commands in json mode", async (t) => {
|
|
|
48
54
|
assert.deepEqual(JSON.parse(image.stdout).artifacts, [{ url: "https://cdn.test/image.png" }]);
|
|
49
55
|
assert.deepEqual(JSON.parse(video.stdout).artifacts, [{ url: "https://cdn.test/video.mp4" }]);
|
|
50
56
|
assert.deepEqual(JSON.parse(audio.stdout).artifacts, [{ url: "https://cdn.test/audio.mp3" }]);
|
|
57
|
+
assert.equal(JSON.parse(text.stdout).text, "headline");
|
|
51
58
|
assert.equal(JSON.parse(credits.stdout).remaining, 42);
|
|
52
59
|
assert.equal(JSON.parse(status.stdout).status, "ok");
|
|
53
60
|
assert.deepEqual(JSON.parse(models.stdout).models, [
|
|
@@ -57,6 +64,79 @@ test("runs generation and utility commands in json mode", async (t) => {
|
|
|
57
64
|
assert.ok(requests.some((request) => request.url.startsWith("/v1beta/models/")));
|
|
58
65
|
assert.ok(requests.some((request) => request.url === "/v1/videos/generations"));
|
|
59
66
|
assert.ok(requests.some((request) => request.url === "/v1/audio/generations"));
|
|
67
|
+
assert.ok(requests.some((request) => request.url === "/v1/chat/completions"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("dry-run returns planned request without calling network", async () => {
|
|
71
|
+
const result = await runCli([
|
|
72
|
+
"image",
|
|
73
|
+
"generate",
|
|
74
|
+
"--prompt",
|
|
75
|
+
"poster",
|
|
76
|
+
"--model",
|
|
77
|
+
"gpt-image-2",
|
|
78
|
+
"--dry-run",
|
|
79
|
+
"--json",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const payload = JSON.parse(result.stdout);
|
|
83
|
+
assert.equal(payload.dryRun, true);
|
|
84
|
+
assert.equal(payload.kind, "image");
|
|
85
|
+
assert.equal(payload.request.url, "https://router.flatkey.ai/v1/images/generations");
|
|
86
|
+
assert.equal(payload.request.body.model, "gpt-image-2");
|
|
87
|
+
assert.equal(result.stderr, "");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("generation commands write explicit output files", async (t) => {
|
|
91
|
+
const server = createServer((request, response) => {
|
|
92
|
+
request.on("data", () => {});
|
|
93
|
+
request.on("end", () => {
|
|
94
|
+
response.setHeader("content-type", "application/json");
|
|
95
|
+
if (request.url === "/v1/images/generations") {
|
|
96
|
+
response.end(JSON.stringify({
|
|
97
|
+
data: [{ b64_json: Buffer.from("image-file").toString("base64") }],
|
|
98
|
+
}));
|
|
99
|
+
} else if (request.url === "/v1/chat/completions") {
|
|
100
|
+
response.end(JSON.stringify({ choices: [{ message: { content: "text-file" } }] }));
|
|
101
|
+
} else {
|
|
102
|
+
response.statusCode = 404;
|
|
103
|
+
response.end(JSON.stringify({ error: { message: "not found" } }));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
t.after(() => server.close());
|
|
108
|
+
await listen(server);
|
|
109
|
+
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
110
|
+
const dir = await mkdtemp(join(tmpdir(), "flatkey-output-"));
|
|
111
|
+
const imageOutput = join(dir, "poster.png");
|
|
112
|
+
const textOutput = join(dir, "headline.txt");
|
|
113
|
+
|
|
114
|
+
const common = ["--base-url", baseUrl, "--api-key", "test-key", "--json"];
|
|
115
|
+
const image = await runCli([
|
|
116
|
+
"image",
|
|
117
|
+
"generate",
|
|
118
|
+
"--model",
|
|
119
|
+
"gpt-image-2",
|
|
120
|
+
"--prompt",
|
|
121
|
+
"poster",
|
|
122
|
+
"--output",
|
|
123
|
+
imageOutput,
|
|
124
|
+
...common,
|
|
125
|
+
]);
|
|
126
|
+
const text = await runCli([
|
|
127
|
+
"text",
|
|
128
|
+
"generate",
|
|
129
|
+
"--prompt",
|
|
130
|
+
"headline",
|
|
131
|
+
"-o",
|
|
132
|
+
textOutput,
|
|
133
|
+
...common,
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
assert.deepEqual(JSON.parse(image.stdout).artifacts, [{ path: imageOutput }]);
|
|
137
|
+
assert.equal(await readFile(imageOutput, "utf8"), "image-file");
|
|
138
|
+
assert.equal(JSON.parse(text.stdout).output, textOutput);
|
|
139
|
+
assert.equal(await readFile(textOutput, "utf8"), "text-file");
|
|
60
140
|
});
|
|
61
141
|
|
|
62
142
|
test("prints json errors to stderr in json mode", async () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
|
|
8
|
+
const BIN = new URL("../bin/flatkey.js", import.meta.url).pathname;
|
|
9
|
+
const LIVE = process.env.FLATKEY_LIVE_TESTS === "1";
|
|
10
|
+
|
|
11
|
+
const cases = [
|
|
12
|
+
{
|
|
13
|
+
name: "models list",
|
|
14
|
+
args: ["models", "--json"],
|
|
15
|
+
paid: false,
|
|
16
|
+
expect: (payload) => {
|
|
17
|
+
assert.ok(Array.isArray(payload.models));
|
|
18
|
+
assert.ok(payload.models.length > 0);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "image gpt",
|
|
23
|
+
args: ["image", "generate", "--model", "gpt-image-2", "--prompt", "one tiny red square icon", "--json"],
|
|
24
|
+
paid: true,
|
|
25
|
+
dryRun: false,
|
|
26
|
+
expect: (payload) => assert.equal(payload.kind, "image"),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "image nano",
|
|
30
|
+
args: ["image", "generate", "--model", "nano-banana-pro-preview", "--prompt", "one tiny blue square icon", "--json"],
|
|
31
|
+
paid: true,
|
|
32
|
+
dryRun: false,
|
|
33
|
+
expect: (payload) => assert.equal(payload.kind, "image"),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "video seedance2",
|
|
37
|
+
args: ["video", "generate", "--model", "seedance2", "--prompt", "one second newsroom establishing shot", "--duration", "1", "--json"],
|
|
38
|
+
paid: true,
|
|
39
|
+
dryRun: false,
|
|
40
|
+
expect: (payload) => assert.equal(payload.kind, "video"),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "text gpt-5.5",
|
|
44
|
+
args: ["text", "generate", "--model", "gpt-5.5", "--prompt", "Write a five word headline.", "--json"],
|
|
45
|
+
paid: true,
|
|
46
|
+
dryRun: false,
|
|
47
|
+
expect: (payload) => {
|
|
48
|
+
assert.equal(payload.kind, "text");
|
|
49
|
+
assert.equal(typeof payload.text, "string");
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const item of cases) {
|
|
55
|
+
test(`live flatkey ${item.name}${item.dryRun ? " dry-run" : ""}`, { skip: !LIVE }, async () => {
|
|
56
|
+
assert.ok(process.env.FLATKEY_API_KEY, "FLATKEY_API_KEY is required");
|
|
57
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-live-"));
|
|
58
|
+
const args = [...item.args, "--out", outDir];
|
|
59
|
+
if (item.dryRun) args.push("--dry-run");
|
|
60
|
+
const result = await runCli(args);
|
|
61
|
+
const payload = JSON.parse(result.stdout);
|
|
62
|
+
item.expect(payload);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runCli(args) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn(process.execPath, [BIN, ...args], {
|
|
69
|
+
env: {
|
|
70
|
+
...process.env,
|
|
71
|
+
FLATKEY_API_KEY: process.env.FLATKEY_API_KEY,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
child.stdout.on("data", (chunk) => {
|
|
77
|
+
stdout += chunk;
|
|
78
|
+
});
|
|
79
|
+
child.stderr.on("data", (chunk) => {
|
|
80
|
+
stderr += chunk;
|
|
81
|
+
});
|
|
82
|
+
child.on("error", reject);
|
|
83
|
+
child.on("close", (code) => {
|
|
84
|
+
if (code !== 0) {
|
|
85
|
+
reject(new Error(`CLI exited ${code}\nstdout:${stdout}\nstderr:${stderr}`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
resolve({ stdout, stderr });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
package/test/models.test.js
CHANGED
|
@@ -7,7 +7,10 @@ test("returns bundled fallback models with source marker", () => {
|
|
|
7
7
|
const models = getBundledModels();
|
|
8
8
|
|
|
9
9
|
assert.ok(models.some((model) => model.id === "nano-banana-pro-preview"));
|
|
10
|
+
assert.ok(models.some((model) => model.id === "seedance2"));
|
|
11
|
+
assert.ok(models.some((model) => model.id === "gpt-5.5"));
|
|
10
12
|
assert.ok(models.some((model) => model.type === "video"));
|
|
13
|
+
assert.ok(models.some((model) => model.type === "text"));
|
|
11
14
|
assert.ok(models.some((model) => model.type === "audio"));
|
|
12
15
|
assert.equal(models[0].source, "bundled");
|
|
13
16
|
});
|
|
@@ -32,3 +35,22 @@ test("normalizes remote model arrays", () => {
|
|
|
32
35
|
{ id: "vid-x", type: "video", source: "remote" },
|
|
33
36
|
]);
|
|
34
37
|
});
|
|
38
|
+
|
|
39
|
+
test("normalizes OpenAI model list response from /v1/models", () => {
|
|
40
|
+
const models = normalizeModels({
|
|
41
|
+
object: "list",
|
|
42
|
+
data: [
|
|
43
|
+
{ id: "gpt-5.5", object: "model" },
|
|
44
|
+
{ id: "seedance2", object: "model" },
|
|
45
|
+
{ id: "gpt-image-2", object: "model" },
|
|
46
|
+
{ id: "nano-banana-pro-preview", object: "model" },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(models, [
|
|
51
|
+
{ id: "gpt-5.5", type: "text", source: "remote" },
|
|
52
|
+
{ id: "seedance2", type: "video", source: "remote" },
|
|
53
|
+
{ id: "gpt-image-2", type: "image", source: "remote" },
|
|
54
|
+
{ id: "nano-banana-pro-preview", type: "image", source: "remote" },
|
|
55
|
+
]);
|
|
56
|
+
});
|