@gentbajko/slopify 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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/adapter-registry.js +68 -0
  4. package/dist/adapters/image/bytes.js +64 -0
  5. package/dist/adapters/image/fal.js +164 -0
  6. package/dist/adapters/image/openai.js +155 -0
  7. package/dist/adapters/image/replicate.js +183 -0
  8. package/dist/adapters/llm/claude-code.js +158 -0
  9. package/dist/adapters/llm/codex.js +126 -0
  10. package/dist/adapters/llm/openrouter.js +193 -0
  11. package/dist/adapters/llm/run-cli.js +134 -0
  12. package/dist/adapters/llm/sse-lines.js +46 -0
  13. package/dist/adapters/retry-after.js +21 -0
  14. package/dist/adapters/tts/cartesia.js +105 -0
  15. package/dist/adapters/tts/elevenlabs.js +107 -0
  16. package/dist/adapters/tts/openai.js +95 -0
  17. package/dist/edge/cli.js +36 -0
  18. package/dist/edge/events/hub.js +76 -0
  19. package/dist/edge/http/actions.js +125 -0
  20. package/dist/edge/http/app.js +72 -0
  21. package/dist/edge/http/entries.js +41 -0
  22. package/dist/edge/http/files.js +67 -0
  23. package/dist/edge/http/problem.js +65 -0
  24. package/dist/edge/http/projects.js +128 -0
  25. package/dist/edge/http/prompts.js +73 -0
  26. package/dist/edge/http/providers.js +62 -0
  27. package/dist/edge/http/settings.js +90 -0
  28. package/dist/edge/http/staging.js +104 -0
  29. package/dist/edge/http/telemetry.js +29 -0
  30. package/dist/edge/http/usage.js +19 -0
  31. package/dist/edge/open-browser.js +20 -0
  32. package/dist/kernel/clock.js +18 -0
  33. package/dist/kernel/config/index.js +32 -0
  34. package/dist/kernel/db/index.js +24 -0
  35. package/dist/kernel/db/migrate.js +59 -0
  36. package/dist/kernel/db/migrations/0001-init.sql +17 -0
  37. package/dist/kernel/db/tx.js +53 -0
  38. package/dist/kernel/events.js +1 -0
  39. package/dist/kernel/ids.js +4 -0
  40. package/dist/kernel/lock.js +73 -0
  41. package/dist/kernel/log.js +34 -0
  42. package/dist/kernel/paths.js +21 -0
  43. package/dist/kernel/pipeline.js +22 -0
  44. package/dist/kernel/ports/image.js +1 -0
  45. package/dist/kernel/ports/llm.js +1 -0
  46. package/dist/kernel/ports/model.js +34 -0
  47. package/dist/kernel/ports/registry.js +1 -0
  48. package/dist/kernel/ports/tts.js +1 -0
  49. package/dist/kernel/runner/attempt-repo.js +50 -0
  50. package/dist/kernel/runner/attempt.js +146 -0
  51. package/dist/kernel/runner/graph.js +82 -0
  52. package/dist/kernel/runner/index.js +205 -0
  53. package/dist/kernel/runner/piece-repo.js +58 -0
  54. package/dist/kernel/runner/providers.js +92 -0
  55. package/dist/kernel/version.js +14 -0
  56. package/dist/main.js +212 -0
  57. package/dist/slices/admission/model.js +6 -0
  58. package/dist/slices/admission/repo.js +178 -0
  59. package/dist/slices/admission/rules.js +201 -0
  60. package/dist/slices/admission/start.js +103 -0
  61. package/dist/slices/admission/substitute.js +80 -0
  62. package/dist/slices/article/continuation.js +81 -0
  63. package/dist/slices/article/plain.js +14 -0
  64. package/dist/slices/article/run.js +166 -0
  65. package/dist/slices/article/split.js +51 -0
  66. package/dist/slices/article/store.js +21 -0
  67. package/dist/slices/cancel/index.js +61 -0
  68. package/dist/slices/images/run.js +221 -0
  69. package/dist/slices/library/lint.js +53 -0
  70. package/dist/slices/library/model.js +13 -0
  71. package/dist/slices/library/repo.js +111 -0
  72. package/dist/slices/library/save.js +87 -0
  73. package/dist/slices/library/slots.js +86 -0
  74. package/dist/slices/narration/chunk.js +71 -0
  75. package/dist/slices/narration/concat.js +79 -0
  76. package/dist/slices/narration/run.js +280 -0
  77. package/dist/slices/reruns/cascade.js +66 -0
  78. package/dist/slices/reruns/index.js +269 -0
  79. package/dist/slices/research/planner.js +81 -0
  80. package/dist/slices/research/run.js +192 -0
  81. package/dist/slices/research/synthesis.js +48 -0
  82. package/dist/slices/settings/cli-status.js +37 -0
  83. package/dist/slices/settings/keys.js +55 -0
  84. package/dist/slices/settings/model.js +54 -0
  85. package/dist/slices/settings/playback.js +60 -0
  86. package/dist/slices/settings/readiness.js +21 -0
  87. package/dist/slices/settings/repo.js +59 -0
  88. package/dist/slices/settings/voices.js +48 -0
  89. package/dist/slices/storage/asset-name.js +16 -0
  90. package/dist/slices/storage/delete-project.js +35 -0
  91. package/dist/slices/storage/downloads.js +100 -0
  92. package/dist/slices/storage/layout.js +59 -0
  93. package/dist/slices/storage/model.js +20 -0
  94. package/dist/slices/storage/reconcile.js +102 -0
  95. package/dist/slices/storage/repo.js +104 -0
  96. package/dist/slices/storage/staging.js +224 -0
  97. package/dist/slices/telemetry/collector-client.js +51 -0
  98. package/dist/slices/telemetry/flush.js +111 -0
  99. package/dist/slices/telemetry/machine.js +33 -0
  100. package/dist/slices/telemetry/model.js +39 -0
  101. package/dist/slices/telemetry/record.js +41 -0
  102. package/dist/slices/telemetry/repo.js +73 -0
  103. package/dist/slices/telemetry/usage.js +82 -0
  104. package/dist/slices/thumbnail/by-llm.js +30 -0
  105. package/dist/slices/thumbnail/run.js +200 -0
  106. package/dist/slices/video/ffmpeg.js +230 -0
  107. package/dist/slices/video/plan.js +71 -0
  108. package/dist/slices/video/run.js +179 -0
  109. package/dist/web/app-icon.svg +1 -0
  110. package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  111. package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  112. package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
  113. package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
  114. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  115. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  116. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
  117. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
  118. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  119. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  120. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
  121. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
  122. package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
  123. package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
  124. package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
  125. package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
  126. package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
  127. package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
  128. package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
  129. package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
  130. package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
  131. package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
  132. package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
  133. package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
  134. package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
  135. package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
  136. package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
  137. package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
  138. package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
  139. package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
  140. package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
  141. package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
  142. package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
  143. package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
  144. package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
  145. package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
  146. package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
  147. package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
  148. package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
  149. package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
  150. package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
  151. package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
  152. package/dist/web/assets/index-BPqQnrpy.css +1 -0
  153. package/dist/web/assets/index-D_sWbKQi.js +81 -0
  154. package/dist/web/favicon.svg +1 -0
  155. package/dist/web/index.html +15 -0
  156. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gent Bajko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Slopify
2
+
3
+ A prompt and a few keywords in. A narrated slideshow video out. Your keys, your machine, free.
4
+
5
+ ```sh
6
+ npx @gentbajko/slopify@latest
7
+ ```
8
+
9
+ That opens a browser at `http://127.0.0.1:4242`. Nothing to configure first; add
10
+ provider keys on the Settings screen when you want a stage to generate rather than
11
+ take what you paste in.
12
+
13
+ Six stages run as a graph: research, article, narration, images, thumbnail, video.
14
+ Generate any of them, or provide the output yourself and that stage is skipped. The
15
+ video is a slideshow with alternating zoom over the narration, rendered with ffmpeg.
16
+
17
+ ## Options
18
+
19
+ | Flag | Environment variable | Default |
20
+ |---|---|---|
21
+ | `--port` | `SLOPIFY_PORT` | `4242` |
22
+ | `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
23
+ | `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
24
+ | `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
25
+ | — | `SLOPIFY_FFMPEG` | the bundled binary |
26
+
27
+ There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
28
+ it to whoever reaches the port, and the CLI says so on startup.
29
+
30
+ Everything lives in one SQLite file and one directory tree under the data directory:
31
+ `slopify.db`, `projects/`, `staging/`, `logs/`. Delete it and nothing of yours remains.
32
+
33
+ ## ffmpeg and the GPL
34
+
35
+ Slopify renders video with ffmpeg. The `ffmpeg-static` dependency downloads a
36
+ prebuilt binary to `node_modules/ffmpeg-static/` at install time.
37
+
38
+ That binary is a separate program, run as a child process with an argument array. It
39
+ is licensed under the **GPL-3.0-or-later**. Slopify does not link against it, does not
40
+ embed it, and does not distribute it inside this package; its licence text and the
41
+ location of its corresponding source ship beside it in `ffmpeg-static`. Slopify's own
42
+ code is MIT and stays MIT. Anyone redistributing the downloaded binary takes on the
43
+ GPL's obligations for it, including offering that corresponding source.
44
+
45
+ Point `SLOPIFY_FFMPEG` at your own build to use that instead.
46
+
47
+ ## Telemetry
48
+
49
+ Slopify sends anonymous counters to a collector: installs, projects created, stages
50
+ completed, images and videos made, audio seconds, provider and model names, token
51
+ counts, and the time each of those happened. Every event carries a random id of its
52
+ own and this machine's random id, and nothing else.
53
+
54
+ Never your keys, prompts, keywords, titles, article text, filenames, or anything about
55
+ your machine. A notice says all of this the first time you run it, before the machine
56
+ id exists, and the Usage screen shows you your own numbers at any time.
57
+
58
+ ## Licence
59
+
60
+ MIT. The ffmpeg binary fetched at install time is a separate GPL-3.0-or-later program,
61
+ as described above.
62
+
63
+ Source, issues and the full guide: <https://github.com/GentBajko/slopify>
@@ -0,0 +1,68 @@
1
+ import { falImage } from "./adapters/image/fal.js";
2
+ import { openAiImage } from "./adapters/image/openai.js";
3
+ import { replicateImage } from "./adapters/image/replicate.js";
4
+ import { claudeCodeLlm } from "./adapters/llm/claude-code.js";
5
+ import { codexLlm } from "./adapters/llm/codex.js";
6
+ import { openRouterLlm } from "./adapters/llm/openrouter.js";
7
+ import { cartesiaTts } from "./adapters/tts/cartesia.js";
8
+ import { elevenLabsTts } from "./adapters/tts/elevenlabs.js";
9
+ import { openAiTts } from "./adapters/tts/openai.js";
10
+ import { keyForAttempt } from "./slices/settings/keys.js";
11
+ import { providerStatuses } from "./slices/settings/readiness.js";
12
+ export function buildRegistry(deps) {
13
+ // `logic/02` §Q16: read per request, never held, so an attempt in flight finishes on
14
+ // the key it started with and the next one picks up a key saved since. The closure is
15
+ // handed to the one adapter that provider belongs to and to nothing else (§Q18).
16
+ const keyOf = (provider) => () => {
17
+ const found = keyForAttempt({ db: deps.db, clock: deps.clock }, provider);
18
+ return found.ok ? found.key : undefined;
19
+ };
20
+ const llms = new Map([
21
+ ["openrouter", openRouterLlm({ fetch: deps.fetch, key: keyOf("openrouter") })],
22
+ // No key: both CLIs authenticate with their own login (`logic/02` §Q135).
23
+ ["claude-code", claudeCodeLlm({ run: deps.spawn })],
24
+ ["codex", codexLlm({ run: deps.spawn })],
25
+ ]);
26
+ // One key per provider (`logic/02` invariant), so each adapter is handed the reader for
27
+ // its own row and no other. OpenAI keeps two rows because it ships an adapter in two
28
+ // families and a user may key one without the other (`slices/settings/model.ts`).
29
+ const ttses = new Map([
30
+ ["elevenlabs", elevenLabsTts({ fetch: deps.fetch, key: keyOf("elevenlabs") })],
31
+ ["openai-tts", openAiTts({ fetch: deps.fetch, key: keyOf("openai-tts") })],
32
+ ["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
33
+ ]);
34
+ // `logic/09` and `logic/02` §Q15: three image providers behind one port, each handed
35
+ // the reader for its own key row. Replicate also takes the clock: `Prefer: wait` gives
36
+ // up after 60 s and the prediction has to be polled, and the wait is spent on the
37
+ // app's clock so a test never sits through one.
38
+ const images = new Map([
39
+ ["fal", falImage({ fetch: deps.fetch, key: keyOf("fal") })],
40
+ [
41
+ "replicate",
42
+ replicateImage({ fetch: deps.fetch, key: keyOf("replicate"), clock: deps.clock }),
43
+ ],
44
+ ["openai-image", openAiImage({ fetch: deps.fetch, key: keyOf("openai-image") })],
45
+ ]);
46
+ return {
47
+ llm: (id) => resolve(llms, "llm", id),
48
+ tts: (id) => resolve(ttses, "tts", id),
49
+ image: (id) => resolve(images, "image", id),
50
+ // `logic/02` step 5: every supported provider, keyed or not, found or not, so Play
51
+ // can grey one out with a reason instead of hiding it.
52
+ list: async () => (await providerStatuses({ db: deps.db, probe: deps.probe })).map((status) => ({
53
+ family: status.family,
54
+ id: status.id,
55
+ name: status.displayName,
56
+ readiness: status.readiness,
57
+ })),
58
+ };
59
+ }
60
+ // The Registry contract: an id the catalogue does not carry is a bug in admission, not a
61
+ // user error, so it throws rather than answering undefined.
62
+ function resolve(ports, family, id) {
63
+ const port = ports.get(id);
64
+ if (port === undefined) {
65
+ throw new Error(`no ${family} adapter is registered for ${id}`);
66
+ }
67
+ return port;
68
+ }
@@ -0,0 +1,64 @@
1
+ import { providerError } from "../../kernel/ports/model.js";
2
+ // Two of the three image providers answer with a link rather than with bytes, so fetching
3
+ // that link is part of the provider call and belongs inside the attempt with it: a URL
4
+ // that 404s or hands back an HTML error page is a failed attempt, not a corrupt file on
5
+ // disk. It sits beside the adapters, as `retry-after.ts` does, because the rule is the
6
+ // port's - `GeneratedImage` is a PNG or a JPEG and nothing else - rather than any one
7
+ // vendor's. Nothing else is shared between the three adapters.
8
+ const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
9
+ const jpeg = [0xff, 0xd8, 0xff];
10
+ // Not a format the port carries, but the likeliest wrong answer: it is what most
11
+ // diffusion models return by default, so naming it turns a puzzling failure into one
12
+ // sentence the user can act on.
13
+ const riff = [0x52, 0x49, 0x46, 0x46];
14
+ const webp = [0x57, 0x45, 0x42, 0x50];
15
+ // The bytes decide, never the Content-Type header. A CDN in front of an expired link
16
+ // serves `image/png` over an HTML error page often enough to be worth not trusting, and
17
+ // the mime is stored with the file and becomes its extension (`logic/09` step 3).
18
+ export function sniffImage(bytes) {
19
+ if (startsWith(bytes, png, 0)) {
20
+ return "image/png";
21
+ }
22
+ return startsWith(bytes, jpeg, 0) ? "image/jpeg" : undefined;
23
+ }
24
+ // What to call what arrived when it is not an image, without echoing bytes that may be a
25
+ // signed URL, a key quoted back, or a megabyte of HTML.
26
+ export function describeBytes(bytes) {
27
+ if (bytes.length === 0) {
28
+ return "an empty body";
29
+ }
30
+ if (startsWith(bytes, riff, 0) && startsWith(bytes, webp, 8)) {
31
+ return "a WebP image";
32
+ }
33
+ return `${String(bytes.length)} bytes beginning ${hex(bytes)}`;
34
+ }
35
+ export async function downloadImage(download) {
36
+ const response = await download.fetch(download.url, { signal: download.signal });
37
+ if (!response.ok) {
38
+ // `other`, so the attempt wrapper retries it: a link that is not ready yet or a CDN
39
+ // hiccup is exactly the transient failure the retry policy exists for. The URL is not
40
+ // quoted back - a fal or Replicate delivery link carries its own signature.
41
+ throw providerError({
42
+ kind: "other",
43
+ message: `${download.provider} answered ${String(response.status)} for the image it said it had made`,
44
+ });
45
+ }
46
+ const bytes = new Uint8Array(await response.arrayBuffer());
47
+ const mime = sniffImage(bytes);
48
+ if (mime === undefined) {
49
+ throw providerError({
50
+ kind: "other",
51
+ message: `${download.provider}'s image link answered with ${describeBytes(bytes)} rather than a PNG or a JPEG`,
52
+ });
53
+ }
54
+ return { bytes, mime };
55
+ }
56
+ function startsWith(bytes, magic, at) {
57
+ if (bytes.length < at + magic.length) {
58
+ return false;
59
+ }
60
+ return magic.every((byte, index) => bytes[at + index] === byte);
61
+ }
62
+ function hex(bytes) {
63
+ return [...bytes.slice(0, 4)].map((byte) => byte.toString(16).padStart(2, "0")).join(" ");
64
+ }
@@ -0,0 +1,164 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { retryAfter } from "../retry-after.js";
5
+ import { downloadImage } from "./bytes.js";
6
+ // The HTTP gateway adapter for fal.ai (01-architecture Module boundaries). `fetch` and
7
+ // the downloader beside this file, no SDK: `@fal-ai/client` exists and would buy the
8
+ // queue polling this endpoint does not need, so 05-dependencies' rung 3 - the platform's
9
+ // own `fetch` - already covers the whole call. The synchronous host runs the model on the
10
+ // open connection, which is what the attempt wrapper's 300 s is measuring.
11
+ export const falBase = "https://fal.run";
12
+ // `logic/02` §Q15: the model dropdown is filled from what the provider offers, and fal has
13
+ // no endpoint that lists only its text-to-image models, so the list is this adapter's own
14
+ // data. Adding next month's model is a line here and no code change anywhere else.
15
+ // Every entry takes the same input shape: `image_size` as an enum, `num_images`, and
16
+ // `output_format`. ceiling: a fal model that spells its aspect `aspect_ratio` instead -
17
+ // `xai/grok-imagine-image` does - cannot be added to this list without a per-model input
18
+ // map, which is the upgrade when one is wanted.
19
+ export const falModels = [
20
+ { id: "fal-ai/flux-2", name: "FLUX.2" },
21
+ { id: "fal-ai/flux/dev", name: "FLUX.1 [dev]" },
22
+ { id: "fal-ai/flux/schnell", name: "FLUX.1 [schnell]" },
23
+ ];
24
+ // `logic/09` step 1: the closest supported size to the run's aspect. fal names the two
25
+ // 16:9 frames from the orientation, so the portrait one is "portrait_16_9" and is 9:16.
26
+ const sizes = {
27
+ "16:9": "landscape_16_9",
28
+ "9:16": "portrait_16_9",
29
+ };
30
+ // A wire payload is narrowed, never cast: everything unlisted is dropped at the seam so
31
+ // no vendor shape can leak past this file (01-architecture §Q10, §Q33).
32
+ const generated = z.object({
33
+ images: z.array(z.object({ url: z.string() })),
34
+ // The safety checker's verdict, one flag per image. It is how fal says it declined:
35
+ // the request is a 200 and the image that comes back is blank.
36
+ has_nsfw_concepts: z.array(z.boolean()).nullish(),
37
+ });
38
+ // fal answers a rejected request with FastAPI's own envelope: an object per failed field
39
+ // for a validation error, a bare string for everything else.
40
+ const errorBody = z.object({
41
+ detail: z.union([
42
+ z.string(),
43
+ z.array(z.object({ msg: z.string(), type: z.string().optional() })),
44
+ ]),
45
+ });
46
+ export function falImage(deps) {
47
+ return {
48
+ id: "fal",
49
+ models: () => Promise.resolve(falModels),
50
+ generate: async (req) => {
51
+ const response = await deps.fetch(`${falBase}/${req.model}`, {
52
+ method: "POST",
53
+ signal: req.signal,
54
+ headers: { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" },
55
+ body: JSON.stringify({
56
+ prompt: req.prompt,
57
+ image_size: sizes[req.aspect],
58
+ // `logic/09` step 2 sends Number as that many independent calls, each its own
59
+ // resumable piece, so one image per request is what the stage asks for.
60
+ num_images: 1,
61
+ // The port stores a PNG or a JPEG; fal's own default is WebP on some models and
62
+ // the download would refuse it. Nothing else about quality or style is set:
63
+ // step 2 asks for the provider's defaults.
64
+ output_format: "png",
65
+ }),
66
+ });
67
+ if (!response.ok) {
68
+ throw await failure(response);
69
+ }
70
+ const answer = parse(await response.text());
71
+ refused(answer, req.prompt);
72
+ const first = answer.images[0];
73
+ if (first === undefined) {
74
+ throw providerError({ kind: "other", message: "fal answered with no image" });
75
+ }
76
+ // The download is inside the attempt with the call that produced the link, so a
77
+ // link that 404s is a failed attempt rather than a broken file on the project.
78
+ return await downloadImage({
79
+ fetch: deps.fetch,
80
+ provider: "fal",
81
+ url: first.url,
82
+ signal: req.signal,
83
+ });
84
+ },
85
+ };
86
+ }
87
+ function keyOf(deps) {
88
+ // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
89
+ // `missing_key` rather than `auth` because the same rule makes it terminal.
90
+ const key = deps.key();
91
+ if (key === undefined || key === "") {
92
+ throw providerError({ kind: "missing_key", message: "no fal key is stored" });
93
+ }
94
+ return key;
95
+ }
96
+ // `logic/09` §Q74: a content-policy refusal is the provider's final answer, so it is named
97
+ // here and the wrapper makes it terminal. fal declines inside a 200: the safety checker
98
+ // flags the image and hands back a blank one. There is no sentence to quote, so this is
99
+ // the one refusal in the app whose words are the app's; fal's own verdict is the flag.
100
+ function refused(answer, prompt) {
101
+ const flags = answer.has_nsfw_concepts ?? [];
102
+ if (flags.length > 0 && flags.every((flagged) => flagged)) {
103
+ throw providerError({
104
+ kind: "refusal",
105
+ message: `fal's safety checker rejected every image for this prompt: ${redact(prompt)}`,
106
+ });
107
+ }
108
+ }
109
+ // Only the adapter can read a vendor's status code, so only the adapter names the kind;
110
+ // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
111
+ function kindOf(status) {
112
+ if (status === 401 || status === 403) {
113
+ return "auth";
114
+ }
115
+ if (status === 429) {
116
+ return "rate_limit";
117
+ }
118
+ // ceiling: everything else is `other` and is retried, so a 422 naming an input this
119
+ // model does not take fails the same way four times over. A terminal kind for "this
120
+ // request will never work" would have to be added to the port's error contract first,
121
+ // which is not this adapter's to widen.
122
+ return "other";
123
+ }
124
+ async function failure(response) {
125
+ const text = await response.text().catch(() => "");
126
+ // The provider's own words, verbatim, through the same redactor the wrapper uses: an
127
+ // error body is free to quote the key back and this is the first place it is held.
128
+ const message = redact(detailOf(text) || response.statusText);
129
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
130
+ return providerError({
131
+ kind: kindOf(response.status),
132
+ message: `fal answered ${String(response.status)}: ${message}`,
133
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
134
+ });
135
+ }
136
+ function detailOf(text) {
137
+ const parsed = errorBody.safeParse(safeJson(text));
138
+ if (!parsed.success) {
139
+ return text.trim();
140
+ }
141
+ const { detail } = parsed.data;
142
+ if (typeof detail === "string") {
143
+ return detail;
144
+ }
145
+ return detail.map((one) => one.msg).join("; ");
146
+ }
147
+ function parse(text) {
148
+ const parsed = generated.safeParse(safeJson(text));
149
+ if (!parsed.success) {
150
+ throw providerError({
151
+ kind: "other",
152
+ message: "fal's answer was not in the shape this app can read",
153
+ });
154
+ }
155
+ return parsed.data;
156
+ }
157
+ function safeJson(text) {
158
+ try {
159
+ return JSON.parse(text);
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ }
@@ -0,0 +1,155 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { retryAfter } from "../retry-after.js";
5
+ import { describeBytes, sniffImage } from "./bytes.js";
6
+ // The HTTP gateway adapter for OpenAI's images endpoint (01-architecture Module
7
+ // boundaries). `fetch` and nothing else: 05-dependencies records global `fetch` at rung 3
8
+ // and the whole call is one request. Unlike fal and Replicate this one hands back the
9
+ // image itself - a GPT image model always answers with base64 and never with a URL - so
10
+ // there is no link to follow.
11
+ export const openAiImagesBase = "https://api.openai.com/v1";
12
+ // `logic/02` §Q15: the dropdown is filled from what the provider offers, and `/v1/models`
13
+ // lists every model on the account, chat and embeddings among them, so the image
14
+ // shortlist is this adapter's own data. These are the four GPT image models OpenAI
15
+ // documents; adding the next one is a line here and no code change anywhere else.
16
+ export const openAiImageModels = [
17
+ { id: "gpt-image-2", name: "GPT Image 2" },
18
+ { id: "gpt-image-1.5", name: "GPT Image 1.5" },
19
+ { id: "gpt-image-1", name: "GPT Image 1" },
20
+ { id: "gpt-image-1-mini", name: "GPT Image 1 mini" },
21
+ ];
22
+ // `logic/09` step 1: "the provider's closest supported size to the run's aspect". The
23
+ // standard GPT image sizes are 3:2 and 2:3, so 16:9 is asked for as 1536×1024 and the
24
+ // remaining sliver is cropped by the render (`logic/11` step 4).
25
+ const standard = {
26
+ "16:9": "1536x1024",
27
+ "9:16": "1024x1536",
28
+ };
29
+ // gpt-image-2 takes an arbitrary WIDTH×HEIGHT whose sides divide by 16, between 1:3 and
30
+ // 3:1, so on that model the closest supported size is the aspect exactly and the render
31
+ // crops nothing.
32
+ const exact = {
33
+ "16:9": "1536x864",
34
+ "9:16": "864x1536",
35
+ };
36
+ const arbitrarySizes = /^gpt-image-2/;
37
+ // A wire payload is narrowed, never cast (01-architecture §Q10, §Q33).
38
+ const generated = z.object({ data: z.array(z.object({ b64_json: z.string().nullish() })) });
39
+ const errorBody = z.object({
40
+ error: z.object({
41
+ message: z.string(),
42
+ type: z.string().nullish(),
43
+ code: z.string().nullish(),
44
+ }),
45
+ });
46
+ // `logic/09` §Q74: a content-policy refusal is the provider's final answer and is never
47
+ // retried. OpenAI names it in the body rather than in the status, so the code is what
48
+ // tells a declined prompt from a malformed request that shares its 400.
49
+ const refusalCodes = ["moderation_blocked", "content_policy_violation"];
50
+ export function sizeFor(model, aspect) {
51
+ return arbitrarySizes.test(model) ? exact[aspect] : standard[aspect];
52
+ }
53
+ export function openAiImage(deps) {
54
+ return {
55
+ id: "openai-image",
56
+ models: () => Promise.resolve(openAiImageModels),
57
+ generate: async (req) => {
58
+ const response = await deps.fetch(`${openAiImagesBase}/images/generations`, {
59
+ method: "POST",
60
+ signal: req.signal,
61
+ headers: { Authorization: `Bearer ${keyOf(deps)}`, "Content-Type": "application/json" },
62
+ body: JSON.stringify({
63
+ model: req.model,
64
+ prompt: req.prompt,
65
+ // `logic/09` step 2 sends Number as that many independent calls, each its own
66
+ // resumable piece, so one image per request is what the stage asks for.
67
+ n: 1,
68
+ size: sizeFor(req.model, req.aspect),
69
+ // No `quality` and no `background`: step 2 asks for the provider's default
70
+ // quality and style, which is what leaving them off means.
71
+ }),
72
+ });
73
+ if (!response.ok) {
74
+ throw await failure(response);
75
+ }
76
+ const first = parse(await response.text()).data[0]?.b64_json;
77
+ if (first === undefined || first === null || first === "") {
78
+ throw providerError({ kind: "other", message: "OpenAI answered with no image" });
79
+ }
80
+ return decode(first);
81
+ },
82
+ };
83
+ }
84
+ // A GPT image model always answers with base64, so the bytes arrive with the call and
85
+ // there is no link to follow. They are still sniffed: what the port stores is a PNG or a
86
+ // JPEG, and a truncated payload that decodes to something else must not reach the disk.
87
+ function decode(b64) {
88
+ const bytes = new Uint8Array(Buffer.from(b64, "base64"));
89
+ const mime = sniffImage(bytes);
90
+ if (mime === undefined) {
91
+ throw providerError({
92
+ kind: "other",
93
+ message: `OpenAI's image decoded to ${describeBytes(bytes)} rather than a PNG or a JPEG`,
94
+ });
95
+ }
96
+ return { bytes, mime };
97
+ }
98
+ function keyOf(deps) {
99
+ // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
100
+ // `missing_key` rather than `auth` because the same rule makes it terminal.
101
+ const key = deps.key();
102
+ if (key === undefined || key === "") {
103
+ throw providerError({ kind: "missing_key", message: "no OpenAI image key is stored" });
104
+ }
105
+ return key;
106
+ }
107
+ // Only the adapter can read a vendor's status code, so only the adapter names the kind;
108
+ // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
109
+ function kindOf(status, code) {
110
+ if (code !== undefined && code !== null && refusalCodes.includes(code)) {
111
+ return "refusal";
112
+ }
113
+ if (status === 401 || status === 403) {
114
+ return "auth";
115
+ }
116
+ if (status === 429) {
117
+ return "rate_limit";
118
+ }
119
+ // ceiling: everything else is `other` and is retried, so a prompt past the model's
120
+ // length limit fails the same way four times over. A terminal kind for "this request
121
+ // will never work" would have to be added to the port's error contract first, which is
122
+ // not this adapter's to widen.
123
+ return "other";
124
+ }
125
+ async function failure(response) {
126
+ const text = await response.text().catch(() => "");
127
+ const parsed = errorBody.safeParse(safeJson(text));
128
+ // The provider's own words, verbatim, through the same redactor the wrapper uses: an
129
+ // error body is free to quote the key back and this is the first place it is held.
130
+ const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
131
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
132
+ return providerError({
133
+ kind: kindOf(response.status, parsed.success ? parsed.data.error.code : undefined),
134
+ message: `OpenAI answered ${String(response.status)}: ${message}`,
135
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
136
+ });
137
+ }
138
+ function parse(text) {
139
+ const parsed = generated.safeParse(safeJson(text));
140
+ if (!parsed.success) {
141
+ throw providerError({
142
+ kind: "other",
143
+ message: "OpenAI's answer was not in the shape this app can read",
144
+ });
145
+ }
146
+ return parsed.data;
147
+ }
148
+ function safeJson(text) {
149
+ try {
150
+ return JSON.parse(text);
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }