scalemax 0.1.17 → 0.1.19

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
@@ -17,6 +17,8 @@ It detects supported AI coding clients, safely backs up existing config files, a
17
17
  - A ScaleMax API key
18
18
  - Network access to `https://api.scalemax.pro`
19
19
 
20
+ The CLI identifies whether the supplied key belongs to the API-credit surface or the token-pack surface. It then stores and configures the matching base URL automatically; users do not need to choose it manually.
21
+
20
22
  ## Installation
21
23
 
22
24
  ### One-time run with npx
@@ -102,6 +104,7 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
102
104
 
103
105
  - Codex
104
106
  - Cursor
107
+ - Roo Code (VS Code)
105
108
  - TRAE SOLO
106
109
  - Claude Code
107
110
  - OpenCode
@@ -112,21 +115,29 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
112
115
 
113
116
  For clients without a stable official config file, the installer writes a ScaleMax setup guide under `~/.scalemax/` instead of modifying private app storage.
114
117
 
118
+ ### Codex
119
+
120
+ 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.
121
+
115
122
  ### Claude Code
116
123
 
117
124
  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. 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-4-8`), 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. 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-4-8[1m]` are normalized by the gateway.
118
125
 
119
126
  ### OpenCode
120
127
 
121
- ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.json` (or the platform-equivalent config directory). It uses `https://api.scalemax.pro/v1`, reads its credential from the private `~/.config/opencode/scalemax.key` file through OpenCode's `{file:...}` substitution, keeps unrelated providers and the user's non-ScaleMax default model, and loads every model available to the authenticated key. A fresh ScaleMax setup uses authenticated Haiku as `small_model` for quicker lightweight work. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
128
+ ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.json` (or the platform-equivalent config directory). It uses `https://api.scalemax.pro/v1`, reads its credential from the private `~/.config/opencode/scalemax.key` file through OpenCode's `{file:...}` substitution, keeps unrelated providers and the user's non-ScaleMax default model, and exposes only authenticated Codex/OpenAI-family models. A fresh ScaleMax setup uses an authenticated GPT mini model as `small_model` when available. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
122
129
 
123
130
  ### OpenClaw
124
131
 
125
- ScaleMax merges a native `anthropic-messages` provider into `~/.openclaw/openclaw.json`, using the Anthropic origin `https://api.scalemax.pro`. The API key and the compatibility User-Agent are both OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the provider JSON. The migration removes exact plaintext ScaleMax key copies from generated agent model registries. It adds every authenticated model to the provider and agent allowlist, uses authenticated Haiku for utility work when ScaleMax is the active provider, caps generated output limits at 4096 tokens, explicitly denies private-network routing for the public gateway, and preserves unrelated providers and agent settings. Legacy ScaleMax artifacts are removed only when they carry the ScaleMax managed marker.
132
+ ScaleMax partitions OpenClaw models into native `openai-responses` (`scalemax-codex`, `/v1`) and `anthropic-messages` (`scalemax-claude`, origin) providers. The API key and compatibility User-Agent are OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the main provider JSON. Migration removes the obsolete mixed provider, preserves the active model family, and scrubs plaintext credentials from generated ScaleMax agent registries even when they contain an older key. Output is capped at 4096 tokens, private-network routing is denied for the public gateway, and unrelated providers and agent settings are preserved.
126
133
 
127
134
  ### Hermes
128
135
 
129
- ScaleMax merges a named custom provider into `~/.hermes/config.yaml` and stores its credential in `~/.hermes/.env` with private permissions. Hermes uses OpenAI chat-completions routing at `https://api.scalemax.pro/v1`, a scoped `ScaleMax-Hermes/1.0` user agent, and a 4096-token output cap. Existing non-ScaleMax providers and defaults are preserved; a fresh Hermes config activates `custom:scalemax` automatically.
136
+ ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and stores one credential in `~/.hermes/.env` with private permissions. `scalemax-codex` uses Hermes's `codex_responses` transport at `/v1`; `scalemax-claude` uses `anthropic_messages` at the gateway origin. Both use a scoped `ScaleMax-Hermes/1.0` user agent and a 4096-token output cap. Existing non-ScaleMax providers and defaults are preserved, and the obsolete mixed provider is migrated safely.
137
+
138
+ ### Roo Code (VS Code)
139
+
140
+ 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. Reload VS Code once so Roo imports the Codex/OpenAI-only profile into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
130
141
 
131
142
  ### Cursor BYOK routing on macOS
132
143
 
@@ -279,7 +290,7 @@ Never publish over an existing npm version. Use semantic versioning for every re
279
290
  - Invalid JSON/JSON5 client files fail closed instead of being replaced; valid JSON5 values are merged safely.
280
291
  - Backup directories are user-only and backup files are written with private permissions.
281
292
  - API keys are masked in terminal output.
282
- - Claude Code, OpenCode, OpenClaw, and Hermes keep generated client credentials in private files rather than shared provider configuration blocks.
293
+ - Codex, Claude Code, OpenCode, OpenClaw, Hermes, and the Roo Code import handoff keep generated client credentials in private files rather than shared provider configuration blocks.
283
294
  - The published npm package contains only runtime files: `dist/`, `README.md`, `LICENSE`, and `package.json`.
284
295
  - Tests, TypeScript source, scripts, local caches, `.env` files, tarballs, and `node_modules` are excluded from the published package.
285
296
 
@@ -313,4 +324,4 @@ Yes. The CLI uses Windows-aware config paths and writes `%APPDATA%\scalemax\conf
313
324
 
314
325
  ### What endpoint does it configure?
315
326
 
316
- OpenAI-compatible clients use `https://api.scalemax.pro/v1`. Anthropic-compatible clients such as Claude Code and OpenClaw use the origin `https://api.scalemax.pro`, because those clients append `/v1/messages` themselves.
327
+ API-credit keys use `https://api.scalemax.pro/v1` for OpenAI-compatible clients and `https://api.scalemax.pro` for Anthropic-compatible clients. Token-pack keys use `https://api.scalemax.pro/token/v1` and `https://api.scalemax.pro/token` respectively. The CLI detects the key type and configures the correct surface automatically; the two key types are intentionally not interchangeable.
package/dist/clients.js CHANGED
@@ -2,6 +2,8 @@ import * as codexDetect from './detect/codex.js';
2
2
  import * as codexConfig from './configure/codex.js';
3
3
  import * as cursorDetect from './detect/cursor.js';
4
4
  import * as cursorConfig from './configure/cursor.js';
5
+ import * as rooCodeDetect from './detect/roo-code.js';
6
+ import * as rooCodeConfig from './configure/roo-code.js';
5
7
  import * as traeSoloDetect from './detect/trae-solo.js';
6
8
  import * as traeSoloConfig from './configure/trae-solo.js';
7
9
  import * as claudeDetect from './detect/claude.js';
@@ -19,6 +21,7 @@ import * as apiCodeConfig from './configure/api-code.js';
19
21
  export const clients = [
20
22
  { id: 'codex', name: 'Codex', detect: codexDetect.detect, install: codexConfig.install, configure: codexConfig.configure, verify: codexConfig.verify, launch: codexConfig.launch, remove: codexConfig.remove },
21
23
  { id: 'cursor', name: 'Cursor', detect: cursorDetect.detect, configure: cursorConfig.configure, verify: cursorConfig.verify, remove: cursorConfig.remove },
24
+ { id: 'roo-code', name: 'Roo Code (VS Code)', detect: rooCodeDetect.detect, configure: rooCodeConfig.configure, verify: rooCodeConfig.verify, remove: rooCodeConfig.remove },
22
25
  { id: 'trae-solo', name: 'TRAE SOLO', detect: traeSoloDetect.detect, configure: traeSoloConfig.configure, remove: traeSoloConfig.remove },
23
26
  { id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, verify: claudeConfig.verify, remove: claudeConfig.remove },
24
27
  { id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, verify: opencodeConfig.verify, remove: opencodeConfig.remove },
@@ -18,7 +18,7 @@ export async function printDetected(home = homeDir()) {
18
18
  console.log('');
19
19
  return detections;
20
20
  }
21
- async function maybeLaunchCodex(chosen, successful) {
21
+ async function maybeLaunchCodex(chosen, successful, baseUrl = SCALEMAX_BASE_URL) {
22
22
  if (!chosen.includes('codex') || !successful.has('codex'))
23
23
  return;
24
24
  console.log(chalk.green('\nConfiguration completed.\n'));
@@ -31,7 +31,7 @@ async function maybeLaunchCodex(chosen, successful) {
31
31
  const apiKey = await loadApiKey(homeDir());
32
32
  if (!apiKey)
33
33
  throw new Error('No API key found.');
34
- const result = await codex?.launch?.({ home: homeDir(), platform: process.platform, env: process.env, apiKey, baseUrl: SCALEMAX_BASE_URL });
34
+ const result = await codex?.launch?.({ home: homeDir(), platform: process.platform, env: process.env, apiKey, baseUrl });
35
35
  console.log(chalk.green(`✓ ${result?.message || 'Codex launched.'}`));
36
36
  }
37
37
  catch (err) {
@@ -42,7 +42,7 @@ async function maybeLaunchCodex(chosen, successful) {
42
42
  export function clientAllowedForModels(clientId, models) {
43
43
  if (!models.length)
44
44
  return false;
45
- const crossProtocolClients = new Set(['claude', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
45
+ const crossProtocolClients = new Set(['claude', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
46
46
  if (crossProtocolClients.has(clientId))
47
47
  return true;
48
48
  return models.some(model => !isAnthropicModel(model));
@@ -61,6 +61,7 @@ export async function configureCommand(opts = {}) {
61
61
  if (!keyInfo.models.length)
62
62
  throw new Error(keyInfo.error || 'ScaleMax returned no models for this API key. No client configuration was changed.');
63
63
  const providerScope = keyInfo.key?.provider_scope || 'both';
64
+ const selectedBaseUrl = keyInfo.baseUrl || SCALEMAX_BASE_URL;
64
65
  const availableClients = clients.filter(c => clientAllowedForModels(c.id, keyInfo.models));
65
66
  console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}. Clients are filtered against the authenticated model catalog.`));
66
67
  const detections = await printDetected(home);
@@ -119,7 +120,7 @@ export async function configureCommand(opts = {}) {
119
120
  }
120
121
  const spinner = ora(`Configuring ${client.name}`).start();
121
122
  try {
122
- const ctx = { ...baseCtx, apiKey, baseUrl: SCALEMAX_BASE_URL, providerScope, models: keyInfo.models, interactive: Boolean(process.stdin.isTTY), allowAppModification };
123
+ const ctx = { ...baseCtx, apiKey, baseUrl: selectedBaseUrl, providerScope, models: keyInfo.models, interactive: Boolean(process.stdin.isTTY), allowAppModification };
123
124
  const res = await client.configure(ctx);
124
125
  spinner.succeed(`${client.name} ${res.message}`);
125
126
  if (client.verify) {
@@ -148,7 +149,7 @@ export async function configureCommand(opts = {}) {
148
149
  }
149
150
  }
150
151
  if (opts.promptLaunch !== false && !opts.all && process.stdin.isTTY)
151
- await maybeLaunchCodex(chosen, successful);
152
+ await maybeLaunchCodex(chosen, successful, selectedBaseUrl);
152
153
  if (failures.length)
153
154
  throw new Error(`${failures.length} client configuration(s) failed:\n${failures.map(f => `- ${f}`).join('\n')}`);
154
155
  }
@@ -22,7 +22,7 @@ export async function doctorCommand(home = homeDir()) {
22
22
  s.succeed(`API reachable, key valid, ${models.length} models loaded`);
23
23
  console.log(`${chalk.green('✓')} Usage and key metadata accessible through the authenticated API`);
24
24
  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);
25
+ const stream = await testStreaming(key, models.find(m => m.id === 'fable-5')?.id || models[0]?.id, res.baseUrl);
26
26
  if (stream.ok)
27
27
  st.succeed('Streaming available');
28
28
  else {
@@ -49,7 +49,8 @@ export async function doctorCommand(home = homeDir()) {
49
49
  }
50
50
  if (client.verify && key && models.length) {
51
51
  try {
52
- const v = await client.verify({ home, platform: process.platform, env: process.env, apiKey: key, baseUrl: SCALEMAX_BASE_URL, models });
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 });
53
54
  console.log(`${chalk.green('✓')} ${client.name} ${v.message}`);
54
55
  }
55
56
  catch (error) {
@@ -3,7 +3,6 @@ import ora from 'ora';
3
3
  import chalk from 'chalk';
4
4
  import { validateApiKey } from '../utils/api.js';
5
5
  import { saveApiKey } from '../utils/keyStore.js';
6
- import { SCALEMAX_BASE_URL } from '../constants.js';
7
6
  import { homeDir } from '../utils/paths.js';
8
7
  import { sanitizeApiKey } from '../utils/apiKey.js';
9
8
  export async function loginCommand(home = homeDir()) {
@@ -20,7 +19,7 @@ export async function loginCommand(home = homeDir()) {
20
19
  spinner.fail('Invalid API Key');
21
20
  throw new Error(result.error || 'Invalid API Key');
22
21
  }
23
- await saveApiKey(sanitizedKey, SCALEMAX_BASE_URL, home);
22
+ await saveApiKey(sanitizedKey, result.baseUrl, home);
24
23
  spinner.succeed(`API Key verified (${result.key?.provider_scope || 'both'} provider scope)`);
25
24
  outro(chalk.green('ScaleMax API key saved.'));
26
25
  return sanitizedKey;
@@ -5,17 +5,16 @@ import { codexAuthPath, codexConfigPath } from './paths.js';
5
5
  import { atomicWrite, backupFile, listBackups } from '../utils/fs.js';
6
6
  import { GENERATED_HEADER } from '../constants.js';
7
7
  import { commandExists } from '../utils/exec.js';
8
+ import { preferredOpenAiModel } from './models.js';
8
9
  const id = 'codex';
9
10
  const name = 'Codex';
10
11
  const provider = 'scalemax';
11
12
  const fallbackModel = 'gpt-5.5';
12
13
  const reasoningEffort = 'medium';
13
14
  function selectedModel(ctx) {
14
- const ids = new Set((ctx.models || []).map(m => m.id));
15
- for (const candidate of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex'])
16
- if (ids.has(candidate))
17
- return candidate;
18
- return ctx.models?.[0]?.id || fallbackModel;
15
+ if (!ctx.models)
16
+ return fallbackModel;
17
+ return preferredOpenAiModel(ctx);
19
18
  }
20
19
  function scalemaxCodexBlock(baseUrl, model = fallbackModel) {
21
20
  return `# ${GENERATED_HEADER}\nmodel = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n# ScaleMax models are loaded from the authenticated live model catalog.\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
@@ -195,7 +194,7 @@ export async function verifyCodexConfiguration(ctx) {
195
194
  const res = await fetch(`${ctx.baseUrl}/responses`, {
196
195
  method: 'POST',
197
196
  headers: { Authorization: `Bearer ${ctx.apiKey}`, 'Content-Type': 'application/json' },
198
- body: JSON.stringify({ model, input: 'Say OK', stream: false }),
197
+ body: JSON.stringify({ model, input: 'Reply K', stream: false, max_output_tokens: 8 }),
199
198
  signal: AbortSignal.timeout(15000)
200
199
  });
201
200
  if (res.status === 404)
@@ -204,6 +203,12 @@ export async function verifyCodexConfiguration(ctx) {
204
203
  errors.push(`ScaleMax rejected the API key on /v1/responses with HTTP ${res.status}`);
205
204
  else if (!res.ok)
206
205
  warnings.push(`ScaleMax /v1/responses probe returned HTTP ${res.status}`);
206
+ else {
207
+ const body = await res.json().catch(() => undefined);
208
+ const hasOutput = typeof body?.output_text === 'string' || (Array.isArray(body?.output) && body.output.length > 0);
209
+ if (!body?.id || !hasOutput)
210
+ errors.push('ScaleMax /v1/responses returned a success status without a valid Responses payload.');
211
+ }
207
212
  }
208
213
  catch (err) {
209
214
  warnings.push(`Could not probe ScaleMax /v1/responses: ${err instanceof Error ? err.message : String(err)}`);
@@ -4,11 +4,14 @@ import { parseDocument } from 'yaml';
4
4
  import { atomicWrite, backupFile } from '../utils/fs.js';
5
5
  import { clientConfigPath } from './paths.js';
6
6
  import { isScaleMaxManagedText } from './common.js';
7
- import { modelIds, openAiBaseUrl, preferredModel } from './models.js';
7
+ import { anthropicModelIds, anthropicOrigin, openAiBaseUrl, openAiModelIds, preferredAnthropicModel, preferredOpenAiModel } from './models.js';
8
8
  const id = 'hermes';
9
9
  const name = 'Hermes';
10
10
  const envKey = 'SCALEMAX_HERMES_API_KEY';
11
11
  const envMarker = '# ScaleMax managed Hermes API key';
12
+ const legacyProviderName = 'scalemax';
13
+ const codexProviderName = 'scalemax-codex';
14
+ const claudeProviderName = 'scalemax-claude';
12
15
  function asRecord(value) {
13
16
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
17
  }
@@ -33,8 +36,8 @@ function parseYaml(text) {
33
36
  doc.contents = doc.createNode({});
34
37
  return doc;
35
38
  }
36
- function modelMetadata(ctx) {
37
- return Object.fromEntries(modelIds(ctx).map(modelId => {
39
+ function modelMetadata(ctx, ids) {
40
+ return Object.fromEntries(ids.map(modelId => {
38
41
  const model = (ctx.models || []).find(candidate => candidate.id === modelId);
39
42
  const metadata = {};
40
43
  if (model?.context_window)
@@ -61,21 +64,39 @@ export async function configure(ctx) {
61
64
  const originalEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
62
65
  const doc = parseYaml(originalConfig);
63
66
  const data = asRecord(doc.toJS());
64
- const providers = Array.isArray(data.custom_providers) ? data.custom_providers.filter((provider) => provider?.name !== 'scalemax') : [];
65
- providers.push({
66
- name: 'scalemax',
67
- base_url: openAiBaseUrl(ctx.baseUrl),
68
- key_env: envKey,
69
- api_mode: 'chat_completions',
70
- extra_headers: { 'User-Agent': 'ScaleMax-Hermes/1.0' },
71
- max_output_tokens: 4096,
72
- models: modelMetadata(ctx)
73
- });
67
+ const codexIds = openAiModelIds(ctx);
68
+ const claudeIds = anthropicModelIds(ctx);
69
+ const managedNames = new Set([legacyProviderName, codexProviderName, claudeProviderName]);
70
+ const providers = Array.isArray(data.custom_providers) ? data.custom_providers.filter((provider) => !managedNames.has(provider?.name)) : [];
71
+ if (codexIds.length)
72
+ providers.push({
73
+ name: codexProviderName,
74
+ base_url: openAiBaseUrl(ctx.baseUrl),
75
+ key_env: envKey,
76
+ api_mode: 'codex_responses',
77
+ extra_headers: { 'User-Agent': 'ScaleMax-Hermes/1.0' },
78
+ max_output_tokens: 4096,
79
+ models: modelMetadata(ctx, codexIds)
80
+ });
81
+ if (claudeIds.length)
82
+ providers.push({
83
+ name: claudeProviderName,
84
+ base_url: anthropicOrigin(ctx.baseUrl),
85
+ key_env: envKey,
86
+ api_mode: 'anthropic_messages',
87
+ extra_headers: { 'User-Agent': 'ScaleMax-Hermes/1.0' },
88
+ max_output_tokens: 4096,
89
+ models: modelMetadata(ctx, claudeIds)
90
+ });
74
91
  doc.set('custom_providers', providers);
75
92
  const currentModel = asRecord(data.model);
76
- const activatesScaleMax = !currentModel.provider || String(currentModel.provider).startsWith('custom:scalemax');
93
+ const currentProvider = String(currentModel.provider || '');
94
+ const activatesScaleMax = !currentProvider || currentProvider.startsWith('custom:scalemax');
77
95
  if (activatesScaleMax) {
78
- doc.set('model', { ...currentModel, provider: 'custom:scalemax', default: preferredModel(ctx), max_tokens: 4096 });
96
+ const keepClaude = currentProvider === `custom:${claudeProviderName}` || claudeIds.includes(String(currentModel.default || ''));
97
+ const activeName = codexIds.length && !keepClaude ? codexProviderName : claudeProviderName;
98
+ const activeModel = activeName === codexProviderName ? preferredOpenAiModel(ctx) : preferredAnthropicModel(ctx);
99
+ doc.set('model', { ...currentModel, provider: `custom:${activeName}`, default: activeModel, max_tokens: 4096 });
79
100
  }
80
101
  const nextConfig = String(doc);
81
102
  const nextEnv = managedEnv(originalEnv, ctx.apiKey);
@@ -83,7 +104,7 @@ export async function configure(ctx) {
83
104
  await fs.chmod(file, 0o600).catch(() => undefined);
84
105
  await fs.chmod(secrets, 0o600).catch(() => undefined);
85
106
  await removeLegacyArtifact(ctx);
86
- return { id, name, changed: false, path: file, message: `already current with ${modelIds(ctx).length} models and a private credential` };
107
+ return { id, name, changed: false, path: file, message: `already current with ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models and a private credential` };
87
108
  }
88
109
  const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
89
110
  try {
@@ -102,25 +123,33 @@ export async function configure(ctx) {
102
123
  await fs.remove(secrets).catch(() => undefined);
103
124
  throw error;
104
125
  }
105
- return { id, name, changed: true, path: file, message: `configured named ScaleMax provider with ${modelIds(ctx).length} models${backups.length ? ' (backups created)' : ''}` };
126
+ return { id, name, changed: true, path: file, message: `configured family-specific ScaleMax providers with ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models${backups.length ? ' (backups created)' : ''}` };
106
127
  }
107
128
  export async function verify(ctx) {
108
129
  const file = clientConfigPath(id, ctx.home, ctx.platform);
109
130
  const secrets = envPath(ctx.home);
110
131
  const doc = parseYaml(await fs.readFile(file, 'utf8'));
111
132
  const data = asRecord(doc.toJS());
112
- const provider = (Array.isArray(data.custom_providers) ? data.custom_providers : []).find((candidate) => candidate?.name === 'scalemax');
113
- if (!provider || provider.base_url !== openAiBaseUrl(ctx.baseUrl) || provider.key_env !== envKey || provider.api_mode !== 'chat_completions' || provider.extra_headers?.['User-Agent'] !== 'ScaleMax-Hermes/1.0') {
114
- throw new Error('Hermes ScaleMax custom provider settings are incomplete.');
133
+ const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
134
+ const codexIds = openAiModelIds(ctx);
135
+ const claudeIds = anthropicModelIds(ctx);
136
+ const codex = providers.find((candidate) => candidate?.name === codexProviderName);
137
+ const claude = providers.find((candidate) => candidate?.name === claudeProviderName);
138
+ const valid = (provider, baseUrl, apiMode, ids) => provider
139
+ && provider.base_url === baseUrl && provider.key_env === envKey && provider.api_mode === apiMode
140
+ && provider.extra_headers?.['User-Agent'] === 'ScaleMax-Hermes/1.0'
141
+ && Object.keys(asRecord(provider.models)).length === ids.length
142
+ && ids.every(model => Object.hasOwn(asRecord(provider.models), model));
143
+ if ((codexIds.length ? !valid(codex, openAiBaseUrl(ctx.baseUrl), 'codex_responses', codexIds) : Boolean(codex))
144
+ || (claudeIds.length ? !valid(claude, anthropicOrigin(ctx.baseUrl), 'anthropic_messages', claudeIds) : Boolean(claude))) {
145
+ throw new Error('Hermes ScaleMax family-specific custom provider settings are incomplete.');
115
146
  }
116
- const configured = Object.keys(asRecord(provider.models));
117
- const missing = modelIds(ctx).filter(model => !configured.includes(model));
118
147
  const env = await fs.readFile(secrets, 'utf8').catch(() => '');
119
- if (missing.length || !env.includes(envMarker) || !env.includes(`${envKey}=${ctx.apiKey}`))
148
+ if (!env.includes(envMarker) || !env.includes(`${envKey}=${ctx.apiKey}`))
120
149
  throw new Error('Hermes is missing authenticated ScaleMax models or credentials.');
121
- if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens !== 4096)
150
+ if (String(data?.model?.provider || '').startsWith('custom:scalemax') && data?.model?.max_tokens !== 4096)
122
151
  throw new Error('Hermes active ScaleMax model must use max_tokens 4096.');
123
- return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
152
+ return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models` };
124
153
  }
125
154
  export async function remove(ctx) {
126
155
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -131,20 +160,23 @@ export async function remove(ctx) {
131
160
  const doc = parseYaml(text);
132
161
  const data = asRecord(doc.toJS());
133
162
  const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
134
- const managedProvider = providers.find((provider) => provider?.name === 'scalemax' && provider?.base_url === openAiBaseUrl(ctx.baseUrl));
135
- if (!managedProvider)
163
+ const managedNames = new Set([legacyProviderName, codexProviderName, claudeProviderName]);
164
+ const managedProviders = providers.filter((provider) => managedNames.has(provider?.name)
165
+ && (provider?.base_url === openAiBaseUrl(ctx.baseUrl) || provider?.base_url === anthropicOrigin(ctx.baseUrl)));
166
+ if (!managedProviders.length)
136
167
  return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
137
- const managedModels = Object.keys(asRecord(managedProvider.models));
138
- const remaining = providers.filter((provider) => provider?.name !== 'scalemax');
168
+ const managedModels = managedProviders.flatMap((provider) => Object.keys(asRecord(provider.models)));
169
+ const remaining = providers.filter((provider) => !managedProviders.includes(provider));
139
170
  if (remaining.length)
140
171
  doc.set('custom_providers', remaining);
141
172
  else
142
173
  doc.delete('custom_providers');
143
- if (data?.model?.provider === 'custom:scalemax')
174
+ const activeManaged = String(data?.model?.provider || '').startsWith('custom:scalemax');
175
+ if (activeManaged)
144
176
  doc.deleteIn(['model', 'provider']);
145
- if (data?.model?.provider === 'custom:scalemax' && managedModels.includes(String(data?.model?.default || '')))
177
+ if (activeManaged && managedModels.includes(String(data?.model?.default || '')))
146
178
  doc.deleteIn(['model', 'default']);
147
- if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens === 4096)
179
+ if (activeManaged && data?.model?.max_tokens === 4096)
148
180
  doc.deleteIn(['model', 'max_tokens']);
149
181
  const currentEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
150
182
  await backupFile(file, ctx.home);
@@ -1,7 +1,12 @@
1
1
  import { ConfigureContext } from '../types/client.js';
2
2
  export declare function modelIds(ctx: ConfigureContext): string[];
3
+ export declare function anthropicModelIds(ctx: ConfigureContext): string[];
4
+ export declare function openAiModelIds(ctx: ConfigureContext): string[];
5
+ export declare function preferredAnthropicModel(ctx: ConfigureContext): string;
6
+ export declare function preferredOpenAiModel(ctx: ConfigureContext): string;
3
7
  export declare function preferredModel(ctx: ConfigureContext): string;
4
8
  export declare function fastModel(ctx: ConfigureContext): string | undefined;
9
+ export declare function fastOpenAiModel(ctx: ConfigureContext): string | undefined;
5
10
  export declare function modelName(ctx: ConfigureContext, id: string): string;
6
11
  export declare function anthropicOrigin(baseUrl: string): string;
7
12
  export declare function openAiBaseUrl(baseUrl: string): string;
@@ -1,18 +1,49 @@
1
- const preferred = [
1
+ import { isAnthropicModel } from '../utils/api.js';
2
+ const preferredAnthropic = [
2
3
  'claude-opus-4-8',
3
4
  'claude-sonnet-5',
4
5
  'claude-opus-4-7',
5
6
  'claude-opus-4-6',
6
- 'claude-haiku-4-5-20251001',
7
+ 'claude-haiku-4-5-20251001'
8
+ ];
9
+ const preferredOpenAi = [
10
+ 'gpt-5.6-terra',
11
+ 'gpt-5.6-luna',
12
+ 'gpt-5.6-sol',
13
+ 'gpt-5.5',
14
+ 'gpt-5.4',
15
+ 'gpt-5.4-mini',
16
+ 'gpt-5.3-codex',
7
17
  'fable-5',
8
- 'fable-5-ultra'
18
+ 'fable-5-ultra',
19
+ 'codex-auto-review'
9
20
  ];
10
21
  export function modelIds(ctx) {
11
22
  return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(Boolean))];
12
23
  }
24
+ export function anthropicModelIds(ctx) {
25
+ return [...new Set((ctx.models || []).filter(isAnthropicModel).map(model => model.id.trim()).filter(Boolean))];
26
+ }
27
+ export function openAiModelIds(ctx) {
28
+ return [...new Set((ctx.models || []).filter(model => !isAnthropicModel(model)).map(model => model.id.trim()).filter(Boolean))];
29
+ }
30
+ function preferredFrom(ids, candidates, family) {
31
+ for (const candidate of candidates)
32
+ if (ids.includes(candidate))
33
+ return candidate;
34
+ if (ids.length)
35
+ return ids[0];
36
+ throw new Error(`No compatible ${family} models are available for this API key; the client was not changed.`);
37
+ }
38
+ export function preferredAnthropicModel(ctx) {
39
+ return preferredFrom(anthropicModelIds(ctx), preferredAnthropic, 'Claude');
40
+ }
41
+ export function preferredOpenAiModel(ctx) {
42
+ return preferredFrom(openAiModelIds(ctx), preferredOpenAi, 'Codex/OpenAI');
43
+ }
13
44
  export function preferredModel(ctx) {
14
45
  const ids = modelIds(ctx);
15
- for (const candidate of preferred)
46
+ for (const candidate of [...preferredAnthropic, ...preferredOpenAi])
16
47
  if (ids.includes(candidate))
17
48
  return candidate;
18
49
  if (ids.length)
@@ -24,6 +55,11 @@ export function fastModel(ctx) {
24
55
  return ids.find(id => id === 'claude-haiku-4-5-20251001')
25
56
  || ids.find(id => /(^|-)haiku([-.]|$)/i.test(id));
26
57
  }
58
+ export function fastOpenAiModel(ctx) {
59
+ const ids = openAiModelIds(ctx);
60
+ return ids.find(id => id === 'gpt-5.4-mini')
61
+ || ids.find(id => /(^|[-.])(mini|nano)([-.]|$)/i.test(id));
62
+ }
27
63
  export function modelName(ctx, id) {
28
64
  const model = (ctx.models || []).find(candidate => candidate.id === id);
29
65
  if (model?.display_name)
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
3
3
  import { atomicWrite, backupFile } from '../utils/fs.js';
4
4
  import { clientConfigPath } from './paths.js';
5
5
  import { isScaleMaxManagedText, writeMergedJsonConfig } from './common.js';
6
- import { anthropicOrigin, fastModel, modelIds, modelName, preferredModel } from './models.js';
6
+ import { anthropicModelIds, anthropicOrigin, fastModel, fastOpenAiModel, modelName, openAiBaseUrl, openAiModelIds, preferredAnthropicModel, preferredOpenAiModel } from './models.js';
7
7
  import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
8
8
  const id = 'openclaw';
9
9
  const name = 'OpenClaw';
@@ -14,22 +14,33 @@ const userAgentProviderId = 'scalemax_cli_user_agent';
14
14
  const userAgentConfigPath = '~/.openclaw/secrets/scalemax-user-agent';
15
15
  const userAgentValue = 'ScaleMax-OpenClaw/1.0';
16
16
  const userAgentReference = { source: 'file', provider: userAgentProviderId, id: 'value' };
17
+ const legacyProviderName = 'scalemax';
18
+ const codexProviderName = 'scalemax-codex';
19
+ const claudeProviderName = 'scalemax-claude';
17
20
  function secretPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-api-key'); }
18
21
  function userAgentPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-user-agent'); }
19
22
  function userAgentContext(ctx) { return { ...ctx, apiKey: userAgentValue }; }
20
23
  function asRecord(value) {
21
24
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
22
25
  }
23
- function entries(ctx) {
24
- return modelIds(ctx).map(modelId => {
26
+ function isScaleMaxBaseUrl(value) {
27
+ try {
28
+ return new URL(String(value)).hostname === 'api.scalemax.pro';
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function entries(ctx, ids, family) {
35
+ return ids.map(modelId => {
25
36
  const metadata = (ctx.models || []).find(model => model.id === modelId);
26
37
  const result = {
27
38
  id: modelId,
28
39
  name: modelName(ctx, modelId),
29
- input: modelId.startsWith('claude-') && !modelId.startsWith('claude-fable-') ? ['text', 'image'] : ['text'],
40
+ input: family === 'anthropic' ? ['text', 'image'] : ['text'],
30
41
  maxTokens: Math.min(metadata?.max_output_tokens || 4096, 4096)
31
42
  };
32
- if (metadata?.context_window || modelId.startsWith('claude-'))
43
+ if (metadata?.context_window || family === 'anthropic')
33
44
  result.contextWindow = metadata?.context_window || 200000;
34
45
  return result;
35
46
  });
@@ -65,10 +76,14 @@ async function scrubGeneratedPlaintextCredentials(ctx) {
65
76
  const roots = [asRecord(data.providers), asRecord(asRecord(data.models).providers)];
66
77
  let scrubbed = false;
67
78
  for (const providers of roots) {
68
- const provider = asRecord(providers.scalemax);
69
- if (provider.baseUrl === anthropicOrigin(ctx.baseUrl) && provider.apiKey === ctx.apiKey) {
70
- delete provider.apiKey;
71
- scrubbed = true;
79
+ for (const [providerName, rawProvider] of Object.entries(providers)) {
80
+ const provider = asRecord(rawProvider);
81
+ const isScaleMax = [legacyProviderName, codexProviderName, claudeProviderName].includes(providerName)
82
+ && isScaleMaxBaseUrl(provider.baseUrl);
83
+ if (isScaleMax && typeof provider.apiKey === 'string') {
84
+ delete provider.apiKey;
85
+ scrubbed = true;
86
+ }
72
87
  }
73
88
  }
74
89
  if (!scrubbed)
@@ -81,8 +96,12 @@ async function scrubGeneratedPlaintextCredentials(ctx) {
81
96
  }
82
97
  export async function configure(ctx) {
83
98
  const file = clientConfigPath(id, ctx.home, ctx.platform);
84
- const primary = `scalemax/${preferredModel(ctx)}`;
85
- const quick = fastModel(ctx);
99
+ const codexIds = openAiModelIds(ctx);
100
+ const claudeIds = anthropicModelIds(ctx);
101
+ const codexPrimary = codexIds.length ? `${codexProviderName}/${preferredOpenAiModel(ctx)}` : undefined;
102
+ const claudePrimary = claudeIds.length ? `${claudeProviderName}/${preferredAnthropicModel(ctx)}` : undefined;
103
+ const codexQuick = fastOpenAiModel(ctx);
104
+ const claudeQuick = fastModel(ctx);
86
105
  let secret = { changed: false };
87
106
  let userAgentSecret = { changed: false };
88
107
  let result;
@@ -97,16 +116,52 @@ export async function configure(ctx) {
97
116
  const agents = asRecord(existing.agents);
98
117
  const defaults = asRecord(agents.defaults);
99
118
  const allowlist = asRecord(defaults.models);
100
- for (const modelId of modelIds(ctx))
101
- allowlist[`scalemax/${modelId}`] = asRecord(allowlist[`scalemax/${modelId}`]);
119
+ for (const key of Object.keys(allowlist)) {
120
+ if (key.startsWith(`${legacyProviderName}/`) || key.startsWith(`${codexProviderName}/`) || key.startsWith(`${claudeProviderName}/`))
121
+ delete allowlist[key];
122
+ }
123
+ for (const modelId of codexIds)
124
+ allowlist[`${codexProviderName}/${modelId}`] = {};
125
+ for (const modelId of claudeIds)
126
+ allowlist[`${claudeProviderName}/${modelId}`] = {};
102
127
  const currentModel = defaults.model;
103
128
  const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
104
- const activatesScaleMax = !currentPrimary || String(currentPrimary).startsWith('scalemax/');
129
+ const activatesScaleMax = !currentPrimary || String(currentPrimary).startsWith('scalemax');
130
+ const currentId = String(currentPrimary || '').split('/').slice(1).join('/');
131
+ const keepClaude = String(currentPrimary || '').startsWith(`${claudeProviderName}/`) || claudeIds.includes(currentId);
132
+ const primary = keepClaude ? claudePrimary || codexPrimary : codexPrimary || claudePrimary;
133
+ const usesClaude = Boolean(primary?.startsWith(`${claudeProviderName}/`));
105
134
  const nextModel = activatesScaleMax
106
- ? { ...asRecord(currentModel), primary }
135
+ ? { ...asRecord(currentModel), ...(primary ? { primary } : {}) }
107
136
  : currentModel;
108
137
  const currentUtility = typeof defaults.utilityModel === 'string' ? defaults.utilityModel : undefined;
109
- const utilityModel = quick && (activatesScaleMax || currentUtility?.startsWith('scalemax/')) ? `scalemax/${quick}` : currentUtility;
138
+ const quick = usesClaude ? claudeQuick : codexQuick;
139
+ const utilityProvider = usesClaude ? claudeProviderName : codexProviderName;
140
+ const utilityModel = quick && (activatesScaleMax || currentUtility?.startsWith('scalemax')) ? `${utilityProvider}/${quick}` : currentUtility;
141
+ const nextProviders = { ...providers };
142
+ delete nextProviders[legacyProviderName];
143
+ if (codexIds.length)
144
+ nextProviders[codexProviderName] = {
145
+ baseUrl: openAiBaseUrl(ctx.baseUrl),
146
+ apiKey: secretReference,
147
+ api: 'openai-responses',
148
+ maxTokens: 4096,
149
+ request: { headers: { 'User-Agent': userAgentReference }, allowPrivateNetwork: false },
150
+ models: entries(ctx, codexIds, 'openai')
151
+ };
152
+ else
153
+ delete nextProviders[codexProviderName];
154
+ if (claudeIds.length)
155
+ nextProviders[claudeProviderName] = {
156
+ baseUrl: anthropicOrigin(ctx.baseUrl),
157
+ apiKey: secretReference,
158
+ api: 'anthropic-messages',
159
+ maxTokens: 4096,
160
+ request: { headers: { 'User-Agent': userAgentReference }, allowPrivateNetwork: false },
161
+ models: entries(ctx, claudeIds, 'anthropic')
162
+ };
163
+ else
164
+ delete nextProviders[claudeProviderName];
110
165
  return JSON.stringify({
111
166
  ...existing,
112
167
  secrets: {
@@ -120,20 +175,7 @@ export async function configure(ctx) {
120
175
  models: {
121
176
  ...modelsRoot,
122
177
  mode: modelsRoot.mode || 'merge',
123
- providers: {
124
- ...providers,
125
- scalemax: {
126
- baseUrl: anthropicOrigin(ctx.baseUrl),
127
- apiKey: secretReference,
128
- api: 'anthropic-messages',
129
- maxTokens: 4096,
130
- request: {
131
- headers: { 'User-Agent': userAgentReference },
132
- allowPrivateNetwork: false
133
- },
134
- models: entries(ctx)
135
- }
136
- }
178
+ providers: nextProviders
137
179
  },
138
180
  agents: {
139
181
  ...agents,
@@ -149,25 +191,37 @@ export async function configure(ctx) {
149
191
  }
150
192
  await removeLegacyArtifact(ctx);
151
193
  const scrubbed = await scrubGeneratedPlaintextCredentials(ctx);
152
- return { ...result, changed: result.changed || secret.changed || userAgentSecret.changed || scrubbed > 0, message: `configured native Anthropic provider with ${modelIds(ctx).length} models and file-backed SecretRefs${quick ? `; ${quick} handles utility work` : ''}${scrubbed ? `; scrubbed ${scrubbed} generated credential ${scrubbed === 1 ? 'copy' : 'copies'}` : ''}` };
194
+ return { ...result, changed: result.changed || secret.changed || userAgentSecret.changed || scrubbed > 0, message: `configured native Responses (${codexIds.length}) and Anthropic Messages (${claudeIds.length}) providers with file-backed SecretRefs${scrubbed ? `; scrubbed ${scrubbed} generated credential ${scrubbed === 1 ? 'copy' : 'copies'}` : ''}` };
153
195
  }
154
196
  export async function verify(ctx) {
155
197
  const file = clientConfigPath(id, ctx.home, ctx.platform);
156
198
  const config = await fs.readJson(file);
157
- const provider = config?.models?.providers?.scalemax;
199
+ const codex = config?.models?.providers?.[codexProviderName];
200
+ const claude = config?.models?.providers?.[claudeProviderName];
158
201
  const secretProvider = config?.secrets?.providers?.[secretProviderId];
159
202
  const userAgentProvider = config?.secrets?.providers?.[userAgentProviderId];
160
- const userAgent = provider?.request?.headers?.['User-Agent'];
161
- if (provider?.baseUrl !== anthropicOrigin(ctx.baseUrl) || provider?.apiKey?.source !== secretReference.source || provider?.apiKey?.provider !== secretReference.provider || provider?.apiKey?.id !== secretReference.id || provider?.api !== 'anthropic-messages' || secretProvider?.source !== 'file' || secretProvider?.path !== secretConfigPath || secretProvider?.mode !== 'singleValue' || userAgent?.source !== userAgentReference.source || userAgent?.provider !== userAgentReference.provider || userAgent?.id !== userAgentReference.id || userAgentProvider?.source !== 'file' || userAgentProvider?.path !== userAgentConfigPath || userAgentProvider?.mode !== 'singleValue') {
203
+ const codexIds = openAiModelIds(ctx);
204
+ const claudeIds = anthropicModelIds(ctx);
205
+ const valid = (provider, baseUrl, api, ids) => {
206
+ const userAgent = provider?.request?.headers?.['User-Agent'];
207
+ const configured = new Set((Array.isArray(provider?.models) ? provider.models : []).map((model) => model?.id));
208
+ return provider?.baseUrl === baseUrl && provider?.apiKey?.source === secretReference.source
209
+ && provider?.apiKey?.provider === secretReference.provider && provider?.apiKey?.id === secretReference.id
210
+ && provider?.api === api && provider?.request?.allowPrivateNetwork === false
211
+ && userAgent?.source === userAgentReference.source && userAgent?.provider === userAgentReference.provider
212
+ && userAgent?.id === userAgentReference.id && configured.size === ids.length && ids.every(model => configured.has(model));
213
+ };
214
+ if ((codexIds.length ? !valid(codex, openAiBaseUrl(ctx.baseUrl), 'openai-responses', codexIds) : Boolean(codex))
215
+ || (claudeIds.length ? !valid(claude, anthropicOrigin(ctx.baseUrl), 'anthropic-messages', claudeIds) : Boolean(claude))
216
+ || secretProvider?.source !== 'file' || secretProvider?.path !== secretConfigPath || secretProvider?.mode !== 'singleValue'
217
+ || userAgentProvider?.source !== 'file' || userAgentProvider?.path !== userAgentConfigPath || userAgentProvider?.mode !== 'singleValue') {
162
218
  throw new Error('OpenClaw ScaleMax provider settings are incomplete.');
163
219
  }
164
220
  await verifyClientSecret(secretPath(ctx.home), ctx);
165
221
  await verifyClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
166
- const configured = new Set((Array.isArray(provider.models) ? provider.models : []).map((model) => model?.id));
167
- const missing = modelIds(ctx).filter(model => !configured.has(model));
168
- if (missing.length)
169
- throw new Error(`OpenClaw is missing ${missing.length} authenticated model(s).`);
170
- return { id, name, changed: false, path: file, message: `verified ${configured.size} ScaleMax models` };
222
+ if (config?.models?.providers?.[legacyProviderName])
223
+ throw new Error('OpenClaw still contains the obsolete mixed ScaleMax provider.');
224
+ return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models` };
171
225
  }
172
226
  export async function remove(ctx) {
173
227
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -177,10 +231,16 @@ export async function remove(ctx) {
177
231
  return { id, name, changed: removed || removedUserAgent, path: file, message: removed || removedUserAgent ? 'removed orphaned ScaleMax OpenClaw credentials' : 'no ScaleMax configuration found' };
178
232
  }
179
233
  const config = await fs.readJson(file).catch(() => undefined);
180
- if (!config?.models?.providers?.scalemax || config.models.providers.scalemax.baseUrl !== anthropicOrigin(ctx.baseUrl)) {
234
+ const providerRoot = asRecord(config?.models?.providers);
235
+ const managedNames = [legacyProviderName, codexProviderName, claudeProviderName].filter(providerName => {
236
+ const provider = asRecord(providerRoot[providerName]);
237
+ return [anthropicOrigin(ctx.baseUrl), openAiBaseUrl(ctx.baseUrl)].includes(String(provider.baseUrl || ''));
238
+ });
239
+ if (!managedNames.length) {
181
240
  return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
182
241
  }
183
- delete config.models.providers.scalemax;
242
+ for (const providerName of managedNames)
243
+ delete config.models.providers[providerName];
184
244
  if (!Object.keys(config.models.providers).length)
185
245
  delete config.models.providers;
186
246
  if (Object.keys(config.models).length === 1 && config.models.mode === 'merge')
@@ -188,19 +248,19 @@ export async function remove(ctx) {
188
248
  const defaults = config?.agents?.defaults;
189
249
  if (defaults?.models && typeof defaults.models === 'object') {
190
250
  for (const key of Object.keys(defaults.models))
191
- if (key.startsWith('scalemax/'))
251
+ if (key.startsWith('scalemax'))
192
252
  delete defaults.models[key];
193
253
  if (!Object.keys(defaults.models).length)
194
254
  delete defaults.models;
195
255
  }
196
- if (typeof defaults?.model === 'string' && defaults.model.startsWith('scalemax/'))
256
+ if (typeof defaults?.model === 'string' && defaults.model.startsWith('scalemax'))
197
257
  delete defaults.model;
198
- if (defaults?.model?.primary?.startsWith?.('scalemax/')) {
258
+ if (defaults?.model?.primary?.startsWith?.('scalemax')) {
199
259
  delete defaults.model.primary;
200
260
  if (!Object.keys(defaults.model).length)
201
261
  delete defaults.model;
202
262
  }
203
- if (typeof defaults?.utilityModel === 'string' && defaults.utilityModel.startsWith('scalemax/'))
263
+ if (typeof defaults?.utilityModel === 'string' && defaults.utilityModel.startsWith('scalemax'))
204
264
  delete defaults.utilityModel;
205
265
  if (defaults && !Object.keys(defaults).length)
206
266
  delete config.agents.defaults;
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
3
3
  import { atomicWrite, backupFile } from '../utils/fs.js';
4
4
  import { clientConfigPath } from './paths.js';
5
5
  import { writeMergedJsonConfig } from './common.js';
6
- import { fastModel, modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
6
+ import { fastOpenAiModel, modelName, openAiBaseUrl, openAiModelIds, preferredOpenAiModel } from './models.js';
7
7
  import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
8
8
  const id = 'opencode';
9
9
  const name = 'OpenCode';
@@ -13,7 +13,7 @@ function asRecord(value) {
13
13
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
14
  }
15
15
  function providerConfig(ctx) {
16
- const models = Object.fromEntries(modelIds(ctx).map(modelId => {
16
+ const models = Object.fromEntries(openAiModelIds(ctx).map(modelId => {
17
17
  const metadata = (ctx.models || []).find(model => model.id === modelId);
18
18
  return [modelId, {
19
19
  name: modelName(ctx, modelId),
@@ -32,8 +32,8 @@ function providerConfig(ctx) {
32
32
  }
33
33
  export async function configure(ctx) {
34
34
  const file = clientConfigPath(id, ctx.home, ctx.platform);
35
- const selected = `scalemax/${preferredModel(ctx)}`;
36
- const quick = fastModel(ctx);
35
+ const selected = `scalemax/${preferredOpenAiModel(ctx)}`;
36
+ const quick = fastOpenAiModel(ctx);
37
37
  const secret = await writeClientSecret(secretPath(ctx.home), ctx);
38
38
  let result;
39
39
  try {
@@ -66,9 +66,11 @@ export async function verify(ctx) {
66
66
  }
67
67
  await verifyClientSecret(secretPath(ctx.home), ctx);
68
68
  const configured = Object.keys(asRecord(provider.models));
69
- const missing = modelIds(ctx).filter(model => !configured.includes(model));
70
- if (missing.length)
71
- throw new Error(`OpenCode is missing ${missing.length} authenticated model(s).`);
69
+ const expected = openAiModelIds(ctx);
70
+ const missing = expected.filter(model => !configured.includes(model));
71
+ const unexpected = configured.filter(model => !expected.includes(model));
72
+ if (missing.length || unexpected.length)
73
+ throw new Error(`OpenCode model catalog is not restricted to the ${expected.length} authenticated Codex/OpenAI model(s).`);
72
74
  return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
73
75
  }
74
76
  export async function remove(ctx) {
@@ -9,6 +9,7 @@ export function clientConfigPath(id, home, platform, env = process.env) {
9
9
  switch (id) {
10
10
  case 'codex': return codexConfigPath(home);
11
11
  case 'cursor': return platform === 'win32' ? path.join(app, 'Cursor', 'User', 'settings.json') : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json') : path.join(linux, 'Cursor', 'User', 'settings.json');
12
+ case 'roo-code': return platform === 'win32' ? path.join(app, 'Code', 'User', 'settings.json') : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json') : path.join(linux, 'Code', 'User', 'settings.json');
12
13
  case 'trae-solo': return path.join(home, '.scalemax', 'trae-solo.setup.json');
13
14
  case 'claude': return path.join(home, '.claude', 'settings.json');
14
15
  case 'opencode': return path.join(linux, 'opencode', 'opencode.json');
@@ -24,6 +25,7 @@ export function installHints(id, home, platform, env = process.env) {
24
25
  switch (id) {
25
26
  case 'codex': return [codexDir(home), path.join(linux, 'codex')];
26
27
  case 'cursor': return [platform === 'darwin' ? '/Applications/Cursor.app' : path.join(app, 'Cursor'), path.join(linux, 'Cursor')];
28
+ case 'roo-code': return [path.join(home, '.vscode', 'extensions'), path.join(home, '.vscode-insiders', 'extensions')];
27
29
  case 'trae-solo': return [platform === 'darwin' ? '/Applications/Trae.app' : path.join(app, 'Trae'), path.join(linux, 'Trae')];
28
30
  case 'claude': return [path.join(home, '.claude')];
29
31
  case 'opencode': return [path.join(linux, 'opencode')];
@@ -0,0 +1,4 @@
1
+ import { ConfigureContext, ConfigureResult } from '../types/client.js';
2
+ export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
4
+ export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -0,0 +1,119 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import JSON5 from 'json5';
4
+ import { atomicWrite, backupFile } from '../utils/fs.js';
5
+ import { clientConfigPath } from './paths.js';
6
+ import { openAiBaseUrl, openAiModelIds, preferredOpenAiModel } from './models.js';
7
+ const id = 'roo-code';
8
+ const name = 'Roo Code (VS Code)';
9
+ const importSetting = 'roo-cline.autoImportSettingsPath';
10
+ const importSettingValue = '~/.scalemax/roo-code-import.json';
11
+ const profileName = 'ScaleMax Codex';
12
+ const profileId = 'scalemax-codex';
13
+ function importPath(home) { return path.join(home, '.scalemax', 'roo-code-import.json'); }
14
+ function parseSettings(text, file) {
15
+ if (!text)
16
+ return {};
17
+ try {
18
+ const value = JSON5.parse(text);
19
+ if (!value || typeof value !== 'object' || Array.isArray(value))
20
+ throw new Error('root value must be an object');
21
+ return value;
22
+ }
23
+ catch (error) {
24
+ throw new Error(`Roo Code cannot update invalid VS Code settings at ${file}: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+ function handoff(ctx) {
28
+ const selected = preferredOpenAiModel(ctx);
29
+ return JSON.stringify({
30
+ providerProfiles: {
31
+ currentApiConfigName: profileName,
32
+ apiConfigs: {
33
+ [profileName]: {
34
+ id: profileId,
35
+ apiProvider: 'openai',
36
+ openAiBaseUrl: openAiBaseUrl(ctx.baseUrl),
37
+ openAiApiKey: ctx.apiKey,
38
+ openAiModelId: selected,
39
+ openAiStreamingEnabled: true,
40
+ includeMaxTokens: true
41
+ }
42
+ },
43
+ modeApiConfigs: {}
44
+ },
45
+ globalSettings: {}
46
+ }, null, 2) + '\n';
47
+ }
48
+ export async function configure(ctx) {
49
+ if (!openAiModelIds(ctx).length)
50
+ throw new Error('Roo Code OpenAI Compatible setup requires at least one Codex/OpenAI model.');
51
+ const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
52
+ const profileFile = importPath(ctx.home);
53
+ const originalSettings = await fs.readFile(settingsFile, 'utf8').catch(() => '');
54
+ const originalProfile = await fs.readFile(profileFile, 'utf8').catch(() => '');
55
+ const settings = parseSettings(originalSettings, settingsFile);
56
+ const nextSettings = JSON.stringify({ ...settings, [importSetting]: importSettingValue }, null, 2) + '\n';
57
+ const nextProfile = handoff(ctx);
58
+ if (originalSettings === nextSettings && originalProfile === nextProfile) {
59
+ await fs.chmod(profileFile, 0o600).catch(() => undefined);
60
+ return { id, name, changed: false, path: profileFile, message: 'supported Roo Code auto-import handoff is already current; reload VS Code to consume it' };
61
+ }
62
+ const originals = new Map([[settingsFile, originalSettings], [profileFile, originalProfile]]);
63
+ await backupFile(settingsFile, ctx.home);
64
+ await backupFile(profileFile, ctx.home);
65
+ try {
66
+ await atomicWrite(profileFile, nextProfile, 0o600, ctx.platform);
67
+ await atomicWrite(settingsFile, nextSettings, 0o600, ctx.platform);
68
+ }
69
+ catch (error) {
70
+ for (const [file, original] of originals) {
71
+ if (original)
72
+ await atomicWrite(file, original, 0o600, ctx.platform);
73
+ else
74
+ await fs.remove(file).catch(() => undefined);
75
+ }
76
+ throw error;
77
+ }
78
+ return { id, name, changed: true, path: profileFile, message: 'created a private, supported auto-import handoff; reload VS Code so Roo Code imports the profile into SecretStorage' };
79
+ }
80
+ export async function verify(ctx) {
81
+ const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
82
+ const profileFile = importPath(ctx.home);
83
+ const settings = parseSettings(await fs.readFile(settingsFile, 'utf8'), settingsFile);
84
+ const profile = await fs.readJson(profileFile).catch(() => undefined);
85
+ const config = profile?.providerProfiles?.apiConfigs?.[profileName];
86
+ if (settings[importSetting] !== importSettingValue || profile?.providerProfiles?.currentApiConfigName !== profileName
87
+ || config?.id !== profileId || config?.apiProvider !== 'openai' || config?.openAiBaseUrl !== openAiBaseUrl(ctx.baseUrl)
88
+ || config?.openAiApiKey !== ctx.apiKey || config?.openAiModelId !== preferredOpenAiModel(ctx) || config?.openAiStreamingEnabled !== true
89
+ || config?.includeMaxTokens !== true) {
90
+ throw new Error('Roo Code auto-import handoff is incomplete.');
91
+ }
92
+ if (ctx.platform !== 'win32' && ((await fs.stat(profileFile)).mode & 0o077) !== 0)
93
+ throw new Error('Roo Code import handoff permissions are too broad.');
94
+ return { id, name, changed: false, path: profileFile, message: 'verified supported auto-import handoff (runtime activation requires a VS Code reload)' };
95
+ }
96
+ export async function remove(ctx) {
97
+ const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
98
+ const profileFile = importPath(ctx.home);
99
+ let changed = false;
100
+ if (await fs.pathExists(settingsFile)) {
101
+ const text = await fs.readFile(settingsFile, 'utf8');
102
+ const settings = parseSettings(text, settingsFile);
103
+ if (settings[importSetting] === importSettingValue) {
104
+ delete settings[importSetting];
105
+ await backupFile(settingsFile, ctx.home);
106
+ await atomicWrite(settingsFile, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
107
+ changed = true;
108
+ }
109
+ }
110
+ if (await fs.pathExists(profileFile)) {
111
+ const profile = await fs.readJson(profileFile).catch(() => undefined);
112
+ if (profile?.providerProfiles?.apiConfigs?.[profileName]?.id === profileId) {
113
+ await backupFile(profileFile, ctx.home);
114
+ await fs.remove(profileFile);
115
+ changed = true;
116
+ }
117
+ }
118
+ return { id, name, changed, path: profileFile, message: changed ? 'removed only the ScaleMax Roo Code auto-import handoff' : 'no ScaleMax Roo Code handoff found' };
119
+ }
@@ -11,7 +11,7 @@ export function jsonConfig(client, apiKey, baseUrl, extra = {}) {
11
11
  }, null, 2) + '\n';
12
12
  }
13
13
  export function continueConfig(apiKey, baseUrl) {
14
- const models = ['fable-5', 'fable-5-ultra', 'gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'codex-auto-review'];
14
+ const models = ['fable-5', 'gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'codex-auto-review'];
15
15
  return JSON.stringify({
16
16
  models: models.map(model => ({
17
17
  title: `ScaleMax ${model}`,
@@ -1,4 +1,5 @@
1
1
  export declare const SCALEMAX_BASE_URL = "https://api.scalemax.pro/v1";
2
+ export declare const SCALEMAX_TOKEN_BASE_URL = "https://api.scalemax.pro/token/v1";
2
3
  export declare const APP_NAME = "ScaleMax";
3
4
  export declare const GENERATED_HEADER = "ScaleMax managed configuration";
4
5
  export declare const KEY_ENV = "SCALEMAX_API_KEY";
package/dist/constants.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export const SCALEMAX_BASE_URL = 'https://api.scalemax.pro/v1';
2
+ export const SCALEMAX_TOKEN_BASE_URL = 'https://api.scalemax.pro/token/v1';
2
3
  export const APP_NAME = 'ScaleMax';
3
4
  export const GENERATED_HEADER = 'ScaleMax managed configuration';
4
5
  export const KEY_ENV = 'SCALEMAX_API_KEY';
@@ -15,5 +15,17 @@ export async function detect(ctx) {
15
15
  paths.push(auth);
16
16
  if (!paths.length && await fs.pathExists(codexDir))
17
17
  paths.push(codexDir);
18
- return { id: 'codex', name: 'Codex', installed: executable, reason: executable ? undefined : (paths.length ? 'configuration exists but codex executable is missing' : 'not detected'), paths, executable, configured: await fs.pathExists(config) && await fs.pathExists(auth), installable: true };
18
+ let configured = false;
19
+ if (await fs.pathExists(config) && await fs.pathExists(auth)) {
20
+ const configText = await fs.readFile(config, 'utf8').catch(() => '');
21
+ const authJson = await fs.readJson(auth).catch(() => ({}));
22
+ configured = /^\s*model_provider\s*=\s*["']scalemax["']/m.test(configText)
23
+ && /\[model_providers\.scalemax\]/.test(configText)
24
+ && /^\s*wire_api\s*=\s*["']responses["']/m.test(configText)
25
+ && /^\s*base_url\s*=\s*["']https:\/\/api\.scalemax\.pro\/(?:token\/)?v1\/?["']/m.test(configText)
26
+ && authJson.auth_mode === 'apikey'
27
+ && typeof authJson.OPENAI_API_KEY === 'string'
28
+ && /^sm_(?:live|dev|trial)_/.test(authJson.OPENAI_API_KEY);
29
+ }
30
+ return { id: 'codex', name: 'Codex', installed: executable, reason: executable ? undefined : (paths.length ? 'configuration exists but codex executable is missing' : 'not detected'), paths, executable, configured, installable: true };
19
31
  }
@@ -3,7 +3,7 @@ import { commandExists } from '../utils/exec.js';
3
3
  import { clientConfigPath, installHints } from '../configure/paths.js';
4
4
  import { GENERATED_HEADER } from '../constants.js';
5
5
  const commands = {
6
- codex: ['codex'], cursor: ['cursor'], 'trae-solo': ['trae', 'trae-solo'], claude: ['claude'], opencode: ['opencode'], openclaw: ['openclaw'], hermes: ['hermes'], 'cherry-studio': ['cherry-studio', 'cherrystudio'], 'api-code': []
6
+ codex: ['codex'], cursor: ['cursor'], 'roo-code': ['code'], 'trae-solo': ['trae', 'trae-solo'], claude: ['claude'], opencode: ['opencode'], openclaw: ['openclaw'], hermes: ['hermes'], 'cherry-studio': ['cherry-studio', 'cherrystudio'], 'api-code': []
7
7
  };
8
8
  const guiPathDetection = new Set(['trae-solo', 'cherry-studio']);
9
9
  function hasNativeScaleMaxConfig(id, text) {
@@ -12,13 +12,13 @@ function hasNativeScaleMaxConfig(id, text) {
12
12
  if (!text.includes('api.scalemax.pro'))
13
13
  return false;
14
14
  if (id === 'hermes')
15
- return /(?:^|\n)\s*-?\s*name:\s*["']?scalemax["']?\s*(?:#.*)?(?:\n|$)/i.test(text);
15
+ return /(?:^|\n)\s*-?\s*name:\s*["']?scalemax(?:-(?:codex|claude))?["']?\s*(?:#.*)?(?:\n|$)/i.test(text);
16
16
  if (id === 'opencode' || id === 'openclaw') {
17
17
  try {
18
18
  const config = JSON.parse(text);
19
19
  return id === 'opencode'
20
20
  ? config?.provider?.scalemax?.name === 'ScaleMax'
21
- : typeof config?.models?.providers?.scalemax?.baseUrl === 'string';
21
+ : ['scalemax', 'scalemax-codex', 'scalemax-claude'].some(provider => typeof config?.models?.providers?.[provider]?.baseUrl === 'string');
22
22
  }
23
23
  catch {
24
24
  return false;
@@ -0,0 +1,2 @@
1
+ import { DetectContext, DetectionResult } from '../types/client.js';
2
+ export declare function detect(ctx: DetectContext): Promise<DetectionResult>;
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import JSON5 from 'json5';
4
+ import { commandExists } from '../utils/exec.js';
5
+ import { clientConfigPath } from '../configure/paths.js';
6
+ const extensionPattern = /^rooveterinaryinc\.roo-cline-/i;
7
+ async function extensionPaths(home) {
8
+ const roots = [path.join(home, '.vscode', 'extensions'), path.join(home, '.vscode-insiders', 'extensions')];
9
+ const hits = [];
10
+ for (const root of roots) {
11
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
12
+ for (const entry of entries)
13
+ if (entry.isDirectory() && extensionPattern.test(entry.name))
14
+ hits.push(path.join(root, entry.name));
15
+ }
16
+ return hits;
17
+ }
18
+ export async function detect(ctx) {
19
+ const paths = await extensionPaths(ctx.home);
20
+ const executable = await commandExists('code', ctx.env);
21
+ if (executable)
22
+ paths.unshift('command:code');
23
+ const settingsPath = clientConfigPath('roo-code', ctx.home, ctx.platform, ctx.env);
24
+ let configured = false;
25
+ if (await fs.pathExists(settingsPath)) {
26
+ try {
27
+ const settings = JSON5.parse(await fs.readFile(settingsPath, 'utf8'));
28
+ configured = settings?.['roo-cline.autoImportSettingsPath'] === '~/.scalemax/roo-code-import.json';
29
+ if (configured)
30
+ paths.push(settingsPath);
31
+ }
32
+ catch { }
33
+ }
34
+ const installed = paths.some(entry => entry === 'command:code' || extensionPattern.test(path.basename(entry)));
35
+ return { id: 'roo-code', name: 'Roo Code (VS Code)', installed, configured, executable, reason: installed ? undefined : 'Roo Code extension was not detected', paths };
36
+ }
@@ -1,4 +1,4 @@
1
- export type ClientId = 'codex' | 'cursor' | 'trae-solo' | 'claude' | 'opencode' | 'openclaw' | 'hermes' | 'cherry-studio' | 'api-code';
1
+ export type ClientId = 'codex' | 'cursor' | 'roo-code' | 'trae-solo' | 'claude' | 'opencode' | 'openclaw' | 'hermes' | 'cherry-studio' | 'api-code';
2
2
  export type ProviderScope = 'openai' | 'anthropic' | 'both';
3
3
  export interface ClientDefinition {
4
4
  id: ClientId;
@@ -14,15 +14,18 @@ export interface KeyMetadata {
14
14
  prefix?: string;
15
15
  label?: string;
16
16
  }
17
- export declare function isAnthropicModel(model: ModelInfo): boolean;
18
- export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
19
- export declare function validateApiKey(apiKey: string): Promise<{
17
+ export interface ApiValidation {
20
18
  ok: boolean;
21
19
  models: ModelInfo[];
22
20
  key?: KeyMetadata;
21
+ baseUrl?: string;
23
22
  error?: string;
24
- }>;
25
- export declare function testStreaming(apiKey: string, model?: string): Promise<{
23
+ surfaceMismatch?: boolean;
24
+ }
25
+ export declare function isAnthropicModel(model: ModelInfo): boolean;
26
+ export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
27
+ export declare function validateApiKey(apiKey: string): Promise<ApiValidation>;
28
+ export declare function testStreaming(apiKey: string, model?: string, baseUrl?: string): Promise<{
26
29
  ok: boolean;
27
30
  error?: string;
28
31
  }>;
package/dist/utils/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { SCALEMAX_BASE_URL } from '../constants.js';
1
+ import { SCALEMAX_BASE_URL, SCALEMAX_TOKEN_BASE_URL } from '../constants.js';
2
2
  import { redactApiKey, sanitizeApiKey } from './apiKey.js';
3
3
  export function isAnthropicModel(model) {
4
4
  const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
@@ -34,7 +34,7 @@ async function friendlyError(res) {
34
34
  function retryableStatus(status) {
35
35
  return status === 408 || status === 429 || status >= 500;
36
36
  }
37
- async function request(path, apiKey, init = {}, retryGet = false) {
37
+ async function request(path, apiKey, init = {}, retryGet = false, baseUrl = SCALEMAX_BASE_URL) {
38
38
  const headers = new Headers(init.headers);
39
39
  if (apiKey)
40
40
  headers.set('Authorization', `Bearer ${sanitizeApiKey(apiKey)}`);
@@ -47,7 +47,7 @@ async function request(path, apiKey, init = {}, retryGet = false) {
47
47
  const timeout = AbortSignal.timeout(15000);
48
48
  const signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, timeout]) : timeout;
49
49
  try {
50
- const response = await fetch(`${SCALEMAX_BASE_URL}${path}`, { ...init, headers, signal });
50
+ const response = await fetch(`${baseUrl}${path}`, { ...init, headers, signal });
51
51
  if (attempt + 1 === attempts || !retryableStatus(response.status))
52
52
  return response;
53
53
  await response.body?.cancel().catch(() => undefined);
@@ -61,14 +61,14 @@ async function request(path, apiKey, init = {}, retryGet = false) {
61
61
  }
62
62
  throw lastError;
63
63
  }
64
- export async function validateApiKey(apiKey) {
64
+ async function validateOnSurface(apiKey, baseUrl) {
65
65
  try {
66
66
  const [authCheck, modelsRes] = await Promise.all([
67
- request('/usage', apiKey, {}, true),
68
- request('/models', apiKey, {}, true)
67
+ request('/usage', apiKey, {}, true, baseUrl),
68
+ request('/models', apiKey, {}, true, baseUrl)
69
69
  ]);
70
70
  if (!authCheck.ok)
71
- return { ok: false, models: [], error: redactApiKey(await friendlyError(authCheck), apiKey) };
71
+ return { ok: false, models: [], error: redactApiKey(await friendlyError(authCheck), apiKey), surfaceMismatch: authCheck.status === 401 };
72
72
  const usage = await authCheck.json().catch(() => ({}));
73
73
  const keyMeta = { provider_scope: usage?.key?.provider_scope || 'both', mode: usage?.key?.mode, prefix: usage?.key?.prefix, label: usage?.key?.label };
74
74
  if (!modelsRes.ok)
@@ -78,17 +78,28 @@ export async function validateApiKey(apiKey) {
78
78
  const models = filterModelsForScope(catalog, keyMeta.provider_scope);
79
79
  if (!models.length)
80
80
  return { ok: false, models: [], key: keyMeta, error: 'ScaleMax returned an empty model catalog.' };
81
- return { ok: true, models, key: keyMeta };
81
+ return { ok: true, models, key: keyMeta, baseUrl };
82
82
  }
83
83
  catch (err) {
84
84
  const message = redactApiKey(err instanceof Error ? err.message : String(err), apiKey);
85
85
  return { ok: false, models: [], error: /abort|timeout/i.test(message) ? 'ScaleMax request timed out after 15 seconds.' : message };
86
86
  }
87
87
  }
88
- export async function testStreaming(apiKey, model) {
88
+ export async function validateApiKey(apiKey) {
89
+ const credit = await validateOnSurface(apiKey, SCALEMAX_BASE_URL);
90
+ if (credit.ok)
91
+ return credit;
92
+ if (!credit.surfaceMismatch)
93
+ return credit;
94
+ const token = await validateOnSurface(apiKey, SCALEMAX_TOKEN_BASE_URL);
95
+ if (token.ok)
96
+ return token;
97
+ return credit.error && !/Invalid API key|rejected|401/i.test(credit.error) ? credit : token;
98
+ }
99
+ export async function testStreaming(apiKey, model, baseUrl = SCALEMAX_BASE_URL) {
89
100
  const selected = model || 'fable-5';
90
101
  try {
91
- const res = await request('/chat/completions', apiKey, { method: 'POST', body: JSON.stringify({ model: selected, stream: true, max_tokens: 8, messages: [{ role: 'user', content: 'Say OK' }] }) });
102
+ const res = await request('/chat/completions', apiKey, { method: 'POST', body: JSON.stringify({ model: selected, stream: true, max_tokens: 8, messages: [{ role: 'user', content: 'Say OK' }] }) }, false, baseUrl);
92
103
  if (!res.ok)
93
104
  return { ok: false, error: redactApiKey(await friendlyError(res), apiKey) };
94
105
  const ct = res.headers.get('content-type') || '';
@@ -1,7 +1,7 @@
1
1
  import { intro, outro, password, select, confirm, isCancel, cancel } from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
- import { KEY_ENV, SCALEMAX_BASE_URL } from '../constants.js';
4
+ import { KEY_ENV } from '../constants.js';
5
5
  import { validateApiKey } from './api.js';
6
6
  import { loadApiKey, removeApiKey, saveApiKey } from './keyStore.js';
7
7
  import { homeDir } from './paths.js';
@@ -25,7 +25,7 @@ export async function promptForValidatedKey(home, message = 'Enter your ScaleMax
25
25
  spinner.fail('Invalid API key');
26
26
  throw new Error(result.error || 'Invalid API key');
27
27
  }
28
- await saveApiKey(candidate, SCALEMAX_BASE_URL, home);
28
+ await saveApiKey(candidate, result.baseUrl, home);
29
29
  spinner.succeed('API key verified and saved');
30
30
  return candidate;
31
31
  }
@@ -1,3 +1,4 @@
1
1
  export declare function saveApiKey(apiKey: string, baseUrl: string, home: string): Promise<void>;
2
2
  export declare function loadApiKey(home: string): Promise<string | undefined>;
3
+ export declare function loadBaseUrl(home: string): Promise<string | undefined>;
3
4
  export declare function removeApiKey(home: string): Promise<void>;
@@ -13,6 +13,10 @@ export async function loadApiKey(home) {
13
13
  const cfg = await readJson(scaleMaxConfigPath(home), {});
14
14
  return cfg.apiKey ? sanitizeApiKey(cfg.apiKey) : undefined;
15
15
  }
16
+ export async function loadBaseUrl(home) {
17
+ const cfg = await readJson(scaleMaxConfigPath(home), {});
18
+ return cfg.baseUrl;
19
+ }
16
20
  export async function removeApiKey(home) {
17
21
  await fs.remove(scaleMaxConfigPath(home));
18
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {