@gentbajko/slopify 0.5.1 → 0.7.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 +36 -1
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +18 -3
- package/dist/adapters/alignment/audio.js +45 -0
- package/dist/adapters/alignment/cache.js +91 -0
- package/dist/adapters/alignment/ctc.js +128 -0
- package/dist/adapters/alignment/index.js +34 -0
- package/dist/adapters/alignment/lock.js +58 -0
- package/dist/adapters/alignment/numbers.js +91 -0
- package/dist/adapters/alignment/protocol.js +23 -0
- package/dist/adapters/alignment/quality.js +17 -0
- package/dist/adapters/alignment/runner.js +76 -0
- package/dist/adapters/alignment/text.js +61 -0
- package/dist/adapters/alignment/vocabulary.js +69 -0
- package/dist/adapters/alignment/worker.js +163 -0
- package/dist/adapters/image/google.js +12 -15
- package/dist/adapters/image/models.js +72 -0
- package/dist/adapters/image/openai.js +3 -5
- package/dist/adapters/llm/catalogue-files.js +23 -0
- package/dist/adapters/llm/claude-code.js +9 -7
- package/dist/adapters/llm/codex-models.js +46 -0
- package/dist/adapters/llm/codex.js +7 -12
- package/dist/adapters/llm/gemini-models.js +85 -0
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +138 -0
- package/dist/adapters/llm/openrouter.js +11 -2
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/adapters/tts/cartesia.js +9 -1
- package/dist/adapters/tts/elevenlabs.js +33 -2
- package/dist/adapters/tts/inworld-async.js +150 -0
- package/dist/adapters/tts/inworld-text.js +32 -0
- package/dist/adapters/tts/inworld.js +170 -0
- package/dist/adapters/tts/openai.js +31 -4
- package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/assets/fonts/OFL.txt +93 -0
- package/dist/assets/fonts/SOURCE.txt +7 -0
- package/dist/edge/cli.js +5 -0
- package/dist/edge/events/hub.js +9 -2
- package/dist/edge/events/preview-cache.js +40 -0
- package/dist/edge/http/actions.js +2 -0
- package/dist/edge/http/app.js +30 -1
- package/dist/edge/http/audio-preview.js +46 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- package/dist/edge/http/providers.js +29 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/edge/http/update.js +57 -0
- package/dist/edge/update-worker.js +44 -0
- package/dist/kernel/audio-preview.js +188 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +75 -24
- package/dist/main.js +106 -25
- package/dist/model-catalog.js +37 -0
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/control/index.js +1 -0
- package/dist/slices/control/providers.js +2 -0
- package/dist/slices/fonts/catalog.js +95 -0
- package/dist/slices/fonts/discovery.js +81 -0
- package/dist/slices/fonts/files.js +37 -0
- package/dist/slices/fonts/index.js +5 -0
- package/dist/slices/fonts/model.js +4 -0
- package/dist/slices/fonts/preview.js +14 -0
- package/dist/slices/fonts/sfnt.js +262 -0
- package/dist/slices/fonts/upload.js +40 -0
- package/dist/slices/narration/live.js +21 -0
- package/dist/slices/narration/run.js +9 -9
- package/dist/slices/research/run.js +3 -0
- package/dist/slices/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +11 -0
- package/dist/slices/settings/models.js +89 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +10 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +91 -0
- package/dist/slices/subtitles/layout.js +19 -0
- package/dist/slices/subtitles/model.js +27 -0
- package/dist/slices/subtitles/prepare.js +167 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +3 -0
- package/dist/slices/video/ffmpeg.js +14 -7
- package/dist/slices/video/plan.js +4 -4
- package/dist/slices/video/run.js +5 -2
- package/dist/slices/video/write-export.js +85 -20
- package/dist/updater/candidate.js +28 -0
- package/dist/updater/forward.js +35 -0
- package/dist/updater/install-flow.js +26 -0
- package/dist/updater/install.js +50 -0
- package/dist/updater/model.js +24 -0
- package/dist/updater/plan.js +135 -0
- package/dist/updater/readiness.js +15 -0
- package/dist/updater/registry.js +19 -0
- package/dist/updater/service.js +128 -0
- package/dist/updater/worker.js +136 -0
- package/dist/web/assets/index-6QIQ-TzO.css +1 -0
- package/dist/web/assets/index-CNgWL-ct.js +130 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-C1fsrIP5.css +0 -1
- package/dist/web/assets/index-DwJ_XTl3.js +0 -81
|
@@ -7,15 +7,9 @@ import { lines } from "./sse-lines.js";
|
|
|
7
7
|
// vocabulary: Codex writes a JSONL thread of `thread.started`, `item.*` and `turn.*`
|
|
8
8
|
// events. No key here either - the CLI's own login authenticates it.
|
|
9
9
|
export const codexBinary = "codex";
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
// are the ids this machine's `~/.codex/config.toml` names; reading the real catalogue is
|
|
14
|
-
// the upgrade when the CLI grows a command that prints it.
|
|
15
|
-
export const codexModels = [
|
|
16
|
-
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
|
17
|
-
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
|
18
|
-
];
|
|
10
|
+
// Codex publishes its account-specific picker in models_cache.json. With no
|
|
11
|
+
// cache, offer custom entry rather than pretending a pinned model is current.
|
|
12
|
+
export const codexModels = [];
|
|
19
13
|
// `codex exec --help` (0.149.1) for the flags. `-c web_search=<mode>` is a TOML override, and
|
|
20
14
|
// the binary's own error names the modes: "unknown variant `bogus`, expected one of `disabled`,
|
|
21
15
|
// `cached`, `indexed`, `live`". `live` is the grounded mode research asks for and `disabled` is
|
|
@@ -59,6 +53,8 @@ export function codexLlm(deps) {
|
|
|
59
53
|
continue;
|
|
60
54
|
}
|
|
61
55
|
const event = cliEvent(binary, line);
|
|
56
|
+
yield { type: "activity" };
|
|
57
|
+
req.signal.throwIfAborted();
|
|
62
58
|
if (event.type === "item.completed") {
|
|
63
59
|
const { item } = cliShaped(binary, itemCompleted, event.value);
|
|
64
60
|
// A turn also completes `reasoning`, `web_search`, `command_execution` and
|
|
@@ -111,10 +107,9 @@ export function codexLlm(deps) {
|
|
|
111
107
|
}
|
|
112
108
|
return {
|
|
113
109
|
id: "codex",
|
|
114
|
-
//
|
|
115
|
-
// other CLI. Enough for the idle timeout to see life on the stream.
|
|
110
|
+
// Prose arrives as whole messages; other JSONL events carry activity separately.
|
|
116
111
|
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
117
|
-
models: () => Promise.resolve(codexModels),
|
|
112
|
+
models: deps.readModels ?? (() => Promise.resolve(codexModels)),
|
|
118
113
|
complete,
|
|
119
114
|
};
|
|
120
115
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, realpath } from "node:fs/promises";
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { cliCommand } from "../../kernel/cli-command.js";
|
|
5
|
+
import { readCatalogueFile } from "./catalogue-files.js";
|
|
6
|
+
// Official CLI aliases follow the installed CLI's model routing and account.
|
|
7
|
+
// https://geminicli.com/docs/cli/model/
|
|
8
|
+
export const geminiModels = [
|
|
9
|
+
{ id: "auto", name: "Gemini Auto (CLI default)" },
|
|
10
|
+
{ id: "pro", name: "Gemini Pro (CLI alias)" },
|
|
11
|
+
{ id: "flash", name: "Gemini Flash (CLI alias)" },
|
|
12
|
+
{ id: "flash-lite", name: "Gemini Flash-Lite (CLI alias)" },
|
|
13
|
+
];
|
|
14
|
+
export async function nodeGeminiModels(binary) {
|
|
15
|
+
try {
|
|
16
|
+
const command = cliCommand(binary);
|
|
17
|
+
const entry = await executablePath(command.args[0] ?? command.file);
|
|
18
|
+
const candidates = new Set();
|
|
19
|
+
let directory = dirname(entry);
|
|
20
|
+
// npm and bun can install the core package nested under gemini-cli or
|
|
21
|
+
// hoisted beside it. Resolve the configured executable's symlinks first.
|
|
22
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
23
|
+
const suffix = "gemini-cli-core/dist/src/config/models.js";
|
|
24
|
+
candidates.add(join(directory, "node_modules/@google", suffix));
|
|
25
|
+
candidates.add(join(directory, suffix));
|
|
26
|
+
const parent = dirname(directory);
|
|
27
|
+
if (parent === directory)
|
|
28
|
+
break;
|
|
29
|
+
directory = parent;
|
|
30
|
+
}
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
try {
|
|
33
|
+
const models = parseGeminiModels(await readCatalogueFile(candidate, 256 * 1024));
|
|
34
|
+
if (models.length > geminiModels.length)
|
|
35
|
+
return models;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A candidate is an optional package layout, not the final discovery result.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error("No installed model metadata");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error("Gemini CLI model metadata is unavailable. Use a documented CLI alias or enter a custom model ID.");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function parseGeminiModels(source) {
|
|
48
|
+
const models = new Map(geminiModels.map((model) => [model.id, model]));
|
|
49
|
+
// Read literal exported model constants only. Never import or execute an
|
|
50
|
+
// installed package, parse comments as entries, or inspect login/settings.
|
|
51
|
+
const declarations = /^export (?:const|let) ([A-Z][A-Z0-9_]*)\s*=\s*(['"])([^'"\r\n]+)\2\s*;/gm;
|
|
52
|
+
const uncommented = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
53
|
+
for (const match of uncommented.matchAll(declarations)) {
|
|
54
|
+
const name = match[1];
|
|
55
|
+
const id = match[3];
|
|
56
|
+
if (name === undefined ||
|
|
57
|
+
id === undefined ||
|
|
58
|
+
!name.includes("MODEL") ||
|
|
59
|
+
name.includes("EMBEDDING") ||
|
|
60
|
+
!/^(?:gemini|gemma)-[a-z0-9.-]+$/.test(id))
|
|
61
|
+
continue;
|
|
62
|
+
if (!/^(?:(?:PREVIEW|DEFAULT|SECONDARY)_GEMINI_|GEMMA_)/.test(name))
|
|
63
|
+
continue;
|
|
64
|
+
models.set(id, { id, name: id });
|
|
65
|
+
}
|
|
66
|
+
return [...models.values()];
|
|
67
|
+
}
|
|
68
|
+
async function executablePath(binary) {
|
|
69
|
+
if (isAbsolute(binary) || /[\\/]/.test(binary))
|
|
70
|
+
return realpath(binary);
|
|
71
|
+
const path = Object.entries(process.env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
|
|
72
|
+
for (const directory of path.split(delimiter).slice(0, 128)) {
|
|
73
|
+
if (directory === "")
|
|
74
|
+
continue;
|
|
75
|
+
const candidate = join(directory.replace(/^"|"$/g, ""), binary);
|
|
76
|
+
try {
|
|
77
|
+
await access(candidate, constants.X_OK);
|
|
78
|
+
return await realpath(candidate);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Continue normal PATH lookup; no process is launched for discovery.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error("Gemini executable was not found");
|
|
85
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// Gemini 0.16 supports stream-json and tools.core, but headless default mode still
|
|
5
|
+
// exposes file-reading tools. Isolate each writing call while retaining ~/.gemini
|
|
6
|
+
// authentication. Nothing is written into the user's settings or project.
|
|
7
|
+
export function geminiWorkspace(webSearch) {
|
|
8
|
+
const directory = mkdtempSync(join(tmpdir(), "slopify-gemini-"));
|
|
9
|
+
try {
|
|
10
|
+
const settings = join(directory, "settings.json");
|
|
11
|
+
const system = join(directory, "writing.md");
|
|
12
|
+
const trust = join(directory, "trusted-folders.json");
|
|
13
|
+
const mcpAllowlist = `slopify-disabled-${directory.split(/[\\/]/).at(-1)}`;
|
|
14
|
+
writeFileSync(settings, JSON.stringify({
|
|
15
|
+
tools: {
|
|
16
|
+
core: webSearch ? ["google_web_search"] : [],
|
|
17
|
+
allowed: webSearch ? ["google_web_search"] : [],
|
|
18
|
+
discoveryCommand: "",
|
|
19
|
+
callCommand: "",
|
|
20
|
+
enableHooks: false,
|
|
21
|
+
},
|
|
22
|
+
mcp: { allowed: [mcpAllowlist], serverCommand: "" },
|
|
23
|
+
context: {
|
|
24
|
+
fileName: "SLOPIFY_NO_CONTEXT.md",
|
|
25
|
+
includeDirectories: [],
|
|
26
|
+
loadMemoryFromIncludeDirectories: false,
|
|
27
|
+
},
|
|
28
|
+
experimental: { codebaseInvestigatorSettings: { enabled: false } },
|
|
29
|
+
hooks: { enabled: false },
|
|
30
|
+
skills: { enabled: false },
|
|
31
|
+
ide: { enabled: false },
|
|
32
|
+
telemetry: { enabled: false },
|
|
33
|
+
privacy: { usageStatisticsEnabled: false },
|
|
34
|
+
advanced: { autoConfigureMemory: false },
|
|
35
|
+
}), { mode: 0o600 });
|
|
36
|
+
writeFileSync(system, "You are the writing and research component of Slopify, a video creation app. " +
|
|
37
|
+
"Write the requested articles, narration, research notes or image prompts. " +
|
|
38
|
+
"Follow the topic, language, tone, length and format. Return only the requested content. " +
|
|
39
|
+
"Use web search only when available and useful. Never read or modify local files. " +
|
|
40
|
+
"A backslash immediately before @ escapes a literal @ in the supplied text.\n", { mode: 0o600 });
|
|
41
|
+
// Gemini CONCAT-merges includeDirectories (even a null value). Reset the
|
|
42
|
+
// whole context object at workspace scope before the system context above.
|
|
43
|
+
// Trust only this new private workspace, leaving the user's trust file untouched.
|
|
44
|
+
mkdirSync(join(directory, ".gemini"), { mode: 0o700 });
|
|
45
|
+
writeFileSync(join(directory, ".gemini", "settings.json"), JSON.stringify({ context: null }), {
|
|
46
|
+
mode: 0o600,
|
|
47
|
+
});
|
|
48
|
+
writeFileSync(trust, JSON.stringify({ [directory]: "TRUST_FOLDER" }), { mode: 0o600 });
|
|
49
|
+
return {
|
|
50
|
+
mcpAllowlist,
|
|
51
|
+
options: {
|
|
52
|
+
cwd: directory,
|
|
53
|
+
env: {
|
|
54
|
+
...process.env,
|
|
55
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: settings,
|
|
56
|
+
GEMINI_CLI_TRUSTED_FOLDERS_PATH: trust,
|
|
57
|
+
NO_BROWSER: "true",
|
|
58
|
+
GEMINI_SYSTEM_MD: system,
|
|
59
|
+
GEMINI_WRITE_SYSTEM_MD: "false",
|
|
60
|
+
GEMINI_CLI_NO_RELAUNCH: "true",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
remove: () => rmSync(directory, { recursive: true, force: true }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
rmSync(directory, { recursive: true, force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { geminiModels } from "./gemini-models.js";
|
|
5
|
+
import { geminiWorkspace } from "./gemini-workspace.js";
|
|
6
|
+
import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
|
|
7
|
+
import { lines } from "./sse-lines.js";
|
|
8
|
+
export const geminiBinary = "gemini";
|
|
9
|
+
export { geminiModels } from "./gemini-models.js";
|
|
10
|
+
// Verified against installed Gemini CLI 0.16.0's config/nonInteractiveCli sources.
|
|
11
|
+
// Its --allowed-tools flag bypasses approval; tools.core in the isolated settings
|
|
12
|
+
// actually restricts discovery. Never request yolo/auto_edit.
|
|
13
|
+
export function geminiArgs(req, mcpAllowlist) {
|
|
14
|
+
return [
|
|
15
|
+
"--output-format",
|
|
16
|
+
"stream-json",
|
|
17
|
+
"--approval-mode",
|
|
18
|
+
"default",
|
|
19
|
+
"--extensions",
|
|
20
|
+
"none",
|
|
21
|
+
"--allowed-mcp-server-names",
|
|
22
|
+
mcpAllowlist,
|
|
23
|
+
...(req.webSearch === true ? ["--allowed-tools", "google_web_search"] : []),
|
|
24
|
+
...(req.model === "" ? [] : ["--model", req.model]),
|
|
25
|
+
"-p",
|
|
26
|
+
// The prefix prevents slash-command dispatch; escaping @ prevents Gemini's
|
|
27
|
+
// input preprocessor from reading files even when read tools are disabled.
|
|
28
|
+
`Produce the requested content from this conversation:\n\n${promptOf(req.messages).replaceAll("@", "\\@")}`,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
const messageSchema = z.object({
|
|
32
|
+
role: z.string(),
|
|
33
|
+
content: z.string(),
|
|
34
|
+
delta: z.boolean().optional(),
|
|
35
|
+
});
|
|
36
|
+
const errorSchema = z.object({ severity: z.string(), message: z.string() });
|
|
37
|
+
const resultSchema = z.object({
|
|
38
|
+
status: z.string(),
|
|
39
|
+
error: z.object({ message: z.string(), type: z.string().optional() }).optional(),
|
|
40
|
+
stats: z.object({ input_tokens: z.number(), output_tokens: z.number() }).optional(),
|
|
41
|
+
});
|
|
42
|
+
export function geminiLlm(deps) {
|
|
43
|
+
const binary = deps.binary ?? geminiBinary;
|
|
44
|
+
async function* complete(req) {
|
|
45
|
+
req.signal.throwIfAborted();
|
|
46
|
+
const workspace = geminiWorkspace(req.webSearch === true);
|
|
47
|
+
let run;
|
|
48
|
+
try {
|
|
49
|
+
run = deps.run(binary, geminiArgs(req, workspace.mcpAllowlist), req.signal, workspace.options);
|
|
50
|
+
for await (const line of lines(authChecked(run.stdout), req.signal)) {
|
|
51
|
+
if (line.trim() === "")
|
|
52
|
+
continue;
|
|
53
|
+
const event = cliEvent(binary, line);
|
|
54
|
+
yield { type: "activity" };
|
|
55
|
+
req.signal.throwIfAborted();
|
|
56
|
+
if (event.type === "message") {
|
|
57
|
+
const message = cliShaped(binary, messageSchema, event.value);
|
|
58
|
+
if (message.role === "assistant" && message.content !== "")
|
|
59
|
+
yield { type: "delta", text: message.content };
|
|
60
|
+
}
|
|
61
|
+
else if (event.type === "error") {
|
|
62
|
+
const error = cliShaped(binary, errorSchema, event.value);
|
|
63
|
+
if (error.severity !== "warning")
|
|
64
|
+
throw failure(error.message);
|
|
65
|
+
}
|
|
66
|
+
else if (event.type === "result") {
|
|
67
|
+
const result = cliShaped(binary, resultSchema, event.value);
|
|
68
|
+
if (result.status !== "success")
|
|
69
|
+
throw failure(result.error?.message ?? result.status);
|
|
70
|
+
yield {
|
|
71
|
+
type: "done",
|
|
72
|
+
usage: result.stats === undefined
|
|
73
|
+
? null
|
|
74
|
+
: {
|
|
75
|
+
inputTokens: result.stats.input_tokens,
|
|
76
|
+
outputTokens: result.stats.output_tokens,
|
|
77
|
+
},
|
|
78
|
+
finishReason: null,
|
|
79
|
+
};
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
req.signal.throwIfAborted();
|
|
84
|
+
const ended = await run.ended;
|
|
85
|
+
if (authRequired(run.stderr()))
|
|
86
|
+
throw authFailure();
|
|
87
|
+
throw failure(endedWithout(binary, ended, run.stderr()));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
req.signal.throwIfAborted();
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
run?.kill();
|
|
95
|
+
if (run !== undefined)
|
|
96
|
+
await run.ended;
|
|
97
|
+
workspace.remove();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
id: "gemini",
|
|
102
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
103
|
+
models: deps.readModels ?? (() => Promise.resolve(geminiModels)),
|
|
104
|
+
complete,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function failure(message) {
|
|
108
|
+
if (/(?:#?3501\b|do not have a valid licen[cs]e of this product)/i.test(message)) {
|
|
109
|
+
return providerError({
|
|
110
|
+
kind: "unsupported",
|
|
111
|
+
message: "Gemini CLI access was denied by Google's license check (#3501). Update Gemini CLI and sign in again. For a managed account, contact your administrator to request a license.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return authRequired(message)
|
|
115
|
+
? authFailure()
|
|
116
|
+
: providerError({ kind: "other", message: redact(message) });
|
|
117
|
+
}
|
|
118
|
+
function authRequired(message) {
|
|
119
|
+
return /Code Assist login required|authorize the application|authorization code|Please set an Auth method|re-authenticate|reauthenticate/i.test(message);
|
|
120
|
+
}
|
|
121
|
+
function authFailure() {
|
|
122
|
+
return providerError({
|
|
123
|
+
kind: "missing_key",
|
|
124
|
+
message: "Gemini CLI needs sign-in. Run gemini in your terminal, complete login, then retry in Slopify.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function* authChecked(source) {
|
|
128
|
+
const decoder = new TextDecoder();
|
|
129
|
+
let tail = "";
|
|
130
|
+
for await (const bytes of source) {
|
|
131
|
+
tail = (tail + decoder.decode(bytes, { stream: true })).slice(-8192);
|
|
132
|
+
// NO_BROWSER prevents opening OAuth. A logged-out CLI instead writes this
|
|
133
|
+
// non-JSON prompt without a newline; fail before lines() waits for user input.
|
|
134
|
+
if (/^(?:Enter the authorization code:|Please visit the following URL to authorize)/m.test(tail))
|
|
135
|
+
throw authFailure();
|
|
136
|
+
yield bytes;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -14,7 +14,11 @@ const appHeaders = {
|
|
|
14
14
|
// A wire payload is narrowed, never cast: everything unlisted is dropped at the seam so
|
|
15
15
|
// no vendor shape can leak past this file.
|
|
16
16
|
const modelList = z.object({
|
|
17
|
-
data: z.array(z.object({
|
|
17
|
+
data: z.array(z.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
name: z.string().optional(),
|
|
20
|
+
architecture: z.object({ output_modalities: z.array(z.string()).optional() }).optional(),
|
|
21
|
+
})),
|
|
18
22
|
});
|
|
19
23
|
const errorBody = z.object({
|
|
20
24
|
error: z.object({ message: z.string(), code: z.union([z.number(), z.string()]).optional() }),
|
|
@@ -112,6 +116,7 @@ export function openRouterLlm(deps) {
|
|
|
112
116
|
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
113
117
|
models: async () => {
|
|
114
118
|
const response = await deps.fetch(`${openRouterBase}/models`, {
|
|
119
|
+
signal: AbortSignal.timeout(10_000),
|
|
115
120
|
headers: headers(deps.key()),
|
|
116
121
|
});
|
|
117
122
|
if (!response.ok) {
|
|
@@ -124,7 +129,11 @@ export function openRouterLlm(deps) {
|
|
|
124
129
|
message: "OpenRouter's model list was not in the shape this app can read",
|
|
125
130
|
});
|
|
126
131
|
}
|
|
127
|
-
|
|
132
|
+
// /models defaults to text output. Also reject an explicit non-text row
|
|
133
|
+
// if a gateway response includes one; Slopify sends chat completions here.
|
|
134
|
+
return parsed.data.data
|
|
135
|
+
.filter((model) => model.architecture?.output_modalities?.includes("text") !== false)
|
|
136
|
+
.map((model) => ({ id: model.id, name: model.name ?? model.id }));
|
|
128
137
|
},
|
|
129
138
|
complete,
|
|
130
139
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { cliCommand } from "../../kernel/cli-command.js";
|
|
3
4
|
import { redact } from "../../kernel/log.js";
|
|
4
5
|
import { providerError } from "../../kernel/ports/model.js";
|
|
5
6
|
// ceiling: the last 8 KiB of stderr is kept. A CLI that writes megabytes of progress there
|
|
@@ -7,16 +8,18 @@ import { providerError } from "../../kernel/ports/model.js";
|
|
|
7
8
|
// says why it stopped. Streaming stderr to the log is the upgrade if a provider ever
|
|
8
9
|
// buries its reason in the first line of a long report.
|
|
9
10
|
export const stderrMax = 8192;
|
|
10
|
-
export function nodeRunCli(binary, args, signal) {
|
|
11
|
+
export function nodeRunCli(binary, args, signal, options) {
|
|
11
12
|
// An argument array, never a shell string: a prompt carrying backticks, `$(...)`, quotes or
|
|
12
13
|
// newlines is one argv element and nothing in it can become a command. stdin is /dev/null
|
|
13
14
|
// because `codex exec` appends piped stdin to the prompt, and a pipe nobody closes would
|
|
14
15
|
// leave it waiting for an EOF that never comes. `signal` is how the child dies: Node sends it
|
|
15
16
|
// SIGTERM when the stage is cancelled, so no agent session outlives the run that started it.
|
|
16
|
-
const
|
|
17
|
+
const command = cliCommand(binary);
|
|
18
|
+
const child = spawn(command.file, [...command.args, ...args], {
|
|
17
19
|
signal,
|
|
18
20
|
stdio: ["ignore", "pipe", "pipe"],
|
|
19
21
|
windowsHide: true,
|
|
22
|
+
...options,
|
|
20
23
|
});
|
|
21
24
|
let stderr = "";
|
|
22
25
|
const errorStream = child.stderr;
|
|
@@ -38,13 +41,7 @@ export function nodeRunCli(binary, args, signal) {
|
|
|
38
41
|
resolve({ code, error: spawnError });
|
|
39
42
|
};
|
|
40
43
|
child.once("close", settle);
|
|
41
|
-
//
|
|
42
|
-
// where they were not.
|
|
43
|
-
child.once("error", () => {
|
|
44
|
-
setImmediate(() => {
|
|
45
|
-
settle(null);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
44
|
+
// Close follows an error too; waiting for it keeps cleanup behind process exit.
|
|
48
45
|
});
|
|
49
46
|
return {
|
|
50
47
|
pid: child.pid,
|
|
@@ -11,6 +11,13 @@ export const cartesiaBase = "https://api.cartesia.ai";
|
|
|
11
11
|
// constant rather than left to the account's default.
|
|
12
12
|
export const cartesiaVersion = "2026-03-01";
|
|
13
13
|
export const cartesiaModel = "sonic-3.5";
|
|
14
|
+
// Cartesia publishes model IDs in its docs, with no model-list API. Stable family aliases
|
|
15
|
+
// receive new snapshots automatically: https://docs.cartesia.ai/build-with-cartesia/tts-models/latest
|
|
16
|
+
export const cartesiaModels = [
|
|
17
|
+
{ id: "sonic-3.6", name: "Sonic 3.6" },
|
|
18
|
+
{ id: "sonic-3.5", name: "Sonic 3.5" },
|
|
19
|
+
{ id: "sonic-3", name: "Sonic 3" },
|
|
20
|
+
];
|
|
14
21
|
// mp3 is the port's container; `bit_rate` is required for it and `sample_rate` fixes the
|
|
15
22
|
// rate the concatenation then keeps.
|
|
16
23
|
const outputFormat = { container: "mp3", bit_rate: 128_000, sample_rate: 44_100 };
|
|
@@ -24,6 +31,7 @@ export function cartesiaTts(deps) {
|
|
|
24
31
|
return {
|
|
25
32
|
id: "cartesia",
|
|
26
33
|
capabilities: { streams: true },
|
|
34
|
+
models: async () => cartesiaModels,
|
|
27
35
|
synthesize: async (req) => {
|
|
28
36
|
const response = await deps.fetch(`${cartesiaBase}/tts/bytes`, {
|
|
29
37
|
method: "POST",
|
|
@@ -36,7 +44,7 @@ export function cartesiaTts(deps) {
|
|
|
36
44
|
// No pre-check on length; Cartesia's own limit surfaces as its
|
|
37
45
|
// error. `language` is left out so the model reads it off the transcript.
|
|
38
46
|
body: JSON.stringify({
|
|
39
|
-
model_id: cartesiaModel,
|
|
47
|
+
model_id: req.model ?? cartesiaModel,
|
|
40
48
|
transcript: req.text,
|
|
41
49
|
voice: { mode: "id", id: req.voiceId },
|
|
42
50
|
output_format: outputFormat,
|
|
@@ -10,9 +10,22 @@ export const elevenLabsBase = "https://api.elevenlabs.io/v1";
|
|
|
10
10
|
// attempt wrapper measures its 120 s as an idle timeout between chunks only when bytes
|
|
11
11
|
// keep arriving.
|
|
12
12
|
export const elevenLabsModel = "eleven_multilingual_v2";
|
|
13
|
+
export const elevenLabsModels = [
|
|
14
|
+
{ id: "eleven_v3", name: "Eleven v3" },
|
|
15
|
+
{ id: "eleven_multilingual_v2", name: "Eleven Multilingual v2" },
|
|
16
|
+
{ id: "eleven_flash_v2_5", name: "Eleven Flash v2.5" },
|
|
17
|
+
{ id: "eleven_flash_v2", name: "Eleven Flash v2" },
|
|
18
|
+
{ id: "eleven_turbo_v2_5", name: "Eleven Turbo v2.5 (deprecated)" },
|
|
19
|
+
{ id: "eleven_turbo_v2", name: "Eleven Turbo v2 (deprecated)" },
|
|
20
|
+
];
|
|
13
21
|
// mp3 at the port's container, 44.1 kHz, 128 kbps: `kernel/ports/tts.ts` fixes mp3 and the
|
|
14
22
|
// concatenation keeps the provider's own sample rate.
|
|
15
23
|
export const elevenLabsFormat = "mp3_44100_128";
|
|
24
|
+
const modelList = z.array(z.object({
|
|
25
|
+
model_id: z.string().min(1),
|
|
26
|
+
name: z.string().optional(),
|
|
27
|
+
can_do_text_to_speech: z.boolean().optional(),
|
|
28
|
+
}));
|
|
16
29
|
// A wire payload is narrowed, never cast. `detail` is an object on a handled failure and
|
|
17
30
|
// a string on the framework's own; anything else falls back to the raw text.
|
|
18
31
|
const errorBody = z.object({
|
|
@@ -25,6 +38,24 @@ export function elevenLabsTts(deps) {
|
|
|
25
38
|
return {
|
|
26
39
|
id: "elevenlabs",
|
|
27
40
|
capabilities: { streams: true },
|
|
41
|
+
models: async () => {
|
|
42
|
+
const response = await deps.fetch(`${elevenLabsBase}/models`, {
|
|
43
|
+
headers: { "xi-api-key": keyOf(deps) },
|
|
44
|
+
signal: AbortSignal.timeout(10_000),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw await failure(response);
|
|
48
|
+
const parsed = modelList.safeParse(safeJson(await response.text()));
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
throw providerError({
|
|
51
|
+
kind: "other",
|
|
52
|
+
message: "ElevenLabs' model list was not in the shape this app can read",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return parsed.data
|
|
56
|
+
.filter((model) => model.can_do_text_to_speech === true)
|
|
57
|
+
.map((model) => ({ id: model.model_id, name: model.name || model.model_id }));
|
|
58
|
+
},
|
|
28
59
|
synthesize: async (req) => {
|
|
29
60
|
const voice = encodeURIComponent(req.voiceId);
|
|
30
61
|
const response = await deps.fetch(`${elevenLabsBase}/text-to-speech/${voice}/stream?output_format=${elevenLabsFormat}`, {
|
|
@@ -33,7 +64,7 @@ export function elevenLabsTts(deps) {
|
|
|
33
64
|
headers: { "xi-api-key": keyOf(deps), "Content-Type": "application/json" },
|
|
34
65
|
// No pre-check on length. A text past the model's limit comes
|
|
35
66
|
// back as the provider's own 400 and that is what the stage shows.
|
|
36
|
-
body: JSON.stringify({ text: req.text, model_id: elevenLabsModel }),
|
|
67
|
+
body: JSON.stringify({ text: req.text, model_id: req.model ?? elevenLabsModel }),
|
|
37
68
|
});
|
|
38
69
|
if (!response.ok) {
|
|
39
70
|
throw await failure(response, req.voiceId);
|
|
@@ -75,7 +106,7 @@ async function failure(response, voiceId) {
|
|
|
75
106
|
kind: kindOf(response.status),
|
|
76
107
|
// A rejected voice ID has to be named, and it is the one part of the
|
|
77
108
|
// request the user chose. It is not secret, unlike everything else on the wire.
|
|
78
|
-
message: `ElevenLabs answered ${response.status} for voice ${voiceId}: ${message}`,
|
|
109
|
+
message: `ElevenLabs answered ${response.status}${voiceId === undefined ? "" : ` for voice ${voiceId}`}: ${message}`,
|
|
79
110
|
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
80
111
|
});
|
|
81
112
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { retryAfter } from "../retry-after.js";
|
|
5
|
+
const operationName = /^workspaces\/[A-Za-z0-9_-]+\/ttsAsyncJobs\/[A-Za-z0-9_-]+\/operations\/[A-Za-z0-9_-]+$/;
|
|
6
|
+
const operationSchema = z.object({
|
|
7
|
+
name: z.string().regex(operationName),
|
|
8
|
+
done: z.boolean().optional(),
|
|
9
|
+
error: z.object({ code: z.number().optional(), message: z.string().optional() }).nullish(),
|
|
10
|
+
response: z.object({ audioUri: z.string() }).nullish(),
|
|
11
|
+
});
|
|
12
|
+
// Async is documented for TTS-2 only. Flash continues using the streaming API.
|
|
13
|
+
// A continuation survives automatic retries of this call; pausing/restarting a
|
|
14
|
+
// stage stops local polling but cannot cancel an already accepted remote job.
|
|
15
|
+
export async function* inworldAsync(deps, request, key) {
|
|
16
|
+
if (request.text.length > 100_000) {
|
|
17
|
+
throw providerError({
|
|
18
|
+
kind: "unsupported",
|
|
19
|
+
message: "Inworld async accepts up to 100,000 characters per request (10,000 for On-Demand accounts). Select paragraph chunking for longer articles.",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const headers = { Authorization: `Basic ${key}`, "Content-Type": "application/json" };
|
|
23
|
+
const saved = request.continuation?.read();
|
|
24
|
+
if (saved && !operationName.test(saved))
|
|
25
|
+
throw invalid("returned an invalid operation name");
|
|
26
|
+
request.signal.throwIfAborted();
|
|
27
|
+
let response = await deps.fetch(saved
|
|
28
|
+
? `https://api.inworld.ai/lro/v1alpha/${saved}`
|
|
29
|
+
: "https://api.inworld.ai/tts/v1/voice:synthesizeAsync", {
|
|
30
|
+
method: saved ? "GET" : "POST",
|
|
31
|
+
headers,
|
|
32
|
+
signal: request.signal,
|
|
33
|
+
redirect: "error",
|
|
34
|
+
...(saved
|
|
35
|
+
? {}
|
|
36
|
+
: {
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
text: request.text,
|
|
39
|
+
voiceId: request.voiceId,
|
|
40
|
+
modelId: request.model ?? "inworld-tts-2",
|
|
41
|
+
audioConfig: { audioEncoding: "MP3", sampleRateHertz: 48_000, bitRate: 128_000 },
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
let expected = saved;
|
|
46
|
+
for (;;) {
|
|
47
|
+
request.signal.throwIfAborted();
|
|
48
|
+
await check(response, key);
|
|
49
|
+
const parsed = operationSchema.safeParse(await response.json().catch(() => null));
|
|
50
|
+
if (!parsed.success || (expected && parsed.data.name !== expected))
|
|
51
|
+
throw invalid("returned an invalid operation");
|
|
52
|
+
const operation = parsed.data;
|
|
53
|
+
expected = operation.name;
|
|
54
|
+
request.continuation?.write(operation.name);
|
|
55
|
+
request.onActivity?.();
|
|
56
|
+
request.signal.throwIfAborted();
|
|
57
|
+
if (operation.done) {
|
|
58
|
+
if (operation.error) {
|
|
59
|
+
const { code, message } = operation.error;
|
|
60
|
+
throw providerError({
|
|
61
|
+
kind: code === 7 || code === 16
|
|
62
|
+
? "auth"
|
|
63
|
+
: code === 8
|
|
64
|
+
? "rate_limit"
|
|
65
|
+
: code === 3 || code === 5 || code === 9
|
|
66
|
+
? "unsupported"
|
|
67
|
+
: "other",
|
|
68
|
+
message: `Inworld async job failed: ${clean(message ?? "audio generation stopped", key)}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (!operation.response)
|
|
72
|
+
throw invalid("finished without an audio download");
|
|
73
|
+
yield* download(deps.fetch, operation.response.audioUri, request.signal);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await deps.clock.sleep(5000, request.signal);
|
|
77
|
+
request.signal.throwIfAborted();
|
|
78
|
+
response = await deps.fetch(`https://api.inworld.ai/lro/v1alpha/${operation.name}`, {
|
|
79
|
+
headers,
|
|
80
|
+
signal: request.signal,
|
|
81
|
+
redirect: "error",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function* download(fetch, uri, signal) {
|
|
86
|
+
const url = URL.parse(uri);
|
|
87
|
+
if (url?.protocol !== "https:" || url.username || url.password)
|
|
88
|
+
throw invalid("returned an invalid audio download URL");
|
|
89
|
+
// This is a signed storage URL, not an Inworld API endpoint. Never forward Basic auth.
|
|
90
|
+
let response;
|
|
91
|
+
try {
|
|
92
|
+
response = await fetch(url.href, { signal, redirect: "error" });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
// Signed query parameters must not become a persisted error or log entry.
|
|
97
|
+
throw invalid("audio download could not be reached; retrying the existing job");
|
|
98
|
+
}
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
throw invalid(`audio download answered ${response.status}; retrying the existing job`);
|
|
101
|
+
if (!response.body)
|
|
102
|
+
throw invalid("answered with no audio");
|
|
103
|
+
const reader = response.body.getReader();
|
|
104
|
+
let heard = false;
|
|
105
|
+
try {
|
|
106
|
+
for (;;) {
|
|
107
|
+
const { value, done } = await reader.read();
|
|
108
|
+
signal.throwIfAborted();
|
|
109
|
+
if (done)
|
|
110
|
+
break;
|
|
111
|
+
if (value.length) {
|
|
112
|
+
heard = true;
|
|
113
|
+
yield value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
signal.throwIfAborted();
|
|
119
|
+
throw invalid("audio download stopped; retrying the existing job");
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await reader.cancel().catch(() => { });
|
|
123
|
+
reader.releaseLock();
|
|
124
|
+
}
|
|
125
|
+
if (!heard)
|
|
126
|
+
throw invalid("answered with no audio");
|
|
127
|
+
}
|
|
128
|
+
async function check(response, key) {
|
|
129
|
+
if (response.ok)
|
|
130
|
+
return;
|
|
131
|
+
const detail = clean(await response.text().catch(() => ""), key);
|
|
132
|
+
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
133
|
+
throw providerError({
|
|
134
|
+
kind: response.status === 401 || response.status === 403
|
|
135
|
+
? "auth"
|
|
136
|
+
: response.status === 429
|
|
137
|
+
? "rate_limit"
|
|
138
|
+
: response.status === 400 || response.status === 404
|
|
139
|
+
? "unsupported"
|
|
140
|
+
: "other",
|
|
141
|
+
message: `Inworld async answered ${response.status}: ${detail || response.statusText}`,
|
|
142
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function clean(message, key) {
|
|
146
|
+
return redact(message.replaceAll(key, "[redacted]"));
|
|
147
|
+
}
|
|
148
|
+
function invalid(detail) {
|
|
149
|
+
return providerError({ kind: "other", message: `Inworld ${detail}.` });
|
|
150
|
+
}
|