@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
@@ -0,0 +1,183 @@
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 Replicate (01-architecture Module boundaries). `fetch`,
7
+ // the injected clock, and the downloader beside this file; no SDK. `replicate` the package
8
+ // exists and wraps exactly the two requests below, so 05-dependencies' rung 3 covers it.
9
+ export const replicateBase = "https://api.replicate.com/v1";
10
+ // `Prefer: wait` holds the connection open while the model runs, up to the 60 s ceiling
11
+ // Replicate documents. A model slower than that answers `starting` instead, and the
12
+ // prediction has to be polled - which is why this adapter takes a clock. The attempt
13
+ // wrapper's 300 s (`logic/09` §Q77) is what ends the polling, through the request signal.
14
+ export const replicateWaitSeconds = 60;
15
+ export const replicatePollMs = 2000;
16
+ // `logic/02` §Q15: the model dropdown is filled from what the provider offers. Replicate's
17
+ // model index lists every model on the platform, text and video among them, so the
18
+ // text-to-image shortlist is this adapter's own data and adding one is a line here.
19
+ // ceiling: each entry has to take `prompt`, `aspect_ratio` and `output_format`, which is
20
+ // the convention across Replicate's official image models but not a guarantee; a model
21
+ // that spells its inputs differently needs a per-model input map first.
22
+ export const replicateModels = [
23
+ { id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 [pro]" },
24
+ { id: "black-forest-labs/flux-dev", name: "FLUX.1 [dev]" },
25
+ { id: "black-forest-labs/flux-schnell", name: "FLUX.1 [schnell]" },
26
+ ];
27
+ // A wire payload is narrowed, never cast (01-architecture §Q10, §Q33). `output` is one
28
+ // URL on the models that make a single image and an array on the ones that can make
29
+ // several, so both shapes are read and the first is taken.
30
+ const prediction = z.object({
31
+ status: z.string(),
32
+ output: z.union([z.string(), z.array(z.string())]).nullish(),
33
+ error: z.string().nullish(),
34
+ urls: z.object({ get: z.string().optional() }).nullish(),
35
+ });
36
+ const errorBody = z.object({ detail: z.string().optional(), title: z.string().optional() });
37
+ // The two terminal states that are not success. `canceled` cannot happen here - nothing
38
+ // cancels a prediction this app made - but reading it as terminal beats polling forever.
39
+ const settled = ["succeeded", "failed", "canceled"];
40
+ export function replicateImage(deps) {
41
+ return {
42
+ id: "replicate",
43
+ models: () => Promise.resolve(replicateModels),
44
+ generate: async (req) => {
45
+ const response = await deps.fetch(`${replicateBase}/models/${req.model}/predictions`, {
46
+ method: "POST",
47
+ signal: req.signal,
48
+ headers: {
49
+ ...auth(deps),
50
+ "Content-Type": "application/json",
51
+ Prefer: `wait=${String(replicateWaitSeconds)}`,
52
+ },
53
+ body: JSON.stringify({
54
+ input: {
55
+ prompt: req.prompt,
56
+ // `logic/09` step 1: Replicate takes the aspect itself, so the closest
57
+ // supported size is the exact one.
58
+ aspect_ratio: req.aspect,
59
+ // The port stores a PNG or a JPEG and these models default to WebP.
60
+ // Nothing else is set: step 2 asks for the provider's own quality and style.
61
+ output_format: "png",
62
+ },
63
+ }),
64
+ });
65
+ if (!response.ok) {
66
+ throw await failure(response);
67
+ }
68
+ const url = await settle(deps, parse(await response.text()), req.signal);
69
+ // The download is inside the attempt with the call that produced the link, so a
70
+ // link that 404s is a failed attempt rather than a broken file on the project.
71
+ return await downloadImage({
72
+ fetch: deps.fetch,
73
+ provider: "Replicate",
74
+ url,
75
+ signal: req.signal,
76
+ });
77
+ },
78
+ };
79
+ }
80
+ // `Prefer: wait` answers `starting` when the model outlived its 60 s, and Replicate's own
81
+ // guidance is to poll `urls.get` until the prediction settles.
82
+ async function settle(deps, first, signal) {
83
+ let current = first;
84
+ while (!settled.includes(current.status)) {
85
+ const next = current.urls?.get;
86
+ if (next === undefined) {
87
+ throw providerError({
88
+ kind: "other",
89
+ message: `Replicate left the prediction ${current.status} and named no address to read it from`,
90
+ });
91
+ }
92
+ await deps.clock.sleep(replicatePollMs, signal);
93
+ const response = await deps.fetch(next, { signal, headers: auth(deps) });
94
+ if (!response.ok) {
95
+ throw await failure(response);
96
+ }
97
+ current = parse(await response.text());
98
+ }
99
+ return outputOf(current);
100
+ }
101
+ function outputOf(current) {
102
+ if (current.status !== "succeeded") {
103
+ throw declined(current);
104
+ }
105
+ const output = current.output;
106
+ const url = typeof output === "string" ? output : output?.[0];
107
+ if (url === undefined || url === "") {
108
+ throw providerError({ kind: "other", message: "Replicate succeeded with no image" });
109
+ }
110
+ return url;
111
+ }
112
+ // `logic/09` §Q74: a content-policy refusal is the provider's final answer and is never
113
+ // retried. Replicate reports one as a settled prediction whose `error` says so, with the
114
+ // same 201 the successful call had, so the text is what tells them apart.
115
+ const refusalWords = /\b(nsfw|sensitive|safety|content polic|flagged|moderat)/i;
116
+ function declined(current) {
117
+ // The provider's own words, verbatim, through the same redactor the wrapper uses.
118
+ const message = redact(current.error ?? `the prediction ended ${current.status}`);
119
+ // ceiling: read off the sentence, because Replicate carries no machine-readable reason
120
+ // on a failed prediction. A phrase the list does not know is retried three more times
121
+ // and costs the user three more images; a structured field would settle it, and the
122
+ // upgrade is to read one when Replicate ships it.
123
+ return providerError({
124
+ kind: refusalWords.test(message) ? "refusal" : "other",
125
+ message: `Replicate answered: ${message}`,
126
+ });
127
+ }
128
+ function auth(deps) {
129
+ // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
130
+ // `missing_key` rather than `auth` because the same rule makes it terminal.
131
+ const key = deps.key();
132
+ if (key === undefined || key === "") {
133
+ throw providerError({ kind: "missing_key", message: "no Replicate key is stored" });
134
+ }
135
+ return { Authorization: `Bearer ${key}` };
136
+ }
137
+ // Only the adapter can read a vendor's status code, so only the adapter names the kind;
138
+ // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
139
+ function kindOf(status) {
140
+ if (status === 401 || status === 403) {
141
+ return "auth";
142
+ }
143
+ if (status === 429) {
144
+ return "rate_limit";
145
+ }
146
+ // ceiling: everything else is `other` and is retried, so a 402 with no credit left
147
+ // fails the same way four times over. A terminal kind for "this request will never
148
+ // work" would have to be added to the port's error contract first, which is not this
149
+ // adapter's to widen.
150
+ return "other";
151
+ }
152
+ async function failure(response) {
153
+ const text = await response.text().catch(() => "");
154
+ const parsed = errorBody.safeParse(safeJson(text));
155
+ // The provider's own words, verbatim, through the same redactor the wrapper uses: an
156
+ // error body is free to quote the key back and this is the first place it is held.
157
+ const detail = parsed.success ? (parsed.data.detail ?? parsed.data.title) : undefined;
158
+ const message = redact(detail ?? text.trim());
159
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
160
+ return providerError({
161
+ kind: kindOf(response.status),
162
+ message: `Replicate answered ${String(response.status)}: ${message || response.statusText}`,
163
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
164
+ });
165
+ }
166
+ function parse(text) {
167
+ const parsed = prediction.safeParse(safeJson(text));
168
+ if (!parsed.success) {
169
+ throw providerError({
170
+ kind: "other",
171
+ message: "Replicate's answer was not in the shape this app can read",
172
+ });
173
+ }
174
+ return parsed.data;
175
+ }
176
+ function safeJson(text) {
177
+ try {
178
+ return JSON.parse(text);
179
+ }
180
+ catch {
181
+ return undefined;
182
+ }
183
+ }
@@ -0,0 +1,158 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
5
+ import { lines } from "./sse-lines.js";
6
+ // The local-agent adapter for Claude Code (01-architecture Module boundaries): spawned
7
+ // non-interactively, authenticated by the CLI's own login, no key anywhere in this file.
8
+ // Readiness is not computed here - `adapters/**` may not import `slices/**`, and
9
+ // `slices/settings/cli-status.ts` already probes the binary per request (`logic/02`
10
+ // §Q135). The registry `main.ts` builds is where this adapter and that probe meet.
11
+ export const claudeCodeBinary = "claude";
12
+ // The CLI takes an alias for the latest model of a family (`claude --help`, 2.1.258).
13
+ // ceiling: a fixed list, because the CLI has no offline command that prints the models an
14
+ // account may use. A user whose plan carries a model not listed here cannot pick it;
15
+ // reading the list off the CLI is the upgrade when it can print one.
16
+ export const claudeCodeModels = [
17
+ { id: "fable", name: "Claude Fable (latest)" },
18
+ { id: "opus", name: "Claude Opus (latest)" },
19
+ { id: "sonnet", name: "Claude Sonnet (latest)" },
20
+ { id: "haiku", name: "Claude Haiku (latest)" },
21
+ ];
22
+ // Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this
23
+ // CLI still reached for ToolSearch and WebFetch, and the machine's MCP servers were loaded
24
+ // into the session. Neither belongs in a content pipeline - the article stage must not
25
+ // quietly ground itself on the web, since `logic/06` §Q47 makes grounding an explicit ask,
26
+ // and no stage should be able to touch the disk. `--tools ""` empties the built-in set and
27
+ // `--strict-mcp-config` drops the user's MCP servers; the init event of a run with both
28
+ // reports `"tools":[]` and `"mcp_servers":[]`.
29
+ export function claudeCodeArgs(req) {
30
+ return [
31
+ "-p",
32
+ "--output-format",
33
+ "stream-json",
34
+ // stream-json output is refused without it.
35
+ "--verbose",
36
+ "--strict-mcp-config",
37
+ ...(req.webSearch === true
38
+ ? ["--tools", "WebSearch", "--allowedTools", "WebSearch"]
39
+ : ["--tools", ""]),
40
+ ...(req.model === "" ? [] : ["--model", req.model]),
41
+ // Everything after `--` is the prompt, so a prompt opening with a dash is text and not
42
+ // a flag. It is one argv element: no shell sees it and nothing in it is expanded.
43
+ "--",
44
+ promptOf(req.messages),
45
+ ];
46
+ }
47
+ const assistantEvent = z.object({
48
+ message: z.object({
49
+ content: z.array(z.object({ type: z.string(), text: z.string().optional() })),
50
+ }),
51
+ });
52
+ // The result event as 2.1.258 writes it. `subtype` stays "success" even for a failed run -
53
+ // a bad model name answers `{"subtype":"success","is_error":true,"api_error_status":404}` -
54
+ // so `is_error` is what decides, with `subtype` checked as well for a run that never
55
+ // reached the model at all.
56
+ const resultEvent = z.object({
57
+ subtype: z.string(),
58
+ is_error: z.boolean().optional(),
59
+ result: z.string().optional(),
60
+ stop_reason: z.string().nullish(),
61
+ api_error_status: z.number().nullish(),
62
+ modelUsage: z
63
+ .record(z.string(), z.object({ inputTokens: z.number(), outputTokens: z.number() }))
64
+ .optional(),
65
+ });
66
+ export function claudeCodeLlm(deps) {
67
+ const binary = deps.binary ?? claudeCodeBinary;
68
+ async function* complete(req) {
69
+ const run = deps.run(binary, claudeCodeArgs(req), req.signal);
70
+ try {
71
+ for await (const line of lines(run.stdout, req.signal)) {
72
+ if (line.trim() === "") {
73
+ continue;
74
+ }
75
+ const event = cliEvent(binary, line);
76
+ if (event.type === "assistant") {
77
+ for (const block of cliShaped(binary, assistantEvent, event.value).message.content) {
78
+ // A turn also carries `thinking` and `tool_use` blocks; only the prose is the
79
+ // stage's output.
80
+ if (block.type === "text" && block.text !== undefined && block.text !== "") {
81
+ yield { type: "delta", text: block.text };
82
+ }
83
+ }
84
+ continue;
85
+ }
86
+ if (event.type !== "result") {
87
+ continue;
88
+ }
89
+ const result = cliShaped(binary, resultEvent, event.value);
90
+ if (result.is_error === true || result.subtype !== "success") {
91
+ throw providerError({
92
+ kind: kindOf(result.api_error_status ?? null),
93
+ message: redact(result.result ?? result.subtype),
94
+ });
95
+ }
96
+ yield {
97
+ type: "done",
98
+ usage: usageOf(result.modelUsage),
99
+ finishReason: result.stop_reason ?? null,
100
+ };
101
+ return;
102
+ }
103
+ }
104
+ catch (error) {
105
+ // A cancelled stage killed the child; the reason the user's cancel carried is what
106
+ // the runner expects back, not whatever the half-closed pipe threw (`logic/13`).
107
+ req.signal.throwIfAborted();
108
+ throw error;
109
+ }
110
+ finally {
111
+ // The consumer can also abandon the generator - a retry, a timeout - and an agent
112
+ // session left running would keep spending the user's subscription.
113
+ run.kill();
114
+ }
115
+ // A cancelled run ends its stream the same way an exhausted one does: the child was
116
+ // killed, so stdout simply stopped. `logic/13` §Q112 says an aborted call counts
117
+ // nothing, so it must not be reported as the provider failing.
118
+ req.signal.throwIfAborted();
119
+ // The stream ended with no result event at all.
120
+ throw providerError({
121
+ kind: "other",
122
+ message: endedWithout(binary, await run.ended, run.stderr()),
123
+ });
124
+ }
125
+ return {
126
+ id: "claude-code",
127
+ // The CLI emits whole assistant turns rather than token deltas, which is still enough
128
+ // for the idle timeout of `logic/01` §Q62 to see life on the stream.
129
+ // ceiling: `--include-partial-messages` would give per-token deltas for the streamed
130
+ // article of `logic/07` step 2; it is the upgrade when the page needs finer text.
131
+ capabilities: { streams: true, reportsUsage: true, webSearch: true },
132
+ models: () => Promise.resolve(claudeCodeModels),
133
+ complete,
134
+ };
135
+ }
136
+ // The CLI reports the upstream status on the result event; nothing else about a local
137
+ // process says which failure this was.
138
+ function kindOf(status) {
139
+ if (status === 401 || status === 403) {
140
+ return "auth";
141
+ }
142
+ if (status === 429) {
143
+ return "rate_limit";
144
+ }
145
+ return "other";
146
+ }
147
+ // `modelUsage` is keyed by model id and a run may touch more than one - a fallback model,
148
+ // a sub-agent - so the counts are summed. An empty object means the CLI reported none.
149
+ function usageOf(usage) {
150
+ const entries = Object.values(usage ?? {});
151
+ if (entries.length === 0) {
152
+ return null;
153
+ }
154
+ return {
155
+ inputTokens: entries.reduce((total, one) => total + one.inputTokens, 0),
156
+ outputTokens: entries.reduce((total, one) => total + one.outputTokens, 0),
157
+ };
158
+ }
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
5
+ import { lines } from "./sse-lines.js";
6
+ // The local-agent adapter for the Codex CLI. Same shape as Claude Code's and a different
7
+ // vocabulary: Codex writes a JSONL thread of `thread.started`, `item.*` and `turn.*`
8
+ // events. No key here either - the CLI's own login authenticates it (`logic/02` §Q135).
9
+ export const codexBinary = "codex";
10
+ // ceiling: a fixed list. `codex` has no offline command that prints the models an account
11
+ // may use - `codex doctor` reports the install, not the catalogue, and the model refresh
12
+ // it does at start needs the login this list is meant to be readable without. These two
13
+ // are the ids this machine's `~/.codex/config.toml` names; reading the real catalogue is
14
+ // the upgrade when the CLI grows a command that prints it.
15
+ export const codexModels = [
16
+ { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
17
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
18
+ ];
19
+ // `codex exec --help` (0.149.1) for the flags. `-c web_search=<mode>` is a TOML override,
20
+ // and the binary's own error names the modes: "unknown variant `bogus`, expected one of
21
+ // `disabled`, `cached`, `indexed`, `live`". `live` is the grounded mode `logic/06` asks
22
+ // for and `disabled` is what every other stage runs under, so no stage grounds itself by
23
+ // accident. `--ephemeral` keeps no session file, `--skip-git-repo-check` lets it run in
24
+ // the data directory, and the quotes in the value are part of the argv element because
25
+ // the override is parsed as TOML, where a bare `live` is not a string.
26
+ export function codexArgs(req) {
27
+ return [
28
+ "exec",
29
+ "--json",
30
+ "--ephemeral",
31
+ "--skip-git-repo-check",
32
+ "-c",
33
+ `web_search="${req.webSearch === true ? "live" : "disabled"}"`,
34
+ ...(req.model === "" ? [] : ["-m", req.model]),
35
+ // The prompt is one argv element after `--`, so a leading dash is text, not a flag.
36
+ "--",
37
+ promptOf(req.messages),
38
+ ];
39
+ }
40
+ const itemCompleted = z.object({
41
+ item: z.object({ type: z.string(), text: z.string().optional() }),
42
+ });
43
+ // The field names are the shipped binary's own: `TurnCompletedEvent` carries `usage`, and
44
+ // its counts are `input_tokens`, `cached_input_tokens`, `cache_write_input_tokens`,
45
+ // `output_tokens`, `reasoning_output_tokens`. Only the two the port has a home for are read.
46
+ const turnCompleted = z.object({
47
+ usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }).nullish(),
48
+ });
49
+ const turnFailed = z.object({ error: z.object({ message: z.string() }) });
50
+ // The top-level `error` event carries its text in `message`, not in `error.message`.
51
+ const errorEvent = z.object({ message: z.string() });
52
+ export function codexLlm(deps) {
53
+ const binary = deps.binary ?? codexBinary;
54
+ async function* complete(req) {
55
+ const run = deps.run(binary, codexArgs(req), req.signal);
56
+ try {
57
+ for await (const line of lines(run.stdout, req.signal)) {
58
+ if (line.trim() === "") {
59
+ continue;
60
+ }
61
+ const event = cliEvent(binary, line);
62
+ if (event.type === "item.completed") {
63
+ const { item } = cliShaped(binary, itemCompleted, event.value);
64
+ // A turn also completes `reasoning`, `web_search`, `command_execution` and
65
+ // `error` items. The last of those is a warning, not a failure: this machine's
66
+ // codex opens every run with an `error` item saying it has no metadata for the
67
+ // configured model, then answers normally. Only `turn.failed` ends a turn.
68
+ if (item.type === "agent_message" && item.text !== undefined && item.text !== "") {
69
+ yield { type: "delta", text: item.text };
70
+ }
71
+ continue;
72
+ }
73
+ if (event.type === "turn.completed") {
74
+ const { usage } = cliShaped(binary, turnCompleted, event.value);
75
+ // ceiling: Codex reports no stop reason, so the continuation loop of
76
+ // `logic/07` §Q59 cannot tell a finished answer from one cut at the output
77
+ // limit for this provider. A `--output-schema` run would, at the cost of
78
+ // constraining every stage's answer.
79
+ yield { type: "done", usage: usageOf(usage), finishReason: null };
80
+ return;
81
+ }
82
+ if (event.type === "turn.failed") {
83
+ throw providerError({
84
+ kind: "other",
85
+ message: redact(cliShaped(binary, turnFailed, event.value).error.message),
86
+ });
87
+ }
88
+ if (event.type === "error") {
89
+ throw providerError({
90
+ kind: "other",
91
+ message: redact(cliShaped(binary, errorEvent, event.value).message),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ catch (error) {
97
+ req.signal.throwIfAborted();
98
+ throw error;
99
+ }
100
+ finally {
101
+ run.kill();
102
+ }
103
+ // A cancelled run ends its stream the same way an exhausted one does: the child was
104
+ // killed, so stdout simply stopped. `logic/13` §Q112 says an aborted call counts
105
+ // nothing, so it must not be reported as the provider failing.
106
+ req.signal.throwIfAborted();
107
+ // The stream ended with neither a completed turn nor a failure.
108
+ throw providerError({
109
+ kind: "other",
110
+ message: endedWithout(binary, await run.ended, run.stderr()),
111
+ });
112
+ }
113
+ return {
114
+ id: "codex",
115
+ // ceiling: one `item.completed` per whole message rather than per token, same as the
116
+ // other CLI. Enough for the idle timeout of `logic/01` §Q62 to see life on the stream.
117
+ capabilities: { streams: true, reportsUsage: true, webSearch: true },
118
+ models: () => Promise.resolve(codexModels),
119
+ complete,
120
+ };
121
+ }
122
+ function usageOf(usage) {
123
+ return usage === null || usage === undefined
124
+ ? null
125
+ : { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens };
126
+ }
@@ -0,0 +1,193 @@
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 { sseData } from "./sse-lines.js";
6
+ // The HTTP gateway adapter (01-architecture Module boundaries). `fetch` plus the line
7
+ // reader beside this file, no SDK: 05-dependencies records global `fetch` at rung 3 and
8
+ // the reader at rung 6, so nothing here is worth a dependency.
9
+ export const openRouterBase = "https://openrouter.ai/api/v1";
10
+ // OpenRouter ranks apps by these two headers; they carry no user data.
11
+ const appHeaders = {
12
+ "HTTP-Referer": "https://slopify.stream",
13
+ "X-Title": "Slopify",
14
+ };
15
+ // A wire payload is narrowed, never cast: everything unlisted is dropped at the seam so
16
+ // no vendor shape can leak past this file (01-architecture §Q10, §Q33).
17
+ const modelList = z.object({
18
+ data: z.array(z.object({ id: z.string(), name: z.string().optional() })),
19
+ });
20
+ const errorBody = z.object({
21
+ error: z.object({ message: z.string(), code: z.union([z.number(), z.string()]).optional() }),
22
+ });
23
+ const streamChunk = z.object({
24
+ choices: z
25
+ .array(z.object({
26
+ delta: z.object({ content: z.string().nullish() }).optional(),
27
+ finish_reason: z.string().nullish(),
28
+ }))
29
+ .optional(),
30
+ usage: z.object({ prompt_tokens: z.number(), completion_tokens: z.number() }).nullish(),
31
+ error: z
32
+ .object({ message: z.string(), code: z.union([z.number(), z.string()]).optional() })
33
+ .optional(),
34
+ });
35
+ export function openRouterLlm(deps) {
36
+ async function* complete(req) {
37
+ const response = await deps.fetch(`${openRouterBase}/chat/completions`, {
38
+ method: "POST",
39
+ signal: req.signal,
40
+ headers: { ...headers(deps.key()), "Content-Type": "application/json" },
41
+ body: JSON.stringify({
42
+ model: req.model,
43
+ messages: req.messages.map((message) => ({
44
+ role: message.role,
45
+ content: message.content,
46
+ })),
47
+ stream: true,
48
+ // The usage-accounting flag: without it the final chunk carries no token counts
49
+ // and the Usage page would have nothing to count (`logic/16`).
50
+ usage: { include: true },
51
+ // `logic/06` §Q47: web grounding is asked for explicitly. OpenRouter runs the
52
+ // search itself, so the plugin works whatever model was picked.
53
+ ...(req.webSearch === true ? { plugins: [{ id: "web" }] } : {}),
54
+ }),
55
+ });
56
+ if (!response.ok) {
57
+ throw await failure(response);
58
+ }
59
+ if (response.body === null) {
60
+ throw providerError({ kind: "other", message: "OpenRouter answered with no body" });
61
+ }
62
+ let usage = null;
63
+ let finishReason = null;
64
+ let complete = false;
65
+ for await (const data of sseData(response.body, req.signal)) {
66
+ if (data === "") {
67
+ continue;
68
+ }
69
+ if (data === "[DONE]") {
70
+ complete = true;
71
+ break;
72
+ }
73
+ const chunk = parseChunk(data);
74
+ if (chunk.error !== undefined) {
75
+ // A stream that has already answered 200 reports a mid-flight failure in a data
76
+ // frame; without this the stage would store half an article as a success.
77
+ throw providerError({
78
+ kind: kindOf(statusOf(chunk.error.code)),
79
+ message: redact(chunk.error.message),
80
+ });
81
+ }
82
+ const choice = chunk.choices?.[0];
83
+ const text = choice?.delta?.content;
84
+ if (text !== undefined && text !== null && text !== "") {
85
+ yield { type: "delta", text };
86
+ }
87
+ if (choice?.finish_reason !== undefined && choice.finish_reason !== null) {
88
+ // It arrives on the last choice chunk, one frame before the usage frame, so it is
89
+ // held rather than read off whichever chunk happens to be last (`logic/07` §Q59
90
+ // reads it to tell a finished article from a truncated one).
91
+ finishReason = choice.finish_reason;
92
+ }
93
+ if (chunk.usage !== undefined && chunk.usage !== null) {
94
+ usage = {
95
+ inputTokens: chunk.usage.prompt_tokens,
96
+ outputTokens: chunk.usage.completion_tokens,
97
+ };
98
+ complete = true;
99
+ }
100
+ }
101
+ if (!complete) {
102
+ // Neither `[DONE]` nor the usage frame arrived, so the connection dropped part-way.
103
+ // Saying so is what stops a truncated answer being stored as a whole one.
104
+ throw providerError({
105
+ kind: "other",
106
+ message: "OpenRouter's stream ended before the response was complete",
107
+ });
108
+ }
109
+ yield { type: "done", usage, finishReason };
110
+ }
111
+ return {
112
+ id: "openrouter",
113
+ capabilities: { streams: true, reportsUsage: true, webSearch: true },
114
+ models: async () => {
115
+ const response = await deps.fetch(`${openRouterBase}/models`, {
116
+ headers: headers(deps.key()),
117
+ });
118
+ if (!response.ok) {
119
+ throw await failure(response);
120
+ }
121
+ const parsed = modelList.safeParse(await response.json());
122
+ if (!parsed.success) {
123
+ throw providerError({
124
+ kind: "other",
125
+ message: "OpenRouter's model list was not in the shape this app can read",
126
+ });
127
+ }
128
+ return parsed.data.data.map((model) => ({ id: model.id, name: model.name ?? model.id }));
129
+ },
130
+ complete,
131
+ };
132
+ }
133
+ function headers(key) {
134
+ // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously
135
+ // and being told off by the provider in words the user cannot act on. `missing_key`
136
+ // rather than `auth` because the same rule makes it terminal: there is nothing to
137
+ // retry until the user saves a key, and the wrapper is where that is decided.
138
+ if (key === undefined || key === "") {
139
+ throw providerError({ kind: "missing_key", message: "no OpenRouter key is stored" });
140
+ }
141
+ return { Authorization: `Bearer ${key}`, ...appHeaders };
142
+ }
143
+ // Only the adapter can read a vendor's status code, so only the adapter names the kind;
144
+ // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
145
+ function kindOf(status) {
146
+ if (status === 401 || status === 403) {
147
+ return "auth";
148
+ }
149
+ if (status === 429) {
150
+ return "rate_limit";
151
+ }
152
+ // ceiling: everything else is `other` and is retried. 400 and 402 will fail the same
153
+ // way four times over; a terminal kind for "this request will never work" would have to
154
+ // be added to the port's error contract first, which is not this adapter's to widen.
155
+ return "other";
156
+ }
157
+ async function failure(response) {
158
+ const text = await response.text().catch(() => "");
159
+ const parsed = errorBody.safeParse(safeJson(text));
160
+ // The provider's own words, verbatim, through the same redactor the wrapper uses: an
161
+ // error body is free to quote the key back and this is the first place it is held.
162
+ const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
163
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
164
+ return providerError({
165
+ kind: kindOf(response.status),
166
+ message: `OpenRouter answered ${response.status}: ${message}`,
167
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
168
+ });
169
+ }
170
+ function statusOf(code) {
171
+ return typeof code === "number" ? code : Number(code ?? 0);
172
+ }
173
+ function parseChunk(data) {
174
+ const parsed = streamChunk.safeParse(safeJson(data));
175
+ if (!parsed.success) {
176
+ // A frame that will not parse is a stream cut mid-line or a payload this app does not
177
+ // understand. Either way the answer is incomplete, and the text is not echoed back:
178
+ // a half-written frame is noise, and the wrapper stores whatever is thrown.
179
+ throw providerError({
180
+ kind: "other",
181
+ message: "OpenRouter sent a stream frame this app could not read",
182
+ });
183
+ }
184
+ return parsed.data;
185
+ }
186
+ function safeJson(text) {
187
+ try {
188
+ return JSON.parse(text);
189
+ }
190
+ catch {
191
+ return undefined;
192
+ }
193
+ }