@gentbajko/slopify 1.2.1 → 1.4.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.
- package/README.md +15 -8
- package/dist/adapter-registry.js +14 -2
- package/dist/adapters/host-cli/index.js +192 -0
- package/dist/adapters/host-cli/transport.js +135 -0
- package/dist/adapters/image/codex.js +239 -0
- package/dist/adapters/llm/claude-code-models.js +1 -0
- package/dist/adapters/llm/claude-code.js +7 -0
- package/dist/adapters/llm/cli-login-error.js +16 -0
- package/dist/adapters/llm/codex.js +10 -0
- package/dist/catalog/validate.js +3 -1
- package/dist/edge/cli.js +36 -0
- package/dist/edge/docker.js +109 -0
- package/dist/edge/host-cli.js +58 -0
- package/dist/edge/http/actions.js +1 -1
- package/dist/edge/http/diagnostics.js +5 -1
- package/dist/edge/http/host-cli.js +247 -0
- package/dist/edge/http/providers.js +5 -1
- package/dist/host-cli/environment.js +25 -0
- package/dist/host-cli/install.js +61 -0
- package/dist/host-cli/paths.js +68 -0
- package/dist/host-cli/runtime.js +78 -0
- package/dist/host-cli/server.js +98 -0
- package/dist/host-cli/service.js +200 -0
- package/dist/host-cli/status.js +134 -0
- package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
- package/dist/kernel/ports/host-cli.js +95 -0
- package/dist/kernel/ports/model.js +1 -0
- package/dist/kernel/runner/attempt.js +10 -1
- package/dist/main.js +7 -1
- package/dist/slices/admission/rules.js +17 -1
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/estimate/index.js +32 -1
- package/dist/slices/estimate/requests.js +8 -0
- package/dist/slices/library/model.js +2 -3
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/narration/preparation.js +75 -0
- package/dist/slices/narration/steering.js +89 -0
- package/dist/slices/play-drafts/convert.js +3 -0
- package/dist/slices/play-drafts/readiness.js +3 -1
- package/dist/slices/play-drafts/schema.js +1 -0
- package/dist/slices/project-templates/from-project.js +2 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +12 -9
- package/dist/slices/rebuild/preview-plan.js +7 -1
- package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
- package/dist/slices/rebuild/recipe-audio.js +52 -66
- package/dist/slices/rebuild/recipe-input-schema.js +11 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-narration-text.js +33 -0
- package/dist/slices/rebuild/recipe-preparation.js +66 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
- package/dist/slices/rebuild/recipe-validation.js +9 -1
- package/dist/slices/rebuild/recipe-work.js +13 -1
- package/dist/slices/rebuild/runtime-admission.js +11 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
- package/dist/slices/rebuild/runtime-local.js +5 -0
- package/dist/slices/rebuild/runtime-materialize.js +2 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
- package/dist/slices/rebuild/runtime-narration-text.js +75 -0
- package/dist/slices/rebuild/runtime-provider.js +30 -1
- package/dist/slices/rebuild/runtime-publication.js +13 -1
- package/dist/slices/rebuild/runtime-store.js +13 -2
- package/dist/slices/revisions/rules.js +5 -3
- package/dist/slices/settings/cli-paths.js +15 -6
- package/dist/slices/settings/cli-status.js +3 -1
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/readiness.js +24 -3
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +2 -0
- package/dist/slices/storage/schema.js +1 -0
- package/dist/web/assets/index-DV1LKwBP.js +137 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/scripts/docker-run.sh +8 -45
- package/dist/web/assets/index-Cq2O1JRf.js +0 -137
package/README.md
CHANGED
|
@@ -30,13 +30,19 @@ slopify
|
|
|
30
30
|
|
|
31
31
|
Both commands launch the same local app.
|
|
32
32
|
|
|
33
|
-
For Docker on Linux, use `slopify --docker` or
|
|
33
|
+
For Docker on Linux with systemd, use `slopify --docker` or
|
|
34
34
|
`npx @gentbajko/slopify@latest --docker`. The launcher detects installed Codex,
|
|
35
|
-
Claude Code
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
Claude Code and Gemini CLIs. It asks once before installing a private host helper
|
|
36
|
+
and enabling automatic startup, including user lingering. The CLIs run on your
|
|
37
|
+
host with their existing logins; credentials never move into Docker.
|
|
38
|
+
|
|
39
|
+
Slopify runs in the background, restarts with Docker and keeps your data in
|
|
40
|
+
`slopify-data`. Add `--port 7070` to change the default port. Rerun the launcher
|
|
41
|
+
after changing host CLI installations or search paths. Native installs need no helper.
|
|
42
|
+
Plain `docker run` is API-only without a configured helper. Use `--host-cli=off`
|
|
43
|
+
for API-only launcher setup, or `--accept-host-cli` to approve non-interactive setup.
|
|
44
|
+
See the [Docker setup guide](https://github.com/GentBajko/slopify#docker) for
|
|
45
|
+
updates and the dedicated helper's status/disable commands.
|
|
40
46
|
|
|
41
47
|
## How to use it
|
|
42
48
|
|
|
@@ -91,8 +97,9 @@ Everything lives in one SQLite file and one directory tree under the data direct
|
|
|
91
97
|
|
|
92
98
|
## ffmpeg and the GPL
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
prebuilt binary to `node_modules/ffmpeg-static/` at install time.
|
|
100
|
+
No separate FFmpeg installation is required. The `ffmpeg-static` dependency downloads
|
|
101
|
+
a prebuilt binary to `node_modules/ffmpeg-static/` at install time. Docker includes
|
|
102
|
+
the checked binary, licence and source notice in the image, ready on first launch.
|
|
96
103
|
|
|
97
104
|
Slopify checks that ffmpeg runs before starting. If the install-time download is
|
|
98
105
|
missing, it downloads the same platform build into `<data-dir>/bin/`, keeping the
|
package/dist/adapter-registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexImage } from "./adapters/image/codex.js";
|
|
1
2
|
import { falImage } from "./adapters/image/fal.js";
|
|
2
3
|
import { googleImage } from "./adapters/image/google.js";
|
|
3
4
|
import { openAiImage } from "./adapters/image/openai.js";
|
|
@@ -13,6 +14,7 @@ import { cartesiaTts } from "./adapters/tts/cartesia.js";
|
|
|
13
14
|
import { elevenLabsTts } from "./adapters/tts/elevenlabs.js";
|
|
14
15
|
import { inworldTts } from "./adapters/tts/inworld.js";
|
|
15
16
|
import { openAiTts } from "./adapters/tts/openai.js";
|
|
17
|
+
import { hostLlmIds } from "./kernel/ports/host-cli.js";
|
|
16
18
|
import { cliBinary } from "./slices/settings/cli-paths.js";
|
|
17
19
|
import { keyForAttempt } from "./slices/settings/keys.js";
|
|
18
20
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
@@ -60,7 +62,7 @@ export function buildRegistry(deps) {
|
|
|
60
62
|
["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
|
|
61
63
|
["inworld", inworldTts({ fetch: deps.fetch, key: keyOf("inworld"), clock: deps.clock })],
|
|
62
64
|
]);
|
|
63
|
-
//
|
|
65
|
+
// Keyed image providers receive only their own key; Codex uses the shared CLI login.
|
|
64
66
|
// Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
|
|
65
67
|
// to be polled, and the wait is spent on the app's clock so a test never sits through one.
|
|
66
68
|
const images = new Map([
|
|
@@ -71,14 +73,24 @@ export function buildRegistry(deps) {
|
|
|
71
73
|
],
|
|
72
74
|
["openai-image", openAiImage({ fetch: deps.fetch, key: keyOf("openai-image") })],
|
|
73
75
|
["google-image", googleImage({ fetch: deps.fetch, key: keyOf("google-image") })],
|
|
76
|
+
["codex-image", codexImage({ run: cliFor("codex") })],
|
|
74
77
|
]);
|
|
78
|
+
if (deps.hostCli) {
|
|
79
|
+
for (const id of hostLlmIds)
|
|
80
|
+
llms.set(id, deps.hostCli.llm(id));
|
|
81
|
+
images.set("codex-image", deps.hostCli.image);
|
|
82
|
+
}
|
|
75
83
|
return {
|
|
76
84
|
llm: (id) => resolve(llms, "llm", id),
|
|
77
85
|
tts: (id) => resolve(ttses, "tts", id),
|
|
78
86
|
image: (id) => resolve(images, "image", id),
|
|
79
87
|
// Every supported provider, keyed or not, found or not, so Play
|
|
80
88
|
// can grey one out with a reason instead of hiding it.
|
|
81
|
-
list: async () => (await providerStatuses({
|
|
89
|
+
list: async () => (await providerStatuses({
|
|
90
|
+
db: deps.db,
|
|
91
|
+
probe: deps.probe,
|
|
92
|
+
hostCliStatus: deps.hostCli?.status,
|
|
93
|
+
})).map((status) => ({
|
|
82
94
|
family: status.family,
|
|
83
95
|
id: status.id,
|
|
84
96
|
name: status.displayName,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { redact } from "../../kernel/log.js";
|
|
2
|
+
import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostImageSchema, hostLlmSchema, hostModelsSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
|
|
3
|
+
import { isProviderError, providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { sniffImage } from "../image/bytes.js";
|
|
5
|
+
import { hostRequest, hostUnavailable, readHostBytes, } from "./transport.js";
|
|
6
|
+
export function createHostCliClient(options) {
|
|
7
|
+
async function request(input) {
|
|
8
|
+
if (!options.directory)
|
|
9
|
+
throw hostUnavailable();
|
|
10
|
+
const response = await hostRequest({ ...input, directory: options.directory });
|
|
11
|
+
if (response.statusCode !== 200) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = hostFaultSchema.safeParse(JSON.parse((await readHostBytes(response, bridgeLimits.status)).toString("utf8")));
|
|
14
|
+
if (parsed.success)
|
|
15
|
+
throw providerError({ kind: parsed.data.kind, message: redact(parsed.data.message) });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (isProviderError(error))
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
throw hostUnavailable(input.method === "POST");
|
|
22
|
+
}
|
|
23
|
+
return response;
|
|
24
|
+
}
|
|
25
|
+
async function metadata(path, schema, signal = AbortSignal.timeout(35_000)) {
|
|
26
|
+
const response = await request({ path, method: "GET", kind: "metadata", signal });
|
|
27
|
+
try {
|
|
28
|
+
if (response.headers["content-type"]?.split(";")[0] !== "application/json")
|
|
29
|
+
throw hostUnavailable();
|
|
30
|
+
const bytes = await readHostBytes(response, path.includes("/models/") ? bridgeLimits.models : bridgeLimits.status);
|
|
31
|
+
return schema.parse(JSON.parse(bytes.toString("utf8")));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw hostUnavailable();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
response.destroy();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function ready(signal) {
|
|
41
|
+
const health = await metadata("/v1/health", hostHealthSchema, signal);
|
|
42
|
+
if (!health.accepting)
|
|
43
|
+
throw hostUnavailable();
|
|
44
|
+
}
|
|
45
|
+
const models = async (id) => (await metadata(`/v1/models/${id}`, hostModelsSchema)).models.map((model) => ({
|
|
46
|
+
id: model.id,
|
|
47
|
+
name: model.name,
|
|
48
|
+
...(model.group === undefined ? {} : { group: model.group }),
|
|
49
|
+
...(model.thinkingModes === undefined ? {} : { thinkingModes: model.thinkingModes }),
|
|
50
|
+
}));
|
|
51
|
+
return {
|
|
52
|
+
status: async (id) => {
|
|
53
|
+
try {
|
|
54
|
+
const status = await metadata(`/v1/status/${id}`, hostStatusSchema);
|
|
55
|
+
if (status.id !== id)
|
|
56
|
+
throw hostUnavailable();
|
|
57
|
+
return {
|
|
58
|
+
id: status.id,
|
|
59
|
+
command: status.command,
|
|
60
|
+
installed: status.installed,
|
|
61
|
+
login: status.login,
|
|
62
|
+
...(status.version === undefined ? {} : { version: status.version }),
|
|
63
|
+
...(status.issueKind === undefined ? {} : { issueKind: status.issueKind }),
|
|
64
|
+
...(status.issue === undefined ? {} : { issue: status.issue }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
command: id === "claude-code" ? "claude" : id === "codex-image" ? "codex" : id,
|
|
71
|
+
installed: false,
|
|
72
|
+
login: "unknown",
|
|
73
|
+
issueKind: "bridge",
|
|
74
|
+
issue: hostUnavailable().message,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
llm: (id) => ({
|
|
79
|
+
id,
|
|
80
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
81
|
+
models: () => models(id),
|
|
82
|
+
complete: async function* (req) {
|
|
83
|
+
const parsed = hostLlmSchema.safeParse({
|
|
84
|
+
model: req.model,
|
|
85
|
+
messages: req.messages,
|
|
86
|
+
...(req.thinking === undefined ? {} : { thinking: req.thinking }),
|
|
87
|
+
...(req.webSearch === undefined ? {} : { webSearch: req.webSearch }),
|
|
88
|
+
});
|
|
89
|
+
if (!parsed.success)
|
|
90
|
+
throw providerError({ kind: "unsupported", message: "Invalid host LLM request." });
|
|
91
|
+
await ready(req.signal);
|
|
92
|
+
const response = await request({
|
|
93
|
+
path: `/v1/llm/${id}`,
|
|
94
|
+
method: "POST",
|
|
95
|
+
kind: "llm",
|
|
96
|
+
body: Buffer.from(JSON.stringify(parsed.data)),
|
|
97
|
+
signal: req.signal,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
if (response.headers["content-type"]?.split(";")[0] !== "application/x-ndjson")
|
|
101
|
+
throw hostUnavailable(true);
|
|
102
|
+
yield* readEvents(response);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (isProviderError(error))
|
|
106
|
+
throw error;
|
|
107
|
+
throw hostUnavailable(true);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
response.destroy();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
image: {
|
|
115
|
+
id: "codex-image",
|
|
116
|
+
models: () => models("codex-image"),
|
|
117
|
+
generate: async (req) => {
|
|
118
|
+
const parsed = hostImageSchema.safeParse({
|
|
119
|
+
model: req.model,
|
|
120
|
+
prompt: req.prompt,
|
|
121
|
+
aspect: req.aspect,
|
|
122
|
+
});
|
|
123
|
+
if (!parsed.success)
|
|
124
|
+
throw providerError({ kind: "unsupported", message: "Invalid host image request." });
|
|
125
|
+
await ready(req.signal);
|
|
126
|
+
const response = await request({
|
|
127
|
+
path: "/v1/image",
|
|
128
|
+
method: "POST",
|
|
129
|
+
kind: "image",
|
|
130
|
+
body: Buffer.from(JSON.stringify(parsed.data)),
|
|
131
|
+
signal: req.signal,
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
const mime = response.headers["content-type"];
|
|
135
|
+
if (mime !== "image/png" && mime !== "image/jpeg")
|
|
136
|
+
throw hostUnavailable(true);
|
|
137
|
+
const bytes = await readHostBytes(response, bridgeLimits.image);
|
|
138
|
+
if (sniffImage(bytes) !== mime)
|
|
139
|
+
throw hostUnavailable(true);
|
|
140
|
+
return { bytes, mime };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (isProviderError(error))
|
|
144
|
+
throw error;
|
|
145
|
+
throw hostUnavailable(true);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
response.destroy();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function* readEvents(response) {
|
|
155
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
156
|
+
let pending = "";
|
|
157
|
+
let total = 0;
|
|
158
|
+
let terminal;
|
|
159
|
+
for await (const bytes of response) {
|
|
160
|
+
if (!Buffer.isBuffer(bytes))
|
|
161
|
+
throw hostUnavailable(true);
|
|
162
|
+
total += bytes.byteLength;
|
|
163
|
+
if (total > bridgeLimits.stream)
|
|
164
|
+
throw hostUnavailable(true);
|
|
165
|
+
pending += decoder.decode(bytes, { stream: true });
|
|
166
|
+
let newline = pending.indexOf("\n");
|
|
167
|
+
while (newline !== -1) {
|
|
168
|
+
const line = pending.slice(0, newline);
|
|
169
|
+
pending = pending.slice(newline + 1);
|
|
170
|
+
if (Buffer.byteLength(line) + 1 > bridgeLimits.frame)
|
|
171
|
+
throw hostUnavailable(true);
|
|
172
|
+
if (line.trim() !== "") {
|
|
173
|
+
if (terminal)
|
|
174
|
+
throw hostUnavailable(true);
|
|
175
|
+
const event = hostFrameSchema.parse(JSON.parse(line));
|
|
176
|
+
if (event.type === "error")
|
|
177
|
+
throw providerError({ kind: event.kind, message: redact(event.message) });
|
|
178
|
+
if (event.type === "done")
|
|
179
|
+
terminal = event;
|
|
180
|
+
else
|
|
181
|
+
yield event;
|
|
182
|
+
}
|
|
183
|
+
newline = pending.indexOf("\n");
|
|
184
|
+
}
|
|
185
|
+
if (Buffer.byteLength(pending) >= bridgeLimits.frame)
|
|
186
|
+
throw hostUnavailable(true);
|
|
187
|
+
}
|
|
188
|
+
pending += decoder.decode();
|
|
189
|
+
if (!response.complete || pending.trim() !== "" || !terminal)
|
|
190
|
+
throw hostUnavailable(true);
|
|
191
|
+
yield terminal;
|
|
192
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { request } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { bridgeLimits } from "../../kernel/ports/host-cli.js";
|
|
6
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
export function hostUnavailable(submitted = false) {
|
|
8
|
+
return providerError({
|
|
9
|
+
kind: "unavailable",
|
|
10
|
+
message: submitted
|
|
11
|
+
? "The host CLI connection ended without a reliable result; the result may be uncertain. Review the affected rebuild before retrying."
|
|
12
|
+
: "Host helper unavailable. Run npx @gentbajko/slopify@latest --docker on the host to set it up or check its service.",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function tokenAt(directory) {
|
|
16
|
+
const file = await open(join(directory, "token"), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
17
|
+
try {
|
|
18
|
+
const stat = await file.stat();
|
|
19
|
+
if (!stat.isFile() || stat.size !== 64 || (stat.mode & 0o022) !== 0)
|
|
20
|
+
throw hostUnavailable();
|
|
21
|
+
const bytes = Buffer.alloc(65);
|
|
22
|
+
const { bytesRead } = await file.read(bytes, 0, 65, 0);
|
|
23
|
+
const value = bytes.subarray(0, bytesRead).toString("utf8");
|
|
24
|
+
if (!/^[a-f0-9]{64}$/.test(value))
|
|
25
|
+
throw hostUnavailable();
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await file.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function hostRequest(options) {
|
|
33
|
+
if (options.body && options.body.byteLength > bridgeLimits.request)
|
|
34
|
+
throw providerError({
|
|
35
|
+
kind: "unsupported",
|
|
36
|
+
message: "The host provider request exceeds 16 MiB.",
|
|
37
|
+
});
|
|
38
|
+
let token;
|
|
39
|
+
try {
|
|
40
|
+
options.signal.throwIfAborted();
|
|
41
|
+
token = await tokenAt(options.directory);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw hostUnavailable();
|
|
45
|
+
}
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let response;
|
|
48
|
+
let submitted = false;
|
|
49
|
+
const req = request({
|
|
50
|
+
socketPath: join(options.directory, "cli.sock"),
|
|
51
|
+
path: options.path,
|
|
52
|
+
method: options.method,
|
|
53
|
+
agent: false,
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${token}`,
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
...(options.body ? { "content-length": options.body.byteLength } : {}),
|
|
58
|
+
},
|
|
59
|
+
}, (incoming) => {
|
|
60
|
+
response = incoming;
|
|
61
|
+
const maximum = options.kind === "image"
|
|
62
|
+
? bridgeLimits.image
|
|
63
|
+
: options.kind === "llm"
|
|
64
|
+
? bridgeLimits.stream
|
|
65
|
+
: options.path.includes("/models/")
|
|
66
|
+
? bridgeLimits.models
|
|
67
|
+
: bridgeLimits.status;
|
|
68
|
+
const advertised = Number(incoming.headers["content-length"] ?? 0);
|
|
69
|
+
if (!Number.isFinite(advertised) || advertised > maximum) {
|
|
70
|
+
fail();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
incoming.once("error", () => { });
|
|
74
|
+
incoming.once("close", cleanup);
|
|
75
|
+
resolve(incoming);
|
|
76
|
+
});
|
|
77
|
+
const fail = () => {
|
|
78
|
+
const error = hostUnavailable(submitted);
|
|
79
|
+
response?.destroy(error);
|
|
80
|
+
req.destroy(error);
|
|
81
|
+
reject(error);
|
|
82
|
+
};
|
|
83
|
+
const connectTimer = setTimeout(fail, 5000);
|
|
84
|
+
const deadline = setTimeout(fail, options.kind === "image" ? 300_000 : options.kind === "metadata" ? 35_000 : 120_000);
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
clearTimeout(connectTimer);
|
|
87
|
+
clearTimeout(deadline);
|
|
88
|
+
options.signal.removeEventListener("abort", fail);
|
|
89
|
+
};
|
|
90
|
+
req.once("socket", (socket) => {
|
|
91
|
+
socket.once("connect", () => {
|
|
92
|
+
clearTimeout(connectTimer);
|
|
93
|
+
submitted = options.method === "POST";
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
req.on("error", () => {
|
|
97
|
+
cleanup();
|
|
98
|
+
const error = hostUnavailable(submitted);
|
|
99
|
+
response?.destroy(error);
|
|
100
|
+
reject(error);
|
|
101
|
+
});
|
|
102
|
+
if (options.kind === "llm") {
|
|
103
|
+
req.on("response", () => clearTimeout(deadline));
|
|
104
|
+
req.setTimeout(120_000, fail);
|
|
105
|
+
}
|
|
106
|
+
options.signal.addEventListener("abort", fail, { once: true });
|
|
107
|
+
if (options.signal.aborted)
|
|
108
|
+
fail();
|
|
109
|
+
else
|
|
110
|
+
req.end(options.body);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export async function readHostBytes(response, maximum) {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let length = 0;
|
|
116
|
+
try {
|
|
117
|
+
for await (const value of response) {
|
|
118
|
+
if (!Buffer.isBuffer(value))
|
|
119
|
+
throw hostUnavailable();
|
|
120
|
+
length += value.byteLength;
|
|
121
|
+
if (length > maximum)
|
|
122
|
+
throw providerError({
|
|
123
|
+
kind: "unavailable",
|
|
124
|
+
message: "Host response exceeded its size limit.",
|
|
125
|
+
});
|
|
126
|
+
chunks.push(value);
|
|
127
|
+
}
|
|
128
|
+
if (!response.complete)
|
|
129
|
+
throw hostUnavailable();
|
|
130
|
+
return Buffer.concat(chunks, length);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
response.destroy();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, rmSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { redact } from "../../kernel/log.js";
|
|
6
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
import { cliLoginError } from "../llm/cli-login-error.js";
|
|
8
|
+
import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
|
|
9
|
+
import { lines } from "../llm/sse-lines.js";
|
|
10
|
+
import { sniffImage } from "./bytes.js";
|
|
11
|
+
export const codexImageModel = {
|
|
12
|
+
id: "codex-imagegen",
|
|
13
|
+
name: "Codex built-in image generation",
|
|
14
|
+
};
|
|
15
|
+
const maxOutputBytes = 32 * 1024 * 1024;
|
|
16
|
+
const maxEventBytes = 1024 * 1024;
|
|
17
|
+
const disabledFeatures = [
|
|
18
|
+
"shell_tool",
|
|
19
|
+
"unified_exec",
|
|
20
|
+
"hooks",
|
|
21
|
+
"apps",
|
|
22
|
+
"plugins",
|
|
23
|
+
"remote_plugin",
|
|
24
|
+
"skill_search",
|
|
25
|
+
"multi_agent",
|
|
26
|
+
"computer_use",
|
|
27
|
+
"browser_use",
|
|
28
|
+
"view_image",
|
|
29
|
+
"workspace_dependencies",
|
|
30
|
+
];
|
|
31
|
+
export function codexImageArgs(req, directory) {
|
|
32
|
+
const prompt = [
|
|
33
|
+
"You are making exactly one image for Slopify. Use the image generation tool.",
|
|
34
|
+
`Save the final image as ${join(directory, "result.png")}. Do not create any other asset.`,
|
|
35
|
+
"Use PNG or JPEG. Do not write outside this private directory.",
|
|
36
|
+
`Target aspect ratio: ${req.aspect}.`,
|
|
37
|
+
"Image brief follows as data:",
|
|
38
|
+
req.prompt,
|
|
39
|
+
].join("\n\n");
|
|
40
|
+
return [
|
|
41
|
+
"exec",
|
|
42
|
+
"--json",
|
|
43
|
+
"--ephemeral",
|
|
44
|
+
"--ignore-user-config",
|
|
45
|
+
"--ignore-rules",
|
|
46
|
+
"--strict-config",
|
|
47
|
+
"--skip-git-repo-check",
|
|
48
|
+
"--sandbox",
|
|
49
|
+
"workspace-write",
|
|
50
|
+
"--cd",
|
|
51
|
+
directory,
|
|
52
|
+
"--enable",
|
|
53
|
+
"image_generation",
|
|
54
|
+
...disabledFeatures.flatMap((feature) => ["--disable", feature]),
|
|
55
|
+
"-c",
|
|
56
|
+
"project_doc_max_bytes=0",
|
|
57
|
+
"-c",
|
|
58
|
+
"skills.include_instructions=false",
|
|
59
|
+
"-c",
|
|
60
|
+
"skills.bundled.enabled=false",
|
|
61
|
+
"-c",
|
|
62
|
+
"orchestrator.skills.enabled=false",
|
|
63
|
+
"-c",
|
|
64
|
+
"orchestrator.mcp.enabled=false",
|
|
65
|
+
"-c",
|
|
66
|
+
"include_permissions_instructions=false",
|
|
67
|
+
"-c",
|
|
68
|
+
"include_apps_instructions=false",
|
|
69
|
+
"-c",
|
|
70
|
+
"include_collaboration_mode_instructions=false",
|
|
71
|
+
"-c",
|
|
72
|
+
"include_environment_context=false",
|
|
73
|
+
"-c",
|
|
74
|
+
'shell_environment_policy.inherit="none"',
|
|
75
|
+
"-c",
|
|
76
|
+
'web_search="disabled"',
|
|
77
|
+
"--",
|
|
78
|
+
prompt,
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const failure = z.object({ error: z.object({ message: z.string() }) });
|
|
82
|
+
const errorEvent = z.object({ message: z.string() });
|
|
83
|
+
const item = z.object({ item: z.object({ type: z.string(), text: z.string().optional() }) });
|
|
84
|
+
export function codexImage(deps) {
|
|
85
|
+
const binary = deps.binary ?? "codex";
|
|
86
|
+
return {
|
|
87
|
+
id: "codex-image",
|
|
88
|
+
models: async () => [codexImageModel],
|
|
89
|
+
generate: async (req) => {
|
|
90
|
+
if (req.model !== codexImageModel.id)
|
|
91
|
+
throw providerError({ kind: "unsupported", message: "Choose the Codex image capability." });
|
|
92
|
+
req.signal.throwIfAborted();
|
|
93
|
+
const directory = mkdtempSync(join(tmpdir(), "slopify-codex-image-"));
|
|
94
|
+
let run;
|
|
95
|
+
try {
|
|
96
|
+
try {
|
|
97
|
+
run = deps.run(binary, codexImageArgs(req, directory), req.signal, {
|
|
98
|
+
cwd: directory,
|
|
99
|
+
env: imageEnvironment(directory),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
|
|
104
|
+
}
|
|
105
|
+
let completed = false;
|
|
106
|
+
let unavailable = false;
|
|
107
|
+
for await (const line of lines(bounded(run.stdout, maxEventBytes), req.signal)) {
|
|
108
|
+
if (line.trim() === "")
|
|
109
|
+
continue;
|
|
110
|
+
const event = cliEvent(binary, line);
|
|
111
|
+
req.signal.throwIfAborted();
|
|
112
|
+
if (event.type === "turn.completed")
|
|
113
|
+
completed = true;
|
|
114
|
+
else if (event.type === "turn.failed") {
|
|
115
|
+
const message = cliShaped(binary, failure, event.value).error.message;
|
|
116
|
+
const login = cliLoginError("codex", message);
|
|
117
|
+
if (login)
|
|
118
|
+
throw login;
|
|
119
|
+
throw providerError({
|
|
120
|
+
kind: /refus|content.policy|safety/i.test(message) ? "refusal" : "other",
|
|
121
|
+
message: redact(message),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else if (event.type === "error") {
|
|
125
|
+
const message = cliShaped(binary, errorEvent, event.value).message;
|
|
126
|
+
const login = cliLoginError("codex", message);
|
|
127
|
+
if (login)
|
|
128
|
+
throw login;
|
|
129
|
+
throw providerError({
|
|
130
|
+
kind: /image.generation|image tool|feature.*unavailable/i.test(message)
|
|
131
|
+
? "unsupported"
|
|
132
|
+
: "other",
|
|
133
|
+
message: redact(message),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else if (event.type === "item.completed") {
|
|
137
|
+
const { item: value } = cliShaped(binary, item, event.value);
|
|
138
|
+
if (value.type === "agent_message" &&
|
|
139
|
+
/image.generation.*unavailable|cannot generate images|no image tool/i.test(value.text ?? ""))
|
|
140
|
+
unavailable = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
req.signal.throwIfAborted();
|
|
144
|
+
const ended = await run.ended;
|
|
145
|
+
req.signal.throwIfAborted();
|
|
146
|
+
if (ended.error !== null)
|
|
147
|
+
throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
|
|
148
|
+
if (ended.code !== 0 || !completed)
|
|
149
|
+
throw (cliLoginError("codex", run.stderr()) ??
|
|
150
|
+
providerError({
|
|
151
|
+
kind: unavailable ? "unsupported" : "other",
|
|
152
|
+
message: endedWithout(binary, ended, run.stderr()),
|
|
153
|
+
}));
|
|
154
|
+
if (unavailable)
|
|
155
|
+
throw providerError({
|
|
156
|
+
kind: "unsupported",
|
|
157
|
+
message: "This Codex install cannot generate images.",
|
|
158
|
+
});
|
|
159
|
+
return exactImage(join(directory, "result.png"));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
req.signal.throwIfAborted();
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
try {
|
|
167
|
+
if (run !== undefined)
|
|
168
|
+
await stopCliRun(run);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async function* bounded(source, maximum) {
|
|
178
|
+
let size = 0;
|
|
179
|
+
for await (const chunk of source) {
|
|
180
|
+
size += chunk.byteLength;
|
|
181
|
+
if (size > maximum)
|
|
182
|
+
throw providerError({
|
|
183
|
+
kind: "other",
|
|
184
|
+
message: "Codex wrote too much image progress output.",
|
|
185
|
+
});
|
|
186
|
+
yield chunk;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function exactImage(path) {
|
|
190
|
+
const entry = lstatSync(path, { throwIfNoEntry: false });
|
|
191
|
+
if (entry === undefined)
|
|
192
|
+
throw providerError({
|
|
193
|
+
kind: "unsupported",
|
|
194
|
+
message: "Codex did not save an image at the requested path.",
|
|
195
|
+
});
|
|
196
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0 || entry.size > maxOutputBytes)
|
|
197
|
+
throw providerError({ kind: "other", message: "Codex image output was not a safe file." });
|
|
198
|
+
let fd;
|
|
199
|
+
try {
|
|
200
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
201
|
+
const status = fstatSync(fd);
|
|
202
|
+
if (!status.isFile() || status.size === 0 || status.size > maxOutputBytes)
|
|
203
|
+
throw providerError({ kind: "other", message: "Codex image output was not a safe file." });
|
|
204
|
+
const bytes = readFileSync(fd);
|
|
205
|
+
const mime = sniffImage(bytes);
|
|
206
|
+
if (mime === undefined)
|
|
207
|
+
throw providerError({ kind: "other", message: "Codex saved an unsupported image format." });
|
|
208
|
+
return { bytes, mime };
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (fd !== undefined)
|
|
212
|
+
closeSync(fd);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function imageEnvironment(directory) {
|
|
216
|
+
const env = { PWD: directory };
|
|
217
|
+
const allowed = [
|
|
218
|
+
"HOME",
|
|
219
|
+
"PATH",
|
|
220
|
+
"CODEX_HOME",
|
|
221
|
+
"XDG_CONFIG_HOME",
|
|
222
|
+
"XDG_DATA_HOME",
|
|
223
|
+
"XDG_CACHE_HOME",
|
|
224
|
+
"TMPDIR",
|
|
225
|
+
"TEMP",
|
|
226
|
+
"TMP",
|
|
227
|
+
"LANG",
|
|
228
|
+
"LC_ALL",
|
|
229
|
+
"USER",
|
|
230
|
+
"LOGNAME",
|
|
231
|
+
"SYSTEMROOT",
|
|
232
|
+
"SSL_CERT_FILE",
|
|
233
|
+
"NODE_EXTRA_CA_CERTS",
|
|
234
|
+
];
|
|
235
|
+
for (const key of allowed)
|
|
236
|
+
if (process.env[key] !== undefined)
|
|
237
|
+
env[key] = process.env[key];
|
|
238
|
+
return env;
|
|
239
|
+
}
|