@gentbajko/slopify 0.1.0 → 0.3.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 +2 -2
- package/dist/adapter-registry.js +11 -12
- package/dist/adapters/image/bytes.js +1 -1
- package/dist/adapters/image/fal.js +55 -44
- package/dist/adapters/image/openai.js +19 -24
- package/dist/adapters/image/replicate.js +33 -43
- package/dist/adapters/llm/claude-code.js +16 -19
- package/dist/adapters/llm/codex.js +14 -15
- package/dist/adapters/llm/openrouter.js +13 -16
- package/dist/adapters/llm/run-cli.js +9 -11
- package/dist/adapters/llm/sse-lines.js +1 -2
- package/dist/adapters/retry-after.js +6 -6
- package/dist/adapters/tts/cartesia.js +5 -5
- package/dist/adapters/tts/elevenlabs.js +14 -19
- package/dist/adapters/tts/openai.js +9 -10
- package/dist/edge/cli.js +0 -0
- package/dist/edge/http/actions.js +8 -9
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/entries.js +1 -1
- package/dist/edge/http/projects.js +7 -7
- package/dist/edge/http/prompts.js +6 -6
- package/dist/edge/http/providers.js +4 -4
- package/dist/edge/http/settings.js +2 -2
- package/dist/edge/http/staging.js +1 -1
- package/dist/edge/http/telemetry.js +6 -7
- package/dist/edge/http/usage.js +5 -5
- package/dist/kernel/config/index.js +1 -1
- package/dist/kernel/db/tx.js +9 -12
- package/dist/kernel/log.js +5 -5
- package/dist/kernel/pipeline.js +7 -9
- package/dist/kernel/ports/model.js +3 -4
- package/dist/kernel/runner/attempt-repo.js +5 -6
- package/dist/kernel/runner/attempt.js +16 -23
- package/dist/kernel/runner/graph.js +18 -29
- package/dist/kernel/runner/index.js +27 -33
- package/dist/kernel/runner/piece-repo.js +9 -11
- package/dist/kernel/runner/providers.js +5 -7
- package/dist/kernel/version.js +3 -3
- package/dist/main.js +10 -11
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +6 -7
- package/dist/slices/admission/rules.js +12 -15
- package/dist/slices/admission/start.js +7 -8
- package/dist/slices/admission/substitute.js +11 -11
- package/dist/slices/article/continuation.js +9 -9
- package/dist/slices/article/plain.js +5 -5
- package/dist/slices/article/run.js +25 -25
- package/dist/slices/article/split.js +1 -1
- package/dist/slices/article/store.js +5 -5
- package/dist/slices/cancel/index.js +8 -8
- package/dist/slices/images/run.js +32 -34
- package/dist/slices/library/lint.js +3 -3
- package/dist/slices/library/model.js +7 -7
- package/dist/slices/library/repo.js +3 -3
- package/dist/slices/library/save.js +7 -7
- package/dist/slices/library/slots.js +5 -5
- package/dist/slices/narration/chunk.js +12 -12
- package/dist/slices/narration/concat.js +12 -13
- package/dist/slices/narration/run.js +46 -49
- package/dist/slices/reruns/cascade.js +10 -11
- package/dist/slices/reruns/index.js +36 -37
- package/dist/slices/research/planner.js +4 -5
- package/dist/slices/research/run.js +22 -23
- package/dist/slices/research/synthesis.js +5 -5
- package/dist/slices/settings/cli-status.js +9 -10
- package/dist/slices/settings/keys.js +8 -9
- package/dist/slices/settings/model.js +5 -5
- package/dist/slices/settings/readiness.js +3 -4
- package/dist/slices/settings/repo.js +1 -1
- package/dist/slices/settings/voices.js +3 -4
- package/dist/slices/storage/asset-name.js +2 -2
- package/dist/slices/storage/delete-project.js +3 -5
- package/dist/slices/storage/downloads.js +4 -4
- package/dist/slices/storage/layout.js +5 -6
- package/dist/slices/storage/model.js +1 -1
- package/dist/slices/storage/reconcile.js +6 -7
- package/dist/slices/storage/repo.js +1 -1
- package/dist/slices/storage/staging.js +12 -15
- package/dist/slices/telemetry/collector-client.js +2 -2
- package/dist/slices/telemetry/flush.js +8 -8
- package/dist/slices/telemetry/machine.js +6 -7
- package/dist/slices/telemetry/model.js +11 -13
- package/dist/slices/telemetry/record.js +7 -7
- package/dist/slices/telemetry/repo.js +4 -4
- package/dist/slices/telemetry/usage.js +5 -5
- package/dist/slices/thumbnail/by-llm.js +2 -2
- package/dist/slices/thumbnail/run.js +30 -32
- package/dist/slices/video/ffmpeg.js +6 -6
- package/dist/slices/video/plan.js +9 -9
- package/dist/slices/video/run.js +19 -20
- package/dist/web/assets/index-DTqh2zoJ.js +81 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-D_sWbKQi.js +0 -81
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A prompt and a few keywords in. A narrated slideshow video out. Your keys, your
|
|
|
6
6
|
npx @gentbajko/slopify@latest
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
That opens a browser at `http://127.0.0.1:
|
|
9
|
+
That opens a browser at `http://127.0.0.1:6969`. Nothing to configure first; add
|
|
10
10
|
provider keys on the Settings screen when you want a stage to generate rather than
|
|
11
11
|
take what you paste in.
|
|
12
12
|
|
|
@@ -18,7 +18,7 @@ video is a slideshow with alternating zoom over the narration, rendered with ffm
|
|
|
18
18
|
|
|
19
19
|
| Flag | Environment variable | Default |
|
|
20
20
|
|---|---|---|
|
|
21
|
-
| `--port` | `SLOPIFY_PORT` | `
|
|
21
|
+
| `--port` | `SLOPIFY_PORT` | `6969` |
|
|
22
22
|
| `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
|
|
23
23
|
| `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
|
|
24
24
|
| `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
|
package/dist/adapter-registry.js
CHANGED
|
@@ -10,31 +10,30 @@ import { openAiTts } from "./adapters/tts/openai.js";
|
|
|
10
10
|
import { keyForAttempt } from "./slices/settings/keys.js";
|
|
11
11
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
12
12
|
export function buildRegistry(deps) {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// Read per request, never held, so an attempt in flight finishes on the key it started with
|
|
14
|
+
// and the next one picks up a key saved since. The closure is handed to the one adapter that
|
|
15
|
+
// provider belongs to and to nothing else.
|
|
16
16
|
const keyOf = (provider) => () => {
|
|
17
17
|
const found = keyForAttempt({ db: deps.db, clock: deps.clock }, provider);
|
|
18
18
|
return found.ok ? found.key : undefined;
|
|
19
19
|
};
|
|
20
20
|
const llms = new Map([
|
|
21
21
|
["openrouter", openRouterLlm({ fetch: deps.fetch, key: keyOf("openrouter") })],
|
|
22
|
-
// No key: both CLIs authenticate with their own login
|
|
22
|
+
// No key: both CLIs authenticate with their own login.
|
|
23
23
|
["claude-code", claudeCodeLlm({ run: deps.spawn })],
|
|
24
24
|
["codex", codexLlm({ run: deps.spawn })],
|
|
25
25
|
]);
|
|
26
|
-
// One key per provider
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// One key per provider, so each adapter is handed the reader for its own row and no other.
|
|
27
|
+
// OpenAI keeps two rows because it ships an adapter in two families and a user may key one
|
|
28
|
+
// without the other (`slices/settings/model.ts`).
|
|
29
29
|
const ttses = new Map([
|
|
30
30
|
["elevenlabs", elevenLabsTts({ fetch: deps.fetch, key: keyOf("elevenlabs") })],
|
|
31
31
|
["openai-tts", openAiTts({ fetch: deps.fetch, key: keyOf("openai-tts") })],
|
|
32
32
|
["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
|
|
33
33
|
]);
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// app's clock so a test never sits through one.
|
|
34
|
+
// Three image providers behind one port, each handed the reader for its own key row.
|
|
35
|
+
// Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
|
|
36
|
+
// to be polled, and the wait is spent on the app's clock so a test never sits through one.
|
|
38
37
|
const images = new Map([
|
|
39
38
|
["fal", falImage({ fetch: deps.fetch, key: keyOf("fal") })],
|
|
40
39
|
[
|
|
@@ -47,7 +46,7 @@ export function buildRegistry(deps) {
|
|
|
47
46
|
llm: (id) => resolve(llms, "llm", id),
|
|
48
47
|
tts: (id) => resolve(ttses, "tts", id),
|
|
49
48
|
image: (id) => resolve(images, "image", id),
|
|
50
|
-
//
|
|
49
|
+
// Every supported provider, keyed or not, found or not, so Play
|
|
51
50
|
// can grey one out with a reason instead of hiding it.
|
|
52
51
|
list: async () => (await providerStatuses({ db: deps.db, probe: deps.probe })).map((status) => ({
|
|
53
52
|
family: status.family,
|
|
@@ -14,7 +14,7 @@ const riff = [0x52, 0x49, 0x46, 0x46];
|
|
|
14
14
|
const webp = [0x57, 0x45, 0x42, 0x50];
|
|
15
15
|
// The bytes decide, never the Content-Type header. A CDN in front of an expired link
|
|
16
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
|
|
17
|
+
// the mime is stored with the file and becomes its extension.
|
|
18
18
|
export function sniffImage(bytes) {
|
|
19
19
|
if (startsWith(bytes, png, 0)) {
|
|
20
20
|
return "image/png";
|
|
@@ -3,40 +3,59 @@ import { redact } from "../../kernel/log.js";
|
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { retryAfter } from "../retry-after.js";
|
|
5
5
|
import { downloadImage } from "./bytes.js";
|
|
6
|
-
// The HTTP gateway adapter for fal.ai
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// open connection, which is what the attempt wrapper's 300 s is measuring.
|
|
6
|
+
// The HTTP gateway adapter for fal.ai: `fetch` and the downloader beside this file, no SDK.
|
|
7
|
+
// `@fal-ai/client` would buy queue polling this endpoint does not need, so the platform's
|
|
8
|
+
// own `fetch` covers the whole call. The synchronous host runs the model on the open
|
|
9
|
+
// connection, which is what the wrapper's 300 s measures.
|
|
11
10
|
export const falBase = "https://fal.run";
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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.
|
|
11
|
+
// fal has no endpoint listing only its text-to-image models, so the list is this adapter's own
|
|
12
|
+
// data and adding one is a line here plus its aspect shape below.
|
|
19
13
|
export const falModels = [
|
|
20
14
|
{ id: "fal-ai/flux-2", name: "FLUX.2" },
|
|
21
15
|
{ id: "fal-ai/flux/dev", name: "FLUX.1 [dev]" },
|
|
22
16
|
{ id: "fal-ai/flux/schnell", name: "FLUX.1 [schnell]" },
|
|
17
|
+
{ id: "fal-ai/nano-banana", name: "Nano Banana" },
|
|
18
|
+
{ id: "fal-ai/nano-banana-2", name: "Nano Banana 2" },
|
|
19
|
+
{ id: "fal-ai/gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image" },
|
|
23
20
|
];
|
|
24
|
-
//
|
|
25
|
-
//
|
|
21
|
+
// fal spells the frame two ways and the difference is per model, not per family: the FLUX
|
|
22
|
+
// endpoints take an `image_size` enum naming the orientation, the Google ones take a plain
|
|
23
|
+
// `aspect_ratio` string. Both accept everything else identically, so the shape is the only
|
|
24
|
+
// thing a model entry has to declare.
|
|
25
|
+
const aspectFields = ["image_size", "aspect_ratio"];
|
|
26
|
+
// The closest supported size, per shape. fal names both 16:9 `image_size` frames from the
|
|
27
|
+
// orientation, so the portrait one is "portrait_16_9" and is 9:16; `aspect_ratio` takes the
|
|
28
|
+
// run's own words.
|
|
26
29
|
const sizes = {
|
|
27
|
-
"16:9": "landscape_16_9",
|
|
28
|
-
"9:16": "
|
|
30
|
+
image_size: { "16:9": "landscape_16_9", "9:16": "portrait_16_9" },
|
|
31
|
+
aspect_ratio: { "16:9": "16:9", "9:16": "9:16" },
|
|
29
32
|
};
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
const modelAspects = {
|
|
34
|
+
"fal-ai/flux-2": "image_size",
|
|
35
|
+
"fal-ai/flux/dev": "image_size",
|
|
36
|
+
"fal-ai/flux/schnell": "image_size",
|
|
37
|
+
"fal-ai/nano-banana": "aspect_ratio",
|
|
38
|
+
"fal-ai/nano-banana-2": "aspect_ratio",
|
|
39
|
+
"fal-ai/gemini-3.1-flash-image-preview": "aspect_ratio",
|
|
40
|
+
};
|
|
41
|
+
// A model the map does not know is one the user typed rather than picked, so it falls back to
|
|
42
|
+
// `image_size` - the shape the FLUX endpoints use and the one this adapter shipped with.
|
|
43
|
+
// `Object.hasOwn` because a model id is user input and a plain lookup would answer
|
|
44
|
+
// "constructor" from Object's own prototype.
|
|
45
|
+
function aspectOf(model, aspect) {
|
|
46
|
+
const field = Object.hasOwn(modelAspects, model) ? modelAspects[model] : undefined;
|
|
47
|
+
const shape = field ?? "image_size";
|
|
48
|
+
return { [shape]: sizes[shape][aspect] };
|
|
49
|
+
}
|
|
50
|
+
// A wire payload is narrowed, never cast: everything unlisted is dropped at the seam.
|
|
32
51
|
const generated = z.object({
|
|
33
52
|
images: z.array(z.object({ url: z.string() })),
|
|
34
|
-
// The safety checker's verdict, one flag per image
|
|
35
|
-
//
|
|
53
|
+
// The safety checker's verdict, one flag per image - how fal declines: a 200 with a
|
|
54
|
+
// blank image.
|
|
36
55
|
has_nsfw_concepts: z.array(z.boolean()).nullish(),
|
|
37
56
|
});
|
|
38
|
-
//
|
|
39
|
-
//
|
|
57
|
+
// FastAPI's envelope: an object per failed field for a validation error, a bare string
|
|
58
|
+
// otherwise.
|
|
40
59
|
const errorBody = z.object({
|
|
41
60
|
detail: z.union([
|
|
42
61
|
z.string(),
|
|
@@ -54,13 +73,11 @@ export function falImage(deps) {
|
|
|
54
73
|
headers: { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" },
|
|
55
74
|
body: JSON.stringify({
|
|
56
75
|
prompt: req.prompt,
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
// resumable piece, so one image per request is what the stage asks for.
|
|
76
|
+
...aspectOf(req.model, req.aspect),
|
|
77
|
+
// The stage sends Number as that many independent calls, one piece each.
|
|
60
78
|
num_images: 1,
|
|
61
|
-
// The port stores
|
|
62
|
-
//
|
|
63
|
-
// step 2 asks for the provider's defaults.
|
|
79
|
+
// The port stores PNG or JPEG; fal defaults to WebP on some models and the
|
|
80
|
+
// download would refuse it. The stage asks for the provider's own quality.
|
|
64
81
|
output_format: "png",
|
|
65
82
|
}),
|
|
66
83
|
});
|
|
@@ -73,8 +90,7 @@ export function falImage(deps) {
|
|
|
73
90
|
if (first === undefined) {
|
|
74
91
|
throw providerError({ kind: "other", message: "fal answered with no image" });
|
|
75
92
|
}
|
|
76
|
-
// The download
|
|
77
|
-
// link that 404s is a failed attempt rather than a broken file on the project.
|
|
93
|
+
// The download rides inside the attempt: a link that 404s is a failed attempt.
|
|
78
94
|
return await downloadImage({
|
|
79
95
|
fetch: deps.fetch,
|
|
80
96
|
provider: "fal",
|
|
@@ -85,18 +101,16 @@ export function falImage(deps) {
|
|
|
85
101
|
};
|
|
86
102
|
}
|
|
87
103
|
function keyOf(deps) {
|
|
88
|
-
// `
|
|
89
|
-
// `missing_key` rather than `auth` because the same rule makes it terminal.
|
|
104
|
+
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
90
105
|
const key = deps.key();
|
|
91
106
|
if (key === undefined || key === "") {
|
|
92
107
|
throw providerError({ kind: "missing_key", message: "no fal key is stored" });
|
|
93
108
|
}
|
|
94
109
|
return key;
|
|
95
110
|
}
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// the one refusal in the app whose words are the app's; fal's own verdict is the flag.
|
|
111
|
+
// A content-policy refusal is final, named here and made terminal by the wrapper. fal declines
|
|
112
|
+
// inside a 200 - the safety checker flags the image and hands back a blank one. With no
|
|
113
|
+
// sentence to quote, this is the one refusal whose words are the app's.
|
|
100
114
|
function refused(answer, prompt) {
|
|
101
115
|
const flags = answer.has_nsfw_concepts ?? [];
|
|
102
116
|
if (flags.length > 0 && flags.every((flagged) => flagged)) {
|
|
@@ -106,8 +120,7 @@ function refused(answer, prompt) {
|
|
|
106
120
|
});
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
|
-
// Only the adapter
|
|
110
|
-
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
123
|
+
// Only the adapter sees the vendor's status code, so only the adapter names the kind.
|
|
111
124
|
function kindOf(status) {
|
|
112
125
|
if (status === 401 || status === 403) {
|
|
113
126
|
return "auth";
|
|
@@ -115,16 +128,14 @@ function kindOf(status) {
|
|
|
115
128
|
if (status === 429) {
|
|
116
129
|
return "rate_limit";
|
|
117
130
|
}
|
|
118
|
-
// ceiling: everything else is `other` and is retried, so a 422 naming an input this
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// which is not this adapter's to widen.
|
|
131
|
+
// ceiling: everything else is `other` and is retried, so a 422 naming an input this model
|
|
132
|
+
// does not take fails the same way four times over. A terminal "this will never work" kind
|
|
133
|
+
// has to reach the port's error contract first, which is not this adapter's to widen.
|
|
122
134
|
return "other";
|
|
123
135
|
}
|
|
124
136
|
async function failure(response) {
|
|
125
137
|
const text = await response.text().catch(() => "");
|
|
126
|
-
// The provider's own words,
|
|
127
|
-
// error body is free to quote the key back and this is the first place it is held.
|
|
138
|
+
// The provider's own words, through the redactor - an error body may quote the key back.
|
|
128
139
|
const message = redact(detailOf(text) || response.statusText);
|
|
129
140
|
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
130
141
|
return providerError({
|
|
@@ -3,13 +3,12 @@ import { redact } from "../../kernel/log.js";
|
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { retryAfter } from "../retry-after.js";
|
|
5
5
|
import { describeBytes, sniffImage } from "./bytes.js";
|
|
6
|
-
// The HTTP gateway adapter for OpenAI's images endpoint
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// there is no link to follow.
|
|
6
|
+
// The HTTP gateway adapter for OpenAI's images endpoint: the platform's own `fetch` and
|
|
7
|
+
// nothing else, because the whole call is one request. Unlike fal and Replicate this one
|
|
8
|
+
// hands back the image itself - a GPT image model always answers with base64, never a URL -
|
|
9
|
+
// so there is no link to follow.
|
|
11
10
|
export const openAiImagesBase = "https://api.openai.com/v1";
|
|
12
|
-
//
|
|
11
|
+
// The dropdown is filled from what the provider offers, and `/v1/models`
|
|
13
12
|
// lists every model on the account, chat and embeddings among them, so the image
|
|
14
13
|
// shortlist is this adapter's own data. These are the four GPT image models OpenAI
|
|
15
14
|
// documents; adding the next one is a line here and no code change anywhere else.
|
|
@@ -19,9 +18,9 @@ export const openAiImageModels = [
|
|
|
19
18
|
{ id: "gpt-image-1", name: "GPT Image 1" },
|
|
20
19
|
{ id: "gpt-image-1-mini", name: "GPT Image 1 mini" },
|
|
21
20
|
];
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
21
|
+
// The provider's closest supported size to the run's aspect. The standard GPT image sizes are
|
|
22
|
+
// 3:2 and 2:3, so 16:9 is asked for as 1536×1024 and the remaining sliver is cropped by the
|
|
23
|
+
// render.
|
|
25
24
|
const standard = {
|
|
26
25
|
"16:9": "1536x1024",
|
|
27
26
|
"9:16": "1024x1536",
|
|
@@ -34,7 +33,7 @@ const exact = {
|
|
|
34
33
|
"9:16": "864x1536",
|
|
35
34
|
};
|
|
36
35
|
const arbitrarySizes = /^gpt-image-2/;
|
|
37
|
-
// A wire payload is narrowed, never cast
|
|
36
|
+
// A wire payload is narrowed, never cast.
|
|
38
37
|
const generated = z.object({ data: z.array(z.object({ b64_json: z.string().nullish() })) });
|
|
39
38
|
const errorBody = z.object({
|
|
40
39
|
error: z.object({
|
|
@@ -43,7 +42,7 @@ const errorBody = z.object({
|
|
|
43
42
|
code: z.string().nullish(),
|
|
44
43
|
}),
|
|
45
44
|
});
|
|
46
|
-
//
|
|
45
|
+
// A content-policy refusal is the provider's final answer and is never
|
|
47
46
|
// retried. OpenAI names it in the body rather than in the status, so the code is what
|
|
48
47
|
// tells a declined prompt from a malformed request that shares its 400.
|
|
49
48
|
const refusalCodes = ["moderation_blocked", "content_policy_violation"];
|
|
@@ -62,11 +61,11 @@ export function openAiImage(deps) {
|
|
|
62
61
|
body: JSON.stringify({
|
|
63
62
|
model: req.model,
|
|
64
63
|
prompt: req.prompt,
|
|
65
|
-
//
|
|
66
|
-
//
|
|
64
|
+
// The stage sends Number as that many independent calls, one piece each, so one
|
|
65
|
+
// image per request is what it asks for.
|
|
67
66
|
n: 1,
|
|
68
67
|
size: sizeFor(req.model, req.aspect),
|
|
69
|
-
// No `quality` and no `background`:
|
|
68
|
+
// No `quality` and no `background`: the stage asks for the provider's default
|
|
70
69
|
// quality and style, which is what leaving them off means.
|
|
71
70
|
}),
|
|
72
71
|
});
|
|
@@ -96,16 +95,14 @@ function decode(b64) {
|
|
|
96
95
|
return { bytes, mime };
|
|
97
96
|
}
|
|
98
97
|
function keyOf(deps) {
|
|
99
|
-
// `
|
|
100
|
-
// `missing_key` rather than `auth` because the same rule makes it terminal.
|
|
98
|
+
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
101
99
|
const key = deps.key();
|
|
102
100
|
if (key === undefined || key === "") {
|
|
103
101
|
throw providerError({ kind: "missing_key", message: "no OpenAI image key is stored" });
|
|
104
102
|
}
|
|
105
103
|
return key;
|
|
106
104
|
}
|
|
107
|
-
// Only the adapter
|
|
108
|
-
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
105
|
+
// Only the adapter sees the vendor's status code, so only the adapter names the kind.
|
|
109
106
|
function kindOf(status, code) {
|
|
110
107
|
if (code !== undefined && code !== null && refusalCodes.includes(code)) {
|
|
111
108
|
return "refusal";
|
|
@@ -116,17 +113,15 @@ function kindOf(status, code) {
|
|
|
116
113
|
if (status === 429) {
|
|
117
114
|
return "rate_limit";
|
|
118
115
|
}
|
|
119
|
-
// ceiling: everything else is `other` and is retried, so a prompt past the model's
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// not this adapter's to widen.
|
|
116
|
+
// ceiling: everything else is `other` and is retried, so a prompt past the model's length
|
|
117
|
+
// limit fails the same way four times over. A terminal "this will never work" kind has to
|
|
118
|
+
// reach the port's error contract first, which is not this adapter's to widen.
|
|
123
119
|
return "other";
|
|
124
120
|
}
|
|
125
121
|
async function failure(response) {
|
|
126
122
|
const text = await response.text().catch(() => "");
|
|
127
123
|
const parsed = errorBody.safeParse(safeJson(text));
|
|
128
|
-
// The provider's own words,
|
|
129
|
-
// error body is free to quote the key back and this is the first place it is held.
|
|
124
|
+
// The provider's own words, through the redactor - an error body may quote the key back.
|
|
130
125
|
const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
|
|
131
126
|
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
132
127
|
return providerError({
|
|
@@ -3,30 +3,27 @@ import { redact } from "../../kernel/log.js";
|
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { retryAfter } from "../retry-after.js";
|
|
5
5
|
import { downloadImage } from "./bytes.js";
|
|
6
|
-
// The HTTP gateway adapter for Replicate
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// The HTTP gateway adapter for Replicate: `fetch`, the injected clock and the downloader
|
|
7
|
+
// beside this file, no SDK. The `replicate` package wraps exactly the two requests below, so
|
|
8
|
+
// the platform's own `fetch` covers it.
|
|
9
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// wrapper's 300 s (`logic/09` §Q77) is what ends the polling, through the request signal.
|
|
10
|
+
// `Prefer: wait` holds the connection open while the model runs, up to the 60 s Replicate
|
|
11
|
+
// documents. A slower model answers `starting` and has to be polled, which is why this
|
|
12
|
+
// adapter takes a clock. The wrapper's 300 s ends the polling.
|
|
14
13
|
export const replicateWaitSeconds = 60;
|
|
15
14
|
export const replicatePollMs = 2000;
|
|
16
|
-
//
|
|
17
|
-
// model
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// that spells its inputs differently needs a per-model input map first.
|
|
15
|
+
// The model dropdown comes from what the provider offers, but Replicate's index lists every
|
|
16
|
+
// model on the platform, so the text-to-image shortlist is this adapter's own data and adding
|
|
17
|
+
// one is a line here. ceiling: each entry has to take `prompt`, `aspect_ratio` and
|
|
18
|
+
// `output_format` - the convention across Replicate's official image models, not a guarantee. A
|
|
19
|
+
// model spelling its inputs differently needs an input map.
|
|
22
20
|
export const replicateModels = [
|
|
23
21
|
{ id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 [pro]" },
|
|
24
22
|
{ id: "black-forest-labs/flux-dev", name: "FLUX.1 [dev]" },
|
|
25
23
|
{ id: "black-forest-labs/flux-schnell", name: "FLUX.1 [schnell]" },
|
|
26
24
|
];
|
|
27
|
-
// A wire payload is narrowed, never cast
|
|
28
|
-
//
|
|
29
|
-
// several, so both shapes are read and the first is taken.
|
|
25
|
+
// A wire payload is narrowed, never cast. `output` is one URL
|
|
26
|
+
// on single-image models and an array on the rest, so both shapes are read.
|
|
30
27
|
const prediction = z.object({
|
|
31
28
|
status: z.string(),
|
|
32
29
|
output: z.union([z.string(), z.array(z.string())]).nullish(),
|
|
@@ -34,8 +31,8 @@ const prediction = z.object({
|
|
|
34
31
|
urls: z.object({ get: z.string().optional() }).nullish(),
|
|
35
32
|
});
|
|
36
33
|
const errorBody = z.object({ detail: z.string().optional(), title: z.string().optional() });
|
|
37
|
-
//
|
|
38
|
-
//
|
|
34
|
+
// `canceled` cannot happen here - nothing cancels a prediction this app made - but reading
|
|
35
|
+
// it as terminal beats polling forever.
|
|
39
36
|
const settled = ["succeeded", "failed", "canceled"];
|
|
40
37
|
export function replicateImage(deps) {
|
|
41
38
|
return {
|
|
@@ -53,11 +50,10 @@ export function replicateImage(deps) {
|
|
|
53
50
|
body: JSON.stringify({
|
|
54
51
|
input: {
|
|
55
52
|
prompt: req.prompt,
|
|
56
|
-
//
|
|
57
|
-
// supported size is the exact one.
|
|
53
|
+
// Replicate takes the aspect, so the closest size is exact.
|
|
58
54
|
aspect_ratio: req.aspect,
|
|
59
|
-
// The port stores
|
|
60
|
-
//
|
|
55
|
+
// The port stores PNG or JPEG and these models default to WebP. Nothing else
|
|
56
|
+
// is set: the stage asks for the provider's own quality and style.
|
|
61
57
|
output_format: "png",
|
|
62
58
|
},
|
|
63
59
|
}),
|
|
@@ -66,8 +62,7 @@ export function replicateImage(deps) {
|
|
|
66
62
|
throw await failure(response);
|
|
67
63
|
}
|
|
68
64
|
const url = await settle(deps, parse(await response.text()), req.signal);
|
|
69
|
-
// The download
|
|
70
|
-
// link that 404s is a failed attempt rather than a broken file on the project.
|
|
65
|
+
// The download rides inside the attempt: a link that 404s is a failed attempt.
|
|
71
66
|
return await downloadImage({
|
|
72
67
|
fetch: deps.fetch,
|
|
73
68
|
provider: "Replicate",
|
|
@@ -77,8 +72,8 @@ export function replicateImage(deps) {
|
|
|
77
72
|
},
|
|
78
73
|
};
|
|
79
74
|
}
|
|
80
|
-
// `Prefer: wait` answers `starting` when the model outlived its 60 s
|
|
81
|
-
//
|
|
75
|
+
// `Prefer: wait` answers `starting` when the model outlived its 60 s; Replicate's guidance
|
|
76
|
+
// is to poll `urls.get` until the prediction settles.
|
|
82
77
|
async function settle(deps, first, signal) {
|
|
83
78
|
let current = first;
|
|
84
79
|
while (!settled.includes(current.status)) {
|
|
@@ -109,33 +104,30 @@ function outputOf(current) {
|
|
|
109
104
|
}
|
|
110
105
|
return url;
|
|
111
106
|
}
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
107
|
+
// A content-policy refusal is final and never retried. Replicate reports one as a settled
|
|
108
|
+
// prediction whose `error` says so, under the same 201 a success gets, so the text is all that
|
|
109
|
+
// tells them apart.
|
|
115
110
|
const refusalWords = /\b(nsfw|sensitive|safety|content polic|flagged|moderat)/i;
|
|
116
111
|
function declined(current) {
|
|
117
112
|
// The provider's own words, verbatim, through the same redactor the wrapper uses.
|
|
118
113
|
const message = redact(current.error ?? `the prediction ended ${current.status}`);
|
|
119
|
-
// ceiling: read off the sentence, because
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// upgrade is to read one when Replicate ships it.
|
|
114
|
+
// ceiling: read off the sentence, because a failed prediction carries no machine-readable
|
|
115
|
+
// reason. A phrase the list does not know costs the user three more images; the upgrade
|
|
116
|
+
// is a structured field, when Replicate ships one.
|
|
123
117
|
return providerError({
|
|
124
118
|
kind: refusalWords.test(message) ? "refusal" : "other",
|
|
125
119
|
message: `Replicate answered: ${message}`,
|
|
126
120
|
});
|
|
127
121
|
}
|
|
128
122
|
function auth(deps) {
|
|
129
|
-
// `
|
|
130
|
-
// `missing_key` rather than `auth` because the same rule makes it terminal.
|
|
123
|
+
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
131
124
|
const key = deps.key();
|
|
132
125
|
if (key === undefined || key === "") {
|
|
133
126
|
throw providerError({ kind: "missing_key", message: "no Replicate key is stored" });
|
|
134
127
|
}
|
|
135
128
|
return { Authorization: `Bearer ${key}` };
|
|
136
129
|
}
|
|
137
|
-
// Only the adapter
|
|
138
|
-
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
130
|
+
// Only the adapter sees the vendor's status code, so only the adapter names the kind.
|
|
139
131
|
function kindOf(status) {
|
|
140
132
|
if (status === 401 || status === 403) {
|
|
141
133
|
return "auth";
|
|
@@ -143,17 +135,15 @@ function kindOf(status) {
|
|
|
143
135
|
if (status === 429) {
|
|
144
136
|
return "rate_limit";
|
|
145
137
|
}
|
|
146
|
-
// ceiling: everything else is `other` and is retried, so a 402 with no credit left
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// adapter's to widen.
|
|
138
|
+
// ceiling: everything else is `other` and is retried, so a 402 with no credit left fails the
|
|
139
|
+
// same way four times over. A terminal "this will never work" kind has to reach the port's
|
|
140
|
+
// error contract first, which is not this adapter's to widen.
|
|
150
141
|
return "other";
|
|
151
142
|
}
|
|
152
143
|
async function failure(response) {
|
|
153
144
|
const text = await response.text().catch(() => "");
|
|
154
145
|
const parsed = errorBody.safeParse(safeJson(text));
|
|
155
|
-
// The provider's own words,
|
|
156
|
-
// error body is free to quote the key back and this is the first place it is held.
|
|
146
|
+
// The provider's own words, through the redactor - an error body may quote the key back.
|
|
157
147
|
const detail = parsed.success ? (parsed.data.detail ?? parsed.data.title) : undefined;
|
|
158
148
|
const message = redact(detail ?? text.trim());
|
|
159
149
|
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
@@ -3,11 +3,10 @@ import { redact } from "../../kernel/log.js";
|
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
|
|
5
5
|
import { lines } from "./sse-lines.js";
|
|
6
|
-
// The local-agent adapter for Claude Code
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// `
|
|
10
|
-
// §Q135). The registry `main.ts` builds is where this adapter and that probe meet.
|
|
6
|
+
// The local-agent adapter for Claude Code: spawned non-interactively, authenticated by the
|
|
7
|
+
// CLI's own login, no key anywhere in this file. Readiness is not computed here - `adapters/**`
|
|
8
|
+
// may not import `slices/**`, and `slices/settings/cli-status.ts` already probes the binary per
|
|
9
|
+
// request. The registry `main.ts` builds is where this adapter and that probe meet.
|
|
11
10
|
export const claudeCodeBinary = "claude";
|
|
12
11
|
// The CLI takes an alias for the latest model of a family (`claude --help`, 2.1.258).
|
|
13
12
|
// ceiling: a fixed list, because the CLI has no offline command that prints the models an
|
|
@@ -19,13 +18,12 @@ export const claudeCodeModels = [
|
|
|
19
18
|
{ id: "sonnet", name: "Claude Sonnet (latest)" },
|
|
20
19
|
{ id: "haiku", name: "Claude Haiku (latest)" },
|
|
21
20
|
];
|
|
22
|
-
// Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// reports `"tools":[]` and `"mcp_servers":[]`.
|
|
21
|
+
// Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this CLI
|
|
22
|
+
// still reached for ToolSearch and WebFetch, and the machine's MCP servers were loaded into the
|
|
23
|
+
// session. Neither belongs in a content pipeline - grounding on the web is an explicit ask,
|
|
24
|
+
// never something a stage does quietly, and no stage should touch the disk. `--tools ""`
|
|
25
|
+
// empties the built-in set and `--strict-mcp-config` drops the user's MCP servers; the init
|
|
26
|
+
// event of a run with both reports `"tools":[]` and `"mcp_servers":[]`.
|
|
29
27
|
export function claudeCodeArgs(req) {
|
|
30
28
|
return [
|
|
31
29
|
"-p",
|
|
@@ -103,7 +101,7 @@ export function claudeCodeLlm(deps) {
|
|
|
103
101
|
}
|
|
104
102
|
catch (error) {
|
|
105
103
|
// 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
|
|
104
|
+
// the runner expects back, not whatever the half-closed pipe threw.
|
|
107
105
|
req.signal.throwIfAborted();
|
|
108
106
|
throw error;
|
|
109
107
|
}
|
|
@@ -113,8 +111,8 @@ export function claudeCodeLlm(deps) {
|
|
|
113
111
|
run.kill();
|
|
114
112
|
}
|
|
115
113
|
// A cancelled run ends its stream the same way an exhausted one does: the child was
|
|
116
|
-
// killed, so stdout simply stopped.
|
|
117
|
-
//
|
|
114
|
+
// killed, so stdout simply stopped. An aborted call counts as nothing, so it must not
|
|
115
|
+
// be reported as the provider failing.
|
|
118
116
|
req.signal.throwIfAborted();
|
|
119
117
|
// The stream ended with no result event at all.
|
|
120
118
|
throw providerError({
|
|
@@ -124,10 +122,9 @@ export function claudeCodeLlm(deps) {
|
|
|
124
122
|
}
|
|
125
123
|
return {
|
|
126
124
|
id: "claude-code",
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// article of `logic/07` step 2; it is the upgrade when the page needs finer text.
|
|
125
|
+
// Whole assistant turns rather than token deltas, still enough for the idle timeout to
|
|
126
|
+
// see life on the stream. ceiling: `--include-partial-messages` would give per-token
|
|
127
|
+
// deltas for the streamed article; it is the upgrade when the page needs finer text.
|
|
131
128
|
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
132
129
|
models: () => Promise.resolve(claudeCodeModels),
|
|
133
130
|
complete,
|