scalemax 0.1.28 → 0.1.30
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 +6 -6
- package/dist/commands/helper-docs.js +3 -3
- package/dist/commands/login.js +2 -2
- package/dist/configure/hermes.js +39 -8
- package/dist/configure/models.d.ts +2 -0
- package/dist/configure/models.js +5 -0
- package/dist/configure/openclaw.js +21 -5
- package/dist/configure/opencode.js +3 -1
- package/dist/configure/roo-code.js +11 -2
- package/dist/types/client.d.ts +4 -0
- package/dist/utils/api.d.ts +5 -0
- package/dist/utils/api.js +47 -26
- package/dist/utils/installerConfig.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ Recognized setup, repair, and verification requests execute the CLI's allowliste
|
|
|
125
125
|
For Claude tool access, run `scalemax claude-tools`, or ask helper-bot to `install GitHub skill tools for Claude Desktop and Claude Code`. Claude Code receives Anthropic's official `example-skills@anthropic-agent-skills` plugin plus public-GitHub read permissions. Claude Desktop/Cowork receives a local MCP server with only three tools: `github_fetch`, `github_tree`, and `github_install_skill`. The MCP server accepts only public GitHub HTTPS hosts, limits downloads, rejects unsafe archive paths and symlinks, refuses to replace an existing skill, and can write only beneath `~/Claude/skills`. It does not expose a general shell, arbitrary URLs, or unrestricted filesystem access. Restart Claude Desktop after installation and use Cowork or Code; a normal Chat tab does not expose local tools.
|
|
126
126
|
Only install skills from repositories you trust: a `SKILL.md` is instruction content that Claude may follow after installation, even though the bridge itself cannot execute arbitrary repository code.
|
|
127
127
|
|
|
128
|
-
For Roo Code, helper-bot can install VS Code on Windows with the fixed Microsoft winget package, install the official Roo Code extension, write OpenAI and Claude ScaleMax profiles when the key exposes those families, and verify the import handoff. For DeepSeek V4 Flash, helper-bot
|
|
128
|
+
For Roo Code, helper-bot can install VS Code on Windows with the fixed Microsoft winget package, install the official Roo Code extension, write OpenAI and Claude ScaleMax profiles when the key exposes those families, and verify the import handoff. For DeepSeek V4 Flash, helper-bot creates an agent profile only when authenticated discovery reports `capabilities.tools=true`; otherwise it installs Continue's safe Chat/Edit fallback. For general troubleshooting that cannot be completed by a fixed client action, helper-bot sends a small `gpt-5.4` request grounded with bundled ScaleMax documentation about stored authentication, credit/token base URLs, provider scope, client setup, and common errors. The language model never generates or executes shell commands; only fixed ScaleMax client detection, installation, configuration, verification, and rollback operations are available to the controller.
|
|
129
129
|
|
|
130
130
|
The helper refuses unrelated requests and redacts ScaleMax-style secrets from prompts, provider output, errors, and progress notifications. Use `/status` for non-secret connection details, `/help` for commands, and `/quit` to exit.
|
|
131
131
|
|
|
@@ -153,7 +153,7 @@ ScaleMax configures the official Codex custom-provider interface in `~/.codex/co
|
|
|
153
153
|
|
|
154
154
|
### Claude Code
|
|
155
155
|
|
|
156
|
-
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. Because Claude Code's gateway discovery only accepts IDs beginning with `claude` or `anthropic`, a catalog that includes Fable also receives a managed `ANTHROPIC_CUSTOM_MODEL_OPTION=fable-5` entry (with a friendly name and description), making `/model fable-5` selectable; any prior custom picker values are restored when ScaleMax configuration is removed. 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-
|
|
156
|
+
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. Because Claude Code's gateway discovery only accepts IDs beginning with `claude` or `anthropic`, a catalog that includes Fable also receives a managed `ANTHROPIC_CUSTOM_MODEL_OPTION=fable-5` entry (with a friendly name and description), making `/model fable-5` selectable; any prior custom picker values are restored when ScaleMax configuration is removed. 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-5`), pins authenticated Haiku for lightweight background work when available, and sets `CLAUDE_CODE_DISABLE_THINKING=1` because third-party Anthropic-compatible providers do not support Claude Code's proprietary extended-thinking control envelope. The ScaleMax gateway separately locks supported Claude-family requests to provider-side `low` effort, including Fable's translated route; clients cannot raise that setting. Haiku 4.5 omits the unsupported effort field. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-5[1m]` are normalized by the gateway.
|
|
157
157
|
|
|
158
158
|
### OpenCode
|
|
159
159
|
|
|
@@ -161,19 +161,19 @@ ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.js
|
|
|
161
161
|
|
|
162
162
|
### OpenClaw
|
|
163
163
|
|
|
164
|
-
ScaleMax partitions OpenClaw models into native `openai-responses` (`scalemax-codex`, `/v1`), OpenAI-compatible `openai-completions` (`scalemax-deepseek`, `/v1`), and `anthropic-messages` (`scalemax-claude`, origin) providers. `deepseek-v4-flash` is isolated in the Chat Completions provider
|
|
164
|
+
ScaleMax partitions OpenClaw models into native `openai-responses` (`scalemax-codex`, `/v1`), OpenAI-compatible `openai-completions` (`scalemax-deepseek`, `/v1`), and `anthropic-messages` (`scalemax-claude`, origin) providers. `deepseek-v4-flash` is always isolated in the Chat Completions provider and never advertised through Responses. Its agent flags come from authenticated catalog discovery: the official route enables tools, reasoning effort, streaming usage, and mandatory `reasoning_content` replay while disabling unsupported thinking-mode `tool_choice`; a fallback route remains explicitly tool-free. The API key and compatibility User-Agent are OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the main provider JSON. Migration removes the obsolete mixed provider, preserves the active model family, and scrubs plaintext credentials from generated ScaleMax agent registries even when they contain an older key. Output is capped at 4096 tokens, private-network routing is denied for the public gateway, and unrelated providers and agent settings are preserved.
|
|
165
165
|
|
|
166
166
|
### Hermes
|
|
167
167
|
|
|
168
|
-
ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and stores one credential in `~/.hermes/.env` with private permissions. `scalemax-codex` uses Hermes's `codex_responses` transport at `/v1`; `scalemax-claude` uses `anthropic_messages` at the gateway origin.
|
|
168
|
+
ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and stores one credential in `~/.hermes/.env` with private permissions. `scalemax-codex` uses Hermes's `codex_responses` transport at `/v1`; `scalemax-claude` uses `anthropic_messages` at the gateway origin; and an authenticated tool-capable `deepseek-v4-flash` route receives a separate `scalemax-deepseek` Chat Completions provider. A text-only DeepSeek route fails closed for Hermes agent mode. All providers use a scoped `ScaleMax-Hermes/1.0` user agent and a 4096-token output cap. Existing non-ScaleMax providers and defaults are preserved, and the obsolete mixed provider is migrated safely.
|
|
169
169
|
|
|
170
170
|
### Roo Code (VS Code)
|
|
171
171
|
|
|
172
|
-
Roo Code stores provider profiles and secrets in VS Code extension storage, so ScaleMax does not mutate its private database. Instead, the CLI writes a private `0600` Roo settings-import file at `~/.scalemax/roo-code-import.json` and points the supported `roo-cline.autoImportSettingsPath` VS Code setting at it. Codex and
|
|
172
|
+
Roo Code stores provider profiles and secrets in VS Code extension storage, so ScaleMax does not mutate its private database. Instead, the CLI writes a private `0600` Roo settings-import file at `~/.scalemax/roo-code-import.json` and points the supported `roo-cline.autoImportSettingsPath` VS Code setting at it. Codex, Claude, and tool-capable DeepSeek profiles remain separate. DeepSeek is offered only when authenticated discovery reports `capabilities.tools=true`; otherwise Roo fails closed and points the user to Continue Chat/Edit. Reload VS Code once so Roo imports supported profiles into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
|
|
173
173
|
|
|
174
174
|
### Continue (VS Code)
|
|
175
175
|
|
|
176
|
-
Continue is the
|
|
176
|
+
Continue is the universal VS Code Chat/Edit route for `deepseek-v4-flash`. The CLI installs the official `Continue.continue` extension when VS Code is available, merges an OpenAI-compatible model into `~/.continue/config.yaml`, sets `useResponsesApi: false`, and keeps the authenticated ScaleMax key in private `~/.continue/.env` rather than the YAML. Existing Continue models and environment entries are preserved. Reload VS Code and select **ScaleMax DeepSeek V4 Flash**. For agent workflows, prefer Roo/OpenClaw/Hermes when the authenticated catalog reports `capabilities.tools=true`.
|
|
177
177
|
|
|
178
178
|
### Cursor BYOK routing on macOS
|
|
179
179
|
|
|
@@ -15,19 +15,19 @@ export const HELPER_DOC_SECTIONS = [
|
|
|
15
15
|
id: 'provider-scope',
|
|
16
16
|
title: 'Provider scope and visible models',
|
|
17
17
|
keywords: ['provider', 'scope', 'openai', 'claude', 'anthropic', 'deepseek', 'both', 'models', 'fable'],
|
|
18
|
-
content: `A ScaleMax key may be OpenAI only, Claude only, both, or the exact-model DeepSeek V4 Flash entitlement. OpenAI-only keys should configure OpenAI-compatible models only. Claude-only keys should configure Claude-compatible models only. Both-scope keys may configure both model families in clients that can switch profiles. Fable 5 is a Claude-family frontend model through ScaleMax and should only appear for Claude-capable or both-scope keys, not OpenAI-only keys. A DeepSeek V4 Flash key discovers only deepseek-v4-flash; broad OpenAI and both scopes do not inherit that entitlement. The model uses the OpenAI-compatible /v1/chat/completions route with streaming and max_tokens
|
|
18
|
+
content: `A ScaleMax key may be OpenAI only, Claude only, both, or the exact-model DeepSeek V4 Flash entitlement. OpenAI-only keys should configure OpenAI-compatible models only. Claude-only keys should configure Claude-compatible models only. Both-scope keys may configure both model families in clients that can switch profiles. Fable 5 is a Claude-family frontend model through ScaleMax and should only appear for Claude-capable or both-scope keys, not OpenAI-only keys. A DeepSeek V4 Flash key discovers only deepseek-v4-flash; broad OpenAI and both scopes do not inherit that entitlement. The model always uses the OpenAI-compatible /v1/chat/completions route with streaming and max_tokens, never Responses. Tool support is route-dependent: trust capabilities.tools from the authenticated catalog. The official route exposes thinking-mode tools and reasoning_content replay; a fallback route may remain text-only.`
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
id: 'roo-code',
|
|
22
22
|
title: 'Roo Code setup through VS Code',
|
|
23
23
|
keywords: ['roo', 'roo code', 'vscode', 'vs code', 'extension', 'visual studio code', 'deepseek'],
|
|
24
|
-
content: `For Roo Code, the ScaleMax CLI writes a private settings import file at ~/.scalemax/roo-code-import.json and sets roo-cline.autoImportSettingsPath in VS Code User settings. Roo imports that profile after VS Code is reloaded.
|
|
24
|
+
content: `For Roo Code, the ScaleMax CLI writes a private settings import file at ~/.scalemax/roo-code-import.json and sets roo-cline.autoImportSettingsPath in VS Code User settings. Roo imports that profile after VS Code is reloaded. A DeepSeek V4 Flash profile is offered only when the authenticated catalog reports capabilities.tools=true. If the active route is text-only, Roo fails closed because its task loop requires tools and the helper recommends Continue Chat/Edit instead. The CLI can install the official Roo Code extension with code --install-extension RooVeterinaryInc.roo-cline when VS Code is detected. On Windows, helper-bot may install VS Code first with winget using the fixed Microsoft.VisualStudioCode package id, then install Roo, write the import handoff, and verify the files.`
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
id: 'continue-deepseek',
|
|
28
28
|
title: 'Continue setup for DeepSeek V4 Flash',
|
|
29
29
|
keywords: ['continue', 'continue dev', 'vscode', 'vs code', 'extension', 'deepseek', 'deepseek-v4-flash'],
|
|
30
|
-
content: `Continue is the
|
|
30
|
+
content: `Continue is the universal Chat/Edit integration for DeepSeek V4 Flash. scalemax update installs the official Continue extension when VS Code is available, writes a private ~/.continue/config.yaml Chat/Edit profile, stores the authenticated ScaleMax key in private ~/.continue/.env, and forces useResponsesApi: false. Reload VS Code after setup. Agent clients such as Roo, OpenClaw, and Hermes are configured only when authenticated discovery reports capabilities.tools=true. Never request or configure an upstream credential on the customer device.`
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
id: 'codex-and-clients',
|
package/dist/commands/login.js
CHANGED
|
@@ -16,8 +16,8 @@ export async function loginCommand(home = homeDir()) {
|
|
|
16
16
|
const spinner = ora('Verifying API key').start();
|
|
17
17
|
const result = await validateApiKey(sanitizedKey);
|
|
18
18
|
if (!result.ok) {
|
|
19
|
-
spinner.fail('
|
|
20
|
-
throw new Error(result.error || '
|
|
19
|
+
spinner.fail('Could not verify API key');
|
|
20
|
+
throw new Error(result.error || 'Could not verify API key');
|
|
21
21
|
}
|
|
22
22
|
await saveApiKey(sanitizedKey, result.baseUrl, home);
|
|
23
23
|
spinner.succeed(`API Key verified (${result.key?.provider_scope || 'both'} provider scope)`);
|
package/dist/configure/hermes.js
CHANGED
|
@@ -4,13 +4,14 @@ import { parseDocument } from 'yaml';
|
|
|
4
4
|
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
5
5
|
import { clientConfigPath } from './paths.js';
|
|
6
6
|
import { isScaleMaxManagedText } from './common.js';
|
|
7
|
-
import { anthropicModelIds, anthropicOrigin, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredOpenAiResponsesModel } from './models.js';
|
|
7
|
+
import { anthropicModelIds, anthropicOrigin, deepSeekChatModelIds, modelSupportsTools, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredOpenAiResponsesModel } from './models.js';
|
|
8
8
|
const id = 'hermes';
|
|
9
9
|
const name = 'Hermes';
|
|
10
10
|
const envKey = 'SCALEMAX_HERMES_API_KEY';
|
|
11
11
|
const envMarker = '# ScaleMax managed Hermes API key';
|
|
12
12
|
const legacyProviderName = 'scalemax';
|
|
13
13
|
const codexProviderName = 'scalemax-codex';
|
|
14
|
+
const deepSeekProviderName = 'scalemax-deepseek';
|
|
14
15
|
const claudeProviderName = 'scalemax-claude';
|
|
15
16
|
function asRecord(value) {
|
|
16
17
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
@@ -45,6 +46,9 @@ function modelMetadata(ctx, ids) {
|
|
|
45
46
|
return [modelId, metadata];
|
|
46
47
|
}));
|
|
47
48
|
}
|
|
49
|
+
function toolCapableDeepSeekModelIds(ctx) {
|
|
50
|
+
return deepSeekChatModelIds(ctx).filter(model => modelSupportsTools(ctx, model));
|
|
51
|
+
}
|
|
48
52
|
async function removeLegacyArtifact(ctx) {
|
|
49
53
|
const legacy = path.join(ctx.home, '.hermes', 'config', 'scalemax.json');
|
|
50
54
|
if (!(await fs.pathExists(legacy)))
|
|
@@ -65,8 +69,13 @@ export async function configure(ctx) {
|
|
|
65
69
|
const doc = parseYaml(originalConfig);
|
|
66
70
|
const data = asRecord(doc.toJS());
|
|
67
71
|
const codexIds = openAiResponsesModelIds(ctx);
|
|
72
|
+
const discoveredDeepSeekIds = deepSeekChatModelIds(ctx);
|
|
73
|
+
const deepSeekIds = toolCapableDeepSeekModelIds(ctx);
|
|
68
74
|
const claudeIds = anthropicModelIds(ctx);
|
|
69
|
-
|
|
75
|
+
if (ctx.providerScope === 'deepseek-v4-flash' && discoveredDeepSeekIds.length && !deepSeekIds.length) {
|
|
76
|
+
throw new Error('Hermes agent mode requires tool calling, but the active DeepSeek V4 Flash route is text-only. Configure the official tool-capable route first. No configuration was changed.');
|
|
77
|
+
}
|
|
78
|
+
const managedNames = new Set([legacyProviderName, codexProviderName, deepSeekProviderName, claudeProviderName]);
|
|
70
79
|
const providers = Array.isArray(data.custom_providers) ? data.custom_providers.filter((provider) => !managedNames.has(provider?.name)) : [];
|
|
71
80
|
if (codexIds.length)
|
|
72
81
|
providers.push({
|
|
@@ -78,6 +87,16 @@ export async function configure(ctx) {
|
|
|
78
87
|
max_output_tokens: 4096,
|
|
79
88
|
models: modelMetadata(ctx, codexIds)
|
|
80
89
|
});
|
|
90
|
+
if (deepSeekIds.length)
|
|
91
|
+
providers.push({
|
|
92
|
+
name: deepSeekProviderName,
|
|
93
|
+
base_url: openAiBaseUrl(ctx.baseUrl),
|
|
94
|
+
key_env: envKey,
|
|
95
|
+
api_mode: 'chat_completions',
|
|
96
|
+
extra_headers: { 'User-Agent': 'ScaleMax-Hermes/1.0' },
|
|
97
|
+
max_output_tokens: 4096,
|
|
98
|
+
models: modelMetadata(ctx, deepSeekIds)
|
|
99
|
+
});
|
|
81
100
|
if (claudeIds.length)
|
|
82
101
|
providers.push({
|
|
83
102
|
name: claudeProviderName,
|
|
@@ -94,8 +113,17 @@ export async function configure(ctx) {
|
|
|
94
113
|
const activatesScaleMax = !currentProvider || currentProvider.startsWith('custom:scalemax');
|
|
95
114
|
if (activatesScaleMax) {
|
|
96
115
|
const keepClaude = currentProvider === `custom:${claudeProviderName}` || claudeIds.includes(String(currentModel.default || ''));
|
|
97
|
-
const
|
|
98
|
-
|
|
116
|
+
const keepDeepSeek = currentProvider === `custom:${deepSeekProviderName}`
|
|
117
|
+
|| deepSeekIds.includes(String(currentModel.default || ''))
|
|
118
|
+
|| (!codexIds.length && !claudeIds.length && deepSeekIds.length > 0);
|
|
119
|
+
const activeName = keepDeepSeek
|
|
120
|
+
? deepSeekProviderName
|
|
121
|
+
: codexIds.length && !keepClaude
|
|
122
|
+
? codexProviderName
|
|
123
|
+
: claudeIds.length ? claudeProviderName : deepSeekProviderName;
|
|
124
|
+
const activeModel = activeName === codexProviderName
|
|
125
|
+
? preferredOpenAiResponsesModel(ctx)
|
|
126
|
+
: activeName === deepSeekProviderName ? deepSeekIds[0] : preferredAnthropicModel(ctx);
|
|
99
127
|
doc.set('model', { ...currentModel, provider: `custom:${activeName}`, default: activeModel, max_tokens: 4096 });
|
|
100
128
|
}
|
|
101
129
|
const nextConfig = String(doc);
|
|
@@ -104,7 +132,7 @@ export async function configure(ctx) {
|
|
|
104
132
|
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
105
133
|
await fs.chmod(secrets, 0o600).catch(() => undefined);
|
|
106
134
|
await removeLegacyArtifact(ctx);
|
|
107
|
-
return { id, name, changed: false, path: file, message: `already current with ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models and a private credential` };
|
|
135
|
+
return { id, name, changed: false, path: file, message: `already current with ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} DeepSeek, and ${claudeIds.length} Claude models and a private credential` };
|
|
108
136
|
}
|
|
109
137
|
const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
|
|
110
138
|
try {
|
|
@@ -123,7 +151,7 @@ export async function configure(ctx) {
|
|
|
123
151
|
await fs.remove(secrets).catch(() => undefined);
|
|
124
152
|
throw error;
|
|
125
153
|
}
|
|
126
|
-
return { id, name, changed: true, path: file, message: `configured family-specific ScaleMax providers with ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models${backups.length ? ' (backups created)' : ''}` };
|
|
154
|
+
return { id, name, changed: true, path: file, message: `configured family-specific ScaleMax providers with ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} DeepSeek, and ${claudeIds.length} Claude models${backups.length ? ' (backups created)' : ''}` };
|
|
127
155
|
}
|
|
128
156
|
export async function verify(ctx) {
|
|
129
157
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
@@ -132,8 +160,10 @@ export async function verify(ctx) {
|
|
|
132
160
|
const data = asRecord(doc.toJS());
|
|
133
161
|
const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
|
|
134
162
|
const codexIds = openAiResponsesModelIds(ctx);
|
|
163
|
+
const deepSeekIds = toolCapableDeepSeekModelIds(ctx);
|
|
135
164
|
const claudeIds = anthropicModelIds(ctx);
|
|
136
165
|
const codex = providers.find((candidate) => candidate?.name === codexProviderName);
|
|
166
|
+
const deepSeek = providers.find((candidate) => candidate?.name === deepSeekProviderName);
|
|
137
167
|
const claude = providers.find((candidate) => candidate?.name === claudeProviderName);
|
|
138
168
|
const valid = (provider, baseUrl, apiMode, ids) => provider
|
|
139
169
|
&& provider.base_url === baseUrl && provider.key_env === envKey && provider.api_mode === apiMode
|
|
@@ -141,6 +171,7 @@ export async function verify(ctx) {
|
|
|
141
171
|
&& Object.keys(asRecord(provider.models)).length === ids.length
|
|
142
172
|
&& ids.every(model => Object.hasOwn(asRecord(provider.models), model));
|
|
143
173
|
if ((codexIds.length ? !valid(codex, openAiBaseUrl(ctx.baseUrl), 'codex_responses', codexIds) : Boolean(codex))
|
|
174
|
+
|| (deepSeekIds.length ? !valid(deepSeek, openAiBaseUrl(ctx.baseUrl), 'chat_completions', deepSeekIds) : Boolean(deepSeek))
|
|
144
175
|
|| (claudeIds.length ? !valid(claude, anthropicOrigin(ctx.baseUrl), 'anthropic_messages', claudeIds) : Boolean(claude))) {
|
|
145
176
|
throw new Error('Hermes ScaleMax family-specific custom provider settings are incomplete.');
|
|
146
177
|
}
|
|
@@ -149,7 +180,7 @@ export async function verify(ctx) {
|
|
|
149
180
|
throw new Error('Hermes is missing authenticated ScaleMax models or credentials.');
|
|
150
181
|
if (String(data?.model?.provider || '').startsWith('custom:scalemax') && data?.model?.max_tokens !== 4096)
|
|
151
182
|
throw new Error('Hermes active ScaleMax model must use max_tokens 4096.');
|
|
152
|
-
return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Codex/OpenAI and ${claudeIds.length} Claude models` };
|
|
183
|
+
return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} DeepSeek, and ${claudeIds.length} Claude models` };
|
|
153
184
|
}
|
|
154
185
|
export async function remove(ctx) {
|
|
155
186
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
@@ -160,7 +191,7 @@ export async function remove(ctx) {
|
|
|
160
191
|
const doc = parseYaml(text);
|
|
161
192
|
const data = asRecord(doc.toJS());
|
|
162
193
|
const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
|
|
163
|
-
const managedNames = new Set([legacyProviderName, codexProviderName, claudeProviderName]);
|
|
194
|
+
const managedNames = new Set([legacyProviderName, codexProviderName, deepSeekProviderName, claudeProviderName]);
|
|
164
195
|
const managedProviders = providers.filter((provider) => managedNames.has(provider?.name)
|
|
165
196
|
&& (provider?.base_url === openAiBaseUrl(ctx.baseUrl) || provider?.base_url === anthropicOrigin(ctx.baseUrl)));
|
|
166
197
|
if (!managedProviders.length)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ConfigureContext } from '../types/client.js';
|
|
2
2
|
export declare const DEEPSEEK_V4_FLASH_MODEL_ID = "deepseek-v4-flash";
|
|
3
3
|
export declare function isDeepSeekChatModelId(id: string): boolean;
|
|
4
|
+
/** Trust only the authenticated gateway catalog for endpoint capabilities. */
|
|
5
|
+
export declare function modelSupportsTools(ctx: ConfigureContext, id: string): boolean;
|
|
4
6
|
export declare function modelIds(ctx: ConfigureContext): string[];
|
|
5
7
|
export declare function anthropicModelIds(ctx: ConfigureContext): string[];
|
|
6
8
|
export declare function openAiModelIds(ctx: ConfigureContext): string[];
|
package/dist/configure/models.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAnthropicModel, isRetiredModelId } from '../utils/api.js';
|
|
2
2
|
const preferredAnthropic = [
|
|
3
|
+
'claude-opus-5',
|
|
3
4
|
'claude-opus-4-8',
|
|
4
5
|
'claude-sonnet-5',
|
|
5
6
|
'claude-opus-4-7',
|
|
@@ -21,6 +22,10 @@ export const DEEPSEEK_V4_FLASH_MODEL_ID = 'deepseek-v4-flash';
|
|
|
21
22
|
export function isDeepSeekChatModelId(id) {
|
|
22
23
|
return id.trim().toLowerCase() === DEEPSEEK_V4_FLASH_MODEL_ID;
|
|
23
24
|
}
|
|
25
|
+
/** Trust only the authenticated gateway catalog for endpoint capabilities. */
|
|
26
|
+
export function modelSupportsTools(ctx, id) {
|
|
27
|
+
return (ctx.models || []).find(model => model.id.trim() === id.trim())?.capabilities?.tools === true;
|
|
28
|
+
}
|
|
24
29
|
export function modelIds(ctx) {
|
|
25
30
|
return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
|
|
26
31
|
}
|
|
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
4
4
|
import { clientConfigPath } from './paths.js';
|
|
5
5
|
import { isScaleMaxManagedText, writeMergedJsonConfig } from './common.js';
|
|
6
|
-
import { anthropicModelIds, anthropicOrigin, deepSeekChatModelIds, fastModel, fastOpenAiModel, modelName, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredOpenAiResponsesModel } from './models.js';
|
|
6
|
+
import { anthropicModelIds, anthropicOrigin, deepSeekChatModelIds, fastModel, fastOpenAiModel, modelName, modelSupportsTools, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredOpenAiResponsesModel } from './models.js';
|
|
7
7
|
import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
|
|
8
8
|
const id = 'openclaw';
|
|
9
9
|
const name = 'OpenClaw';
|
|
@@ -39,11 +39,23 @@ function entries(ctx, ids, family) {
|
|
|
39
39
|
id: modelId,
|
|
40
40
|
name: modelName(ctx, modelId),
|
|
41
41
|
input: family === 'anthropic' ? ['text', 'image'] : ['text'],
|
|
42
|
-
maxTokens:
|
|
42
|
+
maxTokens: modelId === 'claude-opus-5'
|
|
43
|
+
? Math.min(metadata?.max_output_tokens || 128000, 128000)
|
|
44
|
+
: Math.min(metadata?.max_output_tokens || 4096, 4096)
|
|
43
45
|
};
|
|
44
46
|
if (family === 'deepseek') {
|
|
45
|
-
|
|
46
|
-
result.
|
|
47
|
+
const supportsTools = modelSupportsTools(ctx, modelId);
|
|
48
|
+
result.reasoning = supportsTools;
|
|
49
|
+
result.compat = supportsTools
|
|
50
|
+
? {
|
|
51
|
+
supportsTools: true,
|
|
52
|
+
supportsToolChoice: false,
|
|
53
|
+
supportsReasoningEffort: true,
|
|
54
|
+
supportsUsageInStreaming: true,
|
|
55
|
+
maxTokensField: 'max_tokens',
|
|
56
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
57
|
+
}
|
|
58
|
+
: { supportsTools: false };
|
|
47
59
|
}
|
|
48
60
|
if (metadata?.context_window || family === 'anthropic')
|
|
49
61
|
result.contextWindow = metadata?.context_window || 200000;
|
|
@@ -242,7 +254,11 @@ export async function verify(ctx) {
|
|
|
242
254
|
};
|
|
243
255
|
const deepSeekModels = Array.isArray(deepSeek?.models) ? deepSeek.models : [];
|
|
244
256
|
const deepSeekValid = valid(deepSeek, openAiBaseUrl(ctx.baseUrl), 'openai-completions', deepSeekIds)
|
|
245
|
-
&& deepSeekModels.every((model) =>
|
|
257
|
+
&& deepSeekModels.every((model) => {
|
|
258
|
+
const expected = modelSupportsTools(ctx, String(model?.id || ''));
|
|
259
|
+
return model?.compat?.supportsTools === expected
|
|
260
|
+
&& (!expected || model?.compat?.requiresReasoningContentOnAssistantMessages === true);
|
|
261
|
+
});
|
|
246
262
|
if ((codexIds.length ? !valid(codex, openAiBaseUrl(ctx.baseUrl), 'openai-responses', codexIds) : Boolean(codex))
|
|
247
263
|
|| (deepSeekIds.length ? !deepSeekValid : Boolean(deepSeek))
|
|
248
264
|
|| (claudeIds.length ? !valid(claude, anthropicOrigin(ctx.baseUrl), 'anthropic-messages', claudeIds) : Boolean(claude))
|
|
@@ -19,7 +19,9 @@ function providerConfig(ctx) {
|
|
|
19
19
|
name: modelName(ctx, modelId),
|
|
20
20
|
limit: {
|
|
21
21
|
context: metadata?.context_window || (modelId.startsWith('claude-') ? 200000 : 128000),
|
|
22
|
-
output:
|
|
22
|
+
output: modelId === 'claude-opus-5'
|
|
23
|
+
? Math.min(metadata?.max_output_tokens || 128000, 128000)
|
|
24
|
+
: Math.min(metadata?.max_output_tokens || 4096, 4096)
|
|
23
25
|
}
|
|
24
26
|
}];
|
|
25
27
|
}));
|
|
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import JSON5 from 'json5';
|
|
4
4
|
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
5
5
|
import { clientConfigPath } from './paths.js';
|
|
6
|
-
import { anthropicModelIds, isDeepSeekChatModelId, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredChatCompletionsModel, preferredOpenAiResponsesModel } from './models.js';
|
|
6
|
+
import { anthropicModelIds, deepSeekChatModelIds, isDeepSeekChatModelId, modelSupportsTools, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredChatCompletionsModel, preferredOpenAiResponsesModel } from './models.js';
|
|
7
7
|
import { isAnthropicModel } from '../utils/api.js';
|
|
8
8
|
const id = 'roo-code';
|
|
9
9
|
const name = 'Roo Code (VS Code)';
|
|
@@ -19,8 +19,10 @@ function importPath(home) { return path.join(home, '.scalemax', 'roo-code-import
|
|
|
19
19
|
function fallbackProfileFor(ctx) {
|
|
20
20
|
const model = preferredChatCompletionsModel(ctx);
|
|
21
21
|
const metadata = (ctx.models || []).find(candidate => candidate.id === model) || { id: model };
|
|
22
|
+
if (isDeepSeekChatModelId(model) && !modelSupportsTools(ctx, model))
|
|
23
|
+
throw new Error('Roo Code requires task-completion tool calling, but the active DeepSeek V4 Flash route is text-only. Use Continue Chat/Edit in VS Code or configure the official tool-capable route. No configuration was changed.');
|
|
22
24
|
if (isDeepSeekChatModelId(model))
|
|
23
|
-
|
|
25
|
+
return { name: deepSeekProfileName, id: deepSeekProfileId, model };
|
|
24
26
|
return isAnthropicModel(metadata)
|
|
25
27
|
? { name: claudeProfileName, id: claudeProfileId, model }
|
|
26
28
|
: { name: codexProfileName, id: codexProfileId, model };
|
|
@@ -35,6 +37,11 @@ function profilesFor(ctx) {
|
|
|
35
37
|
if (scope !== 'openai' && anthropicModelIds(ctx).length) {
|
|
36
38
|
profiles.push({ name: claudeProfileName, id: claudeProfileId, model: preferredAnthropicModel(ctx) });
|
|
37
39
|
}
|
|
40
|
+
for (const model of deepSeekChatModelIds(ctx)) {
|
|
41
|
+
if (modelSupportsTools(ctx, model)) {
|
|
42
|
+
profiles.push({ name: deepSeekProfileName, id: deepSeekProfileId, model });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
38
45
|
return profiles.length ? profiles : [fallbackProfileFor(ctx)];
|
|
39
46
|
}
|
|
40
47
|
function currentProfileFor(ctx, profiles = profilesFor(ctx)) {
|
|
@@ -42,6 +49,8 @@ function currentProfileFor(ctx, profiles = profilesFor(ctx)) {
|
|
|
42
49
|
return profiles.find(profile => profile.id === claudeProfileId) || profiles[0];
|
|
43
50
|
if (ctx.providerScope === 'openai')
|
|
44
51
|
return profiles.find(profile => profile.id === codexProfileId) || profiles[0];
|
|
52
|
+
if (ctx.providerScope === 'deepseek-v4-flash')
|
|
53
|
+
return profiles.find(profile => profile.id === deepSeekProfileId) || profiles[0];
|
|
45
54
|
return profiles.find(profile => profile.id === codexProfileId) || profiles[0];
|
|
46
55
|
}
|
|
47
56
|
function parseSettings(text, file) {
|
package/dist/types/client.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export interface ConfigureContext extends DetectContext {
|
|
|
43
43
|
display_name?: string;
|
|
44
44
|
context_window?: number | null;
|
|
45
45
|
max_output_tokens?: number | null;
|
|
46
|
+
capabilities?: {
|
|
47
|
+
tools?: boolean;
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
};
|
|
46
50
|
}>;
|
|
47
51
|
}
|
|
48
52
|
export interface ConfigureResult {
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface ModelInfo {
|
|
|
8
8
|
display_name?: string;
|
|
9
9
|
context_window?: number | null;
|
|
10
10
|
max_output_tokens?: number | null;
|
|
11
|
+
capabilities?: {
|
|
12
|
+
tools?: boolean;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
};
|
|
11
15
|
}
|
|
12
16
|
export interface KeyMetadata {
|
|
13
17
|
provider_scope: ProviderScope;
|
|
@@ -22,6 +26,7 @@ export interface ApiValidation {
|
|
|
22
26
|
baseUrl?: string;
|
|
23
27
|
error?: string;
|
|
24
28
|
surfaceMismatch?: boolean;
|
|
29
|
+
transportFailure?: boolean;
|
|
25
30
|
}
|
|
26
31
|
export type ChatRole = 'system' | 'user' | 'assistant';
|
|
27
32
|
export interface ChatMessage {
|
package/dist/utils/api.js
CHANGED
|
@@ -59,6 +59,16 @@ async function friendlyError(res) {
|
|
|
59
59
|
function retryableStatus(status) {
|
|
60
60
|
return status === 408 || status === 429 || status >= 500;
|
|
61
61
|
}
|
|
62
|
+
function safeTransportError(error, timeoutSeconds) {
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
if (/abort|timeout|timed?\s*out/i.test(message)) {
|
|
65
|
+
return `ScaleMax request timed out after ${timeoutSeconds} seconds. Please try again.`;
|
|
66
|
+
}
|
|
67
|
+
// Native fetch intentionally hides DNS/TLS/proxy details behind the generic
|
|
68
|
+
// "fetch failed" message. It is not an authentication verdict and may
|
|
69
|
+
// contain environment-specific detail that is not useful in an installer.
|
|
70
|
+
return 'Could not reach the ScaleMax API. Check your network, proxy/VPN, firewall, and TLS settings, then retry.';
|
|
71
|
+
}
|
|
62
72
|
async function request(path, apiKey, init = {}, retryGet = false, baseUrl = SCALEMAX_BASE_URL, timeoutMs = 15000) {
|
|
63
73
|
const headers = new Headers(init.headers);
|
|
64
74
|
if (apiKey)
|
|
@@ -119,37 +129,43 @@ export async function createChatCompletion(options) {
|
|
|
119
129
|
return { ok: true, content: redactApiKey(content, apiKey) };
|
|
120
130
|
}
|
|
121
131
|
catch (err) {
|
|
122
|
-
|
|
123
|
-
return { ok: false, error: /abort|timeout/i.test(message) ? 'The ScaleMax helper request timed out after 45 seconds.' : message };
|
|
132
|
+
return { ok: false, error: safeTransportError(err, 45) };
|
|
124
133
|
}
|
|
125
134
|
}
|
|
126
135
|
async function validateOnSurface(apiKey, baseUrl) {
|
|
136
|
+
let authCheck;
|
|
127
137
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (!authCheck.ok)
|
|
133
|
-
return { ok: false, models: [], error: redactApiKey(await friendlyError(authCheck), apiKey), surfaceMismatch: authCheck.status === 401 };
|
|
134
|
-
const usage = await authCheck.json().catch(() => ({}));
|
|
135
|
-
const rawScope = usage?.key?.provider_scope ?? 'both';
|
|
136
|
-
const parsedScope = providerScope(rawScope);
|
|
137
|
-
if (!parsedScope)
|
|
138
|
-
return { ok: false, models: [], error: 'ScaleMax returned an unsupported API-key model scope.' };
|
|
139
|
-
const keyMeta = { provider_scope: parsedScope, mode: usage?.key?.mode, prefix: usage?.key?.prefix, label: usage?.key?.label };
|
|
140
|
-
if (!modelsRes.ok)
|
|
141
|
-
return { ok: false, models: [], key: keyMeta, error: redactApiKey(await friendlyError(modelsRes), apiKey) };
|
|
142
|
-
const data = await modelsRes.json();
|
|
143
|
-
const catalog = Array.isArray(data.data) ? data.data : [];
|
|
144
|
-
const models = filterModelsForScope(catalog, keyMeta.provider_scope);
|
|
145
|
-
if (!models.length)
|
|
146
|
-
return { ok: false, models: [], key: keyMeta, error: 'ScaleMax returned an empty model catalog.' };
|
|
147
|
-
return { ok: true, models, key: keyMeta, baseUrl };
|
|
138
|
+
// Authenticate first. The previous concurrent /usage + /models probes
|
|
139
|
+
// allowed a transient model-catalog network failure to hide a valid token
|
|
140
|
+
// key's 401 surface hint, so the CLI never tried its correct token URL.
|
|
141
|
+
authCheck = await request('/usage', apiKey, {}, true, baseUrl);
|
|
148
142
|
}
|
|
149
143
|
catch (err) {
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
return { ok: false, models: [], error: safeTransportError(err, 30), transportFailure: true };
|
|
145
|
+
}
|
|
146
|
+
if (!authCheck.ok)
|
|
147
|
+
return { ok: false, models: [], error: redactApiKey(await friendlyError(authCheck), apiKey), surfaceMismatch: authCheck.status === 401 };
|
|
148
|
+
const usage = await authCheck.json().catch(() => ({}));
|
|
149
|
+
const rawScope = usage?.key?.provider_scope ?? 'both';
|
|
150
|
+
const parsedScope = providerScope(rawScope);
|
|
151
|
+
if (!parsedScope)
|
|
152
|
+
return { ok: false, models: [], error: 'ScaleMax returned an unsupported API-key model scope.' };
|
|
153
|
+
const keyMeta = { provider_scope: parsedScope, mode: usage?.key?.mode, prefix: usage?.key?.prefix, label: usage?.key?.label };
|
|
154
|
+
let modelsRes;
|
|
155
|
+
try {
|
|
156
|
+
modelsRes = await request('/models', apiKey, {}, true, baseUrl);
|
|
152
157
|
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
return { ok: false, models: [], key: keyMeta, error: safeTransportError(err, 30), transportFailure: true };
|
|
160
|
+
}
|
|
161
|
+
if (!modelsRes.ok)
|
|
162
|
+
return { ok: false, models: [], key: keyMeta, error: redactApiKey(await friendlyError(modelsRes), apiKey), surfaceMismatch: modelsRes.status === 401 };
|
|
163
|
+
const data = await modelsRes.json();
|
|
164
|
+
const catalog = Array.isArray(data.data) ? data.data : [];
|
|
165
|
+
const models = filterModelsForScope(catalog, keyMeta.provider_scope);
|
|
166
|
+
if (!models.length)
|
|
167
|
+
return { ok: false, models: [], key: keyMeta, error: 'ScaleMax returned an empty model catalog.' };
|
|
168
|
+
return { ok: true, models, key: keyMeta, baseUrl };
|
|
153
169
|
}
|
|
154
170
|
export async function validateApiKey(apiKey) {
|
|
155
171
|
const credit = await validateOnSurface(apiKey, SCALEMAX_BASE_URL);
|
|
@@ -160,7 +176,12 @@ export async function validateApiKey(apiKey) {
|
|
|
160
176
|
const token = await validateOnSurface(apiKey, SCALEMAX_TOKEN_BASE_URL);
|
|
161
177
|
if (token.ok)
|
|
162
178
|
return token;
|
|
163
|
-
|
|
179
|
+
// A credit-surface 401 can be a valid token key. Prefer a token transport
|
|
180
|
+
// diagnosis over incorrectly calling it invalid; when both surfaces give
|
|
181
|
+
// confirmed auth failures, retain the clear original credit error.
|
|
182
|
+
if (token.transportFailure)
|
|
183
|
+
return token;
|
|
184
|
+
return token.surfaceMismatch ? credit : token;
|
|
164
185
|
}
|
|
165
186
|
export async function testStreaming(apiKey, model, baseUrl = SCALEMAX_BASE_URL) {
|
|
166
187
|
const selected = model || 'fable-5';
|
|
@@ -175,6 +196,6 @@ export async function testStreaming(apiKey, model, baseUrl = SCALEMAX_BASE_URL)
|
|
|
175
196
|
return { ok: true };
|
|
176
197
|
}
|
|
177
198
|
catch (err) {
|
|
178
|
-
return { ok: false, error: err
|
|
199
|
+
return { ok: false, error: safeTransportError(err, 15) };
|
|
179
200
|
}
|
|
180
201
|
}
|
|
@@ -22,8 +22,8 @@ export async function promptForValidatedKey(home, message = 'Enter your ScaleMax
|
|
|
22
22
|
const spinner = ora('Validating API key').start();
|
|
23
23
|
const result = await validateApiKey(candidate);
|
|
24
24
|
if (!result.ok) {
|
|
25
|
-
spinner.fail('
|
|
26
|
-
throw new Error(result.error || '
|
|
25
|
+
spinner.fail('Could not verify API key');
|
|
26
|
+
throw new Error(result.error || 'Could not verify API key');
|
|
27
27
|
}
|
|
28
28
|
await saveApiKey(candidate, result.baseUrl, home);
|
|
29
29
|
spinner.succeed('API key verified and saved');
|