scalemax 0.1.15 → 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 +8 -3
- package/dist/configure/claude.js +89 -31
- 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.js +12 -3
- package/dist/configure/models.d.ts +1 -0
- package/dist/configure/models.js +5 -0
- package/dist/configure/openclaw.js +139 -39
- package/dist/configure/opencode.js +39 -16
- package/dist/utils/api.js +32 -11
- 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 +3 -2
package/README.md
CHANGED
|
@@ -114,15 +114,15 @@ For clients without a stable official config file, the installer writes a ScaleM
|
|
|
114
114
|
|
|
115
115
|
### Claude Code
|
|
116
116
|
|
|
117
|
-
ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`,
|
|
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
118
|
|
|
119
119
|
### OpenCode
|
|
120
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`, keeps unrelated providers and the user's non-ScaleMax default model, and loads every model available to the authenticated key. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
|
|
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
122
|
|
|
123
123
|
### OpenClaw
|
|
124
124
|
|
|
125
|
-
ScaleMax merges a native `anthropic-messages` provider into `~/.openclaw/openclaw.json`, using the Anthropic origin `https://api.scalemax.pro`. It adds every authenticated model to the provider and agent allowlist, caps generated output limits at 4096 tokens, and preserves unrelated providers and agent settings. Legacy ScaleMax artifacts are removed only when they carry the ScaleMax managed marker.
|
|
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
126
|
|
|
127
127
|
### Hermes
|
|
128
128
|
|
|
@@ -153,6 +153,7 @@ npx scalemax models
|
|
|
153
153
|
```
|
|
154
154
|
|
|
155
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.
|
|
156
157
|
|
|
157
158
|
Run diagnostics:
|
|
158
159
|
|
|
@@ -274,7 +275,11 @@ Never publish over an existing npm version. Use semantic versioning for every re
|
|
|
274
275
|
|
|
275
276
|
- Existing client configuration files are backed up before writes.
|
|
276
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.
|
|
277
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.
|
|
278
283
|
- The published npm package contains only runtime files: `dist/`, `README.md`, `LICENSE`, and `package.json`.
|
|
279
284
|
- Tests, TypeScript source, scripts, local caches, `.env` files, tarballs, and `node_modules` are excluded from the published package.
|
|
280
285
|
|
package/dist/configure/claude.js
CHANGED
|
@@ -1,57 +1,98 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
4
5
|
import { clientConfigPath } from './paths.js';
|
|
5
6
|
import { writeMergedJsonConfig } from './common.js';
|
|
6
7
|
import { GENERATED_HEADER } from '../constants.js';
|
|
7
|
-
import {
|
|
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';
|
|
8
11
|
const id = 'claude';
|
|
9
12
|
const name = 'Claude Code';
|
|
10
13
|
const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
|
|
11
|
-
const
|
|
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
|
+
}
|
|
12
24
|
function asRecord(value) {
|
|
13
25
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
14
26
|
}
|
|
15
27
|
export async function configure(ctx) {
|
|
16
28
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
17
29
|
const model = preferredModel(ctx);
|
|
30
|
+
const quick = fastModel(ctx);
|
|
18
31
|
const baseUrl = anthropicOrigin(ctx.baseUrl);
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 = {
|
|
28
46
|
...env,
|
|
29
|
-
ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
|
|
30
47
|
ANTHROPIC_BASE_URL: baseUrl,
|
|
31
48
|
ANTHROPIC_MODEL: model,
|
|
32
|
-
[discoveryKey]: '1'
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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` : ''}` };
|
|
46
83
|
}
|
|
47
84
|
export async function verify(ctx) {
|
|
48
85
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
49
86
|
const settings = await fs.readJson(file);
|
|
50
87
|
const env = asRecord(settings?.env);
|
|
51
88
|
const model = String(env.ANTHROPIC_MODEL || '');
|
|
52
|
-
if (!modelIds(ctx).includes(model) || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) ||
|
|
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') {
|
|
53
90
|
throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
|
|
54
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.');
|
|
55
96
|
const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, {
|
|
56
97
|
method: 'POST',
|
|
57
98
|
headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
@@ -65,14 +106,16 @@ export async function verify(ctx) {
|
|
|
65
106
|
message = body?.error?.message || message;
|
|
66
107
|
}
|
|
67
108
|
catch { }
|
|
68
|
-
throw new Error(`Claude Messages verification failed: ${message}`);
|
|
109
|
+
throw new Error(`Claude Messages verification failed: ${redactApiKey(message, ctx.apiKey)}`);
|
|
69
110
|
}
|
|
70
111
|
return { id, name, changed: false, path: file, message: `verified Anthropic Messages with ${model}` };
|
|
71
112
|
}
|
|
72
113
|
export async function remove(ctx) {
|
|
73
114
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
74
|
-
if (!(await fs.pathExists(file)))
|
|
75
|
-
|
|
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
|
+
}
|
|
76
119
|
const settings = await fs.readJson(file).catch(() => undefined);
|
|
77
120
|
if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI')
|
|
78
121
|
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
@@ -91,8 +134,22 @@ export async function remove(ctx) {
|
|
|
91
134
|
restore('ANTHROPIC_MODEL');
|
|
92
135
|
if (env[discoveryKey] === managed.gatewayDiscovery)
|
|
93
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');
|
|
94
145
|
if (typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && crypto.createHash('sha256').update(env.ANTHROPIC_AUTH_TOKEN).digest('hex') === managed.authTokenSha256)
|
|
95
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
|
+
}
|
|
96
153
|
if (Object.keys(env).length)
|
|
97
154
|
settings.env = env;
|
|
98
155
|
else
|
|
@@ -104,5 +161,6 @@ export async function remove(ctx) {
|
|
|
104
161
|
await atomicWrite(file, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
|
|
105
162
|
else
|
|
106
163
|
await fs.remove(file);
|
|
107
|
-
|
|
164
|
+
await removeClientSecret(secretPath(ctx.home), ctx);
|
|
165
|
+
return { id, name, changed: true, path: file, message: 'removed only ScaleMax Claude Code settings and credential' };
|
|
108
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) {
|
package/dist/configure/hermes.js
CHANGED
|
@@ -55,7 +55,8 @@ async function removeLegacyArtifact(ctx) {
|
|
|
55
55
|
export async function configure(ctx) {
|
|
56
56
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
57
57
|
const secrets = envPath(ctx.home);
|
|
58
|
-
await fs.ensureDir(path.dirname(file));
|
|
58
|
+
await fs.ensureDir(path.dirname(file), 0o700);
|
|
59
|
+
await fs.chmod(path.dirname(file), 0o700).catch(() => undefined);
|
|
59
60
|
const originalConfig = await fs.readFile(file, 'utf8').catch(() => '');
|
|
60
61
|
const originalEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
61
62
|
const doc = parseYaml(originalConfig);
|
|
@@ -76,10 +77,18 @@ export async function configure(ctx) {
|
|
|
76
77
|
if (activatesScaleMax) {
|
|
77
78
|
doc.set('model', { ...currentModel, provider: 'custom:scalemax', default: preferredModel(ctx), max_tokens: 4096 });
|
|
78
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
|
+
}
|
|
79
88
|
const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
|
|
80
89
|
try {
|
|
81
|
-
await atomicWrite(file,
|
|
82
|
-
await atomicWrite(secrets,
|
|
90
|
+
await atomicWrite(file, nextConfig, 0o600, ctx.platform);
|
|
91
|
+
await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
|
|
83
92
|
await removeLegacyArtifact(ctx);
|
|
84
93
|
}
|
|
85
94
|
catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConfigureContext } from '../types/client.js';
|
|
2
2
|
export declare function modelIds(ctx: ConfigureContext): string[];
|
|
3
3
|
export declare function preferredModel(ctx: ConfigureContext): string;
|
|
4
|
+
export declare function fastModel(ctx: ConfigureContext): string | undefined;
|
|
4
5
|
export declare function modelName(ctx: ConfigureContext, id: string): string;
|
|
5
6
|
export declare function anthropicOrigin(baseUrl: string): string;
|
|
6
7
|
export declare function openAiBaseUrl(baseUrl: string): string;
|
package/dist/configure/models.js
CHANGED
|
@@ -19,6 +19,11 @@ export function preferredModel(ctx) {
|
|
|
19
19
|
return ids[0];
|
|
20
20
|
throw new Error('No compatible models are available for this API key; the client was not changed.');
|
|
21
21
|
}
|
|
22
|
+
export function fastModel(ctx) {
|
|
23
|
+
const ids = modelIds(ctx);
|
|
24
|
+
return ids.find(id => id === 'claude-haiku-4-5-20251001')
|
|
25
|
+
|| ids.find(id => /(^|-)haiku([-.]|$)/i.test(id));
|
|
26
|
+
}
|
|
22
27
|
export function modelName(ctx, id) {
|
|
23
28
|
const model = (ctx.models || []).find(candidate => candidate.id === id);
|
|
24
29
|
if (model?.display_name)
|
|
@@ -3,9 +3,20 @@ 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, modelIds, modelName, preferredModel } from './models.js';
|
|
6
|
+
import { anthropicOrigin, fastModel, modelIds, modelName, preferredModel } from './models.js';
|
|
7
|
+
import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
|
|
7
8
|
const id = 'openclaw';
|
|
8
9
|
const name = 'OpenClaw';
|
|
10
|
+
const secretProviderId = 'scalemax_cli';
|
|
11
|
+
const secretConfigPath = '~/.openclaw/secrets/scalemax-api-key';
|
|
12
|
+
const secretReference = { source: 'file', provider: secretProviderId, id: 'value' };
|
|
13
|
+
const userAgentProviderId = 'scalemax_cli_user_agent';
|
|
14
|
+
const userAgentConfigPath = '~/.openclaw/secrets/scalemax-user-agent';
|
|
15
|
+
const userAgentValue = 'ScaleMax-OpenClaw/1.0';
|
|
16
|
+
const userAgentReference = { source: 'file', provider: userAgentProviderId, id: 'value' };
|
|
17
|
+
function secretPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-api-key'); }
|
|
18
|
+
function userAgentPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-user-agent'); }
|
|
19
|
+
function userAgentContext(ctx) { return { ...ctx, apiKey: userAgentValue }; }
|
|
9
20
|
function asRecord(value) {
|
|
10
21
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
11
22
|
}
|
|
@@ -33,54 +44,125 @@ async function removeLegacyArtifact(ctx) {
|
|
|
33
44
|
await backupFile(legacy, ctx.home);
|
|
34
45
|
await fs.remove(legacy);
|
|
35
46
|
}
|
|
47
|
+
async function scrubGeneratedPlaintextCredentials(ctx) {
|
|
48
|
+
const agentsDir = path.join(ctx.home, '.openclaw', 'agents');
|
|
49
|
+
const agents = await fs.readdir(agentsDir, { withFileTypes: true }).catch(() => []);
|
|
50
|
+
let changed = 0;
|
|
51
|
+
for (const agent of agents) {
|
|
52
|
+
if (!agent.isDirectory())
|
|
53
|
+
continue;
|
|
54
|
+
const file = path.join(agentsDir, agent.name, 'agent', 'models.json');
|
|
55
|
+
if (!(await fs.pathExists(file)))
|
|
56
|
+
continue;
|
|
57
|
+
const original = await fs.readFile(file, 'utf8').catch(() => '');
|
|
58
|
+
let data;
|
|
59
|
+
try {
|
|
60
|
+
data = JSON.parse(original);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const roots = [asRecord(data.providers), asRecord(asRecord(data.models).providers)];
|
|
66
|
+
let scrubbed = false;
|
|
67
|
+
for (const providers of roots) {
|
|
68
|
+
const provider = asRecord(providers.scalemax);
|
|
69
|
+
if (provider.baseUrl === anthropicOrigin(ctx.baseUrl) && provider.apiKey === ctx.apiKey) {
|
|
70
|
+
delete provider.apiKey;
|
|
71
|
+
scrubbed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!scrubbed)
|
|
75
|
+
continue;
|
|
76
|
+
await backupFile(file, ctx.home);
|
|
77
|
+
await atomicWrite(file, JSON.stringify(data, null, 2) + '\n', 0o600, ctx.platform);
|
|
78
|
+
changed++;
|
|
79
|
+
}
|
|
80
|
+
return changed;
|
|
81
|
+
}
|
|
36
82
|
export async function configure(ctx) {
|
|
37
83
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
38
84
|
const primary = `scalemax/${preferredModel(ctx)}`;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
const quick = fastModel(ctx);
|
|
86
|
+
let secret = { changed: false };
|
|
87
|
+
let userAgentSecret = { changed: false };
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
secret = await writeClientSecret(secretPath(ctx.home), ctx);
|
|
91
|
+
userAgentSecret = await writeClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
|
|
92
|
+
result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
|
|
93
|
+
const modelsRoot = asRecord(existing.models);
|
|
94
|
+
const providers = asRecord(modelsRoot.providers);
|
|
95
|
+
const secretsRoot = asRecord(existing.secrets);
|
|
96
|
+
const secretProviders = asRecord(secretsRoot.providers);
|
|
97
|
+
const agents = asRecord(existing.agents);
|
|
98
|
+
const defaults = asRecord(agents.defaults);
|
|
99
|
+
const allowlist = asRecord(defaults.models);
|
|
100
|
+
for (const modelId of modelIds(ctx))
|
|
101
|
+
allowlist[`scalemax/${modelId}`] = asRecord(allowlist[`scalemax/${modelId}`]);
|
|
102
|
+
const currentModel = defaults.model;
|
|
103
|
+
const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
|
|
104
|
+
const activatesScaleMax = !currentPrimary || String(currentPrimary).startsWith('scalemax/');
|
|
105
|
+
const nextModel = activatesScaleMax
|
|
106
|
+
? { ...asRecord(currentModel), primary }
|
|
107
|
+
: currentModel;
|
|
108
|
+
const currentUtility = typeof defaults.utilityModel === 'string' ? defaults.utilityModel : undefined;
|
|
109
|
+
const utilityModel = quick && (activatesScaleMax || currentUtility?.startsWith('scalemax/')) ? `scalemax/${quick}` : currentUtility;
|
|
110
|
+
return JSON.stringify({
|
|
111
|
+
...existing,
|
|
112
|
+
secrets: {
|
|
113
|
+
...secretsRoot,
|
|
114
|
+
providers: {
|
|
115
|
+
...secretProviders,
|
|
116
|
+
[secretProviderId]: { source: 'file', path: secretConfigPath, mode: 'singleValue' },
|
|
117
|
+
[userAgentProviderId]: { source: 'file', path: userAgentConfigPath, mode: 'singleValue' }
|
|
65
118
|
}
|
|
119
|
+
},
|
|
120
|
+
models: {
|
|
121
|
+
...modelsRoot,
|
|
122
|
+
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
|
+
}
|
|
137
|
+
},
|
|
138
|
+
agents: {
|
|
139
|
+
...agents,
|
|
140
|
+
defaults: { ...defaults, model: nextModel, models: allowlist, ...(utilityModel ? { utilityModel } : {}) }
|
|
66
141
|
}
|
|
67
|
-
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
142
|
+
}, null, 2) + '\n';
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await rollbackClientSecret(userAgentPath(ctx.home), userAgentContext(ctx), userAgentSecret);
|
|
147
|
+
await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
74
150
|
await removeLegacyArtifact(ctx);
|
|
75
|
-
|
|
151
|
+
const scrubbed = await scrubGeneratedPlaintextCredentials(ctx);
|
|
152
|
+
return { ...result, changed: result.changed || secret.changed || userAgentSecret.changed || scrubbed > 0, message: `configured native Anthropic provider with ${modelIds(ctx).length} models and file-backed SecretRefs${quick ? `; ${quick} handles utility work` : ''}${scrubbed ? `; scrubbed ${scrubbed} generated credential ${scrubbed === 1 ? 'copy' : 'copies'}` : ''}` };
|
|
76
153
|
}
|
|
77
154
|
export async function verify(ctx) {
|
|
78
155
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
79
156
|
const config = await fs.readJson(file);
|
|
80
157
|
const provider = config?.models?.providers?.scalemax;
|
|
81
|
-
|
|
158
|
+
const secretProvider = config?.secrets?.providers?.[secretProviderId];
|
|
159
|
+
const userAgentProvider = config?.secrets?.providers?.[userAgentProviderId];
|
|
160
|
+
const userAgent = provider?.request?.headers?.['User-Agent'];
|
|
161
|
+
if (provider?.baseUrl !== anthropicOrigin(ctx.baseUrl) || provider?.apiKey?.source !== secretReference.source || provider?.apiKey?.provider !== secretReference.provider || provider?.apiKey?.id !== secretReference.id || provider?.api !== 'anthropic-messages' || secretProvider?.source !== 'file' || secretProvider?.path !== secretConfigPath || secretProvider?.mode !== 'singleValue' || userAgent?.source !== userAgentReference.source || userAgent?.provider !== userAgentReference.provider || userAgent?.id !== userAgentReference.id || userAgentProvider?.source !== 'file' || userAgentProvider?.path !== userAgentConfigPath || userAgentProvider?.mode !== 'singleValue') {
|
|
82
162
|
throw new Error('OpenClaw ScaleMax provider settings are incomplete.');
|
|
83
163
|
}
|
|
164
|
+
await verifyClientSecret(secretPath(ctx.home), ctx);
|
|
165
|
+
await verifyClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
|
|
84
166
|
const configured = new Set((Array.isArray(provider.models) ? provider.models : []).map((model) => model?.id));
|
|
85
167
|
const missing = modelIds(ctx).filter(model => !configured.has(model));
|
|
86
168
|
if (missing.length)
|
|
@@ -89,8 +171,11 @@ export async function verify(ctx) {
|
|
|
89
171
|
}
|
|
90
172
|
export async function remove(ctx) {
|
|
91
173
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
92
|
-
if (!(await fs.pathExists(file)))
|
|
93
|
-
|
|
174
|
+
if (!(await fs.pathExists(file))) {
|
|
175
|
+
const removed = await removeClientSecret(secretPath(ctx.home), ctx);
|
|
176
|
+
const removedUserAgent = await removeClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
|
|
177
|
+
return { id, name, changed: removed || removedUserAgent, path: file, message: removed || removedUserAgent ? 'removed orphaned ScaleMax OpenClaw credentials' : 'no ScaleMax configuration found' };
|
|
178
|
+
}
|
|
94
179
|
const config = await fs.readJson(file).catch(() => undefined);
|
|
95
180
|
if (!config?.models?.providers?.scalemax || config.models.providers.scalemax.baseUrl !== anthropicOrigin(ctx.baseUrl)) {
|
|
96
181
|
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
@@ -115,15 +200,30 @@ export async function remove(ctx) {
|
|
|
115
200
|
if (!Object.keys(defaults.model).length)
|
|
116
201
|
delete defaults.model;
|
|
117
202
|
}
|
|
203
|
+
if (typeof defaults?.utilityModel === 'string' && defaults.utilityModel.startsWith('scalemax/'))
|
|
204
|
+
delete defaults.utilityModel;
|
|
118
205
|
if (defaults && !Object.keys(defaults).length)
|
|
119
206
|
delete config.agents.defaults;
|
|
120
207
|
if (config.agents && !Object.keys(config.agents).length)
|
|
121
208
|
delete config.agents;
|
|
209
|
+
const secretProvider = config?.secrets?.providers?.[secretProviderId];
|
|
210
|
+
if (secretProvider?.source === 'file' && secretProvider?.path === secretConfigPath && secretProvider?.mode === 'singleValue') {
|
|
211
|
+
delete config.secrets.providers[secretProviderId];
|
|
212
|
+
}
|
|
213
|
+
const userAgentProvider = config?.secrets?.providers?.[userAgentProviderId];
|
|
214
|
+
if (userAgentProvider?.source === 'file' && userAgentProvider?.path === userAgentConfigPath && userAgentProvider?.mode === 'singleValue')
|
|
215
|
+
delete config.secrets.providers[userAgentProviderId];
|
|
216
|
+
if (config?.secrets?.providers && !Object.keys(config.secrets.providers).length)
|
|
217
|
+
delete config.secrets.providers;
|
|
218
|
+
if (config?.secrets && !Object.keys(config.secrets).length)
|
|
219
|
+
delete config.secrets;
|
|
122
220
|
await backupFile(file, ctx.home);
|
|
123
221
|
if (Object.keys(config).length)
|
|
124
222
|
await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
|
|
125
223
|
else
|
|
126
224
|
await fs.remove(file);
|
|
225
|
+
await removeClientSecret(secretPath(ctx.home), ctx);
|
|
226
|
+
await removeClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
|
|
127
227
|
await removeLegacyArtifact(ctx);
|
|
128
|
-
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider
|
|
228
|
+
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider, model entries, and credential' };
|
|
129
229
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import fs from 'fs-extra';
|
|
2
3
|
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
3
4
|
import { clientConfigPath } from './paths.js';
|
|
4
5
|
import { writeMergedJsonConfig } from './common.js';
|
|
5
|
-
import { modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
|
|
6
|
+
import { fastModel, modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
|
|
7
|
+
import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
|
|
6
8
|
const id = 'opencode';
|
|
7
9
|
const name = 'OpenCode';
|
|
10
|
+
const secretReference = '{file:~/.config/opencode/scalemax.key}';
|
|
11
|
+
function secretPath(home) { return path.join(home, '.config', 'opencode', 'scalemax.key'); }
|
|
8
12
|
function asRecord(value) {
|
|
9
13
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
14
|
}
|
|
@@ -22,31 +26,45 @@ function providerConfig(ctx) {
|
|
|
22
26
|
return {
|
|
23
27
|
npm: '@ai-sdk/openai-compatible',
|
|
24
28
|
name: 'ScaleMax',
|
|
25
|
-
options: { baseURL: openAiBaseUrl(ctx.baseUrl), apiKey:
|
|
29
|
+
options: { baseURL: openAiBaseUrl(ctx.baseUrl), apiKey: secretReference },
|
|
26
30
|
models
|
|
27
31
|
};
|
|
28
32
|
}
|
|
29
33
|
export async function configure(ctx) {
|
|
30
34
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
31
35
|
const selected = `scalemax/${preferredModel(ctx)}`;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const quick = fastModel(ctx);
|
|
37
|
+
const secret = await writeClientSecret(secretPath(ctx.home), ctx);
|
|
38
|
+
let result;
|
|
39
|
+
try {
|
|
40
|
+
result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
|
|
41
|
+
const providers = asRecord(existing.provider);
|
|
42
|
+
const currentModel = typeof existing.model === 'string' ? existing.model : undefined;
|
|
43
|
+
const currentSmallModel = typeof existing.small_model === 'string' ? existing.small_model : undefined;
|
|
44
|
+
const activatesScaleMax = !currentModel || currentModel.startsWith('scalemax/');
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
...existing,
|
|
47
|
+
$schema: typeof existing.$schema === 'string' ? existing.$schema : 'https://opencode.ai/config.json',
|
|
48
|
+
provider: { ...providers, scalemax: providerConfig(ctx) },
|
|
49
|
+
model: activatesScaleMax ? selected : currentModel,
|
|
50
|
+
...(quick && (activatesScaleMax || currentSmallModel?.startsWith('scalemax/')) ? { small_model: `scalemax/${quick}` } : {})
|
|
51
|
+
}, null, 2) + '\n';
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return { ...result, changed: result.changed || secret.changed, message: `${result.message}; file-backed credential${quick ? `; ${quick} handles lightweight work` : ''}` };
|
|
42
59
|
}
|
|
43
60
|
export async function verify(ctx) {
|
|
44
61
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
45
62
|
const config = await fs.readJson(file);
|
|
46
63
|
const provider = config?.provider?.scalemax;
|
|
47
|
-
if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !==
|
|
64
|
+
if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !== secretReference) {
|
|
48
65
|
throw new Error('OpenCode ScaleMax provider settings are incomplete.');
|
|
49
66
|
}
|
|
67
|
+
await verifyClientSecret(secretPath(ctx.home), ctx);
|
|
50
68
|
const configured = Object.keys(asRecord(provider.models));
|
|
51
69
|
const missing = modelIds(ctx).filter(model => !configured.includes(model));
|
|
52
70
|
if (missing.length)
|
|
@@ -55,8 +73,10 @@ export async function verify(ctx) {
|
|
|
55
73
|
}
|
|
56
74
|
export async function remove(ctx) {
|
|
57
75
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
58
|
-
if (!(await fs.pathExists(file)))
|
|
59
|
-
|
|
76
|
+
if (!(await fs.pathExists(file))) {
|
|
77
|
+
const removed = await removeClientSecret(secretPath(ctx.home), ctx);
|
|
78
|
+
return { id, name, changed: removed, path: file, message: removed ? 'removed orphaned ScaleMax OpenCode credential' : 'no ScaleMax configuration found' };
|
|
79
|
+
}
|
|
60
80
|
const config = await fs.readJson(file).catch(() => undefined);
|
|
61
81
|
if (!config?.provider?.scalemax || config.provider.scalemax.name !== 'ScaleMax' || config.provider.scalemax.options?.baseURL !== openAiBaseUrl(ctx.baseUrl))
|
|
62
82
|
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
@@ -65,7 +85,10 @@ export async function remove(ctx) {
|
|
|
65
85
|
delete config.provider;
|
|
66
86
|
if (typeof config.model === 'string' && config.model.startsWith('scalemax/'))
|
|
67
87
|
delete config.model;
|
|
88
|
+
if (typeof config.small_model === 'string' && config.small_model.startsWith('scalemax/'))
|
|
89
|
+
delete config.small_model;
|
|
68
90
|
await backupFile(file, ctx.home);
|
|
69
91
|
await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
|
|
70
|
-
|
|
92
|
+
await removeClientSecret(secretPath(ctx.home), ctx);
|
|
93
|
+
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider and credential' };
|
|
71
94
|
}
|
package/dist/utils/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SCALEMAX_BASE_URL } from '../constants.js';
|
|
2
|
-
import { sanitizeApiKey } from './apiKey.js';
|
|
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();
|
|
5
5
|
if (provider)
|
|
@@ -31,27 +31,48 @@ async function friendlyError(res) {
|
|
|
31
31
|
return message || 'ScaleMax is temporarily unavailable. Please try again shortly.';
|
|
32
32
|
return message || `HTTP ${res.status}`;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
function retryableStatus(status) {
|
|
35
|
+
return status === 408 || status === 429 || status >= 500;
|
|
36
|
+
}
|
|
37
|
+
async function request(path, apiKey, init = {}, retryGet = false) {
|
|
35
38
|
const headers = new Headers(init.headers);
|
|
36
39
|
if (apiKey)
|
|
37
40
|
headers.set('Authorization', `Bearer ${sanitizeApiKey(apiKey)}`);
|
|
38
41
|
headers.set('Cache-Control', 'no-cache');
|
|
39
42
|
if (!headers.has('Content-Type') && init.body)
|
|
40
43
|
headers.set('Content-Type', 'application/json');
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
const attempts = retryGet && (!init.method || init.method === 'GET') ? 2 : 1;
|
|
45
|
+
let lastError;
|
|
46
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
47
|
+
const timeout = AbortSignal.timeout(15000);
|
|
48
|
+
const signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(`${SCALEMAX_BASE_URL}${path}`, { ...init, headers, signal });
|
|
51
|
+
if (attempt + 1 === attempts || !retryableStatus(response.status))
|
|
52
|
+
return response;
|
|
53
|
+
await response.body?.cancel().catch(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
lastError = error;
|
|
57
|
+
if (attempt + 1 === attempts)
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, 125 + Math.floor(Math.random() * 126)));
|
|
61
|
+
}
|
|
62
|
+
throw lastError;
|
|
44
63
|
}
|
|
45
64
|
export async function validateApiKey(apiKey) {
|
|
46
65
|
try {
|
|
47
|
-
const authCheck = await
|
|
66
|
+
const [authCheck, modelsRes] = await Promise.all([
|
|
67
|
+
request('/usage', apiKey, {}, true),
|
|
68
|
+
request('/models', apiKey, {}, true)
|
|
69
|
+
]);
|
|
48
70
|
if (!authCheck.ok)
|
|
49
|
-
return { ok: false, models: [], error: await friendlyError(authCheck) };
|
|
71
|
+
return { ok: false, models: [], error: redactApiKey(await friendlyError(authCheck), apiKey) };
|
|
50
72
|
const usage = await authCheck.json().catch(() => ({}));
|
|
51
73
|
const keyMeta = { provider_scope: usage?.key?.provider_scope || 'both', mode: usage?.key?.mode, prefix: usage?.key?.prefix, label: usage?.key?.label };
|
|
52
|
-
const modelsRes = await request('/models', apiKey);
|
|
53
74
|
if (!modelsRes.ok)
|
|
54
|
-
return { ok: false, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
|
|
75
|
+
return { ok: false, models: [], key: keyMeta, error: redactApiKey(await friendlyError(modelsRes), apiKey) };
|
|
55
76
|
const data = await modelsRes.json();
|
|
56
77
|
const catalog = Array.isArray(data.data) ? data.data : [];
|
|
57
78
|
const models = filterModelsForScope(catalog, keyMeta.provider_scope);
|
|
@@ -60,7 +81,7 @@ export async function validateApiKey(apiKey) {
|
|
|
60
81
|
return { ok: true, models, key: keyMeta };
|
|
61
82
|
}
|
|
62
83
|
catch (err) {
|
|
63
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
84
|
+
const message = redactApiKey(err instanceof Error ? err.message : String(err), apiKey);
|
|
64
85
|
return { ok: false, models: [], error: /abort|timeout/i.test(message) ? 'ScaleMax request timed out after 15 seconds.' : message };
|
|
65
86
|
}
|
|
66
87
|
}
|
|
@@ -69,7 +90,7 @@ export async function testStreaming(apiKey, model) {
|
|
|
69
90
|
try {
|
|
70
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' }] }) });
|
|
71
92
|
if (!res.ok)
|
|
72
|
-
return { ok: false, error: await friendlyError(res) };
|
|
93
|
+
return { ok: false, error: redactApiKey(await friendlyError(res), apiKey) };
|
|
73
94
|
const ct = res.headers.get('content-type') || '';
|
|
74
95
|
if (!ct.includes('text/event-stream'))
|
|
75
96
|
return { ok: false, error: `Expected event stream, got ${ct}` };
|
package/dist/utils/apiKey.d.ts
CHANGED
package/dist/utils/apiKey.js
CHANGED
|
@@ -6,3 +6,8 @@ export function sanitizeApiKey(input) {
|
|
|
6
6
|
.replace(ASCII_WHITESPACE_RE, '')
|
|
7
7
|
.trim();
|
|
8
8
|
}
|
|
9
|
+
export function redactApiKey(message, apiKey) {
|
|
10
|
+
const text = String(message ?? '');
|
|
11
|
+
const key = apiKey ? sanitizeApiKey(apiKey) : '';
|
|
12
|
+
return key ? text.split(key).join('[REDACTED]') : text;
|
|
13
|
+
}
|
package/dist/utils/fs.js
CHANGED
|
@@ -52,8 +52,10 @@ export async function backupFile(file, home) {
|
|
|
52
52
|
if (!(await fs.pathExists(file)))
|
|
53
53
|
return undefined;
|
|
54
54
|
const dest = path.join(backupDir(home), `${timestamp()}__${backupNameFor(file)}`);
|
|
55
|
-
await fs.ensureDir(path.dirname(dest));
|
|
55
|
+
await fs.ensureDir(path.dirname(dest), 0o700);
|
|
56
|
+
await fs.chmod(path.dirname(dest), 0o700).catch(() => undefined);
|
|
56
57
|
await fs.copy(file, dest, { overwrite: false, errorOnExist: true });
|
|
58
|
+
await fs.chmod(dest, 0o600).catch(() => undefined);
|
|
57
59
|
return dest;
|
|
58
60
|
}
|
|
59
61
|
export async function listBackups(file, home) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scalemax",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Universal ScaleMax installer for AI coding clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"chalk": "^5.3.0",
|
|
32
32
|
"execa": "^9.5.1",
|
|
33
33
|
"fs-extra": "^11.2.0",
|
|
34
|
+
"json5": "^2.2.3",
|
|
34
35
|
"ora": "^8.1.1",
|
|
35
36
|
"yaml": "^2.9.0"
|
|
36
37
|
},
|
|
@@ -39,7 +40,7 @@
|
|
|
39
40
|
"@types/node": "^22.10.2",
|
|
40
41
|
"tsx": "^4.19.2",
|
|
41
42
|
"typescript": "^5.7.2",
|
|
42
|
-
"vitest": "^
|
|
43
|
+
"vitest": "^4.1.10"
|
|
43
44
|
},
|
|
44
45
|
"engines": {
|
|
45
46
|
"node": ">=18.19"
|