@gentbajko/slopify 2.0.2 → 2.0.3
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/dist/adapters/alignment/protocol.js +11 -1
- package/dist/adapters/alignment/runner.js +4 -1
- package/dist/adapters/alignment/worker.js +28 -5
- package/dist/adapters/explain.js +93 -0
- package/dist/adapters/ffmpeg.js +3 -3
- package/dist/adapters/host-cli/index.js +9 -2
- package/dist/adapters/host-cli/transport.js +4 -4
- package/dist/adapters/image/bytes.js +2 -2
- package/dist/adapters/image/codex-output.js +12 -12
- package/dist/adapters/image/codex.js +24 -8
- package/dist/adapters/image/fal.js +12 -6
- package/dist/adapters/image/google.js +18 -8
- package/dist/adapters/image/models.js +8 -2
- package/dist/adapters/image/openai.js +15 -6
- package/dist/adapters/image/replicate.js +16 -7
- package/dist/adapters/llm/claude-code-models.js +1 -1
- package/dist/adapters/llm/claude-code.js +16 -7
- package/dist/adapters/llm/cli-login-error.js +2 -1
- package/dist/adapters/llm/codex-models.js +1 -1
- package/dist/adapters/llm/codex.js +5 -6
- package/dist/adapters/llm/document-workspace.js +2 -2
- package/dist/adapters/llm/gemini-models.js +1 -1
- package/dist/adapters/llm/gemini-workspace.js +1 -1
- package/dist/adapters/llm/gemini.js +15 -11
- package/dist/adapters/llm/openrouter.js +21 -9
- package/dist/adapters/llm/run-cli.js +14 -11
- package/dist/adapters/tts/cartesia.js +10 -3
- package/dist/adapters/tts/elevenlabs.js +18 -6
- package/dist/adapters/tts/inworld-async.js +47 -16
- package/dist/adapters/tts/inworld.js +25 -15
- package/dist/adapters/tts/openai.js +18 -6
- package/dist/catalog/registry.js +13 -10
- package/dist/catalog/store.js +1 -1
- package/dist/catalog/validate.js +13 -4
- package/dist/edge/cli.js +33 -7
- package/dist/edge/docker-launch.js +6 -6
- package/dist/edge/docker-projects/engine.js +34 -13
- package/dist/edge/docker-projects/install.js +8 -8
- package/dist/edge/docker-projects/state.js +6 -6
- package/dist/edge/docker-projects/tree.js +6 -6
- package/dist/edge/docker.js +13 -13
- package/dist/edge/http/actions.js +27 -25
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/audio-preview.js +1 -1
- package/dist/edge/http/checkpoints.js +5 -1
- package/dist/edge/http/diagnostics.js +3 -1
- package/dist/edge/http/draft-files.js +2 -2
- package/dist/edge/http/draft-problem.js +11 -1
- package/dist/edge/http/files.js +4 -4
- package/dist/edge/http/folder-location.js +3 -3
- package/dist/edge/http/fonts.js +22 -10
- package/dist/edge/http/host-cli.js +34 -12
- package/dist/edge/http/multipart.js +7 -5
- package/dist/edge/http/open-folder.js +2 -2
- package/dist/edge/http/planning.js +4 -4
- package/dist/edge/http/problem.js +2 -2
- package/dist/edge/http/project-templates.js +7 -3
- package/dist/edge/http/projects.js +8 -8
- package/dist/edge/http/prompts.js +9 -4
- package/dist/edge/http/providers.js +15 -7
- package/dist/edge/http/revision-files.js +3 -3
- package/dist/edge/http/revisions.js +30 -4
- package/dist/edge/http/schedules.js +9 -9
- package/dist/edge/http/settings.js +15 -9
- package/dist/edge/http/staging.js +5 -5
- package/dist/edge/http/storage.js +4 -4
- package/dist/edge/http/subtitles.js +1 -1
- package/dist/edge/http/tutorial.js +2 -2
- package/dist/edge/http/update.js +3 -3
- package/dist/host-cli/install.js +28 -6
- package/dist/host-cli/paths.js +6 -6
- package/dist/host-cli/server.js +1 -1
- package/dist/host-cli/service.js +11 -11
- package/dist/host-cli/status.js +7 -3
- package/dist/kernel/config/index.js +6 -4
- package/dist/kernel/db/migrate.js +2 -1
- package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
- package/dist/kernel/lock.js +4 -3
- package/dist/kernel/ports/subtitles.js +15 -1
- package/dist/kernel/runner/attempt.js +26 -2
- package/dist/main.js +2 -2
- package/dist/slices/admission/rules.js +32 -20
- package/dist/slices/article/continuation.js +4 -2
- package/dist/slices/article/plain.js +2 -2
- package/dist/slices/article/provided-entries.js +3 -3
- package/dist/slices/article/run.js +2 -2
- package/dist/slices/article/segments.js +5 -3
- package/dist/slices/control/providers.js +20 -7
- package/dist/slices/fonts/catalog.js +1 -1
- package/dist/slices/fonts/upload.js +1 -1
- package/dist/slices/images/run.js +5 -5
- package/dist/slices/library/lint.js +13 -7
- package/dist/slices/library/slots.js +2 -2
- package/dist/slices/narration/concat.js +1 -1
- package/dist/slices/narration/pronunciation-chunks.js +3 -3
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/narration/run.js +7 -7
- package/dist/slices/narration/steering.js +3 -3
- package/dist/slices/play-drafts/convert.js +5 -4
- package/dist/slices/play-drafts/readiness.js +12 -6
- package/dist/slices/play-drafts/review-inputs.js +7 -4
- package/dist/slices/play-drafts/review.js +3 -3
- package/dist/slices/play-drafts/service.js +3 -1
- package/dist/slices/play-drafts/uploads.js +1 -1
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/recipe-legacy.js +1 -1
- package/dist/slices/rebuild/recipe-narration-text.js +1 -1
- package/dist/slices/rebuild/recipe-visual.js +1 -1
- package/dist/slices/rebuild/recovery.js +2 -2
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/runtime-admission.js +4 -4
- package/dist/slices/rebuild/runtime-article.js +5 -5
- package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
- package/dist/slices/rebuild/runtime-export-inputs.js +6 -6
- package/dist/slices/rebuild/runtime-export.js +4 -4
- package/dist/slices/rebuild/runtime-local.js +6 -6
- package/dist/slices/rebuild/runtime-narration-text.js +46 -7
- package/dist/slices/rebuild/runtime-piece-label.js +62 -0
- package/dist/slices/rebuild/runtime-provider.js +6 -4
- package/dist/slices/rebuild/runtime-publication.js +3 -3
- package/dist/slices/rebuild/runtime-run.js +11 -1
- package/dist/slices/rebuild/runtime-subtitles.js +78 -15
- package/dist/slices/rebuild/transition-repo.js +6 -6
- package/dist/slices/reruns/index.js +1 -1
- package/dist/slices/research/run.js +9 -7
- package/dist/slices/research/synthesis.js +4 -2
- package/dist/slices/revisions/mutation-assets.js +26 -8
- package/dist/slices/revisions/mutation-cues.js +1 -1
- package/dist/slices/revisions/rules.js +18 -9
- package/dist/slices/revisions/schema.js +1 -1
- package/dist/slices/schedules/repo.js +55 -11
- package/dist/slices/schedules/scheduler.js +25 -8
- package/dist/slices/schedules/schema.js +19 -4
- package/dist/slices/schedules/service.js +2 -0
- package/dist/slices/settings/cli-paths.js +7 -4
- package/dist/slices/settings/models.js +4 -4
- package/dist/slices/settings/playback.js +2 -2
- package/dist/slices/storage/assets.js +5 -5
- package/dist/slices/storage/layout.js +1 -1
- package/dist/slices/subtitles/captions.js +1 -1
- package/dist/slices/subtitles/prepare.js +5 -5
- package/dist/slices/subtitles/transcript.js +1 -1
- package/dist/slices/thumbnail/run.js +8 -8
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/ffmpeg.js +6 -4
- package/dist/slices/video/plan.js +1 -1
- package/dist/slices/video/run.js +2 -2
- package/dist/updater/plan.js +1 -1
- package/dist/updater/service.js +4 -3
- package/dist/updater/worker.js +6 -4
- package/dist/web/assets/{index-CZNiHm8P.css → index-Bwtmu9ei.css} +1 -1
- package/dist/web/assets/index-DjhMJqMN.js +141 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DPgGLX5m.js +0 -137
|
@@ -24,5 +24,15 @@ export const workerMessage = z.discriminatedUnion("type", [
|
|
|
24
24
|
})),
|
|
25
25
|
}),
|
|
26
26
|
omissionSchema.extend({ type: z.literal("omission") }),
|
|
27
|
-
z.object({
|
|
27
|
+
z.object({
|
|
28
|
+
type: z.literal("error"),
|
|
29
|
+
message: z.string(),
|
|
30
|
+
mismatch: z
|
|
31
|
+
.object({
|
|
32
|
+
at: z.number().finite().nonnegative(),
|
|
33
|
+
expected: z.string().max(2000),
|
|
34
|
+
heard: z.string().max(2000),
|
|
35
|
+
})
|
|
36
|
+
.optional(),
|
|
37
|
+
}),
|
|
28
38
|
]);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fork } from "node:child_process";
|
|
2
|
+
import { SubtitleMismatch, } from "../../kernel/ports/subtitles.js";
|
|
2
3
|
import { workerMessage } from "./protocol.js";
|
|
3
4
|
export async function runAlignmentWorker(input, signal, onProgress, worker = new URL("./worker.js", import.meta.url), onOmission) {
|
|
4
5
|
signal.throwIfAborted();
|
|
@@ -65,7 +66,9 @@ export async function runAlignmentWorker(input, signal, onProgress, worker = new
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
else if (message.type === "error")
|
|
68
|
-
finish(
|
|
69
|
+
finish(message.mismatch === undefined
|
|
70
|
+
? new Error(message.message)
|
|
71
|
+
: new SubtitleMismatch(message.message, message.mismatch.at, message.mismatch.expected, message.mismatch.heard));
|
|
69
72
|
else
|
|
70
73
|
finish(undefined, message.words.map(({ confidence, ...word }) => ({
|
|
71
74
|
...word,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { open, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import * as ort from "onnxruntime-web/wasm";
|
|
3
|
+
import { SubtitleMismatch } from "../../kernel/ports/subtitles.js";
|
|
3
4
|
import { mismatch } from "./ctc.js";
|
|
4
5
|
import { workerInput } from "./protocol.js";
|
|
5
6
|
import { speechWords } from "./text.js";
|
|
@@ -13,7 +14,13 @@ process.once("message", (raw) => {
|
|
|
13
14
|
send({ type: "error", message: "The subtitle worker received an invalid request." });
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
16
|
-
run(input.data).then((words) => send({ type: "done", words }), (error) => send({
|
|
17
|
+
run(input.data).then((words) => send({ type: "done", words }), (error) => send({
|
|
18
|
+
type: "error",
|
|
19
|
+
message: error instanceof Error ? error.message : String(error),
|
|
20
|
+
...(error instanceof SubtitleMismatch
|
|
21
|
+
? { mismatch: { at: error.at, expected: error.expected, heard: error.heard } }
|
|
22
|
+
: {}),
|
|
23
|
+
}));
|
|
17
24
|
});
|
|
18
25
|
function send(message) {
|
|
19
26
|
process.send?.(message);
|
|
@@ -64,11 +71,20 @@ async function run(input) {
|
|
|
64
71
|
const finalWindow = sampleAt + count >= totalSamples;
|
|
65
72
|
const complete = finalWindow && cursor + candidate.length === source.length;
|
|
66
73
|
const cutoff = finalWindow ? count / sampleRate : count / sampleRate - overlapSeconds;
|
|
67
|
-
const
|
|
74
|
+
const stuck = () => new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(candidate.map((word) => word.text)), snippet(observed.split(/\s+/)));
|
|
75
|
+
let recovered;
|
|
76
|
+
try {
|
|
77
|
+
recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof Error && error.message === mismatch)
|
|
81
|
+
throw stuck();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
68
84
|
const accepted = recovered.words;
|
|
69
85
|
const last = accepted.at(-1);
|
|
70
86
|
if (last === undefined)
|
|
71
|
-
throw
|
|
87
|
+
throw stuck();
|
|
72
88
|
if (recovered.skipped > 0) {
|
|
73
89
|
omitted += recovered.skipped;
|
|
74
90
|
send({
|
|
@@ -95,7 +111,7 @@ async function run(input) {
|
|
|
95
111
|
tensor.dispose();
|
|
96
112
|
}
|
|
97
113
|
if (cursor !== source.length || output.length === 0)
|
|
98
|
-
throw new
|
|
114
|
+
throw new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(source.slice(cursor).map((word) => word.text)), "");
|
|
99
115
|
// A substantial spoken tail absent from the transcript is a mismatch too.
|
|
100
116
|
while (sampleAt + sampleRate < totalSamples) {
|
|
101
117
|
const count = Math.min(windowSeconds * sampleRate, totalSamples - sampleAt);
|
|
@@ -110,7 +126,7 @@ async function run(input) {
|
|
|
110
126
|
if (logits !== undefined &&
|
|
111
127
|
logits.data instanceof Float32Array &&
|
|
112
128
|
greedy(logits.data, logits.dims[1] ?? 0).replace(/[^A-Z]/g, "").length > 8)
|
|
113
|
-
throw new
|
|
129
|
+
throw new SubtitleMismatch(mismatch, sampleAt / sampleRate, "", snippet(greedy(logits.data, logits.dims[1] ?? 0).split(/\s+/)));
|
|
114
130
|
for (const tensor of Object.values(result))
|
|
115
131
|
tensor.dispose();
|
|
116
132
|
}
|
|
@@ -140,6 +156,13 @@ function candidates(source, cursor, observed) {
|
|
|
140
156
|
}
|
|
141
157
|
return selected;
|
|
142
158
|
}
|
|
159
|
+
// A dozen words: enough to find the place in the text, short enough for an error row.
|
|
160
|
+
function snippet(words) {
|
|
161
|
+
return words
|
|
162
|
+
.filter((word) => word !== "")
|
|
163
|
+
.slice(0, 12)
|
|
164
|
+
.join(" ");
|
|
165
|
+
}
|
|
143
166
|
function normalize(audio) {
|
|
144
167
|
let mean = 0;
|
|
145
168
|
for (const value of audio)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The sentences a failed provider call shows. Whatever an adapter throws becomes the stage's
|
|
2
|
+
// failure reason on the project page, read by someone who only wants the video, so each one
|
|
3
|
+
// says what failed, the likely cause and the next step, using the names on screen. The
|
|
4
|
+
// provider's own words stay in, quoted after the plain sentence, because they are often the
|
|
5
|
+
// only clue to a problem Slopify cannot see. Keys never reach here: callers redact first.
|
|
6
|
+
// A provider's error body can be a whole HTML page; the first few hundred characters say why.
|
|
7
|
+
const detailMax = 300;
|
|
8
|
+
export function missingKey(provider) {
|
|
9
|
+
return `No ${provider} API key is saved. Add one in Settings → Providers, then use Retry stage.`;
|
|
10
|
+
}
|
|
11
|
+
export const refreshList = "refresh the list";
|
|
12
|
+
export const checkChoice = "Check the provider and model chosen in the Providers section of Edit project, then use Retry stage.";
|
|
13
|
+
export function httpFailure(failure) {
|
|
14
|
+
const { provider, status } = failure;
|
|
15
|
+
const said = quoted(failure.label ?? `error ${String(status)}`, failure.detail);
|
|
16
|
+
const next = failure.next ?? "use Retry stage";
|
|
17
|
+
if (status === 401) {
|
|
18
|
+
return `${provider} did not accept the API key${said}. The key may be wrong, expired or revoked: paste a current key in Settings → Providers, then ${next}.`;
|
|
19
|
+
}
|
|
20
|
+
if (status === 403) {
|
|
21
|
+
return `${provider} refused access${said}. The API key may not be allowed to use this model, or the account needs attention: check your ${provider} account and the key in Settings → Providers, then ${next}.`;
|
|
22
|
+
}
|
|
23
|
+
if (status === 402) {
|
|
24
|
+
return `${provider} says the account is out of credits${said}. Add credits on the ${provider} website, then ${next}.`;
|
|
25
|
+
}
|
|
26
|
+
if (status === 429) {
|
|
27
|
+
return `${provider} is limiting requests${said}. Too many requests were sent at once or the plan's quota is used up: wait a few minutes or check your ${provider} usage and billing, then ${next}.`;
|
|
28
|
+
}
|
|
29
|
+
if (status >= 400 && status < 500) {
|
|
30
|
+
return `${provider} rejected the ${failure.subject ?? "request"}${said}. ${failure.fix ?? (failure.next === undefined ? checkChoice : `Check the key in Settings → Providers, then ${next}.`)}`;
|
|
31
|
+
}
|
|
32
|
+
if (status >= 500) {
|
|
33
|
+
return `${provider} had a problem on its side${said}. This is usually temporary: wait a few minutes, then ${next}.`;
|
|
34
|
+
}
|
|
35
|
+
return `${provider} answered with an unexpected error${said}. ${failure.next === undefined ? "Use Retry stage" : "Try again"}; if it keeps failing, use Download diagnostics in Settings and report it.`;
|
|
36
|
+
}
|
|
37
|
+
// A failure the provider reported inside an otherwise successful answer, with no status.
|
|
38
|
+
export function providerSaid(provider, what, detail, next) {
|
|
39
|
+
return `${provider} ${what}${quoted("", detail)}. ${next}`;
|
|
40
|
+
}
|
|
41
|
+
export function unreadable(provider) {
|
|
42
|
+
return `${provider} sent back an answer Slopify could not read. This is usually temporary: use Retry stage; if it keeps happening, use Download diagnostics in Settings and report it.`;
|
|
43
|
+
}
|
|
44
|
+
export function droppedStream(provider) {
|
|
45
|
+
return `The connection to ${provider} dropped before the answer was complete. Check your internet connection, then use Retry stage.`;
|
|
46
|
+
}
|
|
47
|
+
export function noAudio(provider) {
|
|
48
|
+
return `${provider} finished without sending any audio. Use Retry stage; if it keeps happening, check the voice in Settings → Voices.`;
|
|
49
|
+
}
|
|
50
|
+
export function noImage(provider) {
|
|
51
|
+
return `${provider} finished without sending an image. Use Retry stage; if it keeps happening, reword the image prompt in the Images section of Edit project or choose another image model in its Providers section.`;
|
|
52
|
+
}
|
|
53
|
+
export function refusedImage(provider, detail) {
|
|
54
|
+
return `${provider} refused to make this image under its content rules${quoted("", detail)}. Reword the image prompt in the Images section of Edit project, or choose another image provider in its Providers section.`;
|
|
55
|
+
}
|
|
56
|
+
// A narration request the provider turned down is most often a voice that no longer exists
|
|
57
|
+
// or a part longer than the model takes.
|
|
58
|
+
export function voiceFix(voiceId) {
|
|
59
|
+
return `Check that voice "${voiceId}" still exists in Settings → Voices and that each narration part fits the model's length limit (Chunking, in the Providers section of Edit project), then use Retry stage.`;
|
|
60
|
+
}
|
|
61
|
+
export function internalError(what) {
|
|
62
|
+
return `Slopify hit an internal error (${what}). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.`;
|
|
63
|
+
}
|
|
64
|
+
const cliNames = {
|
|
65
|
+
claude: "Claude Code CLI",
|
|
66
|
+
"claude-code": "Claude Code CLI",
|
|
67
|
+
codex: "Codex CLI",
|
|
68
|
+
gemini: "Gemini CLI",
|
|
69
|
+
};
|
|
70
|
+
// A configured path (`/opt/bin/claude`, `C:\\tools\\codex.cmd`) still names the CLI the user chose.
|
|
71
|
+
export function cliName(binary) {
|
|
72
|
+
const base = commandOf(binary);
|
|
73
|
+
return cliNames[base] ?? `"${base}" CLI`;
|
|
74
|
+
}
|
|
75
|
+
// What to type in a terminal to run it.
|
|
76
|
+
export function commandOf(binary) {
|
|
77
|
+
return (binary.split(/[\\/]/).at(-1) ?? binary).replace(/\.(?:cmd|exe|bat|js|mjs)$/i, "");
|
|
78
|
+
}
|
|
79
|
+
// An error the CLI itself reported, in its own words, with the step that most often fixes it.
|
|
80
|
+
export function cliReported(binary, detail, next) {
|
|
81
|
+
return `The ${cliName(binary)} reported an error${quoted("", detail)}. ${next}`;
|
|
82
|
+
}
|
|
83
|
+
export function cliCheck(binary) {
|
|
84
|
+
return `Use Retry stage; if it keeps failing, run ${commandOf(binary)} in a terminal to check it works and is signed in, or choose another model in the Providers section of Edit project.`;
|
|
85
|
+
}
|
|
86
|
+
// ` (error 401: "Unauthorized")`, ` (error 401)`, ` ("Unauthorized")`, or nothing.
|
|
87
|
+
export function quoted(label, detail) {
|
|
88
|
+
const text = detail.trim();
|
|
89
|
+
const clipped = text.length > detailMax ? `${text.slice(0, detailMax)}…` : text;
|
|
90
|
+
if (clipped === "")
|
|
91
|
+
return label === "" ? "" : ` (${label})`;
|
|
92
|
+
return label === "" ? ` ("${clipped}")` : ` (${label}: "${clipped}")`;
|
|
93
|
+
}
|
package/dist/adapters/ffmpeg.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function prepareFfmpeg(deps) {
|
|
|
15
15
|
return override;
|
|
16
16
|
}
|
|
17
17
|
if (typeof deps.bundled !== "string" || deps.bundled === "") {
|
|
18
|
-
throw new Error(`
|
|
18
|
+
throw new Error(`Slopify has no built-in copy of ffmpeg (the tool it uses to make video) for this kind of computer. Install ffmpeg, then restart Slopify. ${remedy}`);
|
|
19
19
|
}
|
|
20
20
|
if (existsSync(deps.bundled)) {
|
|
21
21
|
await verify(deps.bundled);
|
|
@@ -58,7 +58,7 @@ export async function prepareFfmpeg(deps) {
|
|
|
58
58
|
rmSync(staging, { recursive: true, force: true });
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
const remedy = "You can also set SLOPIFY_FFMPEG (or FFMPEG_BIN) to a working ffmpeg
|
|
61
|
+
const remedy = "You can also set the SLOPIFY_FFMPEG (or FFMPEG_BIN) environment variable to the full path of a working ffmpeg.";
|
|
62
62
|
async function verify(bin) {
|
|
63
63
|
try {
|
|
64
64
|
const { stdout } = await promisify(execFile)(bin, ["-version"], {
|
|
@@ -70,7 +70,7 @@ async function verify(bin) {
|
|
|
70
70
|
throw new Error("the executable did not identify itself as ffmpeg");
|
|
71
71
|
}
|
|
72
72
|
catch (error) {
|
|
73
|
-
throw new Error(`ffmpeg at ${bin} could not be started. ${remedy} ${detail(error)}`);
|
|
73
|
+
throw new Error(`ffmpeg at ${bin} could not be started. Reinstall ffmpeg or check your antivirus has not quarantined it, then restart Slopify. ${remedy} ${detail(error)}`);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
function detail(error) {
|
|
@@ -3,6 +3,7 @@ import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostI
|
|
|
3
3
|
import { isProviderError, providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { sniffImage } from "../image/bytes.js";
|
|
5
5
|
import { hostRequest, hostUnavailable, readHostBytes, } from "./transport.js";
|
|
6
|
+
const tooBigForHost = "Slopify could not send this request to the host helper because it is larger than, or shaped differently from, what the helper accepts. Make the inputs shorter in Edit project, then use Retry stage; if it happens again, use Download diagnostics in Settings and report it.";
|
|
6
7
|
export function createHostCliClient(options) {
|
|
7
8
|
async function request(input) {
|
|
8
9
|
if (!options.directory)
|
|
@@ -88,7 +89,10 @@ export function createHostCliClient(options) {
|
|
|
88
89
|
...(req.webSearch === undefined ? {} : { webSearch: req.webSearch }),
|
|
89
90
|
});
|
|
90
91
|
if (!parsed.success)
|
|
91
|
-
throw providerError({
|
|
92
|
+
throw providerError({
|
|
93
|
+
kind: "unsupported",
|
|
94
|
+
message: tooBigForHost,
|
|
95
|
+
});
|
|
92
96
|
await ready(req.signal);
|
|
93
97
|
const response = await request({
|
|
94
98
|
path: `/v1/llm/${id}`,
|
|
@@ -122,7 +126,10 @@ export function createHostCliClient(options) {
|
|
|
122
126
|
aspect: req.aspect,
|
|
123
127
|
});
|
|
124
128
|
if (!parsed.success)
|
|
125
|
-
throw providerError({
|
|
129
|
+
throw providerError({
|
|
130
|
+
kind: "unsupported",
|
|
131
|
+
message: tooBigForHost,
|
|
132
|
+
});
|
|
126
133
|
await ready(req.signal);
|
|
127
134
|
const response = await request({
|
|
128
135
|
path: "/v1/image",
|
|
@@ -8,8 +8,8 @@ export function hostUnavailable(submitted = false) {
|
|
|
8
8
|
return providerError({
|
|
9
9
|
kind: "unavailable",
|
|
10
10
|
message: submitted
|
|
11
|
-
? "The host CLI
|
|
12
|
-
: "
|
|
11
|
+
? "The connection to the host helper dropped before the CLI's answer arrived, so Slopify cannot tell whether it finished (it may already have used your quota). Check the host helper is still running, then use Retry stage."
|
|
12
|
+
: "Slopify cannot reach its host helper, which runs the Claude Code, Codex and Gemini CLIs outside Docker. On the host computer, run npx @gentbajko/slopify@latest --docker to set it up or restart it, then use Retry stage.",
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
async function tokenAt(directory) {
|
|
@@ -33,7 +33,7 @@ export async function hostRequest(options) {
|
|
|
33
33
|
if (options.body && options.body.byteLength > bridgeLimits.request)
|
|
34
34
|
throw providerError({
|
|
35
35
|
kind: "unsupported",
|
|
36
|
-
message: "
|
|
36
|
+
message: "This request is larger than the host helper accepts (16 MB), so nothing was sent. Make the inputs shorter in Edit project, or choose an API provider such as OpenRouter in its Providers section, then use Retry stage.",
|
|
37
37
|
});
|
|
38
38
|
let token;
|
|
39
39
|
try {
|
|
@@ -121,7 +121,7 @@ export async function readHostBytes(response, maximum) {
|
|
|
121
121
|
if (length > maximum)
|
|
122
122
|
throw providerError({
|
|
123
123
|
kind: "unavailable",
|
|
124
|
-
message: "
|
|
124
|
+
message: "The host helper sent back more data than Slopify accepts, so the answer was dropped. Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.",
|
|
125
125
|
});
|
|
126
126
|
chunks.push(value);
|
|
127
127
|
}
|
|
@@ -40,7 +40,7 @@ export async function downloadImage(download) {
|
|
|
40
40
|
// quoted back - a fal or Replicate delivery link carries its own signature.
|
|
41
41
|
throw providerError({
|
|
42
42
|
kind: "other",
|
|
43
|
-
message: `${download.provider}
|
|
43
|
+
message: `${download.provider} made the image, but Slopify could not download it (error ${String(response.status)}). Check your internet connection, then use Retry stage.`,
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
@@ -48,7 +48,7 @@ export async function downloadImage(download) {
|
|
|
48
48
|
if (mime === undefined) {
|
|
49
49
|
throw providerError({
|
|
50
50
|
kind: "other",
|
|
51
|
-
message: `${download.provider}
|
|
51
|
+
message: `${download.provider} sent back something that is not a PNG or JPEG image (${describeBytes(bytes)}). Use Retry stage; if it keeps happening, choose another image model in the Providers section of Edit project.`,
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
return { bytes, mime };
|
|
@@ -8,23 +8,23 @@ const maxOutputBytes = 32 * 1024 * 1024;
|
|
|
8
8
|
function unavailable(detail) {
|
|
9
9
|
return providerError({
|
|
10
10
|
kind: "unavailable",
|
|
11
|
-
message: `Codex finished, but ${detail}.
|
|
11
|
+
message: `The Codex CLI finished, but Slopify could not collect the image: ${detail}. Use Retry stage to make it again (this uses your Codex quota again).`,
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
export function codexGeneratedImage(env, threadId, startedAt) {
|
|
15
15
|
if (!z.uuid().safeParse(threadId).success || threadId === undefined)
|
|
16
|
-
throw unavailable("
|
|
16
|
+
throw unavailable("the image job could not be identified");
|
|
17
17
|
const root = join(resolve(env.CODEX_HOME || join(env.HOME || homedir(), ".codex")), "generated_images");
|
|
18
18
|
const directory = join(root, threadId);
|
|
19
19
|
try {
|
|
20
20
|
for (const path of [root, directory]) {
|
|
21
21
|
const entry = lstatSync(path);
|
|
22
22
|
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
23
|
-
throw unavailable("
|
|
23
|
+
throw unavailable("Codex's image folder is not a plain folder");
|
|
24
24
|
}
|
|
25
25
|
const canonical = realpathSync(directory);
|
|
26
26
|
if (canonical !== join(realpathSync(root), threadId))
|
|
27
|
-
throw unavailable("
|
|
27
|
+
throw unavailable("Codex's image folder moved while it was being read");
|
|
28
28
|
// Codex 0.155.1 omits image items from exec JSONL. Its artifact contract is
|
|
29
29
|
// generated_images/<thread.started ID>/<sanitized tool call ID>.png.
|
|
30
30
|
const dir = opendirSync(directory);
|
|
@@ -32,11 +32,11 @@ export function codexGeneratedImage(env, threadId, startedAt) {
|
|
|
32
32
|
try {
|
|
33
33
|
const entry = dir.readSync();
|
|
34
34
|
if (entry === null)
|
|
35
|
-
throw unavailable("no image was saved
|
|
35
|
+
throw unavailable("no image was saved");
|
|
36
36
|
if (dir.readSync() !== null)
|
|
37
|
-
throw unavailable("more than one
|
|
37
|
+
throw unavailable("more than one file was saved");
|
|
38
38
|
if (!entry.isFile() || !/^[a-zA-Z0-9_-]+\.png$/.test(entry.name))
|
|
39
|
-
throw unavailable("
|
|
39
|
+
throw unavailable("the saved file is not a plain image file");
|
|
40
40
|
name = entry.name;
|
|
41
41
|
}
|
|
42
42
|
finally {
|
|
@@ -45,7 +45,7 @@ export function codexGeneratedImage(env, threadId, startedAt) {
|
|
|
45
45
|
const path = join(directory, name);
|
|
46
46
|
const before = lstatSync(path);
|
|
47
47
|
if (!before.isFile() || before.isSymbolicLink() || realpathSync(path) !== join(canonical, name))
|
|
48
|
-
throw unavailable("
|
|
48
|
+
throw unavailable("the saved file is not a plain image file");
|
|
49
49
|
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
50
50
|
try {
|
|
51
51
|
const stat = fstatSync(fd);
|
|
@@ -56,7 +56,7 @@ export function codexGeneratedImage(env, threadId, startedAt) {
|
|
|
56
56
|
stat.size === 0 ||
|
|
57
57
|
stat.size > maxOutputBytes ||
|
|
58
58
|
stat.mtimeMs < Math.floor(startedAt / 1000) * 1000)
|
|
59
|
-
throw unavailable("
|
|
59
|
+
throw unavailable("the saved file is empty, too large, older than this job, or not a plain file");
|
|
60
60
|
const bytes = Buffer.alloc(stat.size + 1);
|
|
61
61
|
let length = 0;
|
|
62
62
|
while (length < bytes.length) {
|
|
@@ -71,11 +71,11 @@ export function codexGeneratedImage(env, threadId, startedAt) {
|
|
|
71
71
|
after.mtimeMs !== stat.mtimeMs ||
|
|
72
72
|
after.ctimeMs !== stat.ctimeMs ||
|
|
73
73
|
realpathSync(directory) !== canonical)
|
|
74
|
-
throw unavailable("
|
|
74
|
+
throw unavailable("the file changed while it was being read");
|
|
75
75
|
const image = bytes.subarray(0, length);
|
|
76
76
|
const mime = sniffImage(image);
|
|
77
77
|
if (mime === undefined)
|
|
78
|
-
throw unavailable("
|
|
78
|
+
throw unavailable("the saved file is not a PNG or JPEG image");
|
|
79
79
|
return { bytes: image, mime };
|
|
80
80
|
}
|
|
81
81
|
finally {
|
|
@@ -85,6 +85,6 @@ export function codexGeneratedImage(env, threadId, startedAt) {
|
|
|
85
85
|
catch (error) {
|
|
86
86
|
if (isProviderError(error))
|
|
87
87
|
throw error;
|
|
88
|
-
throw unavailable("
|
|
88
|
+
throw unavailable("the saved file could not be read");
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { redact } from "../../kernel/log.js";
|
|
6
6
|
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
import { cliReported, quoted, refusedImage } from "../explain.js";
|
|
7
8
|
import { cliLoginError } from "../llm/cli-login-error.js";
|
|
8
9
|
import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
|
|
9
10
|
import { lines } from "../llm/sse-lines.js";
|
|
@@ -87,7 +88,10 @@ export function codexImage(deps) {
|
|
|
87
88
|
models: async () => [codexImageModel],
|
|
88
89
|
generate: async (req) => {
|
|
89
90
|
if (req.model !== codexImageModel.id)
|
|
90
|
-
throw providerError({
|
|
91
|
+
throw providerError({
|
|
92
|
+
kind: "unsupported",
|
|
93
|
+
message: "The Codex CLI makes images only with its own image model. Choose that model for images in the Providers section of Edit project, then use Retry stage.",
|
|
94
|
+
});
|
|
91
95
|
req.signal.throwIfAborted();
|
|
92
96
|
const directory = mkdtempSync(join(tmpdir(), "slopify-codex-image-"));
|
|
93
97
|
const env = imageEnvironment(directory, deps.env ?? process.env);
|
|
@@ -102,7 +106,10 @@ export function codexImage(deps) {
|
|
|
102
106
|
});
|
|
103
107
|
}
|
|
104
108
|
catch {
|
|
105
|
-
throw providerError({
|
|
109
|
+
throw providerError({
|
|
110
|
+
kind: "unsupported",
|
|
111
|
+
message: "The Codex CLI could not be started. Check it is installed and set up in Settings → Providers, then use Retry stage.",
|
|
112
|
+
});
|
|
106
113
|
}
|
|
107
114
|
let completed = false;
|
|
108
115
|
let unavailable = false;
|
|
@@ -115,7 +122,7 @@ export function codexImage(deps) {
|
|
|
115
122
|
if (threadId !== undefined)
|
|
116
123
|
throw providerError({
|
|
117
124
|
kind: "unavailable",
|
|
118
|
-
message: "Codex
|
|
125
|
+
message: "The Codex CLI started more than one image job for one image, so Slopify cannot tell which result is right (both may have used your quota). Use Retry stage to make the image again.",
|
|
119
126
|
});
|
|
120
127
|
threadId = cliShaped(binary, z.object({ thread_id: z.string() }), event.value).thread_id;
|
|
121
128
|
}
|
|
@@ -128,7 +135,9 @@ export function codexImage(deps) {
|
|
|
128
135
|
throw login;
|
|
129
136
|
throw providerError({
|
|
130
137
|
kind: /refus|content.policy|safety/i.test(message) ? "refusal" : "other",
|
|
131
|
-
message:
|
|
138
|
+
message: /refus|content.policy|safety/i.test(message)
|
|
139
|
+
? refusedImage("The Codex CLI", redact(message))
|
|
140
|
+
: cliReported(binary, redact(message), imageNext),
|
|
132
141
|
});
|
|
133
142
|
}
|
|
134
143
|
else if (event.type === "error") {
|
|
@@ -140,7 +149,9 @@ export function codexImage(deps) {
|
|
|
140
149
|
kind: /image.generation|image tool|feature.*unavailable/i.test(message)
|
|
141
150
|
? "unsupported"
|
|
142
151
|
: "other",
|
|
143
|
-
message:
|
|
152
|
+
message: /image.generation|image tool|feature.*unavailable/i.test(message)
|
|
153
|
+
? `The Codex CLI cannot make images${quoted("", redact(message))}. ${cannotDraw}`
|
|
154
|
+
: cliReported(binary, redact(message), imageNext),
|
|
144
155
|
});
|
|
145
156
|
}
|
|
146
157
|
else if (event.type === "item.completed") {
|
|
@@ -154,7 +165,10 @@ export function codexImage(deps) {
|
|
|
154
165
|
const ended = await run.ended;
|
|
155
166
|
req.signal.throwIfAborted();
|
|
156
167
|
if (ended.error !== null)
|
|
157
|
-
throw providerError({
|
|
168
|
+
throw providerError({
|
|
169
|
+
kind: "unsupported",
|
|
170
|
+
message: "The Codex CLI could not be started. Check it is installed and set up in Settings → Providers, then use Retry stage.",
|
|
171
|
+
});
|
|
158
172
|
if (ended.code !== 0 || !completed)
|
|
159
173
|
throw (cliLoginError("codex", run.stderr()) ??
|
|
160
174
|
providerError({
|
|
@@ -164,7 +178,7 @@ export function codexImage(deps) {
|
|
|
164
178
|
if (unavailable)
|
|
165
179
|
throw providerError({
|
|
166
180
|
kind: "unsupported",
|
|
167
|
-
message:
|
|
181
|
+
message: `The Codex CLI cannot make images. ${cannotDraw}`,
|
|
168
182
|
});
|
|
169
183
|
return codexGeneratedImage(env, threadId, startedAt);
|
|
170
184
|
}
|
|
@@ -191,11 +205,13 @@ async function* bounded(source, maximum) {
|
|
|
191
205
|
if (size > maximum)
|
|
192
206
|
throw providerError({
|
|
193
207
|
kind: "other",
|
|
194
|
-
message: "Codex
|
|
208
|
+
message: "The Codex CLI sent far more output than an image job should, so Slopify stopped it. Use Retry stage; if it keeps happening, update the Codex CLI to the latest version.",
|
|
195
209
|
});
|
|
196
210
|
yield chunk;
|
|
197
211
|
}
|
|
198
212
|
}
|
|
213
|
+
const imageNext = "Use Retry stage; if it keeps failing, run codex in a terminal to check it works and is signed in, or choose another image provider in the Providers section of Edit project.";
|
|
214
|
+
const cannotDraw = "Update the Codex CLI and check your ChatGPT plan includes image generation, or choose another image provider in the Providers section of Edit project.";
|
|
199
215
|
function imageEnvironment(directory, source) {
|
|
200
216
|
const env = { PWD: directory };
|
|
201
217
|
const allowed = [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { redact } from "../../kernel/log.js";
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { httpFailure, missingKey, noImage, unreadable } from "../explain.js";
|
|
4
5
|
import { retryAfter } from "../retry-after.js";
|
|
5
6
|
import { downloadImage } from "./bytes.js";
|
|
6
7
|
// The HTTP gateway adapter for fal.ai: `fetch` and the downloader beside this file, no SDK.
|
|
@@ -88,12 +89,12 @@ export function falImage(deps) {
|
|
|
88
89
|
refused(answer, req.prompt);
|
|
89
90
|
const first = answer.images[0];
|
|
90
91
|
if (first === undefined) {
|
|
91
|
-
throw providerError({ kind: "other", message: "fal
|
|
92
|
+
throw providerError({ kind: "other", message: noImage("fal.ai") });
|
|
92
93
|
}
|
|
93
94
|
// The download rides inside the attempt: a link that 404s is a failed attempt.
|
|
94
95
|
return await downloadImage({
|
|
95
96
|
fetch: deps.fetch,
|
|
96
|
-
provider: "fal",
|
|
97
|
+
provider: "fal.ai",
|
|
97
98
|
url: first.url,
|
|
98
99
|
signal: req.signal,
|
|
99
100
|
});
|
|
@@ -104,7 +105,7 @@ function keyOf(deps) {
|
|
|
104
105
|
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
105
106
|
const key = deps.key();
|
|
106
107
|
if (key === undefined || key === "") {
|
|
107
|
-
throw providerError({ kind: "missing_key", message: "
|
|
108
|
+
throw providerError({ kind: "missing_key", message: missingKey("fal.ai") });
|
|
108
109
|
}
|
|
109
110
|
return key;
|
|
110
111
|
}
|
|
@@ -116,7 +117,7 @@ function refused(answer, prompt) {
|
|
|
116
117
|
if (flags.length > 0 && flags.every((flagged) => flagged)) {
|
|
117
118
|
throw providerError({
|
|
118
119
|
kind: "refusal",
|
|
119
|
-
message: `fal's safety checker
|
|
120
|
+
message: `fal.ai's safety checker blocked the image for this prompt: "${redact(prompt)}". Reword the image prompt in the Images section of Edit project, or choose another image provider in its Providers section.`,
|
|
120
121
|
});
|
|
121
122
|
}
|
|
122
123
|
}
|
|
@@ -140,7 +141,12 @@ async function failure(response) {
|
|
|
140
141
|
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
141
142
|
return providerError({
|
|
142
143
|
kind: kindOf(response.status),
|
|
143
|
-
message:
|
|
144
|
+
message: httpFailure({
|
|
145
|
+
provider: "fal.ai",
|
|
146
|
+
status: response.status,
|
|
147
|
+
detail: message,
|
|
148
|
+
subject: "image request",
|
|
149
|
+
}),
|
|
144
150
|
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
145
151
|
});
|
|
146
152
|
}
|
|
@@ -160,7 +166,7 @@ function parse(text) {
|
|
|
160
166
|
if (!parsed.success) {
|
|
161
167
|
throw providerError({
|
|
162
168
|
kind: "other",
|
|
163
|
-
message: "fal
|
|
169
|
+
message: unreadable("fal.ai"),
|
|
164
170
|
});
|
|
165
171
|
}
|
|
166
172
|
return parsed.data;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { redact } from "../../kernel/log.js";
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { httpFailure, missingKey, noImage, quoted, refusedImage, unreadable } from "../explain.js";
|
|
4
5
|
import { retryAfter } from "../retry-after.js";
|
|
5
6
|
import { describeBytes, sniffImage } from "./bytes.js";
|
|
6
7
|
import { discoverGoogleImages } from "./models.js";
|
|
@@ -92,7 +93,7 @@ function imageOf(answer) {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
|
-
throw providerError({ kind: "other", message: "Google
|
|
96
|
+
throw providerError({ kind: "other", message: noImage("Google") });
|
|
96
97
|
}
|
|
97
98
|
// The bytes arrive with the call. They are still sniffed rather than trusted: what the port
|
|
98
99
|
// stores is a PNG or a JPEG, and a truncated payload that decodes to something else must
|
|
@@ -104,7 +105,7 @@ function decode(part) {
|
|
|
104
105
|
const claimed = part.mime_type ?? "nothing";
|
|
105
106
|
throw providerError({
|
|
106
107
|
kind: "other",
|
|
107
|
-
message: `Google
|
|
108
|
+
message: `Google sent back something that is not a PNG or JPEG image (labelled ${claimed}, but ${describeBytes(bytes)}). Use Retry stage; if it keeps happening, choose another image model in the Providers section of Edit project.`,
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
return { bytes, mime };
|
|
@@ -113,7 +114,7 @@ function keyOf(deps) {
|
|
|
113
114
|
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
114
115
|
const key = deps.key();
|
|
115
116
|
if (key === undefined || key === "") {
|
|
116
|
-
throw providerError({ kind: "missing_key", message: "
|
|
117
|
+
throw providerError({ kind: "missing_key", message: missingKey("Google") });
|
|
117
118
|
}
|
|
118
119
|
return key;
|
|
119
120
|
}
|
|
@@ -155,12 +156,21 @@ async function failure(response) {
|
|
|
155
156
|
? parsed.data.error.details?.find((one) => one["@type"] === "type.googleapis.com/google.rpc.RetryInfo")?.retryDelay
|
|
156
157
|
: undefined;
|
|
157
158
|
const retryAfterMs = retryAfter(response.headers.get("retry-after")) ?? googleRetryDelay(delay, message);
|
|
158
|
-
const
|
|
159
|
-
? "Google reports zero image quota for this model. Check the API key's project, billing and model quota in Google AI Studio, or choose another image provider. Retrying will not help until quota is available. "
|
|
160
|
-
: "";
|
|
159
|
+
const detail = message || response.statusText;
|
|
161
160
|
return providerError({
|
|
162
161
|
kind,
|
|
163
|
-
message:
|
|
162
|
+
message: kind === "unsupported"
|
|
163
|
+
? `Google gives this API key no image quota for this model${quoted(`error ${String(response.status)}`, detail)}. Retrying will not help: turn on billing for the key's project in Google AI Studio, or choose another image provider in the Providers section of Edit project.`
|
|
164
|
+
: // PERMISSION_DENIED and FAILED_PRECONDITION also cover keys without access, billing
|
|
165
|
+
// and region limits, so they read as a content refusal only when Google says so.
|
|
166
|
+
kind === "refusal" && refusalWords.test(message)
|
|
167
|
+
? refusedImage("Google", detail)
|
|
168
|
+
: httpFailure({
|
|
169
|
+
provider: "Google",
|
|
170
|
+
status: response.status,
|
|
171
|
+
detail,
|
|
172
|
+
subject: "image request",
|
|
173
|
+
}),
|
|
164
174
|
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
165
175
|
});
|
|
166
176
|
}
|
|
@@ -177,7 +187,7 @@ function parse(text) {
|
|
|
177
187
|
if (!parsed.success) {
|
|
178
188
|
throw providerError({
|
|
179
189
|
kind: "other",
|
|
180
|
-
message: "Google
|
|
190
|
+
message: unreadable("Google"),
|
|
181
191
|
});
|
|
182
192
|
}
|
|
183
193
|
return parsed.data;
|