scalemax 0.1.14 → 0.1.15
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 -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 +90 -18
- package/dist/configure/hermes.d.ts +1 -0
- package/dist/configure/hermes.js +147 -5
- package/dist/configure/models.d.ts +6 -0
- package/dist/configure/models.js +38 -0
- package/dist/configure/openclaw.d.ts +1 -0
- package/dist/configure/openclaw.js +120 -5
- package/dist/configure/opencode.d.ts +1 -0
- package/dist/configure/opencode.js +62 -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 +13 -1
- package/package.json +3 -2
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`, the stored key as `ANTHROPIC_AUTH_TOKEN`, and gateway model discovery. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live model from the authenticated catalog (preferring `claude-opus-4-8`), 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`, 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.
|
|
122
|
+
|
|
123
|
+
### OpenClaw
|
|
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.
|
|
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
|
|
|
@@ -296,4 +308,4 @@ Yes. The CLI uses Windows-aware config paths and writes `%APPDATA%\scalemax\conf
|
|
|
296
308
|
|
|
297
309
|
### What endpoint does it configure?
|
|
298
310
|
|
|
299
|
-
`https://api.scalemax.pro/v1`
|
|
311
|
+
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,63 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
1
4
|
import { clientConfigPath } from './paths.js';
|
|
2
|
-
import { writeMergedJsonConfig
|
|
5
|
+
import { writeMergedJsonConfig } from './common.js';
|
|
3
6
|
import { GENERATED_HEADER } from '../constants.js';
|
|
4
|
-
import
|
|
5
|
-
const id = 'claude'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
import { anthropicOrigin, modelIds, preferredModel } from './models.js';
|
|
8
|
+
const id = 'claude';
|
|
9
|
+
const name = 'Claude Code';
|
|
10
|
+
const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
|
|
11
|
+
const managedEnvKeys = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', discoveryKey];
|
|
12
|
+
function asRecord(value) {
|
|
13
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
14
|
+
}
|
|
11
15
|
export async function configure(ctx) {
|
|
12
|
-
const file = clientConfigPath(id, ctx.home, ctx.platform)
|
|
16
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
17
|
+
const model = preferredModel(ctx);
|
|
18
|
+
const baseUrl = anthropicOrigin(ctx.baseUrl);
|
|
13
19
|
const result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
|
|
14
|
-
const env =
|
|
15
|
-
|
|
20
|
+
const env = asRecord(existing.env);
|
|
21
|
+
const oldMetadata = asRecord(existing.scalemax);
|
|
22
|
+
const previousEnv = oldMetadata.managedBy === 'ScaleMax CLI'
|
|
23
|
+
? asRecord(oldMetadata.previousEnv)
|
|
24
|
+
: Object.fromEntries(managedEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(env, key)).map(key => [key, env[key]]));
|
|
25
|
+
return JSON.stringify({
|
|
26
|
+
...existing,
|
|
27
|
+
env: {
|
|
28
|
+
...env,
|
|
29
|
+
ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
|
|
30
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
31
|
+
ANTHROPIC_MODEL: model,
|
|
32
|
+
[discoveryKey]: '1'
|
|
33
|
+
},
|
|
34
|
+
'scalemax.managedBy': GENERATED_HEADER,
|
|
35
|
+
scalemax: {
|
|
36
|
+
managedBy: 'ScaleMax CLI',
|
|
37
|
+
anthropicModel: model,
|
|
38
|
+
anthropicBaseUrl: baseUrl,
|
|
39
|
+
gatewayDiscovery: '1',
|
|
40
|
+
authTokenSha256: crypto.createHash('sha256').update(ctx.apiKey).digest('hex'),
|
|
41
|
+
previousEnv
|
|
42
|
+
}
|
|
43
|
+
}, null, 2) + '\n';
|
|
16
44
|
});
|
|
17
|
-
return { ...result, message: `configured Anthropic
|
|
45
|
+
return { ...result, message: `configured Anthropic Messages routing with ${model} and gateway model discovery` };
|
|
18
46
|
}
|
|
19
47
|
export async function verify(ctx) {
|
|
20
|
-
const file = clientConfigPath(id, ctx.home, ctx.platform)
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
49
|
+
const settings = await fs.readJson(file);
|
|
50
|
+
const env = asRecord(settings?.env);
|
|
51
|
+
const model = String(env.ANTHROPIC_MODEL || '');
|
|
52
|
+
if (!modelIds(ctx).includes(model) || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || env.ANTHROPIC_AUTH_TOKEN !== ctx.apiKey || env[discoveryKey] !== '1') {
|
|
53
|
+
throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
|
|
54
|
+
}
|
|
55
|
+
const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ model, max_tokens: 1, stream: false, messages: [{ role: 'user', content: 'K' }] }),
|
|
59
|
+
signal: AbortSignal.timeout(15000)
|
|
60
|
+
});
|
|
25
61
|
if (!response.ok) {
|
|
26
62
|
let message = `HTTP ${response.status}`;
|
|
27
63
|
try {
|
|
@@ -33,4 +69,40 @@ export async function verify(ctx) {
|
|
|
33
69
|
}
|
|
34
70
|
return { id, name, changed: false, path: file, message: `verified Anthropic Messages with ${model}` };
|
|
35
71
|
}
|
|
36
|
-
export async function remove(ctx) {
|
|
72
|
+
export async function remove(ctx) {
|
|
73
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
74
|
+
if (!(await fs.pathExists(file)))
|
|
75
|
+
return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
|
|
76
|
+
const settings = await fs.readJson(file).catch(() => undefined);
|
|
77
|
+
if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI')
|
|
78
|
+
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
79
|
+
const managed = asRecord(settings.scalemax);
|
|
80
|
+
const env = asRecord(settings.env);
|
|
81
|
+
const previous = asRecord(managed.previousEnv);
|
|
82
|
+
const restore = (key) => {
|
|
83
|
+
if (Object.prototype.hasOwnProperty.call(previous, key))
|
|
84
|
+
env[key] = previous[key];
|
|
85
|
+
else
|
|
86
|
+
delete env[key];
|
|
87
|
+
};
|
|
88
|
+
if (env.ANTHROPIC_BASE_URL === managed.anthropicBaseUrl)
|
|
89
|
+
restore('ANTHROPIC_BASE_URL');
|
|
90
|
+
if (env.ANTHROPIC_MODEL === managed.anthropicModel)
|
|
91
|
+
restore('ANTHROPIC_MODEL');
|
|
92
|
+
if (env[discoveryKey] === managed.gatewayDiscovery)
|
|
93
|
+
restore(discoveryKey);
|
|
94
|
+
if (typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && crypto.createHash('sha256').update(env.ANTHROPIC_AUTH_TOKEN).digest('hex') === managed.authTokenSha256)
|
|
95
|
+
restore('ANTHROPIC_AUTH_TOKEN');
|
|
96
|
+
if (Object.keys(env).length)
|
|
97
|
+
settings.env = env;
|
|
98
|
+
else
|
|
99
|
+
delete settings.env;
|
|
100
|
+
delete settings['scalemax.managedBy'];
|
|
101
|
+
delete settings.scalemax;
|
|
102
|
+
await backupFile(file, ctx.home);
|
|
103
|
+
if (Object.keys(settings).length)
|
|
104
|
+
await atomicWrite(file, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
|
|
105
|
+
else
|
|
106
|
+
await fs.remove(file);
|
|
107
|
+
return { id, name, changed: true, path: file, message: 'removed only ScaleMax Claude Code settings' };
|
|
108
|
+
}
|
|
@@ -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,156 @@
|
|
|
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));
|
|
59
|
+
const originalConfig = await fs.readFile(file, 'utf8').catch(() => '');
|
|
60
|
+
const originalEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
61
|
+
const doc = parseYaml(originalConfig);
|
|
62
|
+
const data = asRecord(doc.toJS());
|
|
63
|
+
const providers = Array.isArray(data.custom_providers) ? data.custom_providers.filter((provider) => provider?.name !== 'scalemax') : [];
|
|
64
|
+
providers.push({
|
|
65
|
+
name: 'scalemax',
|
|
66
|
+
base_url: openAiBaseUrl(ctx.baseUrl),
|
|
67
|
+
key_env: envKey,
|
|
68
|
+
api_mode: 'chat_completions',
|
|
69
|
+
extra_headers: { 'User-Agent': 'ScaleMax-Hermes/1.0' },
|
|
70
|
+
max_output_tokens: 4096,
|
|
71
|
+
models: modelMetadata(ctx)
|
|
72
|
+
});
|
|
73
|
+
doc.set('custom_providers', providers);
|
|
74
|
+
const currentModel = asRecord(data.model);
|
|
75
|
+
const activatesScaleMax = !currentModel.provider || String(currentModel.provider).startsWith('custom:scalemax');
|
|
76
|
+
if (activatesScaleMax) {
|
|
77
|
+
doc.set('model', { ...currentModel, provider: 'custom:scalemax', default: preferredModel(ctx), max_tokens: 4096 });
|
|
78
|
+
}
|
|
79
|
+
const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
|
|
80
|
+
try {
|
|
81
|
+
await atomicWrite(file, String(doc), 0o600, ctx.platform);
|
|
82
|
+
await atomicWrite(secrets, managedEnv(originalEnv, ctx.apiKey), 0o600, ctx.platform);
|
|
83
|
+
await removeLegacyArtifact(ctx);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (originalConfig)
|
|
87
|
+
await atomicWrite(file, originalConfig, 0o600, ctx.platform);
|
|
88
|
+
else
|
|
89
|
+
await fs.remove(file).catch(() => undefined);
|
|
90
|
+
if (originalEnv)
|
|
91
|
+
await atomicWrite(secrets, originalEnv, 0o600, ctx.platform);
|
|
92
|
+
else
|
|
93
|
+
await fs.remove(secrets).catch(() => undefined);
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return { id, name, changed: true, path: file, message: `configured named ScaleMax provider with ${modelIds(ctx).length} models${backups.length ? ' (backups created)' : ''}` };
|
|
97
|
+
}
|
|
98
|
+
export async function verify(ctx) {
|
|
99
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
100
|
+
const secrets = envPath(ctx.home);
|
|
101
|
+
const doc = parseYaml(await fs.readFile(file, 'utf8'));
|
|
102
|
+
const data = asRecord(doc.toJS());
|
|
103
|
+
const provider = (Array.isArray(data.custom_providers) ? data.custom_providers : []).find((candidate) => candidate?.name === 'scalemax');
|
|
104
|
+
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') {
|
|
105
|
+
throw new Error('Hermes ScaleMax custom provider settings are incomplete.');
|
|
106
|
+
}
|
|
107
|
+
const configured = Object.keys(asRecord(provider.models));
|
|
108
|
+
const missing = modelIds(ctx).filter(model => !configured.includes(model));
|
|
109
|
+
const env = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
110
|
+
if (missing.length || !env.includes(envMarker) || !env.includes(`${envKey}=${ctx.apiKey}`))
|
|
111
|
+
throw new Error('Hermes is missing authenticated ScaleMax models or credentials.');
|
|
112
|
+
if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens !== 4096)
|
|
113
|
+
throw new Error('Hermes active ScaleMax model must use max_tokens 4096.');
|
|
114
|
+
return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
|
|
10
115
|
}
|
|
11
116
|
export async function remove(ctx) {
|
|
12
117
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
13
|
-
|
|
118
|
+
const secrets = envPath(ctx.home);
|
|
119
|
+
if (!(await fs.pathExists(file)))
|
|
120
|
+
return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
|
|
121
|
+
const text = await fs.readFile(file, 'utf8');
|
|
122
|
+
const doc = parseYaml(text);
|
|
123
|
+
const data = asRecord(doc.toJS());
|
|
124
|
+
const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
|
|
125
|
+
const managedProvider = providers.find((provider) => provider?.name === 'scalemax' && provider?.base_url === openAiBaseUrl(ctx.baseUrl));
|
|
126
|
+
if (!managedProvider)
|
|
127
|
+
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
128
|
+
const managedModels = Object.keys(asRecord(managedProvider.models));
|
|
129
|
+
const remaining = providers.filter((provider) => provider?.name !== 'scalemax');
|
|
130
|
+
if (remaining.length)
|
|
131
|
+
doc.set('custom_providers', remaining);
|
|
132
|
+
else
|
|
133
|
+
doc.delete('custom_providers');
|
|
134
|
+
if (data?.model?.provider === 'custom:scalemax')
|
|
135
|
+
doc.deleteIn(['model', 'provider']);
|
|
136
|
+
if (data?.model?.provider === 'custom:scalemax' && managedModels.includes(String(data?.model?.default || '')))
|
|
137
|
+
doc.deleteIn(['model', 'default']);
|
|
138
|
+
if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens === 4096)
|
|
139
|
+
doc.deleteIn(['model', 'max_tokens']);
|
|
140
|
+
const currentEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
|
|
141
|
+
await backupFile(file, ctx.home);
|
|
142
|
+
if (currentEnv)
|
|
143
|
+
await backupFile(secrets, ctx.home);
|
|
144
|
+
const nextData = asRecord(doc.toJS());
|
|
145
|
+
if (Object.keys(nextData).length)
|
|
146
|
+
await atomicWrite(file, String(doc), 0o600, ctx.platform);
|
|
147
|
+
else
|
|
148
|
+
await fs.remove(file);
|
|
149
|
+
const nextEnv = withoutManagedEnv(currentEnv);
|
|
150
|
+
if (nextEnv)
|
|
151
|
+
await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
|
|
152
|
+
else
|
|
153
|
+
await fs.remove(secrets).catch(() => undefined);
|
|
154
|
+
await removeLegacyArtifact(ctx);
|
|
155
|
+
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax custom provider and credential' };
|
|
14
156
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
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 modelName(ctx: ConfigureContext, id: string): string;
|
|
5
|
+
export declare function anthropicOrigin(baseUrl: string): string;
|
|
6
|
+
export declare function openAiBaseUrl(baseUrl: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const preferred = [
|
|
2
|
+
'claude-opus-4-8',
|
|
3
|
+
'claude-sonnet-5',
|
|
4
|
+
'claude-opus-4-7',
|
|
5
|
+
'claude-opus-4-6',
|
|
6
|
+
'claude-haiku-4-5-20251001',
|
|
7
|
+
'fable-5',
|
|
8
|
+
'fable-5-ultra'
|
|
9
|
+
];
|
|
10
|
+
export function modelIds(ctx) {
|
|
11
|
+
return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(Boolean))];
|
|
12
|
+
}
|
|
13
|
+
export function preferredModel(ctx) {
|
|
14
|
+
const ids = modelIds(ctx);
|
|
15
|
+
for (const candidate of preferred)
|
|
16
|
+
if (ids.includes(candidate))
|
|
17
|
+
return candidate;
|
|
18
|
+
if (ids.length)
|
|
19
|
+
return ids[0];
|
|
20
|
+
throw new Error('No compatible models are available for this API key; the client was not changed.');
|
|
21
|
+
}
|
|
22
|
+
export function modelName(ctx, id) {
|
|
23
|
+
const model = (ctx.models || []).find(candidate => candidate.id === id);
|
|
24
|
+
if (model?.display_name)
|
|
25
|
+
return model.display_name;
|
|
26
|
+
return id.split('-').map(part => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
|
|
27
|
+
}
|
|
28
|
+
export function anthropicOrigin(baseUrl) {
|
|
29
|
+
const url = new URL(baseUrl);
|
|
30
|
+
url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
|
31
|
+
url.search = '';
|
|
32
|
+
url.hash = '';
|
|
33
|
+
return url.toString().replace(/\/$/, '');
|
|
34
|
+
}
|
|
35
|
+
export function openAiBaseUrl(baseUrl) {
|
|
36
|
+
const origin = anthropicOrigin(baseUrl);
|
|
37
|
+
return `${origin}/v1`;
|
|
38
|
+
}
|
|
@@ -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>;
|
|
@@ -1,14 +1,129 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
1
4
|
import { clientConfigPath } from './paths.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
5
|
+
import { isScaleMaxManagedText, writeMergedJsonConfig } from './common.js';
|
|
6
|
+
import { anthropicOrigin, modelIds, modelName, preferredModel } from './models.js';
|
|
4
7
|
const id = 'openclaw';
|
|
5
8
|
const name = 'OpenClaw';
|
|
9
|
+
function asRecord(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
11
|
+
}
|
|
12
|
+
function entries(ctx) {
|
|
13
|
+
return modelIds(ctx).map(modelId => {
|
|
14
|
+
const metadata = (ctx.models || []).find(model => model.id === modelId);
|
|
15
|
+
const result = {
|
|
16
|
+
id: modelId,
|
|
17
|
+
name: modelName(ctx, modelId),
|
|
18
|
+
input: modelId.startsWith('claude-') && !modelId.startsWith('claude-fable-') ? ['text', 'image'] : ['text'],
|
|
19
|
+
maxTokens: Math.min(metadata?.max_output_tokens || 4096, 4096)
|
|
20
|
+
};
|
|
21
|
+
if (metadata?.context_window || modelId.startsWith('claude-'))
|
|
22
|
+
result.contextWindow = metadata?.context_window || 200000;
|
|
23
|
+
return result;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function removeLegacyArtifact(ctx) {
|
|
27
|
+
const legacy = path.join(ctx.home, '.openclaw', 'config.json');
|
|
28
|
+
if (!(await fs.pathExists(legacy)))
|
|
29
|
+
return;
|
|
30
|
+
const text = await fs.readFile(legacy, 'utf8').catch(() => '');
|
|
31
|
+
if (!isScaleMaxManagedText(text) || !text.includes('api.scalemax.pro'))
|
|
32
|
+
return;
|
|
33
|
+
await backupFile(legacy, ctx.home);
|
|
34
|
+
await fs.remove(legacy);
|
|
35
|
+
}
|
|
6
36
|
export async function configure(ctx) {
|
|
7
37
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
8
|
-
const
|
|
9
|
-
|
|
38
|
+
const primary = `scalemax/${preferredModel(ctx)}`;
|
|
39
|
+
const result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
|
|
40
|
+
const modelsRoot = asRecord(existing.models);
|
|
41
|
+
const providers = asRecord(modelsRoot.providers);
|
|
42
|
+
const agents = asRecord(existing.agents);
|
|
43
|
+
const defaults = asRecord(agents.defaults);
|
|
44
|
+
const allowlist = asRecord(defaults.models);
|
|
45
|
+
for (const modelId of modelIds(ctx))
|
|
46
|
+
allowlist[`scalemax/${modelId}`] = asRecord(allowlist[`scalemax/${modelId}`]);
|
|
47
|
+
const currentModel = defaults.model;
|
|
48
|
+
const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
|
|
49
|
+
const nextModel = !currentPrimary || String(currentPrimary).startsWith('scalemax/')
|
|
50
|
+
? { ...asRecord(currentModel), primary }
|
|
51
|
+
: currentModel;
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
...existing,
|
|
54
|
+
models: {
|
|
55
|
+
...modelsRoot,
|
|
56
|
+
mode: modelsRoot.mode || 'merge',
|
|
57
|
+
providers: {
|
|
58
|
+
...providers,
|
|
59
|
+
scalemax: {
|
|
60
|
+
baseUrl: anthropicOrigin(ctx.baseUrl),
|
|
61
|
+
apiKey: ctx.apiKey,
|
|
62
|
+
api: 'anthropic-messages',
|
|
63
|
+
maxTokens: 4096,
|
|
64
|
+
models: entries(ctx)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
agents: {
|
|
69
|
+
...agents,
|
|
70
|
+
defaults: { ...defaults, model: nextModel, models: allowlist }
|
|
71
|
+
}
|
|
72
|
+
}, null, 2) + '\n';
|
|
73
|
+
});
|
|
74
|
+
await removeLegacyArtifact(ctx);
|
|
75
|
+
return { ...result, message: `configured native Anthropic provider with ${modelIds(ctx).length} models` };
|
|
76
|
+
}
|
|
77
|
+
export async function verify(ctx) {
|
|
78
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
79
|
+
const config = await fs.readJson(file);
|
|
80
|
+
const provider = config?.models?.providers?.scalemax;
|
|
81
|
+
if (provider?.baseUrl !== anthropicOrigin(ctx.baseUrl) || provider?.apiKey !== ctx.apiKey || provider?.api !== 'anthropic-messages') {
|
|
82
|
+
throw new Error('OpenClaw ScaleMax provider settings are incomplete.');
|
|
83
|
+
}
|
|
84
|
+
const configured = new Set((Array.isArray(provider.models) ? provider.models : []).map((model) => model?.id));
|
|
85
|
+
const missing = modelIds(ctx).filter(model => !configured.has(model));
|
|
86
|
+
if (missing.length)
|
|
87
|
+
throw new Error(`OpenClaw is missing ${missing.length} authenticated model(s).`);
|
|
88
|
+
return { id, name, changed: false, path: file, message: `verified ${configured.size} ScaleMax models` };
|
|
10
89
|
}
|
|
11
90
|
export async function remove(ctx) {
|
|
12
91
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
13
|
-
|
|
92
|
+
if (!(await fs.pathExists(file)))
|
|
93
|
+
return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
|
|
94
|
+
const config = await fs.readJson(file).catch(() => undefined);
|
|
95
|
+
if (!config?.models?.providers?.scalemax || config.models.providers.scalemax.baseUrl !== anthropicOrigin(ctx.baseUrl)) {
|
|
96
|
+
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
97
|
+
}
|
|
98
|
+
delete config.models.providers.scalemax;
|
|
99
|
+
if (!Object.keys(config.models.providers).length)
|
|
100
|
+
delete config.models.providers;
|
|
101
|
+
if (Object.keys(config.models).length === 1 && config.models.mode === 'merge')
|
|
102
|
+
delete config.models;
|
|
103
|
+
const defaults = config?.agents?.defaults;
|
|
104
|
+
if (defaults?.models && typeof defaults.models === 'object') {
|
|
105
|
+
for (const key of Object.keys(defaults.models))
|
|
106
|
+
if (key.startsWith('scalemax/'))
|
|
107
|
+
delete defaults.models[key];
|
|
108
|
+
if (!Object.keys(defaults.models).length)
|
|
109
|
+
delete defaults.models;
|
|
110
|
+
}
|
|
111
|
+
if (typeof defaults?.model === 'string' && defaults.model.startsWith('scalemax/'))
|
|
112
|
+
delete defaults.model;
|
|
113
|
+
if (defaults?.model?.primary?.startsWith?.('scalemax/')) {
|
|
114
|
+
delete defaults.model.primary;
|
|
115
|
+
if (!Object.keys(defaults.model).length)
|
|
116
|
+
delete defaults.model;
|
|
117
|
+
}
|
|
118
|
+
if (defaults && !Object.keys(defaults).length)
|
|
119
|
+
delete config.agents.defaults;
|
|
120
|
+
if (config.agents && !Object.keys(config.agents).length)
|
|
121
|
+
delete config.agents;
|
|
122
|
+
await backupFile(file, ctx.home);
|
|
123
|
+
if (Object.keys(config).length)
|
|
124
|
+
await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
|
|
125
|
+
else
|
|
126
|
+
await fs.remove(file);
|
|
127
|
+
await removeLegacyArtifact(ctx);
|
|
128
|
+
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider and model entries' };
|
|
14
129
|
}
|
|
@@ -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>;
|
|
@@ -1,33 +1,71 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
1
3
|
import { clientConfigPath } from './paths.js';
|
|
2
|
-
import {
|
|
4
|
+
import { writeMergedJsonConfig } from './common.js';
|
|
5
|
+
import { modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
|
|
3
6
|
const id = 'opencode';
|
|
4
7
|
const name = 'OpenCode';
|
|
8
|
+
function asRecord(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
|
+
}
|
|
11
|
+
function providerConfig(ctx) {
|
|
12
|
+
const models = Object.fromEntries(modelIds(ctx).map(modelId => {
|
|
13
|
+
const metadata = (ctx.models || []).find(model => model.id === modelId);
|
|
14
|
+
return [modelId, {
|
|
15
|
+
name: modelName(ctx, modelId),
|
|
16
|
+
limit: {
|
|
17
|
+
context: metadata?.context_window || (modelId.startsWith('claude-') ? 200000 : 128000),
|
|
18
|
+
output: Math.min(metadata?.max_output_tokens || 4096, 4096)
|
|
19
|
+
}
|
|
20
|
+
}];
|
|
21
|
+
}));
|
|
22
|
+
return {
|
|
23
|
+
npm: '@ai-sdk/openai-compatible',
|
|
24
|
+
name: 'ScaleMax',
|
|
25
|
+
options: { baseURL: openAiBaseUrl(ctx.baseUrl), apiKey: ctx.apiKey },
|
|
26
|
+
models
|
|
27
|
+
};
|
|
28
|
+
}
|
|
5
29
|
export async function configure(ctx) {
|
|
6
30
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
provider
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const selected = `scalemax/${preferredModel(ctx)}`;
|
|
32
|
+
return writeMergedJsonConfig(ctx, id, name, file, existing => {
|
|
33
|
+
const providers = asRecord(existing.provider);
|
|
34
|
+
const currentModel = typeof existing.model === 'string' ? existing.model : undefined;
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
...existing,
|
|
37
|
+
$schema: typeof existing.$schema === 'string' ? existing.$schema : 'https://opencode.ai/config.json',
|
|
38
|
+
provider: { ...providers, scalemax: providerConfig(ctx) },
|
|
39
|
+
model: !currentModel || currentModel.startsWith('scalemax/') ? selected : currentModel
|
|
40
|
+
}, null, 2) + '\n';
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function verify(ctx) {
|
|
44
|
+
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
45
|
+
const config = await fs.readJson(file);
|
|
46
|
+
const provider = config?.provider?.scalemax;
|
|
47
|
+
if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !== ctx.apiKey) {
|
|
48
|
+
throw new Error('OpenCode ScaleMax provider settings are incomplete.');
|
|
49
|
+
}
|
|
50
|
+
const configured = Object.keys(asRecord(provider.models));
|
|
51
|
+
const missing = modelIds(ctx).filter(model => !configured.includes(model));
|
|
52
|
+
if (missing.length)
|
|
53
|
+
throw new Error(`OpenCode is missing ${missing.length} authenticated model(s).`);
|
|
54
|
+
return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
|
|
29
55
|
}
|
|
30
56
|
export async function remove(ctx) {
|
|
31
57
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
32
|
-
|
|
58
|
+
if (!(await fs.pathExists(file)))
|
|
59
|
+
return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
|
|
60
|
+
const config = await fs.readJson(file).catch(() => undefined);
|
|
61
|
+
if (!config?.provider?.scalemax || config.provider.scalemax.name !== 'ScaleMax' || config.provider.scalemax.options?.baseURL !== openAiBaseUrl(ctx.baseUrl))
|
|
62
|
+
return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
|
|
63
|
+
delete config.provider.scalemax;
|
|
64
|
+
if (!Object.keys(config.provider).length)
|
|
65
|
+
delete config.provider;
|
|
66
|
+
if (typeof config.model === 'string' && config.model.startsWith('scalemax/'))
|
|
67
|
+
delete config.model;
|
|
68
|
+
await backupFile(file, ctx.home);
|
|
69
|
+
await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
|
|
70
|
+
return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider' };
|
|
33
71
|
}
|
package/dist/configure/paths.js
CHANGED
|
@@ -12,8 +12,8 @@ export function clientConfigPath(id, home, platform, env = process.env) {
|
|
|
12
12
|
case 'trae-solo': return path.join(home, '.scalemax', 'trae-solo.setup.json');
|
|
13
13
|
case 'claude': return path.join(home, '.claude', 'settings.json');
|
|
14
14
|
case 'opencode': return path.join(linux, 'opencode', 'opencode.json');
|
|
15
|
-
case 'openclaw': return path.join(home, '.openclaw', '
|
|
16
|
-
case 'hermes': return path.join(home, '.hermes', 'config
|
|
15
|
+
case 'openclaw': return path.join(home, '.openclaw', 'openclaw.json');
|
|
16
|
+
case 'hermes': return path.join(home, '.hermes', 'config.yaml');
|
|
17
17
|
case 'cherry-studio': return path.join(home, '.scalemax', 'cherry-studio.setup.json');
|
|
18
18
|
case 'api-code': return path.join(home, '.scalemax', 'api-code.examples.json');
|
|
19
19
|
}
|
package/dist/detect/common.js
CHANGED
|
@@ -6,6 +6,26 @@ const commands = {
|
|
|
6
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': []
|
|
7
7
|
};
|
|
8
8
|
const guiPathDetection = new Set(['trae-solo', 'cherry-studio']);
|
|
9
|
+
function hasNativeScaleMaxConfig(id, text) {
|
|
10
|
+
if (text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"'))
|
|
11
|
+
return true;
|
|
12
|
+
if (!text.includes('api.scalemax.pro'))
|
|
13
|
+
return false;
|
|
14
|
+
if (id === 'hermes')
|
|
15
|
+
return /(?:^|\n)\s*-?\s*name:\s*["']?scalemax["']?\s*(?:#.*)?(?:\n|$)/i.test(text);
|
|
16
|
+
if (id === 'opencode' || id === 'openclaw') {
|
|
17
|
+
try {
|
|
18
|
+
const config = JSON.parse(text);
|
|
19
|
+
return id === 'opencode'
|
|
20
|
+
? config?.provider?.scalemax?.name === 'ScaleMax'
|
|
21
|
+
: typeof config?.models?.providers?.scalemax?.baseUrl === 'string';
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
9
29
|
export async function detectClient(ctx, id, name) {
|
|
10
30
|
const config = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
|
|
11
31
|
const cmdHits = [];
|
|
@@ -20,7 +40,7 @@ export async function detectClient(ctx, id, name) {
|
|
|
20
40
|
let configured = false;
|
|
21
41
|
if (await fs.pathExists(config)) {
|
|
22
42
|
const text = await fs.readFile(config, 'utf8').catch(() => '');
|
|
23
|
-
configured =
|
|
43
|
+
configured = hasNativeScaleMaxConfig(id, text);
|
|
24
44
|
}
|
|
25
45
|
const hits = [...cmdHits, ...appHits];
|
|
26
46
|
return { id, name, installed: hits.length > 0, configured, reason: hits.length ? undefined : 'not detected', paths: [...hits, ...(configured ? [config] : [])] };
|
package/dist/types/client.d.ts
CHANGED
|
@@ -34,11 +34,15 @@ export interface ConfigureContext extends DetectContext {
|
|
|
34
34
|
dryRun?: boolean;
|
|
35
35
|
interactive?: boolean;
|
|
36
36
|
allowAppModification?: boolean;
|
|
37
|
+
providerScope?: ProviderScope;
|
|
37
38
|
models?: Array<{
|
|
38
39
|
id: string;
|
|
39
40
|
object?: string;
|
|
40
41
|
owned_by?: string;
|
|
42
|
+
provider?: string;
|
|
41
43
|
display_name?: string;
|
|
44
|
+
context_window?: number | null;
|
|
45
|
+
max_output_tokens?: number | null;
|
|
42
46
|
}>;
|
|
43
47
|
}
|
|
44
48
|
export interface ConfigureResult {
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -3,7 +3,10 @@ export interface ModelInfo {
|
|
|
3
3
|
id: string;
|
|
4
4
|
object?: string;
|
|
5
5
|
owned_by?: string;
|
|
6
|
+
provider?: string;
|
|
6
7
|
display_name?: string;
|
|
8
|
+
context_window?: number | null;
|
|
9
|
+
max_output_tokens?: number | null;
|
|
7
10
|
}
|
|
8
11
|
export interface KeyMetadata {
|
|
9
12
|
provider_scope: ProviderScope;
|
|
@@ -11,6 +14,8 @@ export interface KeyMetadata {
|
|
|
11
14
|
prefix?: string;
|
|
12
15
|
label?: string;
|
|
13
16
|
}
|
|
17
|
+
export declare function isAnthropicModel(model: ModelInfo): boolean;
|
|
18
|
+
export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
|
|
14
19
|
export declare function validateApiKey(apiKey: string): Promise<{
|
|
15
20
|
ok: boolean;
|
|
16
21
|
models: ModelInfo[];
|
package/dist/utils/api.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { SCALEMAX_BASE_URL } from '../constants.js';
|
|
2
2
|
import { sanitizeApiKey } from './apiKey.js';
|
|
3
|
+
export function isAnthropicModel(model) {
|
|
4
|
+
const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
|
|
5
|
+
if (provider)
|
|
6
|
+
return provider === 'anthropic' || provider.startsWith('anthropic-');
|
|
7
|
+
return model.id.startsWith('claude-') && !model.id.startsWith('claude-fable-');
|
|
8
|
+
}
|
|
9
|
+
export function filterModelsForScope(models, scope) {
|
|
10
|
+
if (scope === 'both')
|
|
11
|
+
return models;
|
|
12
|
+
return models.filter(model => scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
|
|
13
|
+
}
|
|
3
14
|
async function friendlyError(res) {
|
|
4
15
|
let body = '';
|
|
5
16
|
try {
|
|
@@ -42,7 +53,8 @@ export async function validateApiKey(apiKey) {
|
|
|
42
53
|
if (!modelsRes.ok)
|
|
43
54
|
return { ok: false, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
|
|
44
55
|
const data = await modelsRes.json();
|
|
45
|
-
const
|
|
56
|
+
const catalog = Array.isArray(data.data) ? data.data : [];
|
|
57
|
+
const models = filterModelsForScope(catalog, keyMeta.provider_scope);
|
|
46
58
|
if (!models.length)
|
|
47
59
|
return { ok: false, models: [], key: keyMeta, error: 'ScaleMax returned an empty model catalog.' };
|
|
48
60
|
return { ok: true, models, key: keyMeta };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scalemax",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Universal ScaleMax installer for AI coding clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"chalk": "^5.3.0",
|
|
32
32
|
"execa": "^9.5.1",
|
|
33
33
|
"fs-extra": "^11.2.0",
|
|
34
|
-
"ora": "^8.1.1"
|
|
34
|
+
"ora": "^8.1.1",
|
|
35
|
+
"yaml": "^2.9.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/fs-extra": "^11.0.4",
|