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 +16 -5
- package/dist/clients.js +3 -0
- package/dist/commands/configure.js +6 -5
- package/dist/commands/doctor.js +3 -2
- package/dist/commands/login.js +1 -2
- package/dist/configure/codex.js +11 -6
- package/dist/configure/hermes.js +64 -32
- package/dist/configure/models.d.ts +5 -0
- package/dist/configure/models.js +40 -4
- package/dist/configure/openclaw.js +105 -45
- package/dist/configure/opencode.js +9 -7
- package/dist/configure/paths.js +2 -0
- package/dist/configure/roo-code.d.ts +4 -0
- package/dist/configure/roo-code.js +119 -0
- package/dist/configure/templates.js +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/detect/codex.js +13 -1
- package/dist/detect/common.js +3 -3
- package/dist/detect/roo-code.d.ts +2 -0
- package/dist/detect/roo-code.js +36 -0
- package/dist/types/client.d.ts +1 -1
- package/dist/utils/api.d.ts +8 -5
- package/dist/utils/api.js +21 -10
- package/dist/utils/installerConfig.js +2 -2
- package/dist/utils/keyStore.d.ts +1 -0
- package/dist/utils/keyStore.js +4 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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', '
|
|
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:
|
|
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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/commands/login.js
CHANGED
|
@@ -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,
|
|
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;
|
package/dist/configure/codex.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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: '
|
|
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)}`);
|
package/dist/configure/hermes.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
93
|
+
const currentProvider = String(currentModel.provider || '');
|
|
94
|
+
const activatesScaleMax = !currentProvider || currentProvider.startsWith('custom:scalemax');
|
|
77
95
|
if (activatesScaleMax) {
|
|
78
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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 (
|
|
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
|
|
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 ${
|
|
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
|
|
135
|
-
|
|
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(
|
|
138
|
-
const remaining = providers.filter((provider) => provider
|
|
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
|
-
|
|
174
|
+
const activeManaged = String(data?.model?.provider || '').startsWith('custom:scalemax');
|
|
175
|
+
if (activeManaged)
|
|
144
176
|
doc.deleteIn(['model', 'provider']);
|
|
145
|
-
if (
|
|
177
|
+
if (activeManaged && managedModels.includes(String(data?.model?.default || '')))
|
|
146
178
|
doc.deleteIn(['model', 'default']);
|
|
147
|
-
if (
|
|
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;
|
package/dist/configure/models.js
CHANGED
|
@@ -1,18 +1,49 @@
|
|
|
1
|
-
|
|
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
|
|
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,
|
|
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
|
|
24
|
-
|
|
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:
|
|
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 ||
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
85
|
-
const
|
|
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
|
|
101
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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/${
|
|
36
|
-
const quick =
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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) {
|
package/dist/configure/paths.js
CHANGED
|
@@ -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', '
|
|
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}`,
|
package/dist/constants.d.ts
CHANGED
|
@@ -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';
|
package/dist/detect/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/detect/common.js
CHANGED
|
@@ -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?.
|
|
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,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
|
+
}
|
package/dist/types/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -14,15 +14,18 @@ export interface KeyMetadata {
|
|
|
14
14
|
prefix?: string;
|
|
15
15
|
label?: string;
|
|
16
16
|
}
|
|
17
|
-
export
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
28
|
+
await saveApiKey(candidate, result.baseUrl, home);
|
|
29
29
|
spinner.succeed('API key verified and saved');
|
|
30
30
|
return candidate;
|
|
31
31
|
}
|
package/dist/utils/keyStore.d.ts
CHANGED
|
@@ -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>;
|
package/dist/utils/keyStore.js
CHANGED
|
@@ -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
|
}
|