scalemax 0.1.18 → 0.1.20

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
@@ -104,6 +104,7 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
104
104
 
105
105
  - Codex
106
106
  - Cursor
107
+ - Roo Code (VS Code)
107
108
  - TRAE SOLO
108
109
  - Claude Code
109
110
  - OpenCode
@@ -114,21 +115,29 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
114
115
 
115
116
  For clients without a stable official config file, the installer writes a ScaleMax setup guide under `~/.scalemax/` instead of modifying private app storage.
116
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
+
117
122
  ### Claude Code
118
123
 
119
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.
120
125
 
121
126
  ### OpenCode
122
127
 
123
- 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.
124
129
 
125
130
  ### OpenClaw
126
131
 
127
- 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.
128
133
 
129
134
  ### Hermes
130
135
 
131
- 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.
132
141
 
133
142
  ### Cursor BYOK routing on macOS
134
143
 
@@ -281,7 +290,7 @@ Never publish over an existing npm version. Use semantic versioning for every re
281
290
  - Invalid JSON/JSON5 client files fail closed instead of being replaced; valid JSON5 values are merged safely.
282
291
  - Backup directories are user-only and backup files are written with private permissions.
283
292
  - API keys are masked in terminal output.
284
- - 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.
285
294
  - The published npm package contains only runtime files: `dist/`, `README.md`, `LICENSE`, and `package.json`.
286
295
  - Tests, TypeScript source, scripts, local caches, `.env` files, tarballs, and `node_modules` are excluded from the published package.
287
296
 
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 },
@@ -42,7 +42,10 @@ async function maybeLaunchCodex(chosen, successful, baseUrl = SCALEMAX_BASE_URL)
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
+ // These clients use either a native family-specific adapter or the gateway's
46
+ // normalized OpenAI-compatible Chat Completions route, which supports both
47
+ // authenticated model families. Codex and TRAE remain OpenAI-only.
48
+ const crossProtocolClients = new Set(['claude', 'cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
46
49
  if (crossProtocolClients.has(clientId))
47
50
  return true;
48
51
  return models.some(model => !isAnthropicModel(model));
@@ -5,7 +5,7 @@ const id = 'api-code';
5
5
  const name = 'API Code';
6
6
  export async function configure(ctx) {
7
7
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "environment": { "SCALEMAX_API_KEY": "your ScaleMax API key", "SCALEMAX_BASE_URL": "https://api.scalemax.pro/v1" }, "node": "import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: 'https://api.scalemax.pro/v1' });", "python": "from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='https://api.scalemax.pro/v1')" });
8
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { environment: { SCALEMAX_API_KEY: 'your ScaleMax API key', SCALEMAX_BASE_URL: ctx.baseUrl }, node: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: '${ctx.baseUrl}' });`, python: `from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='${ctx.baseUrl}')` });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'API code examples written' };
11
11
  }
@@ -1,11 +1,12 @@
1
1
  import { clientConfigPath } from './paths.js';
2
2
  import { writeManagedConfig, removeManagedConfig } from './common.js';
3
3
  import { jsonConfig } from './templates.js';
4
+ import { preferredChatCompletionsModel } from './models.js';
4
5
  const id = 'cherry-studio';
5
6
  const name = 'Cherry Studio';
6
7
  export async function configure(ctx) {
7
8
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Model Service / Model Providers", "Provider type: OpenAI or OpenAI Compatible", "API host/base URL: https://api.scalemax.pro/v1", "Add ScaleMax model IDs or fetch from /v1/models"], "recommendedModel": "fable-5" });
9
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Model Service / Model Providers", "Provider type: OpenAI or OpenAI Compatible", `API host/base URL: ${ctx.baseUrl}`, `Add only model IDs returned by ${ctx.baseUrl}/models`], "recommendedModel": preferredChatCompletionsModel(ctx) });
9
10
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
11
  return { ...result, message: 'setup guide written; enter these values in Cherry Studio model provider settings' };
11
12
  }
@@ -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,21 @@
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;
7
+ /**
8
+ * OpenAI-compatible chat clients can use the gateway's normalized chat route
9
+ * for either family. Keep an OpenAI model as the default when one is present
10
+ * so existing mixed-catalog configurations do not unexpectedly switch model
11
+ * families; a Claude-only catalog naturally selects its preferred Claude model.
12
+ */
13
+ export declare function preferredChatCompletionsModel(ctx: ConfigureContext): string;
3
14
  export declare function preferredModel(ctx: ConfigureContext): string;
4
15
  export declare function fastModel(ctx: ConfigureContext): string | undefined;
16
+ export declare function fastOpenAiModel(ctx: ConfigureContext): string | undefined;
17
+ /** The lightweight model for an OpenAI-compatible chat client, if one exists. */
18
+ export declare function fastChatCompletionsModel(ctx: ConfigureContext): string | undefined;
5
19
  export declare function modelName(ctx: ConfigureContext, id: string): string;
6
20
  export declare function anthropicOrigin(baseUrl: string): string;
7
21
  export declare function openAiBaseUrl(baseUrl: string): string;
@@ -1,18 +1,56 @@
1
- const preferred = [
1
+ import { isAnthropicModel, isRetiredModelId } 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.5',
13
+ 'gpt-5.4',
14
+ 'gpt-5.4-mini',
15
+ 'gpt-5.3-codex',
7
16
  'fable-5',
8
- 'fable-5-ultra'
17
+ 'codex-auto-review'
9
18
  ];
10
19
  export function modelIds(ctx) {
11
- return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(Boolean))];
20
+ return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
21
+ }
22
+ export function anthropicModelIds(ctx) {
23
+ return [...new Set((ctx.models || []).filter(isAnthropicModel).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
24
+ }
25
+ export function openAiModelIds(ctx) {
26
+ return [...new Set((ctx.models || []).filter(model => !isAnthropicModel(model)).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
27
+ }
28
+ function preferredFrom(ids, candidates, family) {
29
+ for (const candidate of candidates)
30
+ if (ids.includes(candidate))
31
+ return candidate;
32
+ if (ids.length)
33
+ return ids[0];
34
+ throw new Error(`No compatible ${family} models are available for this API key; the client was not changed.`);
35
+ }
36
+ export function preferredAnthropicModel(ctx) {
37
+ return preferredFrom(anthropicModelIds(ctx), preferredAnthropic, 'Claude');
38
+ }
39
+ export function preferredOpenAiModel(ctx) {
40
+ return preferredFrom(openAiModelIds(ctx), preferredOpenAi, 'Codex/OpenAI');
41
+ }
42
+ /**
43
+ * OpenAI-compatible chat clients can use the gateway's normalized chat route
44
+ * for either family. Keep an OpenAI model as the default when one is present
45
+ * so existing mixed-catalog configurations do not unexpectedly switch model
46
+ * families; a Claude-only catalog naturally selects its preferred Claude model.
47
+ */
48
+ export function preferredChatCompletionsModel(ctx) {
49
+ return openAiModelIds(ctx).length ? preferredOpenAiModel(ctx) : preferredAnthropicModel(ctx);
12
50
  }
13
51
  export function preferredModel(ctx) {
14
52
  const ids = modelIds(ctx);
15
- for (const candidate of preferred)
53
+ for (const candidate of [...preferredAnthropic, ...preferredOpenAi])
16
54
  if (ids.includes(candidate))
17
55
  return candidate;
18
56
  if (ids.length)
@@ -24,6 +62,15 @@ export function fastModel(ctx) {
24
62
  return ids.find(id => id === 'claude-haiku-4-5-20251001')
25
63
  || ids.find(id => /(^|-)haiku([-.]|$)/i.test(id));
26
64
  }
65
+ export function fastOpenAiModel(ctx) {
66
+ const ids = openAiModelIds(ctx);
67
+ return ids.find(id => id === 'gpt-5.4-mini')
68
+ || ids.find(id => /(^|[-.])(mini|nano)([-.]|$)/i.test(id));
69
+ }
70
+ /** The lightweight model for an OpenAI-compatible chat client, if one exists. */
71
+ export function fastChatCompletionsModel(ctx) {
72
+ return fastOpenAiModel(ctx) || fastModel(ctx);
73
+ }
27
74
  export function modelName(ctx, id) {
28
75
  const model = (ctx.models || []).find(candidate => candidate.id === id);
29
76
  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 { fastChatCompletionsModel, modelIds, modelName, openAiBaseUrl, preferredChatCompletionsModel } from './models.js';
7
7
  import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
8
8
  const id = 'opencode';
9
9
  const name = 'OpenCode';
@@ -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/${preferredChatCompletionsModel(ctx)}`;
36
+ const quick = fastChatCompletionsModel(ctx);
37
37
  const secret = await writeClientSecret(secretPath(ctx.home), ctx);
38
38
  let result;
39
39
  try {
@@ -55,7 +55,7 @@ export async function configure(ctx) {
55
55
  await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
56
56
  throw error;
57
57
  }
58
- return { ...result, changed: result.changed || secret.changed, message: `${result.message}; file-backed credential${quick ? `; ${quick} handles lightweight work` : ''}` };
58
+ return { ...result, changed: result.changed || secret.changed, message: `${result.message}; file-backed credential and ${modelIds(ctx).length} authenticated chat model(s)${quick ? `; ${quick} handles lightweight work` : ''}` };
59
59
  }
60
60
  export async function verify(ctx) {
61
61
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -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 = modelIds(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 chat 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,133 @@
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, preferredChatCompletionsModel } from './models.js';
7
+ import { isAnthropicModel } from '../utils/api.js';
8
+ const id = 'roo-code';
9
+ const name = 'Roo Code (VS Code)';
10
+ const importSetting = 'roo-cline.autoImportSettingsPath';
11
+ const importSettingValue = '~/.scalemax/roo-code-import.json';
12
+ const codexProfileName = 'ScaleMax Codex';
13
+ const claudeProfileName = 'ScaleMax Claude';
14
+ const codexProfileId = 'scalemax-codex';
15
+ const claudeProfileId = 'scalemax-claude';
16
+ function importPath(home) { return path.join(home, '.scalemax', 'roo-code-import.json'); }
17
+ function profileFor(ctx) {
18
+ const model = preferredChatCompletionsModel(ctx);
19
+ const metadata = (ctx.models || []).find(candidate => candidate.id === model) || { id: model };
20
+ return isAnthropicModel(metadata)
21
+ ? { name: claudeProfileName, id: claudeProfileId, model }
22
+ : { name: codexProfileName, id: codexProfileId, model };
23
+ }
24
+ function parseSettings(text, file) {
25
+ if (!text)
26
+ return {};
27
+ try {
28
+ const value = JSON5.parse(text);
29
+ if (!value || typeof value !== 'object' || Array.isArray(value))
30
+ throw new Error('root value must be an object');
31
+ return value;
32
+ }
33
+ catch (error) {
34
+ throw new Error(`Roo Code cannot update invalid VS Code settings at ${file}: ${error instanceof Error ? error.message : String(error)}`);
35
+ }
36
+ }
37
+ function handoff(ctx) {
38
+ const profile = profileFor(ctx);
39
+ return JSON.stringify({
40
+ providerProfiles: {
41
+ currentApiConfigName: profile.name,
42
+ apiConfigs: {
43
+ [profile.name]: {
44
+ id: profile.id,
45
+ apiProvider: 'openai',
46
+ openAiBaseUrl: openAiBaseUrl(ctx.baseUrl),
47
+ openAiApiKey: ctx.apiKey,
48
+ openAiModelId: profile.model,
49
+ openAiStreamingEnabled: true,
50
+ includeMaxTokens: true
51
+ }
52
+ },
53
+ modeApiConfigs: {}
54
+ },
55
+ globalSettings: {}
56
+ }, null, 2) + '\n';
57
+ }
58
+ export async function configure(ctx) {
59
+ const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
60
+ const profileFile = importPath(ctx.home);
61
+ const originalSettings = await fs.readFile(settingsFile, 'utf8').catch(() => '');
62
+ const originalProfile = await fs.readFile(profileFile, 'utf8').catch(() => '');
63
+ const settings = parseSettings(originalSettings, settingsFile);
64
+ const nextSettings = JSON.stringify({ ...settings, [importSetting]: importSettingValue }, null, 2) + '\n';
65
+ const nextProfile = handoff(ctx);
66
+ if (originalSettings === nextSettings && originalProfile === nextProfile) {
67
+ await fs.chmod(profileFile, 0o600).catch(() => undefined);
68
+ return { id, name, changed: false, path: profileFile, message: 'supported Roo Code auto-import handoff is already current; reload VS Code to consume it' };
69
+ }
70
+ const originals = new Map([[settingsFile, originalSettings], [profileFile, originalProfile]]);
71
+ await backupFile(settingsFile, ctx.home);
72
+ await backupFile(profileFile, ctx.home);
73
+ try {
74
+ await atomicWrite(profileFile, nextProfile, 0o600, ctx.platform);
75
+ await atomicWrite(settingsFile, nextSettings, 0o600, ctx.platform);
76
+ }
77
+ catch (error) {
78
+ for (const [file, original] of originals) {
79
+ if (original)
80
+ await atomicWrite(file, original, 0o600, ctx.platform);
81
+ else
82
+ await fs.remove(file).catch(() => undefined);
83
+ }
84
+ throw error;
85
+ }
86
+ 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' };
87
+ }
88
+ export async function verify(ctx) {
89
+ const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
90
+ const profileFile = importPath(ctx.home);
91
+ const settings = parseSettings(await fs.readFile(settingsFile, 'utf8'), settingsFile);
92
+ const profile = await fs.readJson(profileFile).catch(() => undefined);
93
+ const expected = profileFor(ctx);
94
+ const config = profile?.providerProfiles?.apiConfigs?.[expected.name];
95
+ if (settings[importSetting] !== importSettingValue || profile?.providerProfiles?.currentApiConfigName !== expected.name
96
+ || config?.id !== expected.id || config?.apiProvider !== 'openai' || config?.openAiBaseUrl !== openAiBaseUrl(ctx.baseUrl)
97
+ || config?.openAiApiKey !== ctx.apiKey || config?.openAiModelId !== expected.model || config?.openAiStreamingEnabled !== true
98
+ || config?.includeMaxTokens !== true) {
99
+ throw new Error('Roo Code auto-import handoff is incomplete.');
100
+ }
101
+ if (ctx.platform !== 'win32' && ((await fs.stat(profileFile)).mode & 0o077) !== 0)
102
+ throw new Error('Roo Code import handoff permissions are too broad.');
103
+ return { id, name, changed: false, path: profileFile, message: 'verified supported auto-import handoff (runtime activation requires a VS Code reload)' };
104
+ }
105
+ export async function remove(ctx) {
106
+ const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
107
+ const profileFile = importPath(ctx.home);
108
+ let changed = false;
109
+ if (await fs.pathExists(settingsFile)) {
110
+ const text = await fs.readFile(settingsFile, 'utf8');
111
+ const settings = parseSettings(text, settingsFile);
112
+ if (settings[importSetting] === importSettingValue) {
113
+ delete settings[importSetting];
114
+ await backupFile(settingsFile, ctx.home);
115
+ await atomicWrite(settingsFile, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
116
+ changed = true;
117
+ }
118
+ }
119
+ if (await fs.pathExists(profileFile)) {
120
+ const profile = await fs.readJson(profileFile).catch(() => undefined);
121
+ const configs = profile?.providerProfiles?.apiConfigs;
122
+ const managed = [
123
+ configs?.[codexProfileName]?.id === codexProfileId,
124
+ configs?.[claudeProfileName]?.id === claudeProfileId
125
+ ].some(Boolean);
126
+ if (managed) {
127
+ await backupFile(profileFile, ctx.home);
128
+ await fs.remove(profileFile);
129
+ changed = true;
130
+ }
131
+ }
132
+ return { id, name, changed, path: profileFile, message: changed ? 'removed only the ScaleMax Roo Code auto-import handoff' : 'no ScaleMax Roo Code handoff found' };
133
+ }
@@ -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}`,
@@ -5,7 +5,7 @@ const id = 'trae-solo';
5
5
  const name = 'TRAE SOLO';
6
6
  export async function configure(ctx) {
7
7
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", "Request URL: https://api.scalemax.pro/v1/chat/completions or base URL https://api.scalemax.pro/v1", "Model ID: fable-5 or another ScaleMax model"], "recommendedModel": "fable-5" });
8
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", `Request URL: ${ctx.baseUrl}/chat/completions or base URL ${ctx.baseUrl}`, "Use only a model ID returned by the authenticated catalog"], "recommendedModel": "fable-5" });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'setup guide written; enter these values in TRAE SOLO custom model settings' };
11
11
  }
@@ -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;
@@ -22,6 +22,8 @@ export interface ApiValidation {
22
22
  error?: string;
23
23
  surfaceMismatch?: boolean;
24
24
  }
25
+ export declare const RETIRED_MODEL_IDS: Set<string>;
26
+ export declare function isRetiredModelId(id: string): boolean;
25
27
  export declare function isAnthropicModel(model: ModelInfo): boolean;
26
28
  export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
27
29
  export declare function validateApiKey(apiKey: string): Promise<ApiValidation>;
package/dist/utils/api.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { SCALEMAX_BASE_URL, SCALEMAX_TOKEN_BASE_URL } from '../constants.js';
2
2
  import { redactApiKey, sanitizeApiKey } from './apiKey.js';
3
+ // These aliases are intentionally unavailable because their upstream routes
4
+ // were retired after repeated 5xx responses. Keep a stale catalog response
5
+ // from selecting or advertising a model that cannot complete requests.
6
+ export const RETIRED_MODEL_IDS = new Set(['gpt-5.6-sol', 'fable-5-ultra']);
7
+ export function isRetiredModelId(id) {
8
+ return RETIRED_MODEL_IDS.has(id.trim());
9
+ }
3
10
  export function isAnthropicModel(model) {
4
11
  const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
5
12
  if (provider)
@@ -7,9 +14,8 @@ export function isAnthropicModel(model) {
7
14
  return model.id.startsWith('claude-') && !model.id.startsWith('claude-fable-');
8
15
  }
9
16
  export function filterModelsForScope(models, scope) {
10
- if (scope === 'both')
11
- return models;
12
- return models.filter(model => scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
17
+ return models.filter(model => !isRetiredModelId(model.id)
18
+ && (scope === 'both' || (scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model))));
13
19
  }
14
20
  async function friendlyError(res) {
15
21
  let body = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {