@gentbajko/slopify 1.0.1 → 1.1.1

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.
@@ -3,6 +3,7 @@ import { googleImage } from "./adapters/image/google.js";
3
3
  import { openAiImage } from "./adapters/image/openai.js";
4
4
  import { replicateImage } from "./adapters/image/replicate.js";
5
5
  import { claudeCodeLlm } from "./adapters/llm/claude-code.js";
6
+ import { nodeClaudeCodeModels } from "./adapters/llm/claude-code-models.js";
6
7
  import { codexLlm } from "./adapters/llm/codex.js";
7
8
  import { nodeCodexModels } from "./adapters/llm/codex-models.js";
8
9
  import { geminiLlm } from "./adapters/llm/gemini.js";
@@ -28,7 +29,13 @@ export function buildRegistry(deps) {
28
29
  const llms = new Map([
29
30
  ["openrouter", openRouterLlm({ fetch: deps.fetch, key: keyOf("openrouter") })],
30
31
  // Each CLI authenticates with its own login.
31
- ["claude-code", claudeCodeLlm({ run: cliFor("claude-code") })],
32
+ [
33
+ "claude-code",
34
+ claudeCodeLlm({
35
+ run: cliFor("claude-code"),
36
+ readModels: () => nodeClaudeCodeModels(cliBinary(deps.db, "claude-code")),
37
+ }),
38
+ ],
32
39
  ["codex", codexLlm({ run: cliFor("codex"), readModels: () => nodeCodexModels() })],
33
40
  [
34
41
  "gemini",
@@ -0,0 +1,200 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { z } from "zod";
7
+ import { cliCommand } from "../../kernel/cli-command.js";
8
+ const maxOutputBytes = 1024 * 1024;
9
+ const modelId = z
10
+ .string()
11
+ .trim()
12
+ .min(1)
13
+ .max(256)
14
+ .refine((value) => [...value].every((character) => {
15
+ const code = character.charCodeAt(0);
16
+ return code >= 33 && code !== 127;
17
+ }));
18
+ const modelName = z.string().trim().min(1).max(256);
19
+ const responseSchema = z.object({
20
+ type: z.literal("control_response"),
21
+ response: z.object({
22
+ request_id: z.string(),
23
+ subtype: z.literal("success"),
24
+ response: z.object({
25
+ models: z
26
+ .array(z.object({
27
+ value: modelId,
28
+ resolvedModel: modelId.optional(),
29
+ displayName: modelName,
30
+ supportedEffortLevels: z.array(z.string()).max(10).optional(),
31
+ }))
32
+ .min(1)
33
+ .max(100),
34
+ }),
35
+ }),
36
+ });
37
+ const supportedEfforts = ["low", "medium", "high", "xhigh"];
38
+ function unavailable() {
39
+ return new Error("Claude Code model discovery is unavailable. Refresh the list or enter an exact model ID.");
40
+ }
41
+ function discoveryEnv() {
42
+ const names = [
43
+ "HOME",
44
+ "USER",
45
+ "LOGNAME",
46
+ "USERPROFILE",
47
+ "APPDATA",
48
+ "LOCALAPPDATA",
49
+ "XDG_CONFIG_HOME",
50
+ "XDG_DATA_HOME",
51
+ "XDG_CACHE_HOME",
52
+ "PATH",
53
+ "PATHEXT",
54
+ "LANG",
55
+ "LC_ALL",
56
+ "LC_CTYPE",
57
+ "TMPDIR",
58
+ "TEMP",
59
+ "TMP",
60
+ "SystemRoot",
61
+ "WINDIR",
62
+ ];
63
+ const env = {};
64
+ for (const name of names) {
65
+ if (process.env[name] !== undefined)
66
+ env[name] = process.env[name];
67
+ }
68
+ return env;
69
+ }
70
+ function choices(event, requestId) {
71
+ const parsed = responseSchema.safeParse(event);
72
+ if (!parsed.success || parsed.data.response.request_id !== requestId)
73
+ throw unavailable();
74
+ const models = new Map();
75
+ for (const row of parsed.data.response.response.models) {
76
+ const modes = row.supportedEffortLevels?.filter((value) => supportedEfforts.includes(value));
77
+ const thinking = modes?.length ? { thinkingModes: modes } : {};
78
+ if (!models.has(row.value))
79
+ models.set(row.value, {
80
+ id: row.value,
81
+ name: row.resolvedModel === undefined || row.resolvedModel === row.value
82
+ ? row.displayName
83
+ : `${row.displayName} → ${row.resolvedModel}`,
84
+ ...thinking,
85
+ });
86
+ // Claude advertises stable aliases and their current concrete targets.
87
+ // Offer both so the user can choose a moving alias or pin a version.
88
+ if (row.resolvedModel !== undefined && !models.has(row.resolvedModel))
89
+ models.set(row.resolvedModel, {
90
+ id: row.resolvedModel,
91
+ name: `${row.resolvedModel} (exact model)`,
92
+ ...thinking,
93
+ });
94
+ }
95
+ return [...models.values()];
96
+ }
97
+ // The installed CLI has no model-list command; initialize is a no-prompt
98
+ // stream-JSON control exchange. Treat protocol drift as unavailable metadata.
99
+ export async function nodeClaudeCodeModels(binary, timeoutMs = 15_000) {
100
+ const directory = await mkdtemp(join(tmpdir(), "slopify-claude-models-"));
101
+ const requestId = randomUUID();
102
+ const command = cliCommand(binary);
103
+ const child = spawn(command.file, [
104
+ ...command.args,
105
+ "-p",
106
+ "--input-format",
107
+ "stream-json",
108
+ "--output-format",
109
+ "stream-json",
110
+ "--verbose",
111
+ "--safe-mode",
112
+ "--tools",
113
+ "",
114
+ "--strict-mcp-config",
115
+ "--no-session-persistence",
116
+ ], {
117
+ cwd: directory,
118
+ env: discoveryEnv(),
119
+ stdio: ["pipe", "pipe", "pipe"],
120
+ windowsHide: true,
121
+ detached: process.platform !== "win32",
122
+ });
123
+ child.on("error", () => { });
124
+ child.stdin.on("error", () => { });
125
+ child.stderr.resume();
126
+ const closed = new Promise((resolve) => child.once("close", () => resolve()));
127
+ const terminate = (force) => {
128
+ const signal = force ? "SIGKILL" : "SIGTERM";
129
+ if (process.platform !== "win32" && child.pid !== undefined) {
130
+ try {
131
+ process.kill(-child.pid, signal);
132
+ return;
133
+ }
134
+ catch {
135
+ // A child that already exited has no process group to signal.
136
+ }
137
+ }
138
+ child.kill(signal);
139
+ };
140
+ let forceTimer;
141
+ const timer = setTimeout(() => {
142
+ terminate(false);
143
+ forceTimer = setTimeout(() => terminate(true), 500);
144
+ }, timeoutMs);
145
+ try {
146
+ child.stdin.write(`${JSON.stringify({
147
+ request_id: requestId,
148
+ type: "control_request",
149
+ request: { subtype: "initialize" },
150
+ })}\n`);
151
+ let output = "";
152
+ let bytes = 0;
153
+ for await (const piece of child.stdout) {
154
+ bytes += piece.length;
155
+ if (bytes > maxOutputBytes)
156
+ throw unavailable();
157
+ output += piece.toString();
158
+ let newline = output.indexOf("\n");
159
+ while (newline >= 0) {
160
+ const line = output.slice(0, newline).trim();
161
+ output = output.slice(newline + 1);
162
+ if (line !== "") {
163
+ let event;
164
+ try {
165
+ event = JSON.parse(line);
166
+ }
167
+ catch {
168
+ throw unavailable();
169
+ }
170
+ if (typeof event === "object" &&
171
+ event !== null &&
172
+ "type" in event &&
173
+ event.type === "control_response") {
174
+ return choices(event, requestId);
175
+ }
176
+ }
177
+ newline = output.indexOf("\n");
178
+ }
179
+ }
180
+ throw unavailable();
181
+ }
182
+ catch {
183
+ throw unavailable();
184
+ }
185
+ finally {
186
+ clearTimeout(timer);
187
+ if (forceTimer !== undefined)
188
+ clearTimeout(forceTimer);
189
+ terminate(false);
190
+ const grace = await Promise.race([
191
+ closed.then(() => true),
192
+ new Promise((resolve) => setTimeout(() => resolve(false), 500)),
193
+ ]);
194
+ if (!grace) {
195
+ terminate(true);
196
+ await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 500))]);
197
+ }
198
+ await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
199
+ }
200
+ }
@@ -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 { nodeClaudeCodeModels } from "./claude-code-models.js";
4
5
  import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
5
6
  import { lines } from "./sse-lines.js";
6
7
  // The local-agent adapter for Claude Code: spawned non-interactively, authenticated by the
@@ -8,15 +9,6 @@ import { lines } from "./sse-lines.js";
8
9
  // may not import `slices/**`, and `slices/settings/cli-status.ts` already probes the binary per
9
10
  // request. The registry `main.ts` builds is where this adapter and that probe meet.
10
11
  export const claudeCodeBinary = "claude";
11
- // Official stable family aliases resolve to the latest model available to the
12
- // installed CLI/account. Full model IDs remain available through custom entry.
13
- // https://code.claude.com/docs/en/model-config
14
- export const claudeCodeModels = [
15
- { id: "fable", name: "Claude Fable (latest)" },
16
- { id: "opus", name: "Claude Opus (latest)" },
17
- { id: "sonnet", name: "Claude Sonnet (latest)" },
18
- { id: "haiku", name: "Claude Haiku (latest)" },
19
- ];
20
12
  const writingRole = "You are the writing and research component of Slopify, a video creation app. " +
21
13
  "Produce the text requested in the supplied conversation: articles, narration scripts, " +
22
14
  "research notes or image prompts. Follow the requested topic, language, tone, length and format. " +
@@ -47,6 +39,7 @@ export function claudeCodeArgs(req) {
47
39
  ? ["--tools", "WebSearch", "--allowedTools", "WebSearch"]
48
40
  : ["--tools", ""]),
49
41
  ...(req.model === "" ? [] : ["--model", req.model]),
42
+ ...(req.thinking !== undefined && req.thinking !== "off" ? ["--effort", req.thinking] : []),
50
43
  // Everything after `--` is the prompt, so a prompt opening with a dash is text and not
51
44
  // a flag. It is one argv element: no shell sees it and nothing in it is expanded.
52
45
  "--",
@@ -142,7 +135,7 @@ export function claudeCodeLlm(deps) {
142
135
  id: "claude-code",
143
136
  // Partial messages keep the deadline alive; complete assistant turns supply prose.
144
137
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
145
- models: () => Promise.resolve(claudeCodeModels),
138
+ models: deps.readModels ?? (() => nodeClaudeCodeModels(binary)),
146
139
  complete,
147
140
  };
148
141
  }
@@ -17,6 +17,10 @@ const model = z.object({
17
17
  display_name: safeText.optional(),
18
18
  visibility: z.literal("list"),
19
19
  priority: z.number().finite().optional(),
20
+ supported_reasoning_levels: z
21
+ .array(z.object({ effort: z.string() }))
22
+ .max(20)
23
+ .optional(),
20
24
  });
21
25
  export async function nodeCodexModels(env = process.env) {
22
26
  try {
@@ -32,7 +36,14 @@ export async function nodeCodexModels(env = process.env) {
32
36
  const unique = new Map();
33
37
  for (const item of models) {
34
38
  if (!unique.has(item.slug)) {
35
- unique.set(item.slug, { id: item.slug, name: item.display_name ?? item.slug });
39
+ const thinkingModes = item.supported_reasoning_levels
40
+ ?.map((level) => level.effort)
41
+ .filter((level) => ["off", "low", "medium", "high", "xhigh"].includes(level));
42
+ unique.set(item.slug, {
43
+ id: item.slug,
44
+ name: item.display_name ?? item.slug,
45
+ ...(thinkingModes?.length ? { thinkingModes } : {}),
46
+ });
36
47
  }
37
48
  }
38
49
  if (unique.size === 0)
@@ -4,15 +4,13 @@ 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 { nodeCodexModels } from "./codex-models.js";
7
8
  import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
8
9
  import { lines } from "./sse-lines.js";
9
10
  // The local-agent adapter for the Codex CLI. Same shape as Claude Code's and a different
10
11
  // vocabulary: Codex writes a JSONL thread of `thread.started`, `item.*` and `turn.*`
11
12
  // events. No key here either - the CLI's own login authenticates it.
12
13
  export const codexBinary = "codex";
13
- // Codex publishes its account-specific picker in models_cache.json. With no
14
- // cache, offer custom entry rather than pretending a pinned model is current.
15
- export const codexModels = [];
16
14
  const writingRole = "You are the writing and research component of Slopify, a video creation app. " +
17
15
  "Produce the text requested in the supplied conversation: articles, narration scripts, " +
18
16
  "research notes or image prompts. Follow the requested topic, language, tone, length and format. " +
@@ -181,7 +179,7 @@ export function codexLlm(deps) {
181
179
  id: "codex",
182
180
  // Prose arrives as whole messages; other JSONL events carry activity separately.
183
181
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
184
- models: deps.readModels ?? (() => Promise.resolve(codexModels)),
182
+ models: deps.readModels ?? (() => nodeCodexModels()),
185
183
  complete,
186
184
  };
187
185
  }
@@ -3,14 +3,6 @@ import { access, realpath } from "node:fs/promises";
3
3
  import { delimiter, dirname, isAbsolute, join } from "node:path";
4
4
  import { cliCommand } from "../../kernel/cli-command.js";
5
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
6
  export async function nodeGeminiModels(binary) {
15
7
  try {
16
8
  const command = cliCommand(binary);
@@ -31,24 +23,56 @@ export async function nodeGeminiModels(binary) {
31
23
  for (const candidate of candidates) {
32
24
  try {
33
25
  const models = parseGeminiModels(await readCatalogueFile(candidate, 256 * 1024));
34
- if (models.length > geminiModels.length)
26
+ if (models.length > 1)
35
27
  return models;
36
28
  }
37
29
  catch {
38
30
  // A candidate is an optional package layout, not the final discovery result.
39
31
  }
40
32
  }
33
+ // Recent Gemini CLI releases ship only bundled chunks. Follow the entry
34
+ // point's literal chunk imports and read their model constants as text.
35
+ const bundled = await bundledModels(entry);
36
+ if (bundled.length > 1)
37
+ return bundled;
41
38
  throw new Error("No installed model metadata");
42
39
  }
43
40
  catch {
44
41
  throw new Error("Gemini CLI model metadata is unavailable. Use a documented CLI alias or enter a custom model ID.");
45
42
  }
46
43
  }
44
+ async function bundledModels(entry) {
45
+ const source = await readCatalogueFile(entry, 256 * 1024);
46
+ const imports = new Set();
47
+ for (const match of source.matchAll(/\b(?:from\s+|import\s*)["']\.\/(chunk-[A-Za-z0-9-]+\.js)["']/g)) {
48
+ if (match[1] !== undefined)
49
+ imports.add(match[1]);
50
+ }
51
+ if (imports.size === 0 || imports.size > 32)
52
+ return [];
53
+ for (const name of imports) {
54
+ try {
55
+ const source = await readCatalogueFile(join(dirname(entry), name), 24 * 1024 * 1024);
56
+ if (!source.includes("packages/core/dist/src/config/models.js"))
57
+ continue;
58
+ const models = parseGeminiModels(source);
59
+ if (models.length > 1)
60
+ return models;
61
+ }
62
+ catch {
63
+ // Other chunks can be absent or too large; do not execute them.
64
+ }
65
+ }
66
+ return [];
67
+ }
47
68
  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
69
+ const models = new Map();
70
+ let pro = false;
71
+ let flash = false;
72
+ let flashLite = false;
73
+ // Read literal model constants only. Never import or execute an
50
74
  // 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;
75
+ const declarations = /^(?:export\s+)?(?:const|let|var)\s+([A-Z][A-Z0-9_]*)\s*=\s*(['"])([^'"\r\n]+)\2\s*;/gm;
52
76
  const uncommented = source.replace(/\/\*[\s\S]*?\*\//g, "");
53
77
  for (const match of uncommented.matchAll(declarations)) {
54
78
  const name = match[1];
@@ -62,8 +86,22 @@ function parseGeminiModels(source) {
62
86
  if (!/^(?:(?:PREVIEW|DEFAULT|SECONDARY)_GEMINI_|GEMMA_)/.test(name))
63
87
  continue;
64
88
  models.set(id, { id, name: id });
89
+ if (name.includes("FLASH_LITE") || id.includes("flash-lite"))
90
+ flashLite = true;
91
+ else if (name.includes("FLASH") || id.includes("flash"))
92
+ flash = true;
93
+ else if (name.includes("PRO") || id.includes("-pro"))
94
+ pro = true;
65
95
  }
66
- return [...models.values()];
96
+ if (models.size === 0)
97
+ return [];
98
+ return [
99
+ { id: "auto", name: "Gemini Auto (CLI default)" },
100
+ ...(pro ? [{ id: "pro", name: "Gemini Pro (CLI alias)" }] : []),
101
+ ...(flash ? [{ id: "flash", name: "Gemini Flash (CLI alias)" }] : []),
102
+ ...(flashLite ? [{ id: "flash-lite", name: "Gemini Flash-Lite (CLI alias)" }] : []),
103
+ ...models.values(),
104
+ ];
67
105
  }
68
106
  async function executablePath(binary) {
69
107
  if (isAbsolute(binary) || /[\\/]/.test(binary))
@@ -1,12 +1,11 @@
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 { geminiModels } from "./gemini-models.js";
4
+ import { nodeGeminiModels } from "./gemini-models.js";
5
5
  import { geminiWorkspace } from "./gemini-workspace.js";
6
6
  import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
7
7
  import { lines } from "./sse-lines.js";
8
8
  export const geminiBinary = "gemini";
9
- export { geminiModels } from "./gemini-models.js";
10
9
  // Verified against installed Gemini CLI 0.16.0's config/nonInteractiveCli sources.
11
10
  // Its --allowed-tools flag bypasses approval; tools.core in the isolated settings
12
11
  // actually restricts discovery. Never request yolo/auto_edit.
@@ -103,7 +102,7 @@ export function geminiLlm(deps) {
103
102
  return {
104
103
  id: "gemini",
105
104
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
106
- models: deps.readModels ?? (() => Promise.resolve(geminiModels)),
105
+ models: deps.readModels ?? (() => nodeGeminiModels(binary)),
107
106
  complete,
108
107
  };
109
108
  }
@@ -2,16 +2,10 @@
2
2
  # A private copy in the Slopify data directory reloads automatically.
3
3
  # Omitted pricing means unknown, never free. Provider concurrency is capped at five.
4
4
  schemaVersion: 1
5
- updatedAt: 2026-09-10
5
+ updatedAt: 2026-09-24
6
6
  providers:
7
7
  openrouter:
8
8
  maxConcurrent: 3
9
- claude-code:
10
- maxConcurrent: 3
11
- codex:
12
- maxConcurrent: 3
13
- gemini:
14
- maxConcurrent: 3
15
9
  elevenlabs:
16
10
  maxConcurrent: 3
17
11
  openai-tts:
@@ -29,127 +23,6 @@ providers:
29
23
  google-image:
30
24
  maxConcurrent: 3
31
25
  llm:
32
- - provider: claude-code
33
- id: opus
34
- name: Claude Opus (latest)
35
- source: https://code.claude.com/docs/en/model-config
36
- keywords:
37
- - llm
38
- pricing: &a1
39
- note: Uses your CLI login and account plan. No per-run price is available; paid
40
- overage may apply.
41
- llm:
42
- webSearch: true
43
- - provider: claude-code
44
- id: sonnet
45
- name: Claude Sonnet (latest)
46
- source: https://code.claude.com/docs/en/model-config
47
- keywords:
48
- - llm
49
- pricing: *a1
50
- llm:
51
- webSearch: true
52
- - provider: claude-code
53
- id: haiku
54
- name: Claude Haiku (latest)
55
- source: https://code.claude.com/docs/en/model-config
56
- keywords:
57
- - llm
58
- pricing: *a1
59
- llm:
60
- webSearch: true
61
- - provider: codex
62
- id: gpt-6-astra
63
- name: GPT-6-Astra
64
- source: https://developers.openai.com/codex/models
65
- keywords:
66
- - llm
67
- pricing: *a1
68
- llm:
69
- webSearch: true
70
- thinking: &a2
71
- low:
72
- effort: low
73
- medium:
74
- effort: medium
75
- high:
76
- effort: high
77
- - provider: codex
78
- id: gpt-5.6-sol
79
- name: GPT-5.6-Sol
80
- source: https://developers.openai.com/codex/models
81
- keywords:
82
- - llm
83
- pricing: *a1
84
- llm:
85
- webSearch: true
86
- thinking: *a2
87
- - provider: codex
88
- id: gpt-5.6-terra
89
- name: GPT-5.6-Terra
90
- source: https://developers.openai.com/codex/models
91
- keywords:
92
- - llm
93
- pricing: *a1
94
- llm:
95
- webSearch: true
96
- thinking: *a2
97
- - provider: codex
98
- id: gpt-5.6-luna
99
- name: GPT-5.6-Luna
100
- source: https://developers.openai.com/codex/models
101
- keywords:
102
- - llm
103
- pricing: *a1
104
- llm:
105
- webSearch: true
106
- thinking: *a2
107
- - provider: codex
108
- id: gpt-5.5
109
- name: GPT-5.5
110
- source: https://developers.openai.com/codex/models
111
- keywords:
112
- - llm
113
- pricing: *a1
114
- llm:
115
- webSearch: true
116
- thinking: *a2
117
- - provider: codex
118
- id: gpt-5.3-codex-spark
119
- name: GPT-5.3-Codex-Spark
120
- source: https://developers.openai.com/codex/models
121
- keywords:
122
- - llm
123
- pricing: *a1
124
- llm:
125
- webSearch: true
126
- thinking: *a2
127
- - provider: gemini
128
- id: gemini-3.8-flash
129
- name: Gemini 3.8 Flash
130
- source: https://ai.google.dev/gemini-api/docs/models
131
- keywords:
132
- - llm
133
- pricing: *a1
134
- llm:
135
- webSearch: true
136
- thinking: &a3
137
- low:
138
- level: low
139
- medium:
140
- level: medium
141
- high:
142
- level: high
143
- - provider: gemini
144
- id: gemini-3.1-pro-preview
145
- name: Gemini 3.1 Pro (preview)
146
- source: https://ai.google.dev/gemini-api/docs/models
147
- keywords:
148
- - llm
149
- pricing: *a1
150
- llm:
151
- webSearch: true
152
- thinking: *a3
153
26
  - provider: openrouter
154
27
  id: google/gemini-3.8-flash
155
28
  name: "Google: Gemini 3.8 Flash"
@@ -164,7 +37,13 @@ llm:
164
37
  llm:
165
38
  webSearch: true
166
39
  contextTokens: 1048576
167
- thinking: *a2
40
+ thinking: &a2
41
+ low:
42
+ effort: low
43
+ medium:
44
+ effort: medium
45
+ high:
46
+ effort: high
168
47
  - provider: openrouter
169
48
  id: google/gemini-3.1-pro-preview
170
49
  name: "Google: Gemini 3.1 Pro Preview"
@@ -1,4 +1,5 @@
1
1
  import { providerError } from "../kernel/ports/model.js";
2
+ import { isLocalCliProvider } from "../slices/settings/model.js";
2
3
  export function curateRegistry(registry, catalogue) {
3
4
  const requireModel = (provider, family, id) => {
4
5
  const models = catalogue.models(provider, family);
@@ -14,6 +15,49 @@ export function curateRegistry(registry, catalogue) {
14
15
  list: registry.list,
15
16
  llm: (id) => {
16
17
  const port = registry.llm(id);
18
+ if (isLocalCliProvider(id)) {
19
+ return {
20
+ ...port,
21
+ models: () => port.models(),
22
+ complete: async function* (request) {
23
+ if (request.model.trim() === "")
24
+ throw providerError({ kind: "unsupported", message: "Choose a model ID." });
25
+ let models;
26
+ try {
27
+ models = await port.models();
28
+ }
29
+ catch {
30
+ // A failed metadata read permits an exact manual ID; generation still
31
+ // decides whether the installed CLI/account accepts it.
32
+ }
33
+ const selected = models?.find((model) => model.id === request.model);
34
+ if (models !== undefined && selected === undefined)
35
+ throw providerError({
36
+ kind: "unsupported",
37
+ message: "This model is not available in the installed CLI.",
38
+ });
39
+ if (request.thinking !== undefined &&
40
+ selected !== undefined &&
41
+ !selected.thinkingModes?.includes(request.thinking))
42
+ throw providerError({
43
+ kind: "unsupported",
44
+ message: "This model does not support the selected thinking setting.",
45
+ });
46
+ if (request.webSearch && !port.capabilities.webSearch)
47
+ throw providerError({
48
+ kind: "unsupported",
49
+ message: "This CLI does not support research web search.",
50
+ });
51
+ const thinkingConfig = id === "codex" && request.thinking !== undefined
52
+ ? { effort: request.thinking === "off" ? "none" : request.thinking }
53
+ : undefined;
54
+ yield* port.complete({
55
+ ...request,
56
+ ...(request.thinkingConfig === undefined && thinkingConfig ? { thinkingConfig } : {}),
57
+ });
58
+ },
59
+ };
60
+ }
17
61
  return {
18
62
  ...port,
19
63
  models: async () => catalogue.models(id, "llm").map((m) => ({
@@ -48,6 +92,20 @@ export function curateRegistry(registry, catalogue) {
48
92
  },
49
93
  image: (id) => {
50
94
  const port = registry.image(id);
95
+ if (id === "codex-image") {
96
+ return {
97
+ ...port,
98
+ models: () => port.models(),
99
+ generate: async (request) => {
100
+ if (!(await port.models()).some((model) => model.id === request.model))
101
+ throw providerError({
102
+ kind: "unsupported",
103
+ message: "The Codex image capability is unavailable.",
104
+ });
105
+ return port.generate(request);
106
+ },
107
+ };
108
+ }
51
109
  return {
52
110
  ...port,
53
111
  models: async () => catalogue.models(id, "image"),
@@ -0,0 +1,16 @@
1
+ import { isLocalCliProvider } from "../slices/settings/model.js";
2
+ export async function checkRuntimeModel(modelsFor, provider, family, id, thinking) {
3
+ if (id.trim() === "")
4
+ return "missing";
5
+ try {
6
+ const model = (await modelsFor(provider, family)).find((row) => row.id === id);
7
+ if (model === undefined)
8
+ return "missing";
9
+ if (family === "llm" && thinking !== undefined && !model.thinkingModes?.includes(thinking))
10
+ return "thinking";
11
+ return "available";
12
+ }
13
+ catch {
14
+ return isLocalCliProvider(provider) && family === "llm" ? "manual" : "missing";
15
+ }
16
+ }
@@ -1,12 +1,20 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse } from "yaml";
4
+ import { isLocalCliProvider } from "../slices/settings/model.js";
4
5
  import { catalogueSchema } from "./schema.js";
5
6
  export const catalogueSource = "https://raw.githubusercontent.com/GentBajko/slopify/main/packages/app/src/assets/models.yaml";
6
7
  export function parseCatalogue(text) {
7
8
  if (Buffer.byteLength(text) > 1024 * 1024)
8
9
  throw new Error("Model catalogue exceeds 1 MB");
9
- return catalogueSchema.parse(parse(text, { maxAliasCount: 20, uniqueKeys: true }));
10
+ const parsed = catalogueSchema.parse(parse(text, { maxAliasCount: 20, uniqueKeys: true }));
11
+ return {
12
+ ...parsed,
13
+ providers: Object.fromEntries(Object.entries(parsed.providers).filter(([id]) => !isLocalCliProvider(id))),
14
+ llm: parsed.llm.filter((row) => !isLocalCliProvider(row.provider)),
15
+ image: parsed.image.filter((row) => !isLocalCliProvider(row.provider)),
16
+ tts: parsed.tts.filter((row) => !isLocalCliProvider(row.provider)),
17
+ };
10
18
  }
11
19
  export function createCatalogueStore(deps) {
12
20
  const path = join(deps.dataDir, "models.yaml");
@@ -1,3 +1,4 @@
1
+ import { isLocalCliProvider } from "../slices/settings/model.js";
1
2
  export function modelFields(draft, catalogue) {
2
3
  if (!catalogue)
3
4
  return [];
@@ -24,7 +25,7 @@ export function modelFields(draft, catalogue) {
24
25
  },
25
26
  ];
26
27
  for (const { field, family, choice, needed } of checks) {
27
- if (!needed || !choice)
28
+ if (!needed || !choice || isLocalCliProvider(choice.provider))
28
29
  continue;
29
30
  const model = catalogue.models(choice.provider, family).find((m) => m.id === choice.model);
30
31
  if (!model)
@@ -3,7 +3,7 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { cliPathMaxLength, saveCliPath } from "../../slices/settings/cli-paths.js";
5
5
  import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
6
- import { providerById, providerIds } from "../../slices/settings/model.js";
6
+ import { isLocalCliProvider, providerById, providerIds } from "../../slices/settings/model.js";
7
7
  import { createModelCatalog } from "../../slices/settings/models.js";
8
8
  import { providerStatuses } from "../../slices/settings/readiness.js";
9
9
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -46,7 +46,7 @@ export function providerRoutes(deps) {
46
46
  .get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
47
47
  .get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
48
48
  const { id } = c.req.valid("param");
49
- if (deps.catalogue)
49
+ if (deps.catalogue && !isLocalCliProvider(id))
50
50
  return c.json({
51
51
  models: deps.catalogue.models(id, providerById(id).family).map((m) => ({
52
52
  ...m,
package/dist/main.js CHANGED
@@ -43,6 +43,7 @@ import { executionStages, executionStandings, invocationReady, projectStandings,
43
43
  import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
44
44
  import { createScheduleRunner } from "./slices/schedules/scheduler.js";
45
45
  import { nodeCliProbe } from "./slices/settings/cli-status.js";
46
+ import { isLocalCliProvider } from "./slices/settings/model.js";
46
47
  import { providerStatuses } from "./slices/settings/readiness.js";
47
48
  import { reconcileStorage } from "./slices/storage/reconcile.js";
48
49
  import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-client.js";
@@ -407,7 +408,7 @@ export function wireRunner({ audioPreviews, db, paths, clock, ids, log, hub, tel
407
408
  attempts: sqliteAttempts(db, ids),
408
409
  clock,
409
410
  log,
410
- queue: createProviderQueue((provider) => catalogue.read().providers[provider]?.maxConcurrent ?? 1),
411
+ queue: createProviderQueue((provider) => isLocalCliProvider(provider) ? 3 : (catalogue.read().providers[provider]?.maxConcurrent ?? 1)),
411
412
  };
412
413
  const checkpoints = createCheckpointAuthority({
413
414
  decide: (work) => checkpointDecisionForWork(execution, work),
@@ -2,9 +2,6 @@ import { falModels } from "./adapters/image/fal.js";
2
2
  import { googleImageModels } from "./adapters/image/google.js";
3
3
  import { openAiImageModels } from "./adapters/image/openai.js";
4
4
  import { replicateModels } from "./adapters/image/replicate.js";
5
- import { claudeCodeModels } from "./adapters/llm/claude-code.js";
6
- import { codexModels } from "./adapters/llm/codex.js";
7
- import { geminiModels } from "./adapters/llm/gemini.js";
8
5
  import { cartesiaModels } from "./adapters/tts/cartesia.js";
9
6
  import { elevenLabsModels } from "./adapters/tts/elevenlabs.js";
10
7
  import { inworldModels } from "./adapters/tts/inworld.js";
@@ -12,9 +9,6 @@ import { openAiTtsModels } from "./adapters/tts/openai.js";
12
9
  // Composition keeps provider-specific fallback data out of the HTTP and settings layers.
13
10
  export function modelSources(registry) {
14
11
  const fallbacks = {
15
- "claude-code": claudeCodeModels,
16
- codex: codexModels,
17
- gemini: geminiModels,
18
12
  elevenlabs: elevenLabsModels,
19
13
  "openai-tts": openAiTtsModels,
20
14
  cartesia: cartesiaModels,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
3
  import { thinkingModes } from "../../kernel/ports/llm.js";
3
4
  import { readinessIsUsable, } from "../../kernel/ports/model.js";
4
5
  import { chunkModes } from "../narration/chunk.js";
@@ -64,14 +65,11 @@ export async function validateProviderChanges(changes, current) {
64
65
  continue;
65
66
  if (current.allowsCustomModels?.(picked.provider) === true)
66
67
  continue;
67
- const models = await current.modelsFor(picked.provider, families[key]);
68
- const model = models.find((model) => model.id === picked.model);
69
- if (!model) {
68
+ const result = await checkRuntimeModel(current.modelsFor, picked.provider, families[key], picked.model, picked.thinking);
69
+ if (result === "missing") {
70
70
  fields.push({ field: `${key}.model`, message: "Choose a model supported by this provider." });
71
71
  }
72
- else if (key === "llm" &&
73
- picked.thinking &&
74
- !model.thinkingModes?.includes(picked.thinking)) {
72
+ else if (result === "thinking") {
75
73
  fields.push({ field: `${key}.thinking`, message: "Choose a supported thinking setting." });
76
74
  }
77
75
  }
@@ -1,8 +1,9 @@
1
+ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
1
2
  import { modelFields } from "../../catalog/validate.js";
2
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
3
4
  import { cliPathStatus } from "../settings/cli-paths.js";
4
5
  import { hasKey, listVoices } from "../settings/repo.js";
5
- function choices(runs) {
6
+ export function choices(runs) {
6
7
  const selected = runs
7
8
  .flatMap(({ draft: d }) => [
8
9
  {
@@ -49,12 +50,17 @@ export async function checkDraftReadiness(deps, runs) {
49
50
  });
50
51
  continue;
51
52
  }
52
- const models = await deps.modelsFor(c.provider, c.family);
53
- if (!models.some((m) => m.id === c.model))
53
+ const result = await checkRuntimeModel(deps.modelsFor, c.provider, c.family, c.model, c.thinking);
54
+ if (result === "missing")
54
55
  fields.push({
55
56
  field: `${c.field}.model`,
56
57
  message: "Choose an available model before starting.",
57
58
  });
59
+ else if (result === "thinking")
60
+ fields.push({
61
+ field: `${c.field}.thinking`,
62
+ message: "Choose a supported thinking setting.",
63
+ });
58
64
  }
59
65
  return { fields: [...fields, ...localDraftReadiness(deps, runs, providers)], providers };
60
66
  }
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
+ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
3
  import { transact } from "../../kernel/db/tx.js";
3
4
  import { isMissingFont } from "../fonts/index.js";
5
+ import { choices } from "./readiness.js";
4
6
  import { draftRow } from "./repo.js";
5
7
  import { resolveReviewInputs, reviewBinding, reviewRefusal } from "./review-inputs.js";
6
8
  import { draftViewSchema, playReviewSchema } from "./schema.js";
@@ -33,6 +35,22 @@ export async function resolvePlayReview(deps, view) {
33
35
  const after = resolveReviewInputs(deps, parsed.data, font);
34
36
  if (!after.ok)
35
37
  return after;
38
+ const fields = [];
39
+ for (const choice of choices(after.value.runs)) {
40
+ const result = await checkRuntimeModel(deps.modelsFor, choice.provider, choice.family, choice.model, choice.thinking);
41
+ if (result === "missing")
42
+ fields.push({
43
+ field: `${choice.field}.model`,
44
+ message: "Choose an available model before reviewing.",
45
+ });
46
+ else if (result === "thinking")
47
+ fields.push({
48
+ field: `${choice.field}.thinking`,
49
+ message: "Choose a supported thinking setting.",
50
+ });
51
+ }
52
+ if (fields.length)
53
+ return reviewRefusal(view, fields);
36
54
  return reviewBinding(before.value) === reviewBinding(after.value)
37
55
  ? after
38
56
  : reviewRefusal(view, [], "stale-review");
@@ -1,5 +1,7 @@
1
+ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
1
2
  import { readinessIsUsable } from "../../kernel/ports/model.js";
2
3
  import { cliPathStatus } from "../settings/cli-paths.js";
4
+ import { isLocalCliProvider } from "../settings/model.js";
3
5
  import { hasKey, listVoices } from "../settings/repo.js";
4
6
  import { requiresNewSubmission } from "./preview-retained.js";
5
7
  import { recipeProviderChoice } from "./recipe-provider-choice.js";
@@ -36,12 +38,17 @@ export async function checkReadiness(deps, snapshot, view) {
36
38
  });
37
39
  continue;
38
40
  }
39
- const models = await deps.modelsFor(choice.provider, choice.family);
40
- if (!models.some((row) => row.id === choice.model))
41
+ const result = await checkRuntimeModel(deps.modelsFor, choice.provider, choice.family, choice.model, choice.family === "llm" ? (choice.thinking ?? undefined) : undefined);
42
+ if (result === "missing")
41
43
  fields.push({
42
44
  field: `${choice.family}.model`,
43
45
  message: "Choose an available model before rebuilding.",
44
46
  });
47
+ else if (result === "thinking")
48
+ fields.push({
49
+ field: `${choice.family}.thinking`,
50
+ message: "Choose a supported thinking setting.",
51
+ });
45
52
  }
46
53
  return { fields, providers };
47
54
  }
@@ -52,7 +59,7 @@ export function localReadiness(deps, snapshot, view, providers, catalogue) {
52
59
  row.id === choice.model &&
53
60
  row.enabled &&
54
61
  !row.deprecated);
55
- if (model === undefined)
62
+ if (model === undefined && !isLocalCliProvider(choice.provider))
56
63
  fields.push({
57
64
  field: `${choice.family}.model`,
58
65
  message: "The selected model is no longer available.",
@@ -85,6 +92,8 @@ export function localReadiness(deps, snapshot, view, providers, catalogue) {
85
92
  });
86
93
  }
87
94
  else if (input.kind === "llm") {
95
+ if (isLocalCliProvider(input.provider))
96
+ continue;
88
97
  const model = catalogue.llm.find((row) => row.provider === input.provider && row.id === input.model);
89
98
  if (input.thinking !== null && model?.llm.thinking?.[input.thinking] === undefined)
90
99
  fields.push({
@@ -18,6 +18,9 @@ export const providerIds = [
18
18
  "openai-image",
19
19
  "google-image",
20
20
  ];
21
+ export function isLocalCliProvider(id) {
22
+ return id === "claude-code" || id === "codex" || id === "gemini" || id === "codex-image";
23
+ }
21
24
  export const providers = [
22
25
  { id: "openrouter", family: "llm", displayName: "OpenRouter", auth: "key" },
23
26
  {
@@ -1,7 +1,8 @@
1
+ import { isLocalCliProvider } from "./model.js";
1
2
  // These marketplaces host models with different input schemas. Discovery alone cannot
2
3
  // make an arbitrary endpoint compatible with our image request.
3
4
  export function allowsCustomModel(provider) {
4
- return provider !== "fal" && provider !== "replicate";
5
+ return provider !== "fal" && provider !== "replicate" && provider !== "codex-image";
5
6
  }
6
7
  const ttlMs = 5 * 60_000;
7
8
  const retryMs = 30_000;
@@ -33,14 +34,19 @@ export function createModelCatalog(deps) {
33
34
  // A catalogue is metadata, not generation. Never expose an upstream response
34
35
  // here: it can quote credentials that belong only inside its adapter.
35
36
  deps.report(provider);
36
- const saved = previous?.value.models;
37
+ const local = isLocalCliProvider(provider);
38
+ const saved = local ? undefined : previous?.value.models;
37
39
  value = {
38
- models: saved ?? deps.fallback(provider),
40
+ models: local ? [] : (saved ?? deps.fallback(provider)),
39
41
  ...catalogNotice(provider),
40
42
  allowsCustom: allowsCustomModel(provider),
41
- warning: saved === undefined
42
- ? "Could not load the model list. Showing bundled choices; refresh to try again."
43
- : "Could not refresh the model list. Showing the last loaded choices.",
43
+ warning: local
44
+ ? provider === "codex-image"
45
+ ? "Could not check the Codex image capability. Refresh after checking the CLI."
46
+ : "Could not discover models from this CLI. Enter an exact model ID manually or refresh."
47
+ : saved === undefined
48
+ ? "Could not load the model list. Showing bundled choices; refresh to try again."
49
+ : "Could not refresh the model list. Showing the last loaded choices.",
44
50
  };
45
51
  lifetime = retryMs;
46
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gentbajko/slopify",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Turn a prompt into a finished video, locally, with your own provider keys",
5
5
  "license": "MIT",
6
6
  "author": "Gent Bajko",