scalemax 0.1.14 → 0.1.16
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 +21 -4
- package/dist/clients.js +3 -3
- package/dist/commands/configure.d.ts +3 -0
- package/dist/commands/configure.js +9 -10
- package/dist/configure/claude.js +151 -21
- package/dist/configure/client-secret.d.ts +11 -0
- package/dist/configure/client-secret.js +47 -0
- package/dist/configure/common.js +29 -3
- package/dist/configure/hermes.d.ts +1 -0
- package/dist/configure/hermes.js +156 -5
- package/dist/configure/models.d.ts +7 -0
- package/dist/configure/models.js +43 -0
- package/dist/configure/openclaw.d.ts +1 -0
- package/dist/configure/openclaw.js +220 -5
- package/dist/configure/opencode.d.ts +1 -0
- package/dist/configure/opencode.js +85 -24
- package/dist/configure/paths.js +2 -2
- package/dist/detect/common.js +21 -1
- package/dist/types/client.d.ts +4 -0
- package/dist/utils/api.d.ts +5 -0
- package/dist/utils/api.js +45 -12
- package/dist/utils/apiKey.d.ts +1 -0
- package/dist/utils/apiKey.js +5 -0
- package/dist/utils/fs.js +3 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ScaleMax CLI
|
|
2
2
|
|
|
3
|
-
Universal installer for configuring AI coding clients to use the ScaleMax OpenAI-compatible API gateway.
|
|
3
|
+
Universal installer for configuring AI coding clients to use the ScaleMax OpenAI- and Anthropic-compatible API gateway.
|
|
4
4
|
|
|
5
5
|
ScaleMax CLI is designed to be run from any machine with Node.js installed:
|
|
6
6
|
|
|
@@ -98,7 +98,7 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
|
|
|
98
98
|
|
|
99
99
|
## Supported clients
|
|
100
100
|
|
|
101
|
-
`
|
|
101
|
+
`scalemax` supports this client set whether it is run through a global install or with `npx`:
|
|
102
102
|
|
|
103
103
|
- Codex
|
|
104
104
|
- Cursor
|
|
@@ -114,7 +114,19 @@ For clients without a stable official config file, the installer writes a ScaleM
|
|
|
114
114
|
|
|
115
115
|
### Claude Code
|
|
116
116
|
|
|
117
|
-
ScaleMax
|
|
117
|
+
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 verifies a one-token Messages request. If verification fails, it removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
|
|
118
|
+
|
|
119
|
+
### OpenCode
|
|
120
|
+
|
|
121
|
+
ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.json` (or the platform-equivalent config directory). It uses `https://api.scalemax.pro/v1`, reads its credential from the private `~/.config/opencode/scalemax.key` file through OpenCode's `{file:...}` substitution, keeps unrelated providers and the user's non-ScaleMax default model, and loads every model available to the authenticated key. A fresh ScaleMax setup uses authenticated Haiku as `small_model` for quicker lightweight work. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
|
|
122
|
+
|
|
123
|
+
### OpenClaw
|
|
124
|
+
|
|
125
|
+
ScaleMax merges a native `anthropic-messages` provider into `~/.openclaw/openclaw.json`, using the Anthropic origin `https://api.scalemax.pro`. The API key and the compatibility User-Agent are both OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the provider JSON. The migration removes exact plaintext ScaleMax key copies from generated agent model registries. It adds every authenticated model to the provider and agent allowlist, uses authenticated Haiku for utility work when ScaleMax is the active provider, caps generated output limits at 4096 tokens, explicitly denies private-network routing for the public gateway, and preserves unrelated providers and agent settings. Legacy ScaleMax artifacts are removed only when they carry the ScaleMax managed marker.
|
|
126
|
+
|
|
127
|
+
### Hermes
|
|
128
|
+
|
|
129
|
+
ScaleMax merges a named custom provider into `~/.hermes/config.yaml` and stores its credential in `~/.hermes/.env` with private permissions. Hermes uses OpenAI chat-completions routing at `https://api.scalemax.pro/v1`, a scoped `ScaleMax-Hermes/1.0` user agent, and a 4096-token output cap. Existing non-ScaleMax providers and defaults are preserved; a fresh Hermes config activates `custom:scalemax` automatically.
|
|
118
130
|
|
|
119
131
|
### Cursor BYOK routing on macOS
|
|
120
132
|
|
|
@@ -141,6 +153,7 @@ npx scalemax models
|
|
|
141
153
|
```
|
|
142
154
|
|
|
143
155
|
The model list is fetched live from `https://api.scalemax.pro/v1/models` every time, so disabled or unsupported models disappear immediately without manual cache cleanup.
|
|
156
|
+
Key metadata and the authenticated model catalog are fetched concurrently on the normal path. Safe GET checks receive one short, jittered retry on transient network, rate-limit, or server failures; model-generation POST requests are never retried by the installer.
|
|
144
157
|
|
|
145
158
|
Run diagnostics:
|
|
146
159
|
|
|
@@ -262,7 +275,11 @@ Never publish over an existing npm version. Use semantic versioning for every re
|
|
|
262
275
|
|
|
263
276
|
- Existing client configuration files are backed up before writes.
|
|
264
277
|
- Writes are atomic.
|
|
278
|
+
- Identical configurations are not rewritten or backed up again.
|
|
279
|
+
- Invalid JSON/JSON5 client files fail closed instead of being replaced; valid JSON5 values are merged safely.
|
|
280
|
+
- Backup directories are user-only and backup files are written with private permissions.
|
|
265
281
|
- API keys are masked in terminal output.
|
|
282
|
+
- Claude Code, OpenCode, OpenClaw, and Hermes keep generated client credentials in private files rather than shared provider configuration blocks.
|
|
266
283
|
- The published npm package contains only runtime files: `dist/`, `README.md`, `LICENSE`, and `package.json`.
|
|
267
284
|
- Tests, TypeScript source, scripts, local caches, `.env` files, tarballs, and `node_modules` are excluded from the published package.
|
|
268
285
|
|
|
@@ -296,4 +313,4 @@ Yes. The CLI uses Windows-aware config paths and writes `%APPDATA%\scalemax\conf
|
|
|
296
313
|
|
|
297
314
|
### What endpoint does it configure?
|
|
298
315
|
|
|
299
|
-
`https://api.scalemax.pro/v1`
|
|
316
|
+
OpenAI-compatible clients use `https://api.scalemax.pro/v1`. Anthropic-compatible clients such as Claude Code and OpenClaw use the origin `https://api.scalemax.pro`, because those clients append `/v1/messages` themselves.
|
package/dist/clients.js
CHANGED
|
@@ -21,9 +21,9 @@ export const clients = [
|
|
|
21
21
|
{ id: 'cursor', name: 'Cursor', detect: cursorDetect.detect, configure: cursorConfig.configure, verify: cursorConfig.verify, remove: cursorConfig.remove },
|
|
22
22
|
{ id: 'trae-solo', name: 'TRAE SOLO', detect: traeSoloDetect.detect, configure: traeSoloConfig.configure, remove: traeSoloConfig.remove },
|
|
23
23
|
{ id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, verify: claudeConfig.verify, remove: claudeConfig.remove },
|
|
24
|
-
{ id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, remove: opencodeConfig.remove },
|
|
25
|
-
{ id: 'openclaw', name: 'OpenClaw', detect: openclawDetect.detect, configure: openclawConfig.configure, remove: openclawConfig.remove },
|
|
26
|
-
{ id: 'hermes', name: 'Hermes', detect: hermesDetect.detect, configure: hermesConfig.configure, remove: hermesConfig.remove },
|
|
24
|
+
{ id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, verify: opencodeConfig.verify, remove: opencodeConfig.remove },
|
|
25
|
+
{ id: 'openclaw', name: 'OpenClaw', detect: openclawDetect.detect, configure: openclawConfig.configure, verify: openclawConfig.verify, remove: openclawConfig.remove },
|
|
26
|
+
{ id: 'hermes', name: 'Hermes', detect: hermesDetect.detect, configure: hermesConfig.configure, verify: hermesConfig.verify, remove: hermesConfig.remove },
|
|
27
27
|
{ id: 'cherry-studio', name: 'Cherry Studio', detect: cherryStudioDetect.detect, configure: cherryStudioConfig.configure, remove: cherryStudioConfig.remove },
|
|
28
28
|
{ id: 'api-code', name: 'API Code', detect: apiCodeDetect.detect, configure: apiCodeConfig.configure, remove: apiCodeConfig.remove }
|
|
29
29
|
];
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { ModelInfo } from '../utils/api.js';
|
|
2
|
+
import { ClientId } from '../types/client.js';
|
|
1
3
|
export declare function detectAll(home?: string): Promise<import("../types/client.js").DetectionResult[]>;
|
|
2
4
|
export declare function printDetected(home?: string): Promise<import("../types/client.js").DetectionResult[]>;
|
|
5
|
+
export declare function clientAllowedForModels(clientId: ClientId, models: ModelInfo[]): boolean;
|
|
3
6
|
export declare function configureCommand(opts?: {
|
|
4
7
|
all?: boolean;
|
|
5
8
|
home?: string;
|
|
@@ -4,7 +4,7 @@ import ora from 'ora';
|
|
|
4
4
|
import { clients } from '../clients.js';
|
|
5
5
|
import { SCALEMAX_BASE_URL } from '../constants.js';
|
|
6
6
|
import { loadApiKey } from '../utils/keyStore.js';
|
|
7
|
-
import { validateApiKey } from '../utils/api.js';
|
|
7
|
+
import { validateApiKey, isAnthropicModel } from '../utils/api.js';
|
|
8
8
|
import { homeDir } from '../utils/paths.js';
|
|
9
9
|
export async function detectAll(home = homeDir()) {
|
|
10
10
|
const ctx = { home, platform: process.platform, env: process.env };
|
|
@@ -39,14 +39,13 @@ async function maybeLaunchCodex(chosen, successful) {
|
|
|
39
39
|
}
|
|
40
40
|
console.log('\nIf Codex asks for authentication,\nit should already be configured to use ScaleMax.\n\nIf you still see the official login screen,\nthe configuration is incomplete or Codex rejected the generated auth/config files.\n');
|
|
41
41
|
}
|
|
42
|
-
function
|
|
43
|
-
if (
|
|
42
|
+
export function clientAllowedForModels(clientId, models) {
|
|
43
|
+
if (!models.length)
|
|
44
|
+
return false;
|
|
45
|
+
const crossProtocolClients = new Set(['claude', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
|
|
46
|
+
if (crossProtocolClients.has(clientId))
|
|
44
47
|
return true;
|
|
45
|
-
|
|
46
|
-
return scope === 'anthropic';
|
|
47
|
-
if (clientId === 'api-code')
|
|
48
|
-
return true;
|
|
49
|
-
return scope === 'openai';
|
|
48
|
+
return models.some(model => !isAnthropicModel(model));
|
|
50
49
|
}
|
|
51
50
|
function scopeHint(scope) {
|
|
52
51
|
return scope === 'both' ? 'OpenAI + Claude' : scope === 'anthropic' ? 'Claude only' : 'OpenAI only';
|
|
@@ -62,8 +61,8 @@ export async function configureCommand(opts = {}) {
|
|
|
62
61
|
if (!keyInfo.models.length)
|
|
63
62
|
throw new Error(keyInfo.error || 'ScaleMax returned no models for this API key. No client configuration was changed.');
|
|
64
63
|
const providerScope = keyInfo.key?.provider_scope || 'both';
|
|
65
|
-
const availableClients = clients.filter(c =>
|
|
66
|
-
console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}.
|
|
64
|
+
const availableClients = clients.filter(c => clientAllowedForModels(c.id, keyInfo.models));
|
|
65
|
+
console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}. Clients are filtered against the authenticated model catalog.`));
|
|
67
66
|
const detections = await printDetected(home);
|
|
68
67
|
const detectedIds = new Set(detections.filter(d => d.installed).map(d => d.id));
|
|
69
68
|
let chosen;
|
package/dist/configure/claude.js
CHANGED
|
@@ -1,27 +1,104 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
1
5
|
import { clientConfigPath } from './paths.js';
|
|
2
|
-
import { writeMergedJsonConfig
|
|
6
|
+
import { writeMergedJsonConfig } from './common.js';
|
|
3
7
|
import { GENERATED_HEADER } from '../constants.js';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { redactApiKey } from '../utils/apiKey.js';
|
|
9
|
+
import { anthropicOrigin, fastModel, modelIds, preferredModel } from './models.js';
|
|
10
|
+
import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
|
|
11
|
+
const id = 'claude';
|
|
12
|
+
const name = 'Claude Code';
|
|
13
|
+
const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
|
|
14
|
+
const fastModelKey = 'ANTHROPIC_DEFAULT_HAIKU_MODEL';
|
|
15
|
+
const helperTtlKey = 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS';
|
|
16
|
+
const managedEnvKeys = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', discoveryKey, fastModelKey, helperTtlKey];
|
|
17
|
+
function secretPath(home) { return path.join(home, '.claude', 'scalemax.key'); }
|
|
18
|
+
function helperCommand(home, platform) {
|
|
19
|
+
const file = secretPath(home);
|
|
20
|
+
if (platform === 'win32')
|
|
21
|
+
return `powershell -NoProfile -Command "Get-Content -Raw -LiteralPath '${file.replace(/'/g, "''")}'"`;
|
|
22
|
+
return `cat '${file.replace(/'/g, `'\\''`)}'`;
|
|
23
|
+
}
|
|
24
|
+
function asRecord(value) {
|
|
25
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
26
|
+
}
|
|
11
27
|
export async function configure(ctx) {
|
|
12
|
-
const file = clientConfigPath(id, ctx.home, ctx.platform)
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
29
|
+
const model = preferredModel(ctx);
|
|
30
|
+
const quick = fastModel(ctx);
|
|
31
|
+
const baseUrl = anthropicOrigin(ctx.baseUrl);
|
|
32
|
+
const apiKeyHelper = helperCommand(ctx.home, ctx.platform);
|
|
33
|
+
const secret = await writeClientSecret(secretPath(ctx.home), ctx);
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
|
|
37
|
+
const env = asRecord(existing.env);
|
|
38
|
+
const oldMetadata = asRecord(existing.scalemax);
|
|
39
|
+
const previousEnv = oldMetadata.managedBy === 'ScaleMax CLI'
|
|
40
|
+
? asRecord(oldMetadata.previousEnv)
|
|
41
|
+
: Object.fromEntries(managedEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(env, key)).map(key => [key, env[key]]));
|
|
42
|
+
const previousApiKeyHelper = oldMetadata.managedBy === 'ScaleMax CLI'
|
|
43
|
+
? oldMetadata.previousApiKeyHelper ?? null
|
|
44
|
+
: Object.prototype.hasOwnProperty.call(existing, 'apiKeyHelper') ? existing.apiKeyHelper : null;
|
|
45
|
+
const nextEnv = {
|
|
46
|
+
...env,
|
|
47
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
48
|
+
ANTHROPIC_MODEL: model,
|
|
49
|
+
[discoveryKey]: '1',
|
|
50
|
+
[helperTtlKey]: '900000'
|
|
51
|
+
};
|
|
52
|
+
delete nextEnv.ANTHROPIC_AUTH_TOKEN;
|
|
53
|
+
delete nextEnv.ANTHROPIC_API_KEY;
|
|
54
|
+
if (quick)
|
|
55
|
+
nextEnv[fastModelKey] = quick;
|
|
56
|
+
else
|
|
57
|
+
delete nextEnv[fastModelKey];
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
...existing,
|
|
60
|
+
apiKeyHelper,
|
|
61
|
+
env: nextEnv,
|
|
62
|
+
'scalemax.managedBy': GENERATED_HEADER,
|
|
63
|
+
scalemax: {
|
|
64
|
+
managedBy: 'ScaleMax CLI',
|
|
65
|
+
anthropicModel: model,
|
|
66
|
+
anthropicBaseUrl: baseUrl,
|
|
67
|
+
gatewayDiscovery: '1',
|
|
68
|
+
fastModel: quick || null,
|
|
69
|
+
helperTtl: '900000',
|
|
70
|
+
apiKeyHelper,
|
|
71
|
+
authTokenSha256: crypto.createHash('sha256').update(ctx.apiKey).digest('hex'),
|
|
72
|
+
previousEnv,
|
|
73
|
+
previousApiKeyHelper
|
|
74
|
+
}
|
|
75
|
+
}, null, 2) + '\n';
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return { ...result, changed: result.changed || secret.changed, message: `configured Anthropic Messages routing with ${model}, file-backed authentication, and gateway discovery${quick ? `; ${quick} handles background work` : ''}` };
|
|
18
83
|
}
|
|
19
84
|
export async function verify(ctx) {
|
|
20
|
-
const file = clientConfigPath(id, ctx.home, ctx.platform)
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
85
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
86
|
+
const settings = await fs.readJson(file);
|
|
87
|
+
const env = asRecord(settings?.env);
|
|
88
|
+
const model = String(env.ANTHROPIC_MODEL || '');
|
|
89
|
+
if (!modelIds(ctx).includes(model) || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || settings.apiKeyHelper !== helperCommand(ctx.home, ctx.platform) || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || env[discoveryKey] !== '1') {
|
|
90
|
+
throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
|
|
91
|
+
}
|
|
92
|
+
await verifyClientSecret(secretPath(ctx.home), ctx);
|
|
93
|
+
const quick = fastModel(ctx);
|
|
94
|
+
if (quick && env[fastModelKey] !== quick)
|
|
95
|
+
throw new Error('Claude Code background model does not match the fastest authenticated model.');
|
|
96
|
+
const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ model, max_tokens: 1, stream: false, messages: [{ role: 'user', content: 'K' }] }),
|
|
100
|
+
signal: AbortSignal.timeout(15000)
|
|
101
|
+
});
|
|
25
102
|
if (!response.ok) {
|
|
26
103
|
let message = `HTTP ${response.status}`;
|
|
27
104
|
try {
|
|
@@ -29,8 +106,61 @@ export async function verify(ctx) {
|
|
|
29
106
|
message = body?.error?.message || message;
|
|
30
107
|
}
|
|
31
108
|
catch { }
|
|
32
|
-
throw new Error(`Claude Messages verification failed: ${message}`);
|
|
109
|
+
throw new Error(`Claude Messages verification failed: ${redactApiKey(message, ctx.apiKey)}`);
|
|
33
110
|
}
|
|
34
111
|
return { id, name, changed: false, path: file, message: `verified Anthropic Messages with ${model}` };
|
|
35
112
|
}
|
|
36
|
-
export async function remove(ctx) {
|
|
113
|
+
export async function remove(ctx) {
|
|
114
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
115
|
+
if (!(await fs.pathExists(file))) {
|
|
116
|
+
const removed = await removeClientSecret(secretPath(ctx.home), ctx);
|
|
117
|
+
return { id, name, changed: removed, path: file, message: removed ? 'removed orphaned ScaleMax Claude Code credential' : 'no ScaleMax configuration found' };
|
|
118
|
+
}
|
|
119
|
+
const settings = await fs.readJson(file).catch(() => undefined);
|
|
120
|
+
if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI')
|
|
121
|
+
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
122
|
+
const managed = asRecord(settings.scalemax);
|
|
123
|
+
const env = asRecord(settings.env);
|
|
124
|
+
const previous = asRecord(managed.previousEnv);
|
|
125
|
+
const restore = (key) => {
|
|
126
|
+
if (Object.prototype.hasOwnProperty.call(previous, key))
|
|
127
|
+
env[key] = previous[key];
|
|
128
|
+
else
|
|
129
|
+
delete env[key];
|
|
130
|
+
};
|
|
131
|
+
if (env.ANTHROPIC_BASE_URL === managed.anthropicBaseUrl)
|
|
132
|
+
restore('ANTHROPIC_BASE_URL');
|
|
133
|
+
if (env.ANTHROPIC_MODEL === managed.anthropicModel)
|
|
134
|
+
restore('ANTHROPIC_MODEL');
|
|
135
|
+
if (env[discoveryKey] === managed.gatewayDiscovery)
|
|
136
|
+
restore(discoveryKey);
|
|
137
|
+
if (env[fastModelKey] === managed.fastModel)
|
|
138
|
+
restore(fastModelKey);
|
|
139
|
+
if (env[helperTtlKey] === managed.helperTtl)
|
|
140
|
+
restore(helperTtlKey);
|
|
141
|
+
if (!Object.prototype.hasOwnProperty.call(env, 'ANTHROPIC_AUTH_TOKEN'))
|
|
142
|
+
restore('ANTHROPIC_AUTH_TOKEN');
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(env, 'ANTHROPIC_API_KEY'))
|
|
144
|
+
restore('ANTHROPIC_API_KEY');
|
|
145
|
+
if (typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && crypto.createHash('sha256').update(env.ANTHROPIC_AUTH_TOKEN).digest('hex') === managed.authTokenSha256)
|
|
146
|
+
restore('ANTHROPIC_AUTH_TOKEN');
|
|
147
|
+
if (settings.apiKeyHelper === managed.apiKeyHelper) {
|
|
148
|
+
if (managed.previousApiKeyHelper === null || managed.previousApiKeyHelper === undefined)
|
|
149
|
+
delete settings.apiKeyHelper;
|
|
150
|
+
else
|
|
151
|
+
settings.apiKeyHelper = managed.previousApiKeyHelper;
|
|
152
|
+
}
|
|
153
|
+
if (Object.keys(env).length)
|
|
154
|
+
settings.env = env;
|
|
155
|
+
else
|
|
156
|
+
delete settings.env;
|
|
157
|
+
delete settings['scalemax.managedBy'];
|
|
158
|
+
delete settings.scalemax;
|
|
159
|
+
await backupFile(file, ctx.home);
|
|
160
|
+
if (Object.keys(settings).length)
|
|
161
|
+
await atomicWrite(file, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
|
|
162
|
+
else
|
|
163
|
+
await fs.remove(file);
|
|
164
|
+
await removeClientSecret(secretPath(ctx.home), ctx);
|
|
165
|
+
return { id, name, changed: true, path: file, message: 'removed only ScaleMax Claude Code settings and credential' };
|
|
166
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ConfigureContext } from '../types/client.js';
|
|
2
|
+
export declare function writeClientSecret(file: string, ctx: ConfigureContext): Promise<{
|
|
3
|
+
changed: boolean;
|
|
4
|
+
backup?: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function rollbackClientSecret(file: string, ctx: ConfigureContext, change: {
|
|
7
|
+
changed: boolean;
|
|
8
|
+
backup?: string;
|
|
9
|
+
}): Promise<void>;
|
|
10
|
+
export declare function verifyClientSecret(file: string, ctx: ConfigureContext): Promise<void>;
|
|
11
|
+
export declare function removeClientSecret(file: string, ctx: ConfigureContext): Promise<boolean>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { sanitizeApiKey } from '../utils/apiKey.js';
|
|
4
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
5
|
+
export async function writeClientSecret(file, ctx) {
|
|
6
|
+
const content = `${sanitizeApiKey(ctx.apiKey)}\n`;
|
|
7
|
+
const current = await fs.readFile(file, 'utf8').catch(() => '');
|
|
8
|
+
await fs.ensureDir(path.dirname(file), 0o700);
|
|
9
|
+
await fs.chmod(path.dirname(file), 0o700).catch(() => undefined);
|
|
10
|
+
if (current === content) {
|
|
11
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
12
|
+
return { changed: false };
|
|
13
|
+
}
|
|
14
|
+
const backup = await backupFile(file, ctx.home);
|
|
15
|
+
await atomicWrite(file, content, 0o600, ctx.platform);
|
|
16
|
+
return { changed: true, backup };
|
|
17
|
+
}
|
|
18
|
+
export async function rollbackClientSecret(file, ctx, change) {
|
|
19
|
+
if (!change.changed)
|
|
20
|
+
return;
|
|
21
|
+
if (change.backup) {
|
|
22
|
+
const previous = await fs.readFile(change.backup, 'utf8');
|
|
23
|
+
await atomicWrite(file, previous, 0o600, ctx.platform);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
await fs.remove(file).catch(() => undefined);
|
|
27
|
+
}
|
|
28
|
+
export async function verifyClientSecret(file, ctx) {
|
|
29
|
+
const content = await fs.readFile(file, 'utf8').catch(() => '');
|
|
30
|
+
if (sanitizeApiKey(content) !== sanitizeApiKey(ctx.apiKey))
|
|
31
|
+
throw new Error('ScaleMax client credential is missing or does not match the authenticated key.');
|
|
32
|
+
if (ctx.platform !== 'win32') {
|
|
33
|
+
const mode = (await fs.stat(file)).mode & 0o777;
|
|
34
|
+
if (mode !== 0o600)
|
|
35
|
+
throw new Error(`ScaleMax client credential permissions are ${mode.toString(8)}; expected 600.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function removeClientSecret(file, ctx) {
|
|
39
|
+
if (!(await fs.pathExists(file)))
|
|
40
|
+
return false;
|
|
41
|
+
const content = await fs.readFile(file, 'utf8').catch(() => '');
|
|
42
|
+
if (sanitizeApiKey(content) !== sanitizeApiKey(ctx.apiKey))
|
|
43
|
+
return false;
|
|
44
|
+
await backupFile(file, ctx.home);
|
|
45
|
+
await fs.remove(file);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
package/dist/configure/common.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
|
-
import
|
|
3
|
+
import JSON5 from 'json5';
|
|
4
|
+
import { atomicWrite, backupFile, listBackups } from '../utils/fs.js';
|
|
4
5
|
import { GENERATED_HEADER } from '../constants.js';
|
|
5
6
|
export function isScaleMaxManagedText(text) {
|
|
6
7
|
return text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"') || text.includes('"managedBy": "ScaleMax CLI generated file"');
|
|
@@ -11,6 +12,10 @@ export async function writeManagedConfig(ctx, id, name, file, content) {
|
|
|
11
12
|
const current = await fs.readFile(file, 'utf8');
|
|
12
13
|
if (!isScaleMaxManagedText(current))
|
|
13
14
|
throw new Error(`Refusing to overwrite unmanaged configuration: ${file}`);
|
|
15
|
+
if (current === content) {
|
|
16
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
17
|
+
return { id, name, changed: false, path: file, message: 'already current' };
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
const backup = await backupFile(file, ctx.home);
|
|
16
21
|
await atomicWrite(file, content, 0o600, ctx.platform);
|
|
@@ -18,9 +23,30 @@ export async function writeManagedConfig(ctx, id, name, file, content) {
|
|
|
18
23
|
}
|
|
19
24
|
export async function writeMergedJsonConfig(ctx, id, name, file, build) {
|
|
20
25
|
await fs.ensureDir(path.dirname(file));
|
|
21
|
-
const
|
|
26
|
+
const current = await fs.readFile(file, 'utf8').catch((error) => {
|
|
27
|
+
if (error.code === 'ENOENT')
|
|
28
|
+
return '';
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
let existing = {};
|
|
32
|
+
if (current) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON5.parse(current);
|
|
35
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
36
|
+
throw new Error('the root value must be an object');
|
|
37
|
+
existing = parsed;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`Refusing to overwrite invalid JSON/JSON5 configuration at ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const content = build(existing);
|
|
44
|
+
if (current === content) {
|
|
45
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
46
|
+
return { id, name, changed: false, path: file, message: 'already current' };
|
|
47
|
+
}
|
|
22
48
|
const backup = await backupFile(file, ctx.home);
|
|
23
|
-
await atomicWrite(file,
|
|
49
|
+
await atomicWrite(file, content, 0o600, ctx.platform);
|
|
24
50
|
return { id, name, changed: true, path: file, message: backup ? `configured (backup: ${backup})` : 'configured' };
|
|
25
51
|
}
|
|
26
52
|
async function latestRestorableBackup(file, home) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { ConfigureContext, ConfigureResult } from '../types/client.js';
|
|
2
2
|
export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
|
|
3
|
+
export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
|
|
3
4
|
export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
|
package/dist/configure/hermes.js
CHANGED
|
@@ -1,14 +1,165 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { parseDocument } from 'yaml';
|
|
4
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
1
5
|
import { clientConfigPath } from './paths.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
6
|
+
import { isScaleMaxManagedText } from './common.js';
|
|
7
|
+
import { modelIds, openAiBaseUrl, preferredModel } from './models.js';
|
|
4
8
|
const id = 'hermes';
|
|
5
9
|
const name = 'Hermes';
|
|
10
|
+
const envKey = 'SCALEMAX_HERMES_API_KEY';
|
|
11
|
+
const envMarker = '# ScaleMax managed Hermes API key';
|
|
12
|
+
function asRecord(value) {
|
|
13
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
14
|
+
}
|
|
15
|
+
function envPath(home) { return path.join(home, '.hermes', '.env'); }
|
|
16
|
+
function managedEnv(text, apiKey) {
|
|
17
|
+
const kept = text.split(/\r?\n/).filter(line => line.trim() !== envMarker && !line.startsWith(`${envKey}=`));
|
|
18
|
+
while (kept.length && !kept[kept.length - 1])
|
|
19
|
+
kept.pop();
|
|
20
|
+
return `${kept.length ? `${kept.join('\n')}\n` : ''}${envMarker}\n${envKey}=${apiKey}\n`;
|
|
21
|
+
}
|
|
22
|
+
function withoutManagedEnv(text) {
|
|
23
|
+
const kept = text.split(/\r?\n/).filter(line => line.trim() !== envMarker && !line.startsWith(`${envKey}=`));
|
|
24
|
+
while (kept.length && !kept[kept.length - 1])
|
|
25
|
+
kept.pop();
|
|
26
|
+
return kept.length ? `${kept.join('\n')}\n` : '';
|
|
27
|
+
}
|
|
28
|
+
function parseYaml(text) {
|
|
29
|
+
const doc = parseDocument(text || '{}');
|
|
30
|
+
if (doc.errors.length)
|
|
31
|
+
throw new Error(`Hermes config.yaml is invalid: ${doc.errors[0].message}`);
|
|
32
|
+
if (!doc.contents)
|
|
33
|
+
doc.contents = doc.createNode({});
|
|
34
|
+
return doc;
|
|
35
|
+
}
|
|
36
|
+
function modelMetadata(ctx) {
|
|
37
|
+
return Object.fromEntries(modelIds(ctx).map(modelId => {
|
|
38
|
+
const model = (ctx.models || []).find(candidate => candidate.id === modelId);
|
|
39
|
+
const metadata = {};
|
|
40
|
+
if (model?.context_window)
|
|
41
|
+
metadata.context_length = model.context_window;
|
|
42
|
+
return [modelId, metadata];
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
async function removeLegacyArtifact(ctx) {
|
|
46
|
+
const legacy = path.join(ctx.home, '.hermes', 'config', 'scalemax.json');
|
|
47
|
+
if (!(await fs.pathExists(legacy)))
|
|
48
|
+
return;
|
|
49
|
+
const text = await fs.readFile(legacy, 'utf8').catch(() => '');
|
|
50
|
+
if (!isScaleMaxManagedText(text) || !text.includes('api.scalemax.pro'))
|
|
51
|
+
return;
|
|
52
|
+
await backupFile(legacy, ctx.home);
|
|
53
|
+
await fs.remove(legacy);
|
|
54
|
+
}
|
|
6
55
|
export async function configure(ctx) {
|
|
7
56
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
8
|
-
const
|
|
9
|
-
|
|
57
|
+
const secrets = envPath(ctx.home);
|
|
58
|
+
await fs.ensureDir(path.dirname(file), 0o700);
|
|
59
|
+
await fs.chmod(path.dirname(file), 0o700).catch(() => undefined);
|
|
60
|
+
const originalConfig = await fs.readFile(file, 'utf8').catch(() => '');
|
|
61
|
+
const originalEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
62
|
+
const doc = parseYaml(originalConfig);
|
|
63
|
+
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
|
+
});
|
|
74
|
+
doc.set('custom_providers', providers);
|
|
75
|
+
const currentModel = asRecord(data.model);
|
|
76
|
+
const activatesScaleMax = !currentModel.provider || String(currentModel.provider).startsWith('custom:scalemax');
|
|
77
|
+
if (activatesScaleMax) {
|
|
78
|
+
doc.set('model', { ...currentModel, provider: 'custom:scalemax', default: preferredModel(ctx), max_tokens: 4096 });
|
|
79
|
+
}
|
|
80
|
+
const nextConfig = String(doc);
|
|
81
|
+
const nextEnv = managedEnv(originalEnv, ctx.apiKey);
|
|
82
|
+
if (originalConfig === nextConfig && originalEnv === nextEnv) {
|
|
83
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
84
|
+
await fs.chmod(secrets, 0o600).catch(() => undefined);
|
|
85
|
+
await removeLegacyArtifact(ctx);
|
|
86
|
+
return { id, name, changed: false, path: file, message: `already current with ${modelIds(ctx).length} models and a private credential` };
|
|
87
|
+
}
|
|
88
|
+
const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
|
|
89
|
+
try {
|
|
90
|
+
await atomicWrite(file, nextConfig, 0o600, ctx.platform);
|
|
91
|
+
await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
|
|
92
|
+
await removeLegacyArtifact(ctx);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (originalConfig)
|
|
96
|
+
await atomicWrite(file, originalConfig, 0o600, ctx.platform);
|
|
97
|
+
else
|
|
98
|
+
await fs.remove(file).catch(() => undefined);
|
|
99
|
+
if (originalEnv)
|
|
100
|
+
await atomicWrite(secrets, originalEnv, 0o600, ctx.platform);
|
|
101
|
+
else
|
|
102
|
+
await fs.remove(secrets).catch(() => undefined);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return { id, name, changed: true, path: file, message: `configured named ScaleMax provider with ${modelIds(ctx).length} models${backups.length ? ' (backups created)' : ''}` };
|
|
106
|
+
}
|
|
107
|
+
export async function verify(ctx) {
|
|
108
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
109
|
+
const secrets = envPath(ctx.home);
|
|
110
|
+
const doc = parseYaml(await fs.readFile(file, 'utf8'));
|
|
111
|
+
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.');
|
|
115
|
+
}
|
|
116
|
+
const configured = Object.keys(asRecord(provider.models));
|
|
117
|
+
const missing = modelIds(ctx).filter(model => !configured.includes(model));
|
|
118
|
+
const env = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
119
|
+
if (missing.length || !env.includes(envMarker) || !env.includes(`${envKey}=${ctx.apiKey}`))
|
|
120
|
+
throw new Error('Hermes is missing authenticated ScaleMax models or credentials.');
|
|
121
|
+
if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens !== 4096)
|
|
122
|
+
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` };
|
|
10
124
|
}
|
|
11
125
|
export async function remove(ctx) {
|
|
12
126
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
13
|
-
|
|
127
|
+
const secrets = envPath(ctx.home);
|
|
128
|
+
if (!(await fs.pathExists(file)))
|
|
129
|
+
return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
|
|
130
|
+
const text = await fs.readFile(file, 'utf8');
|
|
131
|
+
const doc = parseYaml(text);
|
|
132
|
+
const data = asRecord(doc.toJS());
|
|
133
|
+
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)
|
|
136
|
+
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');
|
|
139
|
+
if (remaining.length)
|
|
140
|
+
doc.set('custom_providers', remaining);
|
|
141
|
+
else
|
|
142
|
+
doc.delete('custom_providers');
|
|
143
|
+
if (data?.model?.provider === 'custom:scalemax')
|
|
144
|
+
doc.deleteIn(['model', 'provider']);
|
|
145
|
+
if (data?.model?.provider === 'custom:scalemax' && managedModels.includes(String(data?.model?.default || '')))
|
|
146
|
+
doc.deleteIn(['model', 'default']);
|
|
147
|
+
if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens === 4096)
|
|
148
|
+
doc.deleteIn(['model', 'max_tokens']);
|
|
149
|
+
const currentEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
150
|
+
await backupFile(file, ctx.home);
|
|
151
|
+
if (currentEnv)
|
|
152
|
+
await backupFile(secrets, ctx.home);
|
|
153
|
+
const nextData = asRecord(doc.toJS());
|
|
154
|
+
if (Object.keys(nextData).length)
|
|
155
|
+
await atomicWrite(file, String(doc), 0o600, ctx.platform);
|
|
156
|
+
else
|
|
157
|
+
await fs.remove(file);
|
|
158
|
+
const nextEnv = withoutManagedEnv(currentEnv);
|
|
159
|
+
if (nextEnv)
|
|
160
|
+
await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
|
|
161
|
+
else
|
|
162
|
+
await fs.remove(secrets).catch(() => undefined);
|
|
163
|
+
await removeLegacyArtifact(ctx);
|
|
164
|
+
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax custom provider and credential' };
|
|
14
165
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ConfigureContext } from '../types/client.js';
|
|
2
|
+
export declare function modelIds(ctx: ConfigureContext): string[];
|
|
3
|
+
export declare function preferredModel(ctx: ConfigureContext): string;
|
|
4
|
+
export declare function fastModel(ctx: ConfigureContext): string | undefined;
|
|
5
|
+
export declare function modelName(ctx: ConfigureContext, id: string): string;
|
|
6
|
+
export declare function anthropicOrigin(baseUrl: string): string;
|
|
7
|
+
export declare function openAiBaseUrl(baseUrl: string): string;
|