scalemax 0.1.41 → 0.1.43

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.
Files changed (44) hide show
  1. package/dist/commands/configure.d.ts +2 -1
  2. package/dist/commands/configure.js +65 -15
  3. package/dist/commands/helper-bot.js +54 -23
  4. package/dist/commands/helper-docs.js +3 -3
  5. package/dist/commands/logout.js +9 -1
  6. package/dist/commands/remove.js +3 -4
  7. package/dist/configure/api-code.d.ts +2 -2
  8. package/dist/configure/cherry-studio.d.ts +2 -2
  9. package/dist/configure/claude.d.ts +2 -2
  10. package/dist/configure/claude.js +52 -11
  11. package/dist/configure/client-secret.d.ts +3 -6
  12. package/dist/configure/client-secret.js +11 -16
  13. package/dist/configure/cline.d.ts +2 -2
  14. package/dist/configure/cline.js +280 -73
  15. package/dist/configure/codex.d.ts +3 -3
  16. package/dist/configure/codex.js +120 -55
  17. package/dist/configure/common.d.ts +4 -2
  18. package/dist/configure/common.js +38 -7
  19. package/dist/configure/continue.d.ts +3 -2
  20. package/dist/configure/continue.js +55 -34
  21. package/dist/configure/cursor-bundle.js +6 -3
  22. package/dist/configure/cursor.d.ts +2 -2
  23. package/dist/configure/cursor.js +39 -7
  24. package/dist/configure/hermes.d.ts +2 -2
  25. package/dist/configure/hermes.js +91 -32
  26. package/dist/configure/models.d.ts +1 -0
  27. package/dist/configure/models.js +10 -8
  28. package/dist/configure/openclaw.d.ts +2 -2
  29. package/dist/configure/openclaw.js +155 -73
  30. package/dist/configure/opencode.d.ts +2 -2
  31. package/dist/configure/opencode.js +36 -8
  32. package/dist/configure/roo-code.d.ts +2 -2
  33. package/dist/configure/roo-code.js +15 -8
  34. package/dist/configure/templates.js +1 -1
  35. package/dist/configure/trae-solo.d.ts +2 -2
  36. package/dist/detect/codex.js +31 -4
  37. package/dist/detect/continue.js +20 -4
  38. package/dist/types/client.d.ts +12 -2
  39. package/dist/types/client.js +21 -1
  40. package/dist/utils/api.d.ts +74 -2
  41. package/dist/utils/api.js +209 -69
  42. package/dist/utils/fs.d.ts +11 -0
  43. package/dist/utils/fs.js +53 -0
  44. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { ModelInfo } from '../utils/api.js';
2
- import { ClientId } from '../types/client.js';
2
+ 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
+ export declare function rollbackAfterVerificationFailure(result: ConfigureResult, error: unknown): Promise<never>;
6
7
  export declare function configureCommand(opts?: {
7
8
  all?: boolean;
8
9
  home?: string;
@@ -4,7 +4,7 @@ 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 } from '../utils/api.js';
7
+ import { validateApiKey, isAnthropicModel, isModelRouteAvailable, AUTO_MODEL_ID, probeWorkingModels, pinClaudeAliasBackend, claudeAliasBackendPinTarget } from '../utils/api.js';
8
8
  import { homeDir } from '../utils/paths.js';
9
9
  import { isExactChatCompletionsModelId } from '../configure/models.js';
10
10
  import { isContinueEligibleModelId } from '../configure/continue.js';
@@ -85,14 +85,38 @@ export function clientAllowedForModels(clientId, models) {
85
85
  return true;
86
86
  return models.some(model => !isAnthropicModel(model));
87
87
  }
88
+ export async function rollbackAfterVerificationFailure(result, error) {
89
+ if (!result.changed)
90
+ throw error;
91
+ if (!result.rollback) {
92
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; exact rollback was unavailable, so the current files were left in place rather than broadly removing the client configuration`);
93
+ }
94
+ try {
95
+ await result.rollback();
96
+ }
97
+ catch (rollback) {
98
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; exact rollback also failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
99
+ }
100
+ throw error;
101
+ }
88
102
  function scopeHint(scope) {
89
- if (scope === 'deepseek-v4-flash')
90
- return 'DeepSeek V4 Flash only';
91
- if (scope === 'glm-5.2')
92
- return 'GLM 5.2 only';
93
- if (scope === 'auto-api')
94
- return 'Auto only';
95
- return scope === 'both' ? 'OpenAI + Claude' : scope === 'anthropic' ? 'Claude only' : 'OpenAI only';
103
+ const labels = {
104
+ openai: 'OpenAI only',
105
+ anthropic: 'Claude only',
106
+ both: 'OpenAI + Claude',
107
+ gemini: 'Gemini only',
108
+ grok: 'Grok only',
109
+ qwen: 'Qwen only',
110
+ deepseek: 'DeepSeek only',
111
+ mimo: 'MiMo only',
112
+ local: 'Local Models',
113
+ reseller: 'reseller model allowlist',
114
+ 'deepseek-v4-flash': 'DeepSeek V4 Flash only',
115
+ 'glm-5.2': 'GLM 5.2 only',
116
+ 'deepseek-v4-pro': 'DeepSeek V4 Pro only',
117
+ 'auto-api': 'Auto only',
118
+ };
119
+ return labels[scope];
96
120
  }
97
121
  export async function configureCommand(opts = {}) {
98
122
  const home = opts.home || homeDir();
@@ -106,6 +130,23 @@ export async function configureCommand(opts = {}) {
106
130
  throw new Error(keyInfo.error || 'ScaleMax returned no models for this API key. No client configuration was changed.');
107
131
  const providerScope = keyInfo.key?.provider_scope || 'both';
108
132
  const selectedBaseUrl = keyInfo.baseUrl || SCALEMAX_BASE_URL;
133
+ // LIVE-PROBE the key's models with a small request and keep only the ones
134
+ // that actually answer, so a client is configured with working models and
135
+ // defaults to a working one. Bounded (concurrency + total time); if nothing
136
+ // probes clean we KEEP the entitlement-filtered list rather than fail — a
137
+ // slow/degraded upstream must not leave the user with zero models.
138
+ try {
139
+ const probeSpinner = ora('Checking which models respond').start();
140
+ const probe = await probeWorkingModels({ apiKey, baseUrl: selectedBaseUrl, models: keyInfo.models, providerScope });
141
+ if (probe.models.length && !probe.fallback) {
142
+ keyInfo.models = probe.models;
143
+ probeSpinner.succeed(`${probe.models.length}/${probe.probedCount} models responded`);
144
+ }
145
+ else {
146
+ probeSpinner.warn(probe.warning || 'No model answered the probe in time; using the full authenticated catalog');
147
+ }
148
+ }
149
+ catch { /* probing is best-effort; never blocks setup */ }
109
150
  const availableClients = clients.filter(c => clientAllowedForModels(c.id, keyInfo.models));
110
151
  console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}. Clients are filtered against the authenticated model catalog.`));
111
152
  const detections = await printDetected(home);
@@ -175,13 +216,7 @@ export async function configureCommand(opts = {}) {
175
216
  }
176
217
  catch (error) {
177
218
  verify.fail(`${client.name} verification failed`);
178
- try {
179
- await client.remove(ctx);
180
- }
181
- catch (rollback) {
182
- throw new Error(`${error instanceof Error ? error.message : String(error)}; rollback also failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
183
- }
184
- throw error;
219
+ await rollbackAfterVerificationFailure(res, error);
185
220
  }
186
221
  }
187
222
  successful.add(client.id);
@@ -192,6 +227,21 @@ export async function configureCommand(opts = {}) {
192
227
  failures.push(`${client.name}: ${message}`);
193
228
  }
194
229
  }
230
+ // Pin the working default as this key's `claude-sonnet-4-6` backend so Claude
231
+ // Desktop (which always sends that id) routes to a model this key can serve.
232
+ // Best-effort: an older gateway without the endpoint, or a scope that already
233
+ // has a fixed default, is skipped silently. Never fails the run.
234
+ if (successful.size > 0) {
235
+ const target = claudeAliasBackendPinTarget(keyInfo.models);
236
+ if (target) {
237
+ try {
238
+ const pin = await pinClaudeAliasBackend({ apiKey, baseUrl: selectedBaseUrl, model: target });
239
+ if (pin.ok && !pin.skipped)
240
+ console.log(chalk.dim(`Claude Desktop's claude-sonnet-4-6 now routes to ${target} on this key.`));
241
+ }
242
+ catch { /* endpoint absent or transient; ignore */ }
243
+ }
244
+ }
195
245
  if (opts.promptLaunch !== false && !opts.all && process.stdin.isTTY)
196
246
  await maybeLaunchCodex(chosen, successful, selectedBaseUrl);
197
247
  if (failures.length)
@@ -4,6 +4,7 @@ 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';
7
8
  import { createChatCompletion, filterModelsForScope, isAnthropicModel } from '../utils/api.js';
8
9
  import { redactApiKey } from '../utils/apiKey.js';
9
10
  import { loadApiKey } from '../utils/keyStore.js';
@@ -12,6 +13,7 @@ import { validateApiKey } from '../utils/api.js';
12
13
  import { commandExists } from '../utils/exec.js';
13
14
  import { installClaudeGithubTools } from '../configure/claude-tools.js';
14
15
  import { isExactChatCompletionsModelId } from '../configure/models.js';
16
+ import { isContinueEligibleModelId } from '../configure/continue.js';
15
17
  export const SCALEMAX_HELPER_REFUSAL = `I am the official ScaleMax Helper.
16
18
 
17
19
  I can only assist with installing, configuring, repairing, verifying, and troubleshooting ScaleMax.
@@ -804,8 +806,24 @@ function helperActionTarget(question) {
804
806
  function requestedProviderScope(question, target) {
805
807
  if (/\bglm[-\s]*5[._\s]*2\b/i.test(question))
806
808
  return 'glm-5.2';
807
- if (/\bdeepseek(?:[-\s]+v4[-\s]+flash)?\b/i.test(question))
809
+ if (/\bdeepseek[-\s]+v4[-\s]+pro\b/i.test(question))
810
+ return 'deepseek-v4-pro';
811
+ if (/\bdeepseek[-\s]+v4[-\s]+flash\b/i.test(question))
808
812
  return 'deepseek-v4-flash';
813
+ if (/\bauto(?:[-\s]+api)?\b/i.test(question))
814
+ return 'auto-api';
815
+ if (/\bdeepseek\b/i.test(question))
816
+ return 'deepseek';
817
+ if (/\bgemini\b/i.test(question))
818
+ return 'gemini';
819
+ if (/\bgrok\b/i.test(question))
820
+ return 'grok';
821
+ if (/\bqwen\b/i.test(question))
822
+ return 'qwen';
823
+ if (/\bmimo\b/i.test(question))
824
+ return 'mimo';
825
+ if (/\blocal models?\b/i.test(question))
826
+ return 'local';
809
827
  if (/\b(?:both|openai\s*\+\s*claude|claude\s*\+\s*openai)\b/i.test(question))
810
828
  return 'both';
811
829
  if (/\b(?:claude|anthropic)\b/i.test(question))
@@ -819,25 +837,34 @@ function actionModels(connection, requestedScope) {
819
837
  ? connection.modelCatalog
820
838
  : (connection.availableModels || []).map(id => ({ id }));
821
839
  const entitlement = connection.providerScope || 'both';
822
- // Always begin with the key's verified entitlement. Natural-language intent
823
- // must never widen an exact-model key into an OpenAI/Claude family key.
840
+ // The authenticated catalog already includes reseller and extra-family
841
+ // grants. Validate the primary scope, then narrow only for explicit user
842
+ // intent; never try to reconstruct effective entitlement client-side.
824
843
  const catalog = filterModelsForScope(rawCatalog, entitlement);
825
- if (!requestedScope)
844
+ if (!requestedScope || requestedScope === 'reseller' || requestedScope === 'local')
826
845
  return catalog;
827
- const exactEntitlement = entitlement === 'deepseek-v4-flash' || entitlement === 'glm-5.2';
828
- if (exactEntitlement && requestedScope !== entitlement)
829
- return [];
830
- if (requestedScope === 'deepseek-v4-flash' || requestedScope === 'glm-5.2') {
831
- return requestedScope === entitlement
832
- ? catalog.filter(model => model.id.trim().toLowerCase() === requestedScope)
833
- : [];
834
- }
846
+ const exactModelByScope = {
847
+ 'deepseek-v4-flash': 'deepseek-v4-flash',
848
+ 'glm-5.2': 'glm-5.2',
849
+ 'deepseek-v4-pro': 'deepseek-v4-pro',
850
+ 'auto-api': 'auto',
851
+ };
852
+ const exactModel = exactModelByScope[requestedScope];
853
+ if (exactModel)
854
+ return catalog.filter(model => model.id.trim().toLowerCase() === exactModel);
835
855
  if (requestedScope === 'both') {
836
- return catalog.filter(model => !isExactChatCompletionsModelId(model.id));
856
+ return catalog.filter(model => isAnthropicModel(model)
857
+ || String(model.provider || model.owned_by || '').trim().toLowerCase() === 'openai');
858
+ }
859
+ if (requestedScope === 'anthropic')
860
+ 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
+ });
837
866
  }
838
- return catalog.filter(model => requestedScope === 'anthropic'
839
- ? isAnthropicModel(model)
840
- : !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id));
867
+ return catalog.filter(model => String(model.provider || model.owned_by || '').trim().toLowerCase() === requestedScope);
841
868
  }
842
869
  function helperClientAllowedForModels(clientId, models) {
843
870
  if (!models.length)
@@ -845,7 +872,7 @@ function helperClientAllowedForModels(clientId, models) {
845
872
  const crossProtocolClients = new Set(['cursor', 'roo-code', 'cline', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
846
873
  const onlyExactChat = models.every(model => isExactChatCompletionsModelId(model.id));
847
874
  if (clientId === 'continue')
848
- return onlyExactChat;
875
+ return models.some(model => isContinueEligibleModelId(model.id));
849
876
  if (clientId === 'claude')
850
877
  return models.some(isAnthropicModel);
851
878
  if (clientId === 'codex' && onlyExactChat)
@@ -1000,17 +1027,21 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
1000
1027
  write(`✓ ${client.name} ${verified.message}`);
1001
1028
  }
1002
1029
  catch (error) {
1003
- if (configured.changed) {
1004
- write(`→ Verification failed; removing the incomplete ScaleMax-managed ${client.name} configuration…`);
1005
- await client.remove(ctx).catch(() => undefined);
1030
+ if (configured.changed && configured.rollback) {
1031
+ write(`→ Verification failed; restoring the exact pre-configuration ${client.name} state…`);
1006
1032
  }
1007
- throw error;
1033
+ await rollbackAfterVerificationFailure(configured, error);
1008
1034
  }
1009
1035
  }
1010
1036
  else {
1011
1037
  detection = await client.detect(ctx);
1012
- if (!detection.configured)
1013
- throw new Error(`${client.name} configuration was written but could not be detected afterward.`);
1038
+ if (!detection.configured) {
1039
+ const error = new Error(`${client.name} configuration was written but could not be detected afterward.`);
1040
+ if (configured.changed && configured.rollback) {
1041
+ write(`→ Detection failed; restoring the exact pre-configuration ${client.name} state…`);
1042
+ }
1043
+ await rollbackAfterVerificationFailure(configured, error);
1044
+ }
1014
1045
  write(`✓ ${client.name} configuration detected after write.`);
1015
1046
  }
1016
1047
  const activation = client.id === 'roo-code'
@@ -14,8 +14,8 @@ export const HELPER_DOC_SECTIONS = [
14
14
  {
15
15
  id: 'provider-scope',
16
16
  title: 'Provider scope and visible models',
17
- keywords: ['provider', 'scope', 'openai', 'claude', 'anthropic', 'deepseek', 'both', 'models', 'fable'],
18
- content: `A ScaleMax key may be OpenAI only, Claude only, both, or the exact-model DeepSeek V4 Flash entitlement. OpenAI-only keys should configure OpenAI-compatible models only. Claude-only keys should configure Claude-compatible models only. Both-scope keys may configure both model families in clients that can switch profiles. Fable 5 is a Claude-family frontend model through ScaleMax and should only appear for Claude-capable or both-scope keys, not OpenAI-only keys. A DeepSeek V4 Flash key discovers only deepseek-v4-flash; broad OpenAI and both scopes do not inherit that entitlement. The model always uses the OpenAI-compatible /v1/chat/completions route with streaming and max_tokens, never Responses. Tool support is route-dependent: trust capabilities.tools from the authenticated catalog. The official route exposes thinking-mode tools and reasoning_content replay; a fallback route may remain text-only.`
17
+ keywords: ['provider', 'scope', 'openai', 'claude', 'anthropic', 'gemini', 'grok', 'qwen', 'deepseek', 'mimo', 'local', 'reseller', 'both', 'models', 'fable'],
18
+ content: `ScaleMax keys may grant an OpenAI, Claude, Gemini, Grok, Qwen, DeepSeek, MiMo, Local Models, combined OpenAI + Claude, exact-model, Auto, or reseller allowlist entitlement. The authenticated /models response is authoritative because it already includes per-key reseller grants and any extra families assigned to a credit key. The CLI must never add models absent from that response or strip models that it returned. Fable 5 is a Claude-family frontend model through ScaleMax. DeepSeek V4 Flash, GLM 5.2, and DeepSeek V4 Pro use /v1/chat/completions rather than Responses; tool support remains route-dependent, so trust capabilities.tools from the authenticated catalog.`
19
19
  },
20
20
  {
21
21
  id: 'roo-code',
@@ -33,7 +33,7 @@ export const HELPER_DOC_SECTIONS = [
33
33
  id: 'codex-and-clients',
34
34
  title: 'Supported client update flow',
35
35
  keywords: ['codex', 'cursor', 'claude code', 'opencode', 'openclaw', 'hermes', 'cherry studio', 'update', 'doctor'],
36
- content: `scalemax update detects supported clients and configures them from the stored key. scalemax doctor validates the stored key and performs a minimal ScaleMax request. Supported clients include Codex CLI, Cursor, Roo Code, Continue, TRAE SOLO, Claude Code, OpenCode, OpenClaw, Hermes, Cherry Studio, n8n, and API Code. The CLI obtains models from the authenticated matching /models endpoint rather than a hard-coded list. Recognized helper-bot setup and verification requests are executed by the CLI controller before any model fallback is used.`
36
+ content: `scalemax update detects supported clients and configures them from the stored key. scalemax doctor validates the stored key and performs a small billable streaming request; configured client verifiers may perform an additional small billable request. Supported clients include Codex CLI, Cursor, Roo Code, Continue, TRAE SOLO, Claude Code, OpenCode, OpenClaw, Hermes, Cherry Studio, n8n, and API Code. The CLI obtains models from the authenticated matching /models endpoint rather than a hard-coded list. Recognized helper-bot setup and verification requests are executed by the CLI controller before any model fallback is used.`
37
37
  },
38
38
  {
39
39
  id: 'claude-github-tools',
@@ -1,4 +1,12 @@
1
1
  import chalk from 'chalk';
2
+ import { KEY_ENV } from '../constants.js';
2
3
  import { removeApiKey } from '../utils/keyStore.js';
3
4
  import { homeDir } from '../utils/paths.js';
4
- export async function logoutCommand(home = homeDir()) { await removeApiKey(home); console.log(chalk.green('✓ ScaleMax API key removed.')); }
5
+ export async function logoutCommand(home = homeDir()) {
6
+ await removeApiKey(home);
7
+ if (process.env[KEY_ENV]) {
8
+ console.log(chalk.yellow(`✓ Stored ScaleMax API key removed. ${KEY_ENV} is still set in the parent environment; a child CLI process cannot unset it. Unset it in your shell to stop using that key.`));
9
+ return;
10
+ }
11
+ console.log(chalk.green('✓ Stored ScaleMax API key removed.'));
12
+ }
@@ -1,16 +1,15 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { clients } from '../clients.js';
4
- import { loadApiKey } from '../utils/keyStore.js';
5
- import { SCALEMAX_BASE_URL } from '../constants.js';
4
+ import { loadApiKey, loadBaseUrl } from '../utils/keyStore.js';
6
5
  import { homeDir } from '../utils/paths.js';
7
6
  export async function removeCommand(home = homeDir()) {
8
- const apiKey = (await loadApiKey(home)) || '';
7
+ const [apiKey, baseUrl] = await Promise.all([loadApiKey(home), loadBaseUrl(home)]);
9
8
  const failures = [];
10
9
  for (const client of clients) {
11
10
  const spinner = ora(`Removing ${client.name} ScaleMax config`).start();
12
11
  try {
13
- const res = await client.remove({ home, platform: process.platform, env: process.env, apiKey, baseUrl: SCALEMAX_BASE_URL });
12
+ const res = await client.remove({ home, platform: process.platform, env: process.env, apiKey, baseUrl });
14
13
  spinner.succeed(`${client.name} ${res.message}`);
15
14
  }
16
15
  catch (err) {
@@ -1,3 +1,3 @@
1
- import { ConfigureContext, ConfigureResult } from '../types/client.js';
1
+ import { ConfigureContext, ConfigureResult, RemoveContext } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
- export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function remove(ctx: RemoveContext): Promise<ConfigureResult>;
@@ -1,3 +1,3 @@
1
- import { ConfigureContext, ConfigureResult } from '../types/client.js';
1
+ import { ConfigureContext, ConfigureResult, RemoveContext } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
- export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function remove(ctx: RemoveContext): Promise<ConfigureResult>;
@@ -1,4 +1,4 @@
1
- import { ConfigureContext, ConfigureResult } from '../types/client.js';
1
+ import { ConfigureContext, ConfigureResult, RemoveContext } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
3
  export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
4
- export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
4
+ export declare function remove(ctx: RemoveContext): Promise<ConfigureResult>;
@@ -1,13 +1,13 @@
1
1
  import fs from 'fs-extra';
2
2
  import crypto from 'node:crypto';
3
3
  import path from 'node:path';
4
- import { atomicWrite, backupFile } from '../utils/fs.js';
4
+ import { atomicWrite, backupFile, captureFileSnapshots, restoreFileSnapshots, snapshotRollback } from '../utils/fs.js';
5
5
  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
9
  import { anthropicOrigin, fastModel, modelIds, preferredModel, AUTO_MODEL_ID } from './models.js';
10
- import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
10
+ import { removeClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
11
11
  const id = 'claude';
12
12
  const name = 'Claude Code';
13
13
  const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
@@ -23,6 +23,14 @@ const customModelOptionDescriptionKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTI
23
23
  const fableModelId = 'fable-5';
24
24
  const fableModelName = 'Fable 5';
25
25
  const fableModelDescription = 'Fable 5 via ScaleMax';
26
+ // Claude's picker exposes ONE custom model slot (shared with Auto). fable-5 keeps
27
+ // it whenever the key has fable-5, so re-running `update` never silently moves an
28
+ // existing user onto a different model and price; fable-5.1 takes the slot only
29
+ // when it is the Fable model this key can reach. Either id stays callable
30
+ // explicitly (e.g. `/model fable-5.1`).
31
+ const fable51ModelId = 'fable-5.1';
32
+ const fable51ModelName = 'Fable 5.1';
33
+ const fable51ModelDescription = 'Fable 5.1 via ScaleMax';
26
34
  const autoModelName = 'Auto';
27
35
  const autoModelDescription = 'Auto via ScaleMax';
28
36
  const verificationTimeoutMs = 60_000;
@@ -43,6 +51,9 @@ const managedCustomModelValues = new Set([
43
51
  fableModelId,
44
52
  fableModelName,
45
53
  fableModelDescription,
54
+ fable51ModelId,
55
+ fable51ModelName,
56
+ fable51ModelDescription,
46
57
  AUTO_MODEL_ID,
47
58
  autoModelName,
48
59
  autoModelDescription
@@ -57,11 +68,11 @@ function helperCommand(home, platform) {
57
68
  function asRecord(value) {
58
69
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
59
70
  }
71
+ function hasModelId(ctx, ...ids) {
72
+ return (ctx.models || []).some(model => ids.includes(String(model.id || '').trim().toLowerCase()));
73
+ }
60
74
  function hasFableModel(ctx) {
61
- return (ctx.models || []).some(model => {
62
- const id = String(model.id || '').trim().toLowerCase();
63
- return id === fableModelId || id === 'claude-fable-5';
64
- });
75
+ return hasModelId(ctx, fableModelId, 'claude-fable-5');
65
76
  }
66
77
  function hasAutoModel(ctx) {
67
78
  return modelIds(ctx).some(id => id.trim().toLowerCase() === AUTO_MODEL_ID);
@@ -74,6 +85,13 @@ function managedCustomModel(ctx) {
74
85
  description: fableModelDescription
75
86
  };
76
87
  }
88
+ if (hasModelId(ctx, fable51ModelId)) {
89
+ return {
90
+ option: fable51ModelId,
91
+ name: fable51ModelName,
92
+ description: fable51ModelDescription
93
+ };
94
+ }
77
95
  if (hasAutoModel(ctx)) {
78
96
  return {
79
97
  option: AUTO_MODEL_ID,
@@ -95,7 +113,9 @@ export async function configure(ctx) {
95
113
  const customModel = managedCustomModel(ctx);
96
114
  const baseUrl = anthropicOrigin(ctx.baseUrl);
97
115
  const apiKeyHelper = helperCommand(ctx.home, ctx.platform);
98
- const secret = await writeClientSecret(secretPath(ctx.home), ctx);
116
+ const credentialFile = secretPath(ctx.home);
117
+ const snapshots = await captureFileSnapshots([file, credentialFile]);
118
+ const secret = await writeClientSecret(credentialFile, ctx);
99
119
  let result;
100
120
  try {
101
121
  result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
@@ -165,10 +185,21 @@ export async function configure(ctx) {
165
185
  });
166
186
  }
167
187
  catch (error) {
168
- await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
188
+ try {
189
+ await restoreFileSnapshots(snapshots, ctx.platform);
190
+ }
191
+ catch (rollback) {
192
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; rollback also failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
193
+ }
169
194
  throw error;
170
195
  }
171
- return { ...result, changed: result.changed || secret.changed, message: `configured Anthropic Messages routing with ${model}, file-backed authentication, and gateway discovery${quick ? `; ${quick} handles background work` : ''}` };
196
+ const changed = result.changed || secret.changed;
197
+ return {
198
+ ...result,
199
+ changed,
200
+ message: `configured Anthropic Messages routing with ${model}, file-backed authentication, and gateway discovery${quick ? `; ${quick} handles background work` : ''}`,
201
+ ...(changed ? { rollback: snapshotRollback(snapshots, ctx.platform) } : {}),
202
+ };
172
203
  }
173
204
  export async function verify(ctx) {
174
205
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -222,8 +253,18 @@ export async function remove(ctx) {
222
253
  return { id, name, changed: removed, path: file, message: removed ? 'removed orphaned ScaleMax Claude Code credential' : 'no ScaleMax configuration found' };
223
254
  }
224
255
  const settings = await fs.readJson(file).catch(() => undefined);
225
- if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI')
226
- return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
256
+ if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI') {
257
+ const removed = await removeClientSecret(secretPath(ctx.home), ctx);
258
+ return {
259
+ id,
260
+ name,
261
+ changed: removed,
262
+ path: file,
263
+ message: removed
264
+ ? 'removed orphaned ScaleMax Claude Code credential; left unmanaged configuration unchanged'
265
+ : 'left unmanaged configuration unchanged',
266
+ };
267
+ }
227
268
  const managed = asRecord(settings.scalemax);
228
269
  const env = asRecord(settings.env);
229
270
  const previous = asRecord(managed.previousEnv);
@@ -1,11 +1,8 @@
1
- import { ConfigureContext } from '../types/client.js';
1
+ import { ConfigureContext, RemoveContext } from '../types/client.js';
2
2
  export declare function writeClientSecret(file: string, ctx: ConfigureContext): Promise<{
3
3
  changed: boolean;
4
4
  backup?: string;
5
5
  }>;
6
- export declare function rollbackClientSecret(file: string, ctx: ConfigureContext, change: {
7
- changed: boolean;
8
- backup?: string;
9
- }): Promise<void>;
10
6
  export declare function verifyClientSecret(file: string, ctx: ConfigureContext): Promise<void>;
11
- export declare function removeClientSecret(file: string, ctx: ConfigureContext): Promise<boolean>;
7
+ /** Dedicated scalemax.key paths are durable ownership markers; current login state is not. */
8
+ export declare function removeClientSecret(file: string, _ctx?: RemoveContext): Promise<boolean>;
@@ -15,16 +15,6 @@ export async function writeClientSecret(file, ctx) {
15
15
  await atomicWrite(file, content, 0o600, ctx.platform);
16
16
  return { changed: true, backup };
17
17
  }
18
- export async function rollbackClientSecret(file, ctx, change) {
19
- if (!change.changed)
20
- return;
21
- if (change.backup) {
22
- const previous = await fs.readFile(change.backup, 'utf8');
23
- await atomicWrite(file, previous, 0o600, ctx.platform);
24
- return;
25
- }
26
- await fs.remove(file).catch(() => undefined);
27
- }
28
18
  export async function verifyClientSecret(file, ctx) {
29
19
  const content = await fs.readFile(file, 'utf8').catch(() => '');
30
20
  if (sanitizeApiKey(content) !== sanitizeApiKey(ctx.apiKey))
@@ -35,13 +25,18 @@ export async function verifyClientSecret(file, ctx) {
35
25
  throw new Error(`ScaleMax client credential permissions are ${mode.toString(8)}; expected 600.`);
36
26
  }
37
27
  }
38
- export async function removeClientSecret(file, ctx) {
39
- if (!(await fs.pathExists(file)))
40
- return false;
41
- const content = await fs.readFile(file, 'utf8').catch(() => '');
42
- if (sanitizeApiKey(content) !== sanitizeApiKey(ctx.apiKey))
28
+ /** Dedicated scalemax.key paths are durable ownership markers; current login state is not. */
29
+ export async function removeClientSecret(file, _ctx) {
30
+ const stat = await fs.lstat(file).catch((error) => {
31
+ if (error.code === 'ENOENT')
32
+ return undefined;
33
+ throw error;
34
+ });
35
+ if (!stat)
43
36
  return false;
44
- await backupFile(file, ctx.home);
37
+ if (stat.isSymbolicLink() || !stat.isFile()) {
38
+ throw new Error(`Refusing to remove non-regular ScaleMax credential path: ${file}`);
39
+ }
45
40
  await fs.remove(file);
46
41
  return true;
47
42
  }
@@ -1,4 +1,4 @@
1
- import { ConfigureContext, ConfigureResult, DetectContext, InstallContext } from '../types/client.js';
1
+ import { ConfigureContext, ConfigureResult, DetectContext, InstallContext, RemoveContext } from '../types/client.js';
2
2
  export interface ClineExtensionSurface {
3
3
  path: string;
4
4
  version?: string;
@@ -26,4 +26,4 @@ export declare function configure(ctx: ConfigureContext): Promise<ConfigureResul
26
26
  export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
27
27
  export declare function isManagedCliStore(value: unknown): boolean;
28
28
  export declare function isManagedExtensionState(globalState: unknown, secrets: unknown): boolean;
29
- export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
29
+ export declare function remove(ctx: RemoveContext): Promise<ConfigureResult>;