scalemax 0.1.29 → 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 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 installs and configures Continue's Chat/Edit integration instead because the model does not support tool calling. 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.
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-4-8`), pins authenticated Haiku for lightweight background work when available, and sets `CLAUDE_CODE_DISABLE_THINKING=1` because third-party Anthropic-compatible providers do not support Claude Code's proprietary extended-thinking control envelope. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
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 with tools disabled; it is never advertised through Responses. 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.
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. Both use a scoped `ScaleMax-Hermes/1.0` user agent and a 4096-token output cap. Existing non-ScaleMax providers and defaults are preserved, and the obsolete mixed provider is migrated safely.
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 Claude profiles remain separate. DeepSeek V4 Flash is deliberately not offered in Roo because Roo's task-completion loop requires tool calling and the model does not support it. 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.
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 supported VS Code route for `deepseek-v4-flash`. The CLI installs the official `Continue.continue` extension when VS Code is available, merges a Chat/Edit-only 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, select **ScaleMax DeepSeek V4 Flash**, and use Chat or Edit; Agent mode is unavailable because the deployment does not expose tool calling.
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; it must not be placed on a Responses route or advertised as tool-capable.`
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. Roo is not offered for a DeepSeek V4 Flash-only key because Roo's task loop requires tool calling and the model does not support it; use 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.`
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 supported VS Code 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. Do not use Agent mode and do not request or configure any upstream credential.`
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',
@@ -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('Invalid API Key');
20
- throw new Error(result.error || 'Invalid API Key');
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)`);
@@ -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
- const managedNames = new Set([legacyProviderName, codexProviderName, claudeProviderName]);
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 activeName = codexIds.length && !keepClaude ? codexProviderName : claudeProviderName;
98
- const activeModel = activeName === codexProviderName ? preferredOpenAiResponsesModel(ctx) : preferredAnthropicModel(ctx);
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[];
@@ -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: Math.min(metadata?.max_output_tokens || 4096, 4096)
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
- result.reasoning = false;
46
- result.compat = { supportsTools: false };
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) => model?.compat?.supportsTools === false);
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: Math.min(metadata?.max_output_tokens || 4096, 4096)
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
- throw new Error('Roo Code requires task-completion tool calling, which DeepSeek V4 Flash does not expose. Use Continue Chat/Edit in VS Code instead. No configuration was changed.');
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) {
@@ -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 {
@@ -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
- const message = redactApiKey(err instanceof Error ? err.message : String(err), apiKey);
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
- const [authCheck, modelsRes] = await Promise.all([
129
- request('/usage', apiKey, {}, true, baseUrl),
130
- request('/models', apiKey, {}, true, baseUrl)
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
- const message = redactApiKey(err instanceof Error ? err.message : String(err), apiKey);
151
- return { ok: false, models: [], error: /abort|timeout/i.test(message) ? 'ScaleMax request timed out after 15 seconds.' : message };
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
- return credit.error && !/Invalid API key|rejected|401/i.test(credit.error) ? credit : token;
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 instanceof Error ? err.message : String(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('Invalid API key');
26
- throw new Error(result.error || 'Invalid API key');
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {