scalemax 0.1.49 → 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
@@ -151,11 +151,11 @@ For clients without a stable official config file, the installer writes a ScaleM
151
151
 
152
152
  ### Codex
153
153
 
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 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.
155
155
 
156
156
  ### Claude Code
157
157
 
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, 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.
159
159
 
160
160
  ### OpenCode
161
161
 
@@ -171,7 +171,7 @@ ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and
171
171
 
172
172
  ### Roo Code (VS Code)
173
173
 
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. 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
175
 
176
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.
177
177
 
@@ -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,7 +241,7 @@ 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}`);
212
247
  if (res.note)
@@ -233,21 +268,6 @@ export async function configureCommand(opts = {}) {
233
268
  failures.push(`${client.name}: ${message}`);
234
269
  }
235
270
  }
236
- // Pin the working default as this key's `claude-sonnet-4-6` backend so Claude
237
- // Desktop (which always sends that id) routes to a model this key can serve.
238
- // Best-effort: an older gateway without the endpoint, or a scope that already
239
- // has a fixed default, is skipped silently. Never fails the run.
240
- if (successful.size > 0) {
241
- const target = claudeAliasBackendPinTarget(keyInfo.models);
242
- if (target) {
243
- try {
244
- const pin = await pinClaudeAliasBackend({ apiKey, baseUrl: selectedBaseUrl, model: target });
245
- if (pin.ok && !pin.skipped)
246
- console.log(chalk.dim(`Claude Desktop's claude-sonnet-4-6 now routes to ${target} on this key.`));
247
- }
248
- catch { /* endpoint absent or transient; ignore */ }
249
- }
250
- }
251
271
  if (opts.promptLaunch !== false && !opts.all && process.stdin.isTTY)
252
272
  await maybeLaunchCodex(chosen, successful, selectedBaseUrl);
253
273
  if (failures.length)
@@ -3,6 +3,8 @@ 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';
@@ -30,6 +32,13 @@ export async function doctorCommand(home = homeDir()) {
30
32
  let failures = 0;
31
33
  const key = await loadApiKey(home);
32
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) : [];
33
42
  if (!key) {
34
43
  console.log(`${chalk.red('✗')} API key missing`);
35
44
  failures++;
@@ -38,11 +47,24 @@ export async function doctorCommand(home = homeDir()) {
38
47
  const s = ora('Checking API key and models').start();
39
48
  const res = await validateApiKey(key);
40
49
  models = res.models;
50
+ baseUrl = res.baseUrl || SCALEMAX_BASE_URL;
51
+ providerScope = res.key?.provider_scope;
41
52
  if (res.ok && models.length) {
42
53
  s.succeed(`API reachable, key valid, ${models.length} models loaded`);
43
54
  console.log(`${chalk.green('✓')} Usage and key metadata accessible through the authenticated API`);
44
55
  const st = ora('Checking streaming (small billable request)').start();
45
- 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);
46
68
  if (stream.ok)
47
69
  st.succeed('Streaming available');
48
70
  else {
@@ -69,8 +91,7 @@ export async function doctorCommand(home = homeDir()) {
69
91
  }
70
92
  if (client.verify && key && models.length) {
71
93
  try {
72
- const checked = await validateApiKey(key);
73
- 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 });
74
95
  console.log(`${chalk.green('✓')} ${client.name} ${v.message}`);
75
96
  }
76
97
  catch (error) {
@@ -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
  };
@@ -6,20 +6,30 @@ import { clientConfigPath } from './paths.js';
6
6
  import { writeMergedJsonConfig } from './common.js';
7
7
  import { GENERATED_HEADER } from '../constants.js';
8
8
  import { redactApiKey } from '../utils/apiKey.js';
9
- import { anthropicOrigin, fastModel, modelIds, preferredModel, AUTO_MODEL_ID } from './models.js';
9
+ import { anthropicOrigin, claudeCodeBackgroundModel, claudeCodeCustomModelId, claudeCodeMainModel, claudeCodeModelIds, modelAcceptsImages, modelIdKey, AUTO_MODEL_ID, canonicalModelId, sameModelId } from './models.js';
10
+ import { contextSuffix } from '../utils/modelId.js';
10
11
  import { removeClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
11
12
  const id = 'claude';
12
13
  const name = 'Claude Code';
13
14
  const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
14
15
  const fastModelKey = 'ANTHROPIC_DEFAULT_HAIKU_MODEL';
15
16
  const helperTtlKey = 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS';
16
- const disableThinkingKey = 'CLAUDE_CODE_DISABLE_THINKING';
17
+ // Written (as '1') by 0.1.49 and earlier, no longer managed. It is a real
18
+ // Claude Code switch that omits `thinking` from every request, but the
19
+ // gateway already strips `thinking` from Claude Code requests unless the
20
+ // operator turns passthrough on, so writing it only overrode that switch.
21
+ // Reconfiguring removes the value earlier versions wrote.
22
+ const legacyDisableThinkingKey = 'CLAUDE_CODE_DISABLE_THINKING';
17
23
  // Claude Code's gateway discovery intentionally ignores model IDs that do not
18
24
  // start with `claude` or `anthropic`. Fable and Auto both need managed custom
19
25
  // picker entries so their public ScaleMax IDs remain selectable.
20
26
  const customModelOptionKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION';
21
27
  const customModelOptionNameKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME';
22
28
  const customModelOptionDescriptionKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION';
29
+ // Bare public ids. The value written to ANTHROPIC_CUSTOM_MODEL_OPTION carries
30
+ // the catalog's context suffix when it has one (`fable-5[1m]`): Claude Code
31
+ // reads `[1m]` as a 1M context window and strips it before calling the API,
32
+ // while a bare id is budgeted (and auto-compacted) at 200k.
23
33
  const fableModelId = 'fable-5';
24
34
  const fableModelName = 'Fable 5';
25
35
  const fableModelDescription = 'Fable 5 via ScaleMax';
@@ -31,6 +41,8 @@ const fableModelDescription = 'Fable 5 via ScaleMax';
31
41
  const fable51ModelId = 'fable-5.1';
32
42
  const fable51ModelName = 'Fable 5.1';
33
43
  const fable51ModelDescription = 'Fable 5.1 via ScaleMax';
44
+ // Fable 5.1 cannot read images; say so when it is the picker option.
45
+ const fable51TextOnlyDescription = 'Fable 5.1 via ScaleMax (text only, no images)';
34
46
  const autoModelName = 'Auto';
35
47
  const autoModelDescription = 'Auto via ScaleMax';
36
48
  const verificationTimeoutMs = 60_000;
@@ -44,8 +56,7 @@ const managedEnvKeys = [
44
56
  customModelOptionDescriptionKey,
45
57
  discoveryKey,
46
58
  fastModelKey,
47
- helperTtlKey,
48
- disableThinkingKey
59
+ helperTtlKey
49
60
  ];
50
61
  const managedCustomModelValues = new Set([
51
62
  fableModelId,
@@ -54,6 +65,7 @@ const managedCustomModelValues = new Set([
54
65
  fable51ModelId,
55
66
  fable51ModelName,
56
67
  fable51ModelDescription,
68
+ fable51TextOnlyDescription,
57
69
  AUTO_MODEL_ID,
58
70
  autoModelName,
59
71
  autoModelDescription
@@ -68,48 +80,45 @@ function helperCommand(home, platform) {
68
80
  function asRecord(value) {
69
81
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
70
82
  }
71
- function hasModelId(ctx, ...ids) {
72
- return (ctx.models || []).some(model => ids.includes(String(model.id || '').trim().toLowerCase()));
73
- }
74
- function hasFableModel(ctx) {
75
- return hasModelId(ctx, fableModelId, 'claude-fable-5');
76
- }
77
- function hasAutoModel(ctx) {
78
- return modelIds(ctx).some(id => id.trim().toLowerCase() === AUTO_MODEL_ID);
79
- }
83
+ /**
84
+ * The one custom picker entry (see claudeCodeCustomModelId): Fable 5 when the
85
+ * key has it, otherwise an image-capable hidden model, otherwise Fable 5.1
86
+ * (labelled text only) or Auto. Written in the catalog's spelling, so
87
+ * `fable-5[1m]` keeps Claude Code's 1M window.
88
+ */
80
89
  function managedCustomModel(ctx) {
81
- if (hasFableModel(ctx)) {
82
- return {
83
- option: fableModelId,
84
- name: fableModelName,
85
- description: fableModelDescription
86
- };
90
+ const pick = claudeCodeCustomModelId(ctx);
91
+ if (!pick)
92
+ return null;
93
+ const key = modelIdKey(pick);
94
+ if (key === fableModelId || key === 'claude-fable-5') {
95
+ return { option: `${fableModelId}${contextSuffix(pick)}`, name: fableModelName, description: fableModelDescription };
87
96
  }
88
- if (hasModelId(ctx, fable51ModelId)) {
97
+ if (key === fable51ModelId) {
89
98
  return {
90
- option: fable51ModelId,
99
+ option: `${fable51ModelId}${contextSuffix(pick)}`,
91
100
  name: fable51ModelName,
92
- description: fable51ModelDescription
101
+ description: modelAcceptsImages(ctx, pick) ? fable51ModelDescription : fable51TextOnlyDescription,
93
102
  };
94
103
  }
95
- if (hasAutoModel(ctx)) {
96
- return {
97
- option: AUTO_MODEL_ID,
98
- name: autoModelName,
99
- description: autoModelDescription
100
- };
101
- }
102
- return null;
104
+ if (key === AUTO_MODEL_ID)
105
+ return { option: AUTO_MODEL_ID, name: autoModelName, description: autoModelDescription };
106
+ const label = ctx.models?.find(model => model.id.trim() === pick)?.display_name || canonicalModelId(pick);
107
+ return { option: pick, name: label, description: `${label} via ScaleMax` };
103
108
  }
104
109
  function matchesManagedCustomModel(env, customModel) {
105
- return env[customModelOptionKey] === customModel.option
110
+ // `fable-5` written before the catalog advertised `fable-5[1m]` still works,
111
+ // and so does Fable 5.1's label from before it said "text only".
112
+ const description = env[customModelOptionDescriptionKey];
113
+ return sameModelId(env[customModelOptionKey], customModel.option)
106
114
  && env[customModelOptionNameKey] === customModel.name
107
- && env[customModelOptionDescriptionKey] === customModel.description;
115
+ && (description === customModel.description
116
+ || (customModel.description === fable51TextOnlyDescription && description === fable51ModelDescription));
108
117
  }
109
118
  export async function configure(ctx) {
110
119
  const file = clientConfigPath(id, ctx.home, ctx.platform);
111
- const model = preferredModel(ctx);
112
- const quick = fastModel(ctx);
120
+ const model = claudeCodeMainModel(ctx);
121
+ const quick = claudeCodeBackgroundModel(ctx);
113
122
  const customModel = managedCustomModel(ctx);
114
123
  const baseUrl = anthropicOrigin(ctx.baseUrl);
115
124
  const apiKeyHelper = helperCommand(ctx.home, ctx.platform);
@@ -132,9 +141,14 @@ export async function configure(ctx) {
132
141
  ANTHROPIC_BASE_URL: baseUrl,
133
142
  ANTHROPIC_MODEL: model,
134
143
  [discoveryKey]: '1',
135
- [helperTtlKey]: '900000',
136
- [disableThinkingKey]: '1'
144
+ [helperTtlKey]: '900000'
137
145
  };
146
+ if (oldMetadata.managedBy === 'ScaleMax CLI' && oldMetadata.disableThinking !== undefined && oldMetadata.disableThinking === nextEnv[legacyDisableThinkingKey]) {
147
+ if (Object.prototype.hasOwnProperty.call(previousEnv, legacyDisableThinkingKey))
148
+ nextEnv[legacyDisableThinkingKey] = previousEnv[legacyDisableThinkingKey];
149
+ else
150
+ delete nextEnv[legacyDisableThinkingKey];
151
+ }
138
152
  if (customModel) {
139
153
  // These are public model IDs, not upstream/provider aliases. Claude
140
154
  // Code's custom option bypasses its `claude*` discovery filter while
@@ -147,10 +161,15 @@ export async function configure(ctx) {
147
161
  // A key can be reconfigured from a Fable/Auto catalog to one that does
148
162
  // not include either model. Remove only the values this CLI previously
149
163
  // managed, restoring any user values captured on the first configure.
164
+ const written = {
165
+ [customModelOptionKey]: oldMetadata.customModelOption,
166
+ [customModelOptionNameKey]: oldMetadata.customModelOptionName,
167
+ [customModelOptionDescriptionKey]: oldMetadata.customModelOptionDescription,
168
+ };
150
169
  for (const key of [customModelOptionKey, customModelOptionNameKey, customModelOptionDescriptionKey]) {
151
170
  if (Object.prototype.hasOwnProperty.call(previousEnv, key))
152
171
  nextEnv[key] = previousEnv[key];
153
- else if (managedCustomModelValues.has(String(nextEnv[key] ?? '')))
172
+ else if (managedCustomModelValues.has(canonicalModelId(nextEnv[key])) || (written[key] != null && nextEnv[key] === written[key]))
154
173
  delete nextEnv[key];
155
174
  }
156
175
  }
@@ -172,7 +191,6 @@ export async function configure(ctx) {
172
191
  gatewayDiscovery: '1',
173
192
  fastModel: quick || null,
174
193
  helperTtl: '900000',
175
- disableThinking: '1',
176
194
  customModelOption: customModel?.option ?? null,
177
195
  customModelOptionName: customModel?.name ?? null,
178
196
  customModelOptionDescription: customModel?.description ?? null,
@@ -208,13 +226,25 @@ export async function verify(ctx) {
208
226
  const model = String(env.ANTHROPIC_MODEL || '');
209
227
  const customModel = managedCustomModel(ctx);
210
228
  const customModelMatches = !customModel || matchesManagedCustomModel(env, customModel);
211
- if (!modelIds(ctx).includes(model) || !customModelMatches || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || settings.apiKeyHelper !== helperCommand(ctx.home, ctx.platform) || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || env[discoveryKey] !== '1' || env[disableThinkingKey] !== '1') {
212
- throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
229
+ // Any Claude model the key has is a working main model, in any spelling (a
230
+ // config written before the catalog advertised `[1m]` still reaches it).
231
+ // The `claude-sonnet-4-6` alias, a GPT model or a model the key lacks is not:
232
+ // 0.1.49 wrote the alias, which cannot read images.
233
+ const usable = claudeCodeModelIds(ctx);
234
+ if (!usable.some(id => sameModelId(id, model))) {
235
+ throw new Error(`Claude Code is set to ${model || 'no model'}, which is not a Claude model on this API key. Run \`npx scalemax@latest update\` to switch it to ${claudeCodeMainModel(ctx)}.`);
236
+ }
237
+ if (!customModelMatches || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || settings.apiKeyHelper !== helperCommand(ctx.home, ctx.platform) || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || env[discoveryKey] !== '1') {
238
+ throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog. Run `npx scalemax@latest update`.');
213
239
  }
214
240
  await verifyClientSecret(secretPath(ctx.home), ctx);
215
- const quick = fastModel(ctx);
216
- if (quick && env[fastModelKey] !== quick)
217
- throw new Error('Claude Code background model does not match the fastest authenticated model.');
241
+ const quick = claudeCodeBackgroundModel(ctx);
242
+ const background = env[fastModelKey];
243
+ // Unset, Claude Code calls its built-in Haiku id for background work, which
244
+ // a key without Haiku refuses; set, it must be a model this key lists.
245
+ if (quick ? !usable.some(id => sameModelId(id, background)) : background !== undefined && !usable.some(id => sameModelId(id, background))) {
246
+ throw new Error(`Claude Code's background model (${background === undefined ? 'not set' : String(background)}) is not a Claude model on this API key. Run \`npx scalemax@latest update\` to set it to ${quick || 'a listed model'}.`);
247
+ }
218
248
  // Verification proves that the authenticated Messages route works; it does
219
249
  // not need to warm the user's slower default Opus model. Prefer Haiku when
220
250
  // it is present, while keeping ANTHROPIC_MODEL unchanged for real sessions.
@@ -284,8 +314,9 @@ export async function remove(ctx) {
284
314
  restore(fastModelKey);
285
315
  if (env[helperTtlKey] === managed.helperTtl)
286
316
  restore(helperTtlKey);
287
- if (env[disableThinkingKey] === managed.disableThinking)
288
- restore(disableThinkingKey);
317
+ // Only configs written by 0.1.49 and earlier recorded this value.
318
+ if (managed.disableThinking !== undefined && env[legacyDisableThinkingKey] === managed.disableThinking)
319
+ restore(legacyDisableThinkingKey);
289
320
  if (env[customModelOptionKey] === managed.customModelOption)
290
321
  restore(customModelOptionKey);
291
322
  if (env[customModelOptionNameKey] === managed.customModelOptionName)
@@ -14,6 +14,7 @@ const cliVersion = '3.0.47';
14
14
  const extensionVersion = '4.0.12';
15
15
  const extensionId = 'saoudrizwan.claude-dev';
16
16
  const cliProviderId = 'openai-native';
17
+ const CLINE_MAX_OUTPUT_TOKENS = 32_768;
17
18
  const legacyProviderId = 'scalemax';
18
19
  const extensionCredentialHashKey = 'scalemaxManagedOpenAiApiKeySha256';
19
20
  /**
@@ -330,7 +331,8 @@ function positiveInteger(value) {
330
331
  function extensionModelInfo(ctx, modelId) {
331
332
  const source = (ctx.models || []).find(candidate => candidate.id === modelId);
332
333
  return {
333
- maxTokens: positiveInteger(source?.max_output_tokens) ?? -1,
334
+ // Same output budget as Roo: the gateway holds balance for all of it.
335
+ maxTokens: Math.min(positiveInteger(source?.max_output_tokens) ?? -1, CLINE_MAX_OUTPUT_TOKENS),
334
336
  contextWindow: positiveInteger(source?.context_window) ?? 128_000,
335
337
  supportsImages: source?.capabilities?.images === true || source?.capabilities?.vision === true,
336
338
  supportsPromptCache: source?.capabilities?.prompt_cache === true,