scalemax 0.1.48 → 0.1.50

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 CHANGED
@@ -78,6 +78,7 @@ scalemax doctor Run diagnostics
78
78
  scalemax models List available ScaleMax models
79
79
  scalemax helper-bot Open the ScaleMax-only support assistant
80
80
  scalemax claude-tools Install bounded GitHub skill tools for Claude
81
+ scalemax fix-roo Fix Roo Code stuck at "API Request…" on recent VS Code
81
82
  scalemax update Reconfigure detected clients with the stored key
82
83
  scalemax remove Remove ScaleMax-generated client configuration
83
84
  scalemax --help Show help
@@ -150,11 +151,11 @@ For clients without a stable official config file, the installer writes a ScaleM
150
151
 
151
152
  ### Codex
152
153
 
153
- ScaleMax configures the official Codex custom-provider interface in `~/.codex/config.toml` with `wire_api = "responses"`, and stores the selected key in Codex's private `~/.codex/auth.json`. Detection is semantic: unrelated or ChatGPT-authenticated Codex files are not reported as ScaleMax-configured. Only authenticated Codex/OpenAI-family models are eligible, the generated files are private, and verification performs a minimal `/v1/responses` request before reporting success.
154
+ ScaleMax configures the official Codex custom-provider interface in `~/.codex/config.toml` with `wire_api = "responses"`, and stores the selected key in Codex's private `~/.codex/auth.json`. Detection is semantic: unrelated or ChatGPT-authenticated Codex files are not reported as ScaleMax-configured. Only GPT models from the authenticated catalog are eligible (`gpt-5.6-terra` first, or `auto` on an Auto key); a key with no GPT model is told so and Codex is left unchanged. The generated files are private, and verification performs a minimal `/v1/responses` request before reporting success.
154
155
 
155
156
  ### Claude Code
156
157
 
157
- ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, gateway model discovery, and a cached `apiKeyHelper` that reads a private `~/.claude/scalemax.key` file. The raw key is not stored in the settings JSON. Because Claude Code's gateway discovery only accepts IDs beginning with `claude` or `anthropic`, catalogs that include Fable or Auto also receive a managed custom picker entry (`ANTHROPIC_CUSTOM_MODEL_OPTION=fable-5` or `auto`, with friendly names and descriptions), making `/model fable-5` and `/model auto` selectable; any prior custom picker values are restored when ScaleMax configuration is removed. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live main model from the authenticated catalog (preferring `claude-opus-5`), pins authenticated Haiku for lightweight background work when available, and sets `CLAUDE_CODE_DISABLE_THINKING=1` because third-party Anthropic-compatible providers do not support Claude Code's proprietary extended-thinking control envelope. The ScaleMax gateway separately locks native Claude requests to provider-side `low` effort. Fable uses its verified translated controls and supports `low`, `medium`, `high`, and `max`, with `low` as the default. Haiku 4.5 omits the unsupported effort field. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-5[1m]` are normalized by the gateway.
158
+ ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, gateway model discovery, and a cached `apiKeyHelper` that reads a private `~/.claude/scalemax.key` file. The raw key is not stored in the settings JSON. Because Claude Code's gateway discovery only accepts IDs beginning with `claude` or `anthropic`, catalogs that include Fable or Auto also receive a managed custom picker entry (`ANTHROPIC_CUSTOM_MODEL_OPTION=fable-5` or `auto`, with friendly names and descriptions), making `/model fable-5` and `/model auto` selectable; any prior custom picker values are restored when ScaleMax configuration is removed. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings and selects the main model from the key's own Claude models (Opus 5.5, then Opus 5, Sonnet 5.5, Sonnet 5, older Claude; never the `claude-sonnet-4-6` Claude Desktop alias or another provider's model). The background model (`ANTHROPIC_DEFAULT_HAIKU_MODEL`) is Haiku when the key lists it, otherwise the cheapest Claude it lists, so Claude Code never falls back to a Haiku id the key cannot use. The picker entry prefers an image-capable model; Fable 5.1 is labelled text only. The CLI does not write `CLAUDE_CODE_DISABLE_THINKING`: the ScaleMax gateway strips Claude Code's `thinking` control itself and locks native Claude requests to provider-side `low` effort. Fable uses its verified translated controls and supports `low`, `medium`, `high`, and `max`, with `low` as the default. Haiku 4.5 omits the unsupported effort field. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-5[1m]` are normalized by the gateway.
158
159
 
159
160
  ### OpenCode
160
161
 
@@ -170,7 +171,9 @@ ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and
170
171
 
171
172
  ### Roo Code (VS Code)
172
173
 
173
- Roo Code stores provider profiles and secrets in VS Code extension storage, so ScaleMax does not mutate its private database. Instead, the CLI writes a private `0600` Roo settings-import file at `~/.scalemax/roo-code-import.json` and points the supported `roo-cline.autoImportSettingsPath` VS Code setting at it. Codex, Claude, and tool-capable DeepSeek profiles remain separate. DeepSeek is offered only when authenticated discovery reports `capabilities.tools=true`; otherwise Roo fails closed and points the user to Continue Chat/Edit. Reload VS Code once so Roo imports supported profiles into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
174
+ Roo Code stores provider profiles and secrets in VS Code extension storage, so ScaleMax does not mutate its private database. Instead, the CLI writes a private `0600` Roo settings-import file at `~/.scalemax/roo-code-import.json` and points the supported `roo-cline.autoImportSettingsPath` VS Code setting at it. Codex, Claude, and tool-capable DeepSeek profiles remain separate; the Codex profile is a GPT model on OpenAI keys and a strong Local model on Local Models keys, never the `claude-sonnet-4-6` alias, and every profile asks for at most 32,768 output tokens. DeepSeek is offered only when authenticated discovery reports `capabilities.tools=true`; otherwise Roo fails closed and points the user to Continue Chat/Edit. Reload VS Code once so Roo imports supported profiles into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
175
+
176
+ Roo Code 3.54 (its latest release) looks for ripgrep only in folders newer VS Code releases no longer use, so on recent VS Code versions every task hangs at "API Request…" with "Could not find ripgrep binary". Configuring Roo Code also patches the installed extension's `dist/extension.js` (in the editor's per-user extensions folder, never the VS Code app) so it tries `node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/<platform>-<arch>/` first and then its original folders. The untouched file is kept once as `extension.js.scalemax-orig`. Run `npx scalemax@latest fix-roo` to apply only this fix (VS Code, Insiders, Cursor, Windsurf, VSCodium, Trae, Kiro, and remote servers), `scalemax fix-roo --undo` to restore the original, and reload VS Code afterwards. `scalemax doctor` flags installs that still need it.
174
177
 
175
178
  ### Cline (CLI and VS Code)
176
179
 
@@ -3,6 +3,8 @@ import { ClientId, ConfigureResult } from '../types/client.js';
3
3
  export declare function detectAll(home?: string): Promise<import("../types/client.js").DetectionResult[]>;
4
4
  export declare function printDetected(home?: string): Promise<import("../types/client.js").DetectionResult[]>;
5
5
  export declare function clientAllowedForModels(clientId: ClientId, models: ModelInfo[]): boolean;
6
+ /** Why a detected client is not offered for this key, when the reason is worth saying. */
7
+ export declare function clientUnavailableReason(clientId: ClientId, models: ModelInfo[]): string | undefined;
6
8
  export declare function rollbackAfterVerificationFailure(result: ConfigureResult, error: unknown): Promise<never>;
7
9
  export declare function configureCommand(opts?: {
8
10
  all?: boolean;
@@ -4,9 +4,10 @@ import ora from 'ora';
4
4
  import { clients } from '../clients.js';
5
5
  import { SCALEMAX_BASE_URL } from '../constants.js';
6
6
  import { loadApiKey } from '../utils/keyStore.js';
7
- import { validateApiKey, isAnthropicModel, isModelRouteAvailable, AUTO_MODEL_ID, probeWorkingModels, pinClaudeAliasBackend, claudeAliasBackendPinTarget } from '../utils/api.js';
7
+ import { validateApiKey, isAnthropicModel, isChatModel, isModelRouteAvailable, AUTO_MODEL_ID, probeWorkingModels } from '../utils/api.js';
8
+ import { saveModelCheck } from '../utils/modelCheck.js';
8
9
  import { homeDir } from '../utils/paths.js';
9
- import { isExactChatCompletionsModelId } from '../configure/models.js';
10
+ import { CLAUDE_CODE_NEEDS_CLAUDE_MESSAGE, CODEX_NEEDS_GPT_MESSAGE, codexModelIds, defaultModelIdsFor, isExactChatCompletionsModelId, modelIdKey } from '../configure/models.js';
10
11
  import { isContinueEligibleModelId } from '../configure/continue.js';
11
12
  export async function detectAll(home = homeDir()) {
12
13
  const ctx = { home, platform: process.platform, env: process.env };
@@ -57,10 +58,12 @@ async function maybeLaunchCodex(chosen, successful, baseUrl = SCALEMAX_BASE_URL)
57
58
  * for "did Anthropic make this."
58
59
  */
59
60
  function reachableByClaudeCode(model) {
60
- return isAnthropicModel(model) || model.id === AUTO_MODEL_ID;
61
+ return isAnthropicModel(model) || modelIdKey(model.id) === AUTO_MODEL_ID;
61
62
  }
62
63
  export function clientAllowedForModels(clientId, models) {
63
- models = models.filter(isModelRouteAvailable);
64
+ // Image, video, embedding and voice rows share the catalog; an image model
65
+ // owned by `scalemax` must not make Codex look usable on a Claude-only key.
66
+ models = models.filter(model => isModelRouteAvailable(model) && isChatModel(model));
64
67
  if (!models.length)
65
68
  return false;
66
69
  // These clients use either a native family-specific adapter or the gateway's
@@ -72,8 +75,11 @@ export function clientAllowedForModels(clientId, models) {
72
75
  return models.some(model => isContinueEligibleModelId(model.id));
73
76
  if (clientId === 'claude')
74
77
  return models.some(reachableByClaudeCode);
78
+ // Codex runs on GPT models only (and on Auto for an Auto key). A Claude id,
79
+ // the `claude-sonnet-4-6` alias, DeepSeek or a free preview model failed
80
+ // every Codex request it was configured with.
75
81
  if (clientId === 'codex')
76
- return models.some(model => !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id));
82
+ return codexModelIds({ models }).length > 0 || models.some(model => modelIdKey(model.id) === AUTO_MODEL_ID);
77
83
  if (clientId === 'hermes' && models.every(model => isExactChatCompletionsModelId(model.id)))
78
84
  return models.some(model => model.capabilities?.tools === true);
79
85
  // Roo is an agent, not a text-only chat surface. Its task loop depends on
@@ -85,6 +91,16 @@ export function clientAllowedForModels(clientId, models) {
85
91
  return true;
86
92
  return models.some(model => !isAnthropicModel(model));
87
93
  }
94
+ /** Why a detected client is not offered for this key, when the reason is worth saying. */
95
+ export function clientUnavailableReason(clientId, models) {
96
+ if (clientAllowedForModels(clientId, models))
97
+ return undefined;
98
+ if (clientId === 'codex')
99
+ return CODEX_NEEDS_GPT_MESSAGE.replace('was not set up', 'is not offered');
100
+ if (clientId === 'claude')
101
+ return CLAUDE_CODE_NEEDS_CLAUDE_MESSAGE.replace('was not set up', 'is not offered');
102
+ return undefined;
103
+ }
88
104
  export async function rollbackAfterVerificationFailure(result, error) {
89
105
  if (!result.changed)
90
106
  throw error;
@@ -131,27 +147,17 @@ export async function configureCommand(opts = {}) {
131
147
  throw new Error(keyInfo.error || 'ScaleMax returned no models for this API key. No client configuration was changed.');
132
148
  const providerScope = keyInfo.key?.provider_scope || 'both';
133
149
  const selectedBaseUrl = keyInfo.baseUrl || SCALEMAX_BASE_URL;
134
- // LIVE-PROBE the key's models with a small request and keep only the ones
135
- // that actually answer, so a client is configured with working models and
136
- // defaults to a working one. Bounded (concurrency + total time); if nothing
137
- // probes clean we KEEP the entitlement-filtered list rather than fail — a
138
- // slow/degraded upstream must not leave the user with zero models.
139
- try {
140
- const probeSpinner = ora('Checking which models respond').start();
141
- const probe = await probeWorkingModels({ apiKey, baseUrl: selectedBaseUrl, models: keyInfo.models, providerScope });
142
- if (probe.models.length && !probe.fallback) {
143
- keyInfo.models = probe.models;
144
- probeSpinner.succeed(`${probe.models.length}/${probe.probedCount} models responded`);
145
- }
146
- else {
147
- probeSpinner.warn(probe.warning || 'No model answered the probe in time; using the full authenticated catalog');
148
- }
149
- }
150
- catch { /* probing is best-effort; never blocks setup */ }
151
150
  const availableClients = clients.filter(c => clientAllowedForModels(c.id, keyInfo.models));
152
151
  console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}. Clients are filtered against the authenticated model catalog.`));
153
152
  const detections = await printDetected(home);
154
153
  const detectedIds = new Set(detections.filter(d => d.installed).map(d => d.id));
154
+ for (const client of clients) {
155
+ if (!detectedIds.has(client.id))
156
+ continue;
157
+ const reason = clientUnavailableReason(client.id, keyInfo.models);
158
+ if (reason)
159
+ console.log(chalk.yellow(`${client.name}: ${reason}`));
160
+ }
155
161
  let chosen;
156
162
  if (opts.all)
157
163
  chosen = availableClients.filter(c => detectedIds.has(c.id)).map(c => c.id);
@@ -170,6 +176,35 @@ export async function configureCommand(opts = {}) {
170
176
  console.log(chalk.yellow('No detected clients to configure.'));
171
177
  return;
172
178
  }
179
+ // Check, with one small request each, the models setup is about to write
180
+ // as defaults for the chosen and detected clients. A default that fails is
181
+ // skipped (the next pick for that slot is checked instead); every entitled
182
+ // model stays in the lists written to clients. Bounded in count and time,
183
+ // and if nothing answers nothing is skipped: a slow upstream or an empty
184
+ // balance must not leave a client with no model.
185
+ let avoidModelIds = [];
186
+ const probeClients = [...new Set([...chosen, ...availableClients.filter(c => detectedIds.has(c.id)).map(c => c.id)])];
187
+ try {
188
+ const probeSpinner = ora('Checking the default models (one small request each)').start();
189
+ const probe = await probeWorkingModels({
190
+ apiKey,
191
+ baseUrl: selectedBaseUrl,
192
+ models: keyInfo.models,
193
+ providerScope,
194
+ targets: models => defaultModelIdsFor({ models, providerScope }, probeClients),
195
+ });
196
+ if (probe.fallback)
197
+ probeSpinner.warn(probe.warning || 'No default model answered in time; nothing was skipped.');
198
+ else if (probe.failedIds.length) {
199
+ avoidModelIds = probe.failedIds;
200
+ probeSpinner.warn(`${probe.workingCount}/${probe.probedCount} default models answered; skipped ${probe.failedIds.join(', ')} for now`);
201
+ }
202
+ else
203
+ probeSpinner.succeed(`${probe.workingCount}/${probe.probedCount} default models answered`);
204
+ // doctor reads this so it expects the same defaults setup wrote.
205
+ await saveModelCheck(home, apiKey, avoidModelIds).catch(() => undefined);
206
+ }
207
+ catch { /* checking is best-effort; never blocks setup */ }
173
208
  const successful = new Set();
174
209
  const failures = [];
175
210
  for (const client of availableClients.filter(c => chosen.includes(c.id))) {
@@ -206,9 +241,11 @@ export async function configureCommand(opts = {}) {
206
241
  }
207
242
  const spinner = ora(`Configuring ${client.name}`).start();
208
243
  try {
209
- const ctx = { ...baseCtx, apiKey, baseUrl: selectedBaseUrl, providerScope, models: keyInfo.models, interactive: Boolean(process.stdin.isTTY), allowAppModification };
244
+ const ctx = { ...baseCtx, apiKey, baseUrl: selectedBaseUrl, providerScope, models: keyInfo.models, avoidModelIds, interactive: Boolean(process.stdin.isTTY), allowAppModification };
210
245
  const res = await client.configure(ctx);
211
246
  spinner.succeed(`${client.name} ${res.message}`);
247
+ if (res.note)
248
+ console.log(chalk.dim(` ${res.note}`));
212
249
  if (client.verify) {
213
250
  const verify = ora(`Verifying ${client.name}`).start();
214
251
  try {
@@ -231,21 +268,6 @@ export async function configureCommand(opts = {}) {
231
268
  failures.push(`${client.name}: ${message}`);
232
269
  }
233
270
  }
234
- // Pin the working default as this key's `claude-sonnet-4-6` backend so Claude
235
- // Desktop (which always sends that id) routes to a model this key can serve.
236
- // Best-effort: an older gateway without the endpoint, or a scope that already
237
- // has a fixed default, is skipped silently. Never fails the run.
238
- if (successful.size > 0) {
239
- const target = claudeAliasBackendPinTarget(keyInfo.models);
240
- if (target) {
241
- try {
242
- const pin = await pinClaudeAliasBackend({ apiKey, baseUrl: selectedBaseUrl, model: target });
243
- if (pin.ok && !pin.skipped)
244
- console.log(chalk.dim(`Claude Desktop's claude-sonnet-4-6 now routes to ${target} on this key.`));
245
- }
246
- catch { /* endpoint absent or transient; ignore */ }
247
- }
248
- }
249
271
  if (opts.promptLaunch !== false && !opts.all && process.stdin.isTTY)
250
272
  await maybeLaunchCodex(chosen, successful, selectedBaseUrl);
251
273
  if (failures.length)
@@ -1 +1,8 @@
1
+ /**
2
+ * Roo Code 3.54 cannot find newer VS Code's relocated ripgrep, so every task
3
+ * hangs at "API Request…". Read-only: reports installs that still need
4
+ * `scalemax fix-roo`. A warning, not a failure, because older VS Code builds
5
+ * still work unpatched.
6
+ */
7
+ export declare function rooRipgrepDoctorLines(home: string): Promise<string[]>;
1
8
  export declare function doctorCommand(home?: string): Promise<void>;
@@ -3,13 +3,42 @@ import ora from 'ora';
3
3
  import { detectAll } from './configure.js';
4
4
  import { loadApiKey } from '../utils/keyStore.js';
5
5
  import { validateApiKey, testStreaming } from '../utils/api.js';
6
+ import { claudeCodeBackgroundModel, fastChatCompletionsModel, preferredChatCompletionsModel } from '../configure/models.js';
7
+ import { loadModelCheck } from '../utils/modelCheck.js';
6
8
  import { homeDir } from '../utils/paths.js';
7
9
  import { clients } from '../clients.js';
8
10
  import { SCALEMAX_BASE_URL } from '../constants.js';
11
+ import { repairRooRipgrep, rooInstallLabel } from '../repair/roo-ripgrep.js';
12
+ /**
13
+ * Roo Code 3.54 cannot find newer VS Code's relocated ripgrep, so every task
14
+ * hangs at "API Request…". Read-only: reports installs that still need
15
+ * `scalemax fix-roo`. A warning, not a failure, because older VS Code builds
16
+ * still work unpatched.
17
+ */
18
+ export async function rooRipgrepDoctorLines(home) {
19
+ const results = await repairRooRipgrep(home, { dryRun: true });
20
+ return results.map(result => {
21
+ const label = rooInstallLabel(result);
22
+ if (result.status === 'patched')
23
+ return `${chalk.yellow('!')} ${label} cannot find ripgrep on recent VS Code, so tasks hang at "API Request…". Run ${chalk.bold('npx scalemax@latest fix-roo')}, then Developer: Reload Window.`;
24
+ if (result.status === 'already_patched')
25
+ return `${chalk.green('✓')} ${label} ripgrep fix for newer VS Code applied`;
26
+ if (result.status === 'unsupported')
27
+ return `${chalk.dim('•')} ${label} ripgrep lookup not recognised; fix-roo not needed`;
28
+ return `${chalk.yellow('!')} ${label} could not be checked for the ripgrep fix: ${result.error}`;
29
+ });
30
+ }
9
31
  export async function doctorCommand(home = homeDir()) {
10
32
  let failures = 0;
11
33
  const key = await loadApiKey(home);
12
34
  let models = [];
35
+ let baseUrl = SCALEMAX_BASE_URL;
36
+ // Setup passes the key's scope to every client (Roo writes its profiles by
37
+ // scope); without it doctor expected a different Roo profile set.
38
+ let providerScope;
39
+ // The models setup's live check skipped, so each client is checked against
40
+ // the same defaults setup wrote (configure/models.ts picks them).
41
+ const avoidModelIds = key ? await loadModelCheck(home, key) : [];
13
42
  if (!key) {
14
43
  console.log(`${chalk.red('✗')} API key missing`);
15
44
  failures++;
@@ -18,11 +47,24 @@ export async function doctorCommand(home = homeDir()) {
18
47
  const s = ora('Checking API key and models').start();
19
48
  const res = await validateApiKey(key);
20
49
  models = res.models;
50
+ baseUrl = res.baseUrl || SCALEMAX_BASE_URL;
51
+ providerScope = res.key?.provider_scope;
21
52
  if (res.ok && models.length) {
22
53
  s.succeed(`API reachable, key valid, ${models.length} models loaded`);
23
54
  console.log(`${chalk.green('✓')} Usage and key metadata accessible through the authenticated API`);
24
55
  const st = ora('Checking streaming (small billable request)').start();
25
- const stream = await testStreaming(key, models.find(m => m.id === 'fable-5')?.id || models[0]?.id, res.baseUrl);
56
+ // Stream a cheap chat default: the catalog also lists image, video and
57
+ // embedding rows (which /chat/completions 404s) and lists the
58
+ // claude-sonnet-4-6 alias first.
59
+ const catalog = { models, avoidModelIds };
60
+ let streamModel;
61
+ try {
62
+ streamModel = fastChatCompletionsModel(catalog) || claudeCodeBackgroundModel(catalog) || preferredChatCompletionsModel(catalog);
63
+ }
64
+ catch {
65
+ streamModel = undefined;
66
+ }
67
+ const stream = await testStreaming(key, streamModel, baseUrl);
26
68
  if (stream.ok)
27
69
  st.succeed('Streaming available');
28
70
  else {
@@ -49,8 +91,7 @@ export async function doctorCommand(home = homeDir()) {
49
91
  }
50
92
  if (client.verify && key && models.length) {
51
93
  try {
52
- const checked = await validateApiKey(key);
53
- const v = await client.verify({ home, platform: process.platform, env: process.env, apiKey: key, baseUrl: checked.baseUrl || SCALEMAX_BASE_URL, models });
94
+ const v = await client.verify({ home, platform: process.platform, env: process.env, apiKey: key, baseUrl, providerScope, models, avoidModelIds });
54
95
  console.log(`${chalk.green('✓')} ${client.name} ${v.message}`);
55
96
  }
56
97
  catch (error) {
@@ -61,6 +102,8 @@ export async function doctorCommand(home = homeDir()) {
61
102
  else
62
103
  console.log(`${chalk.green('✓')} ${client.name} configured (${d.reason || 'detector confirmed'})`);
63
104
  }
105
+ for (const line of await rooRipgrepDoctorLines(home))
106
+ console.log(line);
64
107
  console.log('');
65
108
  if (failures === 0)
66
109
  console.log(chalk.green('Everything looks good!'));
@@ -0,0 +1 @@
1
+ export declare function fixRooCommand(args?: readonly string[], home?: string): Promise<void>;
@@ -0,0 +1,64 @@
1
+ import chalk from 'chalk';
2
+ import { repairRooRipgrep, restoreRooRipgrep, rooInstallLabel } from '../repair/roo-ripgrep.js';
3
+ import { homeDir } from '../utils/paths.js';
4
+ const RELOAD_HINT = 'Reload VS Code (Developer: Reload Window) so Roo Code picks up the fix.';
5
+ export async function fixRooCommand(args = [], home = homeDir()) {
6
+ if (args.includes('--undo'))
7
+ return undoFixRoo(home);
8
+ const results = await repairRooRipgrep(home);
9
+ if (!results.length) {
10
+ console.log(chalk.yellow('! Roo Code is not installed in VS Code or a VS Code-based editor on this computer. Nothing to fix.'));
11
+ return;
12
+ }
13
+ console.log(chalk.dim(`Found ${results.length} Roo Code installation${results.length === 1 ? '' : 's'}.`));
14
+ let failures = 0;
15
+ for (const result of results) {
16
+ const label = rooInstallLabel(result);
17
+ if (result.status === 'patched') {
18
+ console.log(chalk.green(`✓ ${label}: patched — it now finds newer VS Code's ripgrep.`));
19
+ console.log(chalk.dim(` Original saved as ${result.backup}`));
20
+ }
21
+ else if (result.status === 'already_patched') {
22
+ console.log(chalk.green(`✓ ${label}: already patched.`));
23
+ }
24
+ else if (result.status === 'unsupported') {
25
+ console.log(chalk.yellow(`! ${label}: ripgrep lookup not recognised; left unchanged (this version may not need the fix).`));
26
+ }
27
+ else {
28
+ failures++;
29
+ console.log(chalk.red(`✗ ${label}: could not patch ${result.script}: ${result.error}`));
30
+ }
31
+ }
32
+ if (results.some(result => result.status === 'patched' || result.status === 'already_patched'))
33
+ console.log(`\n${RELOAD_HINT}`);
34
+ if (failures)
35
+ throw new Error(`${failures} Roo Code installation${failures === 1 ? '' : 's'} could not be patched. Close VS Code and run \`npx scalemax@latest fix-roo\` again.`);
36
+ }
37
+ async function undoFixRoo(home) {
38
+ const results = await restoreRooRipgrep(home);
39
+ if (!results.length) {
40
+ console.log(chalk.yellow('! Roo Code is not installed in VS Code or a VS Code-based editor on this computer. Nothing to undo.'));
41
+ return;
42
+ }
43
+ let failures = 0;
44
+ for (const result of results) {
45
+ const label = rooInstallLabel(result);
46
+ if (result.status === 'restored')
47
+ console.log(chalk.green(`✓ ${label}: original extension restored.`));
48
+ else if (result.status === 'not_patched')
49
+ console.log(chalk.dim(`• ${label}: not patched; nothing to undo.`));
50
+ else {
51
+ failures++;
52
+ const reason = result.status === 'no_backup'
53
+ ? `no ScaleMax backup found next to ${result.script}`
54
+ : result.status === 'mismatch'
55
+ ? `the extension changed after it was patched, so the backup at ${result.backup} was not applied`
56
+ : result.error;
57
+ console.log(chalk.red(`✗ ${label}: ${reason}. Reinstall Roo Code to get a clean copy.`));
58
+ }
59
+ }
60
+ if (results.some(result => result.status === 'restored'))
61
+ console.log('\nReload VS Code (Developer: Reload Window) so Roo Code loads the original files.');
62
+ if (failures)
63
+ throw new Error(`${failures} Roo Code installation${failures === 1 ? '' : 's'} could not be restored.`);
64
+ }
@@ -4,15 +4,16 @@ import { createInterface } from 'node:readline/promises';
4
4
  import { execa } from 'execa';
5
5
  import { clients } from '../clients.js';
6
6
  import { helperDocsContext } from './helper-docs.js';
7
- import { rollbackAfterVerificationFailure } from './configure.js';
8
- import { createChatCompletion, filterModelsForScope, isAnthropicModel } from '../utils/api.js';
7
+ import { clientAllowedForModels, rollbackAfterVerificationFailure } from './configure.js';
8
+ import { loadModelCheck } from '../utils/modelCheck.js';
9
+ import { createChatCompletion, filterModelsForScope, isAnthropicModel, isChatModel } from '../utils/api.js';
9
10
  import { redactApiKey } from '../utils/apiKey.js';
10
11
  import { loadApiKey } from '../utils/keyStore.js';
11
12
  import { homeDir } from '../utils/paths.js';
12
13
  import { validateApiKey } from '../utils/api.js';
13
14
  import { commandExists } from '../utils/exec.js';
14
15
  import { installClaudeGithubTools } from '../configure/claude-tools.js';
15
- import { isExactChatCompletionsModelId } from '../configure/models.js';
16
+ import { CODEX_NEEDS_GPT_MESSAGE, isExactChatCompletionsModelId, modelIdKey, sameModelId } from '../configure/models.js';
16
17
  import { isContinueEligibleModelId } from '../configure/continue.js';
17
18
  export const SCALEMAX_HELPER_REFUSAL = `I am the official ScaleMax Helper.
18
19
 
@@ -708,21 +709,25 @@ codex
708
709
  return undefined;
709
710
  }
710
711
  export function selectHelperModel(models, requested) {
712
+ // The helper talks over /chat/completions: never pick an image, video,
713
+ // embedding or voice row. Ids match in any `[1m]` spelling.
714
+ const chat = models.filter(isChatModel);
711
715
  if (requested) {
712
- const exact = models.find(model => model.id.toLowerCase() === requested.toLowerCase());
716
+ const exact = chat.find(model => model.id.trim().toLowerCase() === requested.trim().toLowerCase())
717
+ || chat.find(model => sameModelId(model.id, requested));
713
718
  if (!exact)
714
719
  throw new Error(`Model "${requested}" is not available to this API key. Run \`scalemax models\` to see available models.`);
715
720
  return exact.id;
716
721
  }
717
722
  const preferred = ['gpt-5.6-luna', 'claude-haiku-4-5-20251001', 'fable-5', 'gpt-5.6-terra', 'gpt-5.6-sol'];
718
723
  for (const id of preferred) {
719
- const match = models.find(model => model.id.toLowerCase() === id);
724
+ const match = chat.find(model => sameModelId(model.id, id));
720
725
  if (match)
721
726
  return match.id;
722
727
  }
723
- if (!models[0])
728
+ if (!chat[0])
724
729
  throw new Error('No ScaleMax model is available to this API key.');
725
- return models[0].id;
730
+ return chat[0].id;
726
731
  }
727
732
  export async function requestHelperReply(question, history, connection, complete = createChatCompletion) {
728
733
  const cleanQuestion = redactHelperSecrets(question.trim(), connection.apiKey);
@@ -832,6 +837,19 @@ function requestedProviderScope(question, target) {
832
837
  return 'openai';
833
838
  return undefined;
834
839
  }
840
+ /**
841
+ * The GPT/Codex family. The live catalog publishes those models under the
842
+ * `scalemax` owner (not `openai`, which now labels only embedding rows), so
843
+ * the owner alone matched no chat model at all.
844
+ */
845
+ function isOpenAiFamilyModel(model) {
846
+ const family = String(model.provider || model.owned_by || '').trim().toLowerCase();
847
+ if (family === 'openai')
848
+ return true;
849
+ if (family === 'scalemax')
850
+ return /^(gpt-|codex-)/i.test(modelIdKey(model.id));
851
+ return !family && !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id);
852
+ }
835
853
  function actionModels(connection, requestedScope) {
836
854
  const rawCatalog = connection.modelCatalog?.length
837
855
  ? connection.modelCatalog
@@ -840,7 +858,9 @@ function actionModels(connection, requestedScope) {
840
858
  // The authenticated catalog already includes reseller and extra-family
841
859
  // grants. Validate the primary scope, then narrow only for explicit user
842
860
  // intent; never try to reconstruct effective entitlement client-side.
843
- const catalog = filterModelsForScope(rawCatalog, entitlement);
861
+ // Client actions configure chat clients only; image, video, embedding and
862
+ // voice rows would otherwise be written into them as chat models.
863
+ const catalog = filterModelsForScope(rawCatalog, entitlement).filter(isChatModel);
844
864
  if (!requestedScope || requestedScope === 'reseller' || requestedScope === 'local')
845
865
  return catalog;
846
866
  const exactModelByScope = {
@@ -851,19 +871,14 @@ function actionModels(connection, requestedScope) {
851
871
  };
852
872
  const exactModel = exactModelByScope[requestedScope];
853
873
  if (exactModel)
854
- return catalog.filter(model => model.id.trim().toLowerCase() === exactModel);
874
+ return catalog.filter(model => modelIdKey(model.id) === exactModel);
855
875
  if (requestedScope === 'both') {
856
- return catalog.filter(model => isAnthropicModel(model)
857
- || String(model.provider || model.owned_by || '').trim().toLowerCase() === 'openai');
876
+ return catalog.filter(model => isAnthropicModel(model) || isOpenAiFamilyModel(model));
858
877
  }
859
878
  if (requestedScope === 'anthropic')
860
879
  return catalog.filter(isAnthropicModel);
861
- if (requestedScope === 'openai') {
862
- return catalog.filter(model => {
863
- const family = String(model.provider || model.owned_by || '').trim().toLowerCase();
864
- return family ? family === 'openai' : !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id);
865
- });
866
- }
880
+ if (requestedScope === 'openai')
881
+ return catalog.filter(isOpenAiFamilyModel);
867
882
  return catalog.filter(model => String(model.provider || model.owned_by || '').trim().toLowerCase() === requestedScope);
868
883
  }
869
884
  function helperClientAllowedForModels(clientId, models) {
@@ -968,8 +983,8 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
968
983
  const models = actionModels(connection, requestedScope);
969
984
  if (!models.length)
970
985
  throw new Error(`The authenticated key does not expose ${requestedScope === 'anthropic' ? 'Claude' : requestedScope === 'openai' ? 'OpenAI' : requestedScope === 'deepseek-v4-flash' ? 'DeepSeek V4 Flash' : requestedScope === 'glm-5.2' ? 'GLM 5.2' : 'compatible'} models for ${client.name}. No configuration was changed.`);
971
- if (client.id === 'codex' && models.every(model => isAnthropicModel(model) || isExactChatCompletionsModelId(model.id)))
972
- throw new Error('Codex requires a Responses-capable model, but this key exposes only Claude or Chat-Completions-only models. No configuration was changed.');
986
+ if (client.id === 'codex' && !clientAllowedForModels('codex', models))
987
+ throw new Error(`${CODEX_NEEDS_GPT_MESSAGE} No configuration was changed.`);
973
988
  if (client.id === 'cursor' && connection.platform !== 'darwin')
974
989
  throw new Error(`Automatic Cursor configuration is currently verified on macOS only. ${helperPlatformName(connection.platform)} was not modified.`);
975
990
  const ctx = {
@@ -980,6 +995,8 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
980
995
  baseUrl: connection.baseUrl,
981
996
  providerScope: connection.providerScope || 'both',
982
997
  models,
998
+ // The same defaults setup and doctor use for this key.
999
+ avoidModelIds: await loadModelCheck(home, connection.apiKey).catch(() => []),
983
1000
  interactive: false,
984
1001
  allowAppModification: false
985
1002
  };
@@ -1020,6 +1037,8 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
1020
1037
  write(`→ Configuring ${client.name} with the authenticated stored key…`);
1021
1038
  const configured = await client.configure(ctx);
1022
1039
  write(`✓ ${client.name} ${configured.message}`);
1040
+ if (configured.note)
1041
+ write(` ${configured.note}`);
1023
1042
  if (client.verify) {
1024
1043
  write(`→ Verifying ${client.name} configuration…`);
1025
1044
  try {
@@ -23,6 +23,12 @@ export const HELPER_DOC_SECTIONS = [
23
23
  keywords: ['roo', 'roo code', 'vscode', 'vs code', 'extension', 'visual studio code', 'deepseek'],
24
24
  content: `For Roo Code, the ScaleMax CLI writes a private settings import file at ~/.scalemax/roo-code-import.json and sets roo-cline.autoImportSettingsPath in VS Code User settings. Roo imports that profile after VS Code is reloaded. A DeepSeek V4 Flash profile is offered only when the authenticated catalog reports capabilities.tools=true. If the active route is text-only, Roo fails closed because its task loop requires tools and the helper recommends Continue Chat/Edit instead. The CLI can install the official Roo Code extension with code --install-extension RooVeterinaryInc.roo-cline when VS Code is detected. On Windows, helper-bot may install VS Code first with winget using the fixed Microsoft.VisualStudioCode package id, then install Roo, write the import handoff, and verify the files.`
25
25
  },
26
+ {
27
+ id: 'roo-code-ripgrep',
28
+ title: 'Roo Code stuck at API Request on recent VS Code versions',
29
+ keywords: ['ripgrep', 'stuck', 'hang', 'hangs', 'hanging', 'frozen', 'fix-roo'],
30
+ content: `Roo Code 3.54, its latest release, looks for VS Code's bundled ripgrep only in folders newer VS Code releases moved to node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/<platform>-<arch>/, so on recent VS Code versions every Roo task hangs at "API Request..." with "Could not find ripgrep binary", whatever the model or provider; the ScaleMax key is fine. Fix: run npx scalemax@latest fix-roo, then Developer: Reload Window. It patches only the installed Roo Code extension in the home folder, keeps the original as extension.js.scalemax-orig, never changes the VS Code app, and fix-roo --undo restores it. Configuring Roo Code with the CLI applies the same fix and scalemax doctor reports installs that still need it. Alternatives: copy rg from that folder to <VS Code app>/resources/app/node_modules/@vscode/ripgrep/bin/ (removed by every VS Code update), or use Cline or Kilo Code.`
31
+ },
26
32
  {
27
33
  id: 'continue-deepseek',
28
34
  title: 'Continue setup for DeepSeek V4 Flash',
@@ -4,12 +4,12 @@ import { jsonConfig } from './templates.js';
4
4
  const id = 'api-code';
5
5
  const name = 'API Code';
6
6
  export async function configure(ctx) {
7
- const file = clientConfigPath(id, ctx.home, ctx.platform);
7
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
8
8
  const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { environment: { SCALEMAX_API_KEY: 'your ScaleMax API key', SCALEMAX_BASE_URL: ctx.baseUrl }, node: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: '${ctx.baseUrl}' });`, python: `from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='${ctx.baseUrl}')` });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'API code examples written' };
11
11
  }
12
12
  export async function remove(ctx) {
13
- const file = clientConfigPath(id, ctx.home, ctx.platform);
13
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
14
14
  return removeManagedConfig(ctx, id, name, file);
15
15
  }