scalemax 0.1.31 → 0.1.32

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
@@ -161,7 +161,7 @@ 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 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.
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 only from authenticated catalog discovery: tools, forced `tool_choice`, reasoning, reasoning effort, streamed usage, and `reasoning_content` replay are each enabled only when the exact model advertises that capability. In particular, a tool-capable route is never forced to replay reasoning content unless `capabilities.reasoning_replay=true`; 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
 
@@ -4,9 +4,9 @@ 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, isAnthropicModel } from '../utils/api.js';
7
+ import { validateApiKey, isAnthropicModel, isModelRouteAvailable } from '../utils/api.js';
8
8
  import { homeDir } from '../utils/paths.js';
9
- import { isDeepSeekChatModelId } from '../configure/models.js';
9
+ import { isExactChatCompletionsModelId } from '../configure/models.js';
10
10
  export async function detectAll(home = homeDir()) {
11
11
  const ctx = { home, platform: process.platform, env: process.env };
12
12
  return Promise.all(clients.map(c => c.detect(ctx)));
@@ -41,6 +41,7 @@ async function maybeLaunchCodex(chosen, successful, baseUrl = SCALEMAX_BASE_URL)
41
41
  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');
42
42
  }
43
43
  export function clientAllowedForModels(clientId, models) {
44
+ models = models.filter(isModelRouteAvailable);
44
45
  if (!models.length)
45
46
  return false;
46
47
  // These clients use either a native family-specific adapter or the gateway's
@@ -49,15 +50,18 @@ export function clientAllowedForModels(clientId, models) {
49
50
  // OpenAI-compatible Chat Completions.
50
51
  const crossProtocolClients = new Set(['cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
51
52
  if (clientId === 'continue')
52
- return models.some(model => isDeepSeekChatModelId(model.id));
53
+ return models.some(model => isExactChatCompletionsModelId(model.id));
53
54
  if (clientId === 'claude')
54
55
  return models.some(isAnthropicModel);
55
56
  if (clientId === 'codex')
56
- return models.some(model => !isAnthropicModel(model) && !isDeepSeekChatModelId(model.id));
57
- if (clientId === 'hermes' && models.every(model => isDeepSeekChatModelId(model.id)))
58
- return false;
59
- if (clientId === 'roo-code' && models.every(model => isDeepSeekChatModelId(model.id)))
60
- return false;
57
+ return models.some(model => !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id));
58
+ if (clientId === 'hermes' && models.every(model => isExactChatCompletionsModelId(model.id)))
59
+ return models.some(model => model.capabilities?.tools === true);
60
+ // Roo is an agent, not a text-only chat surface. Its task loop depends on
61
+ // native function calls for every model family, so offer it only when the
62
+ // authenticated catalog confirms at least one viable route.
63
+ if (clientId === 'roo-code')
64
+ return models.some(model => model.capabilities?.tools === true);
61
65
  if (crossProtocolClients.has(clientId))
62
66
  return true;
63
67
  return models.some(model => !isAnthropicModel(model));
@@ -65,6 +69,8 @@ export function clientAllowedForModels(clientId, models) {
65
69
  function scopeHint(scope) {
66
70
  if (scope === 'deepseek-v4-flash')
67
71
  return 'DeepSeek V4 Flash only';
72
+ if (scope === 'glm-5.2')
73
+ return 'GLM 5.2 only';
68
74
  return scope === 'both' ? 'OpenAI + Claude' : scope === 'anthropic' ? 'Claude only' : 'OpenAI only';
69
75
  }
70
76
  export async function configureCommand(opts = {}) {
@@ -4,13 +4,14 @@ import { createInterface } from 'node:readline/promises';
4
4
  import { execa } from 'execa';
5
5
  import { clients } from '../clients.js';
6
6
  import { helperDocsContext } from './helper-docs.js';
7
- import { createChatCompletion, isAnthropicModel } from '../utils/api.js';
7
+ import { createChatCompletion, filterModelsForScope, isAnthropicModel } from '../utils/api.js';
8
8
  import { redactApiKey } from '../utils/apiKey.js';
9
9
  import { loadApiKey } from '../utils/keyStore.js';
10
10
  import { homeDir } from '../utils/paths.js';
11
11
  import { validateApiKey } from '../utils/api.js';
12
12
  import { commandExists } from '../utils/exec.js';
13
13
  import { installClaudeGithubTools } from '../configure/claude-tools.js';
14
+ import { isExactChatCompletionsModelId } from '../configure/models.js';
14
15
  export const SCALEMAX_HELPER_REFUSAL = `I am the official ScaleMax Helper.
15
16
 
16
17
  I can only assist with installing, configuring, repairing, verifying, and troubleshooting ScaleMax.
@@ -800,6 +801,8 @@ function helperActionTarget(question) {
800
801
  return undefined;
801
802
  }
802
803
  function requestedProviderScope(question, target) {
804
+ if (/\bglm[-\s]*5[._\s]*2\b/i.test(question))
805
+ return 'glm-5.2';
803
806
  if (/\bdeepseek(?:[-\s]+v4[-\s]+flash)?\b/i.test(question))
804
807
  return 'deepseek-v4-flash';
805
808
  if (/\b(?:both|openai\s*\+\s*claude|claude\s*\+\s*openai)\b/i.test(question))
@@ -811,35 +814,48 @@ function requestedProviderScope(question, target) {
811
814
  return undefined;
812
815
  }
813
816
  function actionModels(connection, requestedScope) {
814
- const catalog = connection.modelCatalog?.length
817
+ const rawCatalog = connection.modelCatalog?.length
815
818
  ? connection.modelCatalog
816
819
  : (connection.availableModels || []).map(id => ({ id }));
820
+ const entitlement = connection.providerScope || 'both';
821
+ // Always begin with the key's verified entitlement. Natural-language intent
822
+ // must never widen an exact-model key into an OpenAI/Claude family key.
823
+ const catalog = filterModelsForScope(rawCatalog, entitlement);
817
824
  if (!requestedScope)
818
825
  return catalog;
819
- if (requestedScope === 'deepseek-v4-flash') {
820
- return catalog.filter(model => model.id.trim().toLowerCase() === 'deepseek-v4-flash');
826
+ const exactEntitlement = entitlement === 'deepseek-v4-flash' || entitlement === 'glm-5.2';
827
+ if (exactEntitlement && requestedScope !== entitlement)
828
+ return [];
829
+ if (requestedScope === 'deepseek-v4-flash' || requestedScope === 'glm-5.2') {
830
+ return requestedScope === entitlement
831
+ ? catalog.filter(model => model.id.trim().toLowerCase() === requestedScope)
832
+ : [];
821
833
  }
822
834
  if (requestedScope === 'both') {
823
- return catalog.filter(model => model.id.trim().toLowerCase() !== 'deepseek-v4-flash');
835
+ return catalog.filter(model => !isExactChatCompletionsModelId(model.id));
824
836
  }
825
837
  return catalog.filter(model => requestedScope === 'anthropic'
826
838
  ? isAnthropicModel(model)
827
- : !isAnthropicModel(model) && model.id.trim().toLowerCase() !== 'deepseek-v4-flash');
839
+ : !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id));
828
840
  }
829
841
  function helperClientAllowedForModels(clientId, models) {
830
842
  if (!models.length)
831
843
  return false;
832
844
  const crossProtocolClients = new Set(['cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
833
- const onlyDeepSeek = models.every(model => model.id.trim().toLowerCase() === 'deepseek-v4-flash');
845
+ const onlyExactChat = models.every(model => isExactChatCompletionsModelId(model.id));
834
846
  if (clientId === 'continue')
835
- return onlyDeepSeek;
847
+ return onlyExactChat;
836
848
  if (clientId === 'claude')
837
849
  return models.some(isAnthropicModel);
838
- if ((clientId === 'codex' || clientId === 'hermes' || clientId === 'roo-code') && onlyDeepSeek)
850
+ if (clientId === 'codex' && onlyExactChat)
839
851
  return false;
852
+ if (clientId === 'hermes' && onlyExactChat)
853
+ return models.some(model => model.capabilities?.tools === true);
854
+ if (clientId === 'roo-code')
855
+ return models.some(model => model.capabilities?.tools === true);
840
856
  if (crossProtocolClients.has(clientId))
841
857
  return true;
842
- return models.some(model => !isAnthropicModel(model) && model.id.trim().toLowerCase() !== 'deepseek-v4-flash');
858
+ return models.some(model => !isAnthropicModel(model) && !isExactChatCompletionsModelId(model.id));
843
859
  }
844
860
  function hasRooCodeExtension(paths) {
845
861
  return paths.some(candidate => /^rooveterinaryinc\.roo-cline-/i.test(path.basename(candidate)));
@@ -923,8 +939,8 @@ async function installRooCodeExtension(ctx, client, write) {
923
939
  async function runOneHelperClientAction(kind, client, connection, home, requestedScope, write) {
924
940
  const models = actionModels(connection, requestedScope);
925
941
  if (!models.length)
926
- throw new Error(`The authenticated key does not expose ${requestedScope === 'anthropic' ? 'Claude' : requestedScope === 'openai' ? 'OpenAI' : requestedScope === 'deepseek-v4-flash' ? 'DeepSeek V4 Flash' : 'compatible'} models for ${client.name}. No configuration was changed.`);
927
- if (client.id === 'codex' && models.every(model => isAnthropicModel(model) || model.id.trim().toLowerCase() === 'deepseek-v4-flash'))
942
+ throw new Error(`The authenticated key does not expose ${requestedScope === 'anthropic' ? 'Claude' : requestedScope === 'openai' ? 'OpenAI' : requestedScope === 'deepseek-v4-flash' ? 'DeepSeek V4 Flash' : requestedScope === 'glm-5.2' ? 'GLM 5.2' : 'compatible'} models for ${client.name}. No configuration was changed.`);
943
+ if (client.id === 'codex' && models.every(model => isAnthropicModel(model) || isExactChatCompletionsModelId(model.id)))
928
944
  throw new Error('Codex requires a Responses-capable model, but this key exposes only Claude or Chat-Completions-only models. No configuration was changed.');
929
945
  if (client.id === 'cursor' && connection.platform !== 'darwin')
930
946
  throw new Error(`Automatic Cursor configuration is currently verified on macOS only. ${helperPlatformName(connection.platform)} was not modified.`);
@@ -934,7 +950,7 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
934
950
  env: { ...process.env },
935
951
  apiKey: connection.apiKey,
936
952
  baseUrl: connection.baseUrl,
937
- providerScope: requestedScope || connection.providerScope || 'both',
953
+ providerScope: connection.providerScope || 'both',
938
954
  models,
939
955
  interactive: false,
940
956
  allowAppModification: false
@@ -5,11 +5,9 @@ import { parseDocument } from 'yaml';
5
5
  import { atomicWrite, backupFile } from '../utils/fs.js';
6
6
  import { commandExists } from '../utils/exec.js';
7
7
  import { clientConfigPath } from './paths.js';
8
- import { isDeepSeekChatModelId } from './models.js';
8
+ import { isExactChatCompletionsModelId, modelName as displayModelName } from './models.js';
9
9
  const id = 'continue';
10
10
  const name = 'Continue (VS Code)';
11
- const modelName = 'ScaleMax DeepSeek V4 Flash';
12
- const modelId = 'deepseek-v4-flash';
13
11
  const envKey = 'SCALEMAX_API_KEY';
14
12
  const envMarker = '# ScaleMax managed Continue API key';
15
13
  const userAgent = 'ScaleMax-Continue/1.0';
@@ -29,9 +27,10 @@ function parseYaml(text) {
29
27
  return doc;
30
28
  }
31
29
  function isManagedModel(model) {
32
- return model?.name === modelName
30
+ return typeof model?.name === 'string'
31
+ && model.name.startsWith('ScaleMax ')
33
32
  && ['azure', 'openai'].includes(model?.provider)
34
- && model?.model === modelId
33
+ && isExactChatCompletionsModelId(String(model?.model || ''))
35
34
  && typeof model?.apiBase === 'string'
36
35
  && /^https:\/\/api\.scalemax\.pro\/(?:token\/)?v1\/?$/i.test(model.apiBase);
37
36
  }
@@ -47,10 +46,11 @@ function withoutManagedEnv(text) {
47
46
  kept.pop();
48
47
  return kept.length ? `${kept.join('\n')}\n` : '';
49
48
  }
50
- function requireDeepSeek(ctx) {
51
- if (!(ctx.models || []).some(model => isDeepSeekChatModelId(model.id))) {
52
- throw new Error('Continue automatic setup for this integration requires a DeepSeek V4 Flash key. No configuration was changed.');
53
- }
49
+ function exactChatModel(ctx) {
50
+ const model = (ctx.models || []).find(candidate => isExactChatCompletionsModelId(candidate.id));
51
+ if (!model)
52
+ throw new Error('Continue automatic setup requires an administrator-issued exact Chat Completions model key. No configuration was changed.');
53
+ return model.id;
54
54
  }
55
55
  export async function install(ctx) {
56
56
  const extensionRoots = [path.join(ctx.home, '.vscode', 'extensions'), path.join(ctx.home, '.vscode-insiders', 'extensions')];
@@ -67,7 +67,8 @@ export async function install(ctx) {
67
67
  return { id, name, changed: true, message: 'official Continue extension installed' };
68
68
  }
69
69
  export async function configure(ctx) {
70
- requireDeepSeek(ctx);
70
+ const modelId = exactChatModel(ctx);
71
+ const modelName = `ScaleMax ${displayModelName(ctx, modelId)}`;
71
72
  const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
72
73
  const secrets = envPath(ctx.home);
73
74
  const originalConfig = await fs.readFile(file, 'utf8').catch(() => '');
@@ -77,7 +78,7 @@ export async function configure(ctx) {
77
78
  const existingModels = Array.isArray(data.models) ? data.models.filter((model) => !isManagedModel(model)) : [];
78
79
  const model = {
79
80
  name: modelName,
80
- provider: 'azure',
81
+ provider: 'openai',
81
82
  model: modelId,
82
83
  apiBase: ctx.baseUrl.replace(/\/$/, ''),
83
84
  apiKey: `\${{ secrets.${envKey} }}`,
@@ -99,7 +100,7 @@ export async function configure(ctx) {
99
100
  if (originalConfig === nextConfig && originalEnv === nextEnv) {
100
101
  await fs.chmod(file, 0o600).catch(() => undefined);
101
102
  await fs.chmod(secrets, 0o600).catch(() => undefined);
102
- return { id, name, changed: false, path: file, message: 'DeepSeek V4 Flash Chat/Edit profile is already current' };
103
+ return { id, name, changed: false, path: file, message: `${displayModelName(ctx, modelId)} Chat/Edit profile is already current` };
103
104
  }
104
105
  const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
105
106
  try {
@@ -117,16 +118,17 @@ export async function configure(ctx) {
117
118
  await fs.remove(secrets).catch(() => undefined);
118
119
  throw error;
119
120
  }
120
- return { id, name, changed: true, path: file, message: `configured a text-only DeepSeek V4 Flash Chat/Edit profile with a private credential${backups.length ? ' (backups created)' : ''}` };
121
+ return { id, name, changed: true, path: file, message: `configured a ScaleMax ${displayModelName(ctx, modelId)} Chat/Edit profile with a private credential${backups.length ? ' (backups created)' : ''}` };
121
122
  }
122
123
  export async function verify(ctx) {
123
- requireDeepSeek(ctx);
124
+ const modelId = exactChatModel(ctx);
125
+ const modelName = `ScaleMax ${displayModelName(ctx, modelId)}`;
124
126
  const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
125
127
  const secrets = envPath(ctx.home);
126
128
  const doc = parseYaml(await fs.readFile(file, 'utf8'));
127
129
  const data = asRecord(doc.toJS());
128
130
  const model = Array.isArray(data.models)
129
- ? data.models.find((candidate) => isManagedModel(candidate) && candidate.provider === 'azure')
131
+ ? data.models.find((candidate) => isManagedModel(candidate) && candidate.name === modelName && candidate.model === modelId && candidate.provider === 'openai')
130
132
  : undefined;
131
133
  if (!model
132
134
  || model.apiBase !== ctx.baseUrl.replace(/\/$/, '')
@@ -136,7 +138,7 @@ export async function verify(ctx) {
136
138
  || model.requestOptions?.headers?.['User-Agent'] !== userAgent
137
139
  || model.env?.apiType !== 'openai-compatible'
138
140
  || model.defaultCompletionOptions?.maxTokens !== 4096) {
139
- throw new Error('Continue DeepSeek V4 Flash Chat/Edit configuration is incomplete.');
141
+ throw new Error(`Continue ${displayModelName(ctx, modelId)} Chat/Edit configuration is incomplete.`);
140
142
  }
141
143
  const env = await fs.readFile(secrets, 'utf8').catch(() => '');
142
144
  if (!env.includes(envMarker) || !env.includes(`${envKey}=${ctx.apiKey}`))
@@ -146,7 +148,7 @@ export async function verify(ctx) {
146
148
  if (((await fs.stat(candidate)).mode & 0o077) !== 0)
147
149
  throw new Error(`Continue file permissions are too broad: ${candidate}`);
148
150
  }
149
- return { id, name, changed: false, path: file, message: 'verified DeepSeek V4 Flash Chat/Edit configuration; reload VS Code to activate it' };
151
+ return { id, name, changed: false, path: file, message: `verified ${displayModelName(ctx, modelId)} Chat/Edit configuration; reload VS Code to activate it` };
150
152
  }
151
153
  export async function remove(ctx) {
152
154
  const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
@@ -181,5 +183,5 @@ export async function remove(ctx) {
181
183
  await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
182
184
  else
183
185
  await fs.remove(secrets).catch(() => undefined);
184
- return { id, name, changed: true, path: file, message: 'removed only the ScaleMax DeepSeek model and managed credential' };
186
+ return { id, name, changed: true, path: file, message: 'removed only the ScaleMax exact-model Chat/Edit profile and managed credential' };
185
187
  }
@@ -12,7 +12,18 @@ const envMarker = '# ScaleMax managed Hermes API key';
12
12
  const legacyProviderName = 'scalemax';
13
13
  const codexProviderName = 'scalemax-codex';
14
14
  const deepSeekProviderName = 'scalemax-deepseek';
15
+ const glmProviderName = 'scalemax-glm-5-2';
15
16
  const claudeProviderName = 'scalemax-claude';
17
+ function exactChatProviderName(ids) {
18
+ return ids.length > 0 && ids.every(id => id.trim().toLowerCase() === 'glm-5.2')
19
+ ? glmProviderName
20
+ : deepSeekProviderName;
21
+ }
22
+ function exactChatLabel(ids) {
23
+ return ids.length > 0 && ids.every(id => id.trim().toLowerCase() === 'glm-5.2')
24
+ ? 'GLM 5.2'
25
+ : 'DeepSeek';
26
+ }
16
27
  function asRecord(value) {
17
28
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
18
29
  }
@@ -71,11 +82,13 @@ export async function configure(ctx) {
71
82
  const codexIds = openAiResponsesModelIds(ctx);
72
83
  const discoveredDeepSeekIds = deepSeekChatModelIds(ctx);
73
84
  const deepSeekIds = toolCapableDeepSeekModelIds(ctx);
85
+ const chatProviderName = exactChatProviderName(discoveredDeepSeekIds);
86
+ const chatLabel = exactChatLabel(discoveredDeepSeekIds);
74
87
  const claudeIds = anthropicModelIds(ctx);
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.');
88
+ if ((ctx.providerScope === 'deepseek-v4-flash' || ctx.providerScope === 'glm-5.2') && discoveredDeepSeekIds.length && !deepSeekIds.length) {
89
+ throw new Error(`Hermes agent mode requires tool calling, but the active ${chatLabel} route is text-only. Choose a tool-capable model. No configuration was changed.`);
77
90
  }
78
- const managedNames = new Set([legacyProviderName, codexProviderName, deepSeekProviderName, claudeProviderName]);
91
+ const managedNames = new Set([legacyProviderName, codexProviderName, deepSeekProviderName, glmProviderName, claudeProviderName]);
79
92
  const providers = Array.isArray(data.custom_providers) ? data.custom_providers.filter((provider) => !managedNames.has(provider?.name)) : [];
80
93
  if (codexIds.length)
81
94
  providers.push({
@@ -89,7 +102,7 @@ export async function configure(ctx) {
89
102
  });
90
103
  if (deepSeekIds.length)
91
104
  providers.push({
92
- name: deepSeekProviderName,
105
+ name: chatProviderName,
93
106
  base_url: openAiBaseUrl(ctx.baseUrl),
94
107
  key_env: envKey,
95
108
  api_mode: 'chat_completions',
@@ -114,16 +127,17 @@ export async function configure(ctx) {
114
127
  if (activatesScaleMax) {
115
128
  const keepClaude = currentProvider === `custom:${claudeProviderName}` || claudeIds.includes(String(currentModel.default || ''));
116
129
  const keepDeepSeek = currentProvider === `custom:${deepSeekProviderName}`
130
+ || currentProvider === `custom:${glmProviderName}`
117
131
  || deepSeekIds.includes(String(currentModel.default || ''))
118
132
  || (!codexIds.length && !claudeIds.length && deepSeekIds.length > 0);
119
133
  const activeName = keepDeepSeek
120
- ? deepSeekProviderName
134
+ ? chatProviderName
121
135
  : codexIds.length && !keepClaude
122
136
  ? codexProviderName
123
- : claudeIds.length ? claudeProviderName : deepSeekProviderName;
137
+ : claudeIds.length ? claudeProviderName : chatProviderName;
124
138
  const activeModel = activeName === codexProviderName
125
139
  ? preferredOpenAiResponsesModel(ctx)
126
- : activeName === deepSeekProviderName ? deepSeekIds[0] : preferredAnthropicModel(ctx);
140
+ : activeName === chatProviderName ? deepSeekIds[0] : preferredAnthropicModel(ctx);
127
141
  doc.set('model', { ...currentModel, provider: `custom:${activeName}`, default: activeModel, max_tokens: 4096 });
128
142
  }
129
143
  const nextConfig = String(doc);
@@ -132,7 +146,7 @@ export async function configure(ctx) {
132
146
  await fs.chmod(file, 0o600).catch(() => undefined);
133
147
  await fs.chmod(secrets, 0o600).catch(() => undefined);
134
148
  await removeLegacyArtifact(ctx);
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` };
149
+ return { id, name, changed: false, path: file, message: `already current with ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} ${chatLabel}, and ${claudeIds.length} Claude models and a private credential` };
136
150
  }
137
151
  const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
138
152
  try {
@@ -151,7 +165,7 @@ export async function configure(ctx) {
151
165
  await fs.remove(secrets).catch(() => undefined);
152
166
  throw error;
153
167
  }
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)' : ''}` };
168
+ return { id, name, changed: true, path: file, message: `configured family-specific ScaleMax providers with ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} ${chatLabel}, and ${claudeIds.length} Claude models${backups.length ? ' (backups created)' : ''}` };
155
169
  }
156
170
  export async function verify(ctx) {
157
171
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -161,9 +175,11 @@ export async function verify(ctx) {
161
175
  const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
162
176
  const codexIds = openAiResponsesModelIds(ctx);
163
177
  const deepSeekIds = toolCapableDeepSeekModelIds(ctx);
178
+ const chatProviderName = exactChatProviderName(deepSeekIds);
179
+ const chatLabel = exactChatLabel(deepSeekIds);
164
180
  const claudeIds = anthropicModelIds(ctx);
165
181
  const codex = providers.find((candidate) => candidate?.name === codexProviderName);
166
- const deepSeek = providers.find((candidate) => candidate?.name === deepSeekProviderName);
182
+ const deepSeek = providers.find((candidate) => candidate?.name === chatProviderName);
167
183
  const claude = providers.find((candidate) => candidate?.name === claudeProviderName);
168
184
  const valid = (provider, baseUrl, apiMode, ids) => provider
169
185
  && provider.base_url === baseUrl && provider.key_env === envKey && provider.api_mode === apiMode
@@ -180,7 +196,7 @@ export async function verify(ctx) {
180
196
  throw new Error('Hermes is missing authenticated ScaleMax models or credentials.');
181
197
  if (String(data?.model?.provider || '').startsWith('custom:scalemax') && data?.model?.max_tokens !== 4096)
182
198
  throw new Error('Hermes active ScaleMax model must use max_tokens 4096.');
183
- return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} DeepSeek, and ${claudeIds.length} Claude models` };
199
+ return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Codex/OpenAI, ${deepSeekIds.length} ${chatLabel}, and ${claudeIds.length} Claude models` };
184
200
  }
185
201
  export async function remove(ctx) {
186
202
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -191,7 +207,7 @@ export async function remove(ctx) {
191
207
  const doc = parseYaml(text);
192
208
  const data = asRecord(doc.toJS());
193
209
  const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
194
- const managedNames = new Set([legacyProviderName, codexProviderName, deepSeekProviderName, claudeProviderName]);
210
+ const managedNames = new Set([legacyProviderName, codexProviderName, deepSeekProviderName, glmProviderName, claudeProviderName]);
195
211
  const managedProviders = providers.filter((provider) => managedNames.has(provider?.name)
196
212
  && (provider?.base_url === openAiBaseUrl(ctx.baseUrl) || provider?.base_url === anthropicOrigin(ctx.baseUrl)));
197
213
  if (!managedProviders.length)
@@ -1,6 +1,28 @@
1
1
  import { ConfigureContext } from '../types/client.js';
2
2
  export declare const DEEPSEEK_V4_FLASH_MODEL_ID = "deepseek-v4-flash";
3
- export declare function isDeepSeekChatModelId(id: string): boolean;
3
+ export declare const GLM_5_2_MODEL_ID = "glm-5.2";
4
+ /**
5
+ * OpenClaw has distinct compatibility switches for each part of an
6
+ * OpenAI-compatible tool loop. Do not infer one from another: a route can
7
+ * support ordinary function calls without supporting forced tool choice,
8
+ * reasoning effort, streamed usage, or reasoning-content replay.
9
+ *
10
+ * These values deliberately come only from the authenticated /models catalog.
11
+ * Missing capability metadata fails closed.
12
+ */
13
+ export interface AuthenticatedChatCapabilities {
14
+ tools: boolean;
15
+ toolChoice: boolean;
16
+ reasoning: boolean;
17
+ reasoningEffort: boolean;
18
+ streamingUsage: boolean;
19
+ reasoningReplay: boolean;
20
+ }
21
+ export declare function authenticatedChatCapabilities(ctx: ConfigureContext, id: string): AuthenticatedChatCapabilities;
22
+ /** Exact model aliases that are Chat-Completions-only, never Responses API. */
23
+ export declare function isExactChatCompletionsModelId(id: string): boolean;
24
+ /** Backward-compatible internal name for legacy client configuration code. */
25
+ export declare const isDeepSeekChatModelId: typeof isExactChatCompletionsModelId;
4
26
  /** Trust only the authenticated gateway catalog for endpoint capabilities. */
5
27
  export declare function modelSupportsTools(ctx: ConfigureContext, id: string): boolean;
6
28
  export declare function modelIds(ctx: ConfigureContext): string[];
@@ -1,4 +1,4 @@
1
- import { isAnthropicModel, isRetiredModelId } from '../utils/api.js';
1
+ import { isAnthropicModel, isModelRouteAvailable, isRetiredModelId } from '../utils/api.js';
2
2
  const preferredAnthropic = [
3
3
  'claude-opus-5',
4
4
  'claude-opus-4-8',
@@ -16,32 +16,50 @@ const preferredOpenAi = [
16
16
  'gpt-5.4-mini',
17
17
  'gpt-5.3-codex',
18
18
  'codex-auto-review',
19
+ 'glm-5.2',
19
20
  'deepseek-v4-flash'
20
21
  ];
21
22
  export const DEEPSEEK_V4_FLASH_MODEL_ID = 'deepseek-v4-flash';
22
- export function isDeepSeekChatModelId(id) {
23
- return id.trim().toLowerCase() === DEEPSEEK_V4_FLASH_MODEL_ID;
24
- }
23
+ export const GLM_5_2_MODEL_ID = 'glm-5.2';
24
+ export function authenticatedChatCapabilities(ctx, id) {
25
+ const capabilities = (ctx.models || []).find(model => model.id.trim() === id.trim())?.capabilities;
26
+ return {
27
+ tools: capabilities?.tools === true,
28
+ toolChoice: capabilities?.tool_choice === true,
29
+ reasoning: capabilities?.reasoning === true,
30
+ reasoningEffort: capabilities?.reasoning_effort === true,
31
+ streamingUsage: capabilities?.streaming_usage === true,
32
+ reasoningReplay: capabilities?.reasoning_replay === true,
33
+ };
34
+ }
35
+ /** Exact model aliases that are Chat-Completions-only, never Responses API. */
36
+ export function isExactChatCompletionsModelId(id) {
37
+ const normalized = id.trim().toLowerCase();
38
+ return normalized === DEEPSEEK_V4_FLASH_MODEL_ID || normalized === GLM_5_2_MODEL_ID;
39
+ }
40
+ /** Backward-compatible internal name for legacy client configuration code. */
41
+ export const isDeepSeekChatModelId = isExactChatCompletionsModelId;
25
42
  /** Trust only the authenticated gateway catalog for endpoint capabilities. */
26
43
  export function modelSupportsTools(ctx, id) {
27
- return (ctx.models || []).find(model => model.id.trim() === id.trim())?.capabilities?.tools === true;
44
+ const model = (ctx.models || []).find(candidate => candidate.id.trim() === id.trim());
45
+ return Boolean(model && isModelRouteAvailable(model) && authenticatedChatCapabilities(ctx, id).tools);
28
46
  }
29
47
  export function modelIds(ctx) {
30
- return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
48
+ return [...new Set((ctx.models || []).filter(isModelRouteAvailable).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
31
49
  }
32
50
  export function anthropicModelIds(ctx) {
33
- return [...new Set((ctx.models || []).filter(isAnthropicModel).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
51
+ return [...new Set((ctx.models || []).filter(isModelRouteAvailable).filter(isAnthropicModel).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
34
52
  }
35
53
  export function openAiModelIds(ctx) {
36
- return [...new Set((ctx.models || []).filter(model => !isAnthropicModel(model)).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
54
+ return [...new Set((ctx.models || []).filter(isModelRouteAvailable).filter(model => !isAnthropicModel(model)).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
37
55
  }
38
56
  /** Models that must stay on the OpenAI-compatible Chat Completions route. */
39
57
  export function deepSeekChatModelIds(ctx) {
40
- return openAiModelIds(ctx).filter(isDeepSeekChatModelId);
58
+ return openAiModelIds(ctx).filter(isExactChatCompletionsModelId);
41
59
  }
42
60
  /** Non-Anthropic models that may remain on existing Responses-based clients. */
43
61
  export function openAiResponsesModelIds(ctx) {
44
- return openAiModelIds(ctx).filter(id => !isDeepSeekChatModelId(id));
62
+ return openAiModelIds(ctx).filter(id => !isExactChatCompletionsModelId(id));
45
63
  }
46
64
  function preferredFrom(ids, candidates, family) {
47
65
  for (const candidate of candidates)
@@ -96,7 +114,9 @@ export function modelName(ctx, id) {
96
114
  const model = (ctx.models || []).find(candidate => candidate.id === id);
97
115
  if (model?.display_name)
98
116
  return model.display_name;
99
- if (isDeepSeekChatModelId(id))
117
+ if (id.trim().toLowerCase() === GLM_5_2_MODEL_ID)
118
+ return 'GLM 5.2';
119
+ if (id.trim().toLowerCase() === DEEPSEEK_V4_FLASH_MODEL_ID)
100
120
  return 'DeepSeek V4 Flash';
101
121
  return id.split('-').map(part => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
102
122
  }
@@ -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, modelSupportsTools, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredOpenAiResponsesModel } from './models.js';
6
+ import { anthropicModelIds, anthropicOrigin, authenticatedChatCapabilities, deepSeekChatModelIds, fastModel, fastOpenAiModel, modelName, 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';
@@ -17,10 +17,21 @@ const userAgentReference = { source: 'file', provider: userAgentProviderId, id:
17
17
  const legacyProviderName = 'scalemax';
18
18
  const codexProviderName = 'scalemax-codex';
19
19
  const deepSeekProviderName = 'scalemax-deepseek';
20
+ const glmProviderName = 'scalemax-glm-5-2';
20
21
  const claudeProviderName = 'scalemax-claude';
21
22
  function secretPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-api-key'); }
22
23
  function userAgentPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-user-agent'); }
23
24
  function userAgentContext(ctx) { return { ...ctx, apiKey: userAgentValue }; }
25
+ function exactChatProviderName(ids) {
26
+ return ids.length > 0 && ids.every(id => id.trim().toLowerCase() === 'glm-5.2')
27
+ ? glmProviderName
28
+ : deepSeekProviderName;
29
+ }
30
+ function exactChatLabel(ids) {
31
+ return ids.length > 0 && ids.every(id => id.trim().toLowerCase() === 'glm-5.2')
32
+ ? 'GLM 5.2 Chat Completions'
33
+ : 'DeepSeek Chat Completions';
34
+ }
24
35
  function asRecord(value) {
25
36
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
26
37
  }
@@ -44,18 +55,16 @@ function entries(ctx, ids, family) {
44
55
  : Math.min(metadata?.max_output_tokens || 4096, 4096)
45
56
  };
46
57
  if (family === 'deepseek') {
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 };
58
+ const capabilities = authenticatedChatCapabilities(ctx, modelId);
59
+ result.reasoning = capabilities.reasoning;
60
+ result.compat = {
61
+ supportsTools: capabilities.tools,
62
+ supportsToolChoice: capabilities.toolChoice,
63
+ supportsReasoningEffort: capabilities.reasoningEffort,
64
+ supportsUsageInStreaming: capabilities.streamingUsage,
65
+ maxTokensField: 'max_tokens',
66
+ requiresReasoningContentOnAssistantMessages: capabilities.reasoningReplay,
67
+ };
59
68
  }
60
69
  if (metadata?.context_window || family === 'anthropic')
61
70
  result.contextWindow = metadata?.context_window || 200000;
@@ -95,7 +104,7 @@ async function scrubGeneratedPlaintextCredentials(ctx) {
95
104
  for (const providers of roots) {
96
105
  for (const [providerName, rawProvider] of Object.entries(providers)) {
97
106
  const provider = asRecord(rawProvider);
98
- const isScaleMax = [legacyProviderName, codexProviderName, deepSeekProviderName, claudeProviderName].includes(providerName)
107
+ const isScaleMax = [legacyProviderName, codexProviderName, deepSeekProviderName, glmProviderName, claudeProviderName].includes(providerName)
99
108
  && isScaleMaxBaseUrl(provider.baseUrl);
100
109
  if (isScaleMax && typeof provider.apiKey === 'string') {
101
110
  delete provider.apiKey;
@@ -115,9 +124,11 @@ export async function configure(ctx) {
115
124
  const file = clientConfigPath(id, ctx.home, ctx.platform);
116
125
  const codexIds = openAiResponsesModelIds(ctx);
117
126
  const deepSeekIds = deepSeekChatModelIds(ctx);
127
+ const chatProviderName = exactChatProviderName(deepSeekIds);
128
+ const chatLabel = exactChatLabel(deepSeekIds);
118
129
  const claudeIds = anthropicModelIds(ctx);
119
130
  const codexPrimary = codexIds.length ? `${codexProviderName}/${preferredOpenAiResponsesModel(ctx)}` : undefined;
120
- const deepSeekPrimary = deepSeekIds.length ? `${deepSeekProviderName}/${deepSeekIds[0]}` : undefined;
131
+ const deepSeekPrimary = deepSeekIds.length ? `${chatProviderName}/${deepSeekIds[0]}` : undefined;
121
132
  const claudePrimary = claudeIds.length ? `${claudeProviderName}/${preferredAnthropicModel(ctx)}` : undefined;
122
133
  const codexQuick = fastOpenAiModel(ctx);
123
134
  const deepSeekQuick = deepSeekIds[0];
@@ -137,20 +148,20 @@ export async function configure(ctx) {
137
148
  const defaults = asRecord(agents.defaults);
138
149
  const allowlist = asRecord(defaults.models);
139
150
  for (const key of Object.keys(allowlist)) {
140
- if (key.startsWith(`${legacyProviderName}/`) || key.startsWith(`${codexProviderName}/`) || key.startsWith(`${deepSeekProviderName}/`) || key.startsWith(`${claudeProviderName}/`))
151
+ if (key.startsWith(`${legacyProviderName}/`) || key.startsWith(`${codexProviderName}/`) || key.startsWith(`${deepSeekProviderName}/`) || key.startsWith(`${glmProviderName}/`) || key.startsWith(`${claudeProviderName}/`))
141
152
  delete allowlist[key];
142
153
  }
143
154
  for (const modelId of codexIds)
144
155
  allowlist[`${codexProviderName}/${modelId}`] = {};
145
156
  for (const modelId of deepSeekIds)
146
- allowlist[`${deepSeekProviderName}/${modelId}`] = {};
157
+ allowlist[`${chatProviderName}/${modelId}`] = {};
147
158
  for (const modelId of claudeIds)
148
159
  allowlist[`${claudeProviderName}/${modelId}`] = {};
149
160
  const currentModel = defaults.model;
150
161
  const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
151
162
  const activatesScaleMax = !currentPrimary || String(currentPrimary).startsWith('scalemax');
152
163
  const currentId = String(currentPrimary || '').split('/').slice(1).join('/');
153
- const keepDeepSeek = String(currentPrimary || '').startsWith(`${deepSeekProviderName}/`) || deepSeekIds.includes(currentId);
164
+ const keepDeepSeek = String(currentPrimary || '').startsWith(`${deepSeekProviderName}/`) || String(currentPrimary || '').startsWith(`${glmProviderName}/`) || deepSeekIds.includes(currentId);
154
165
  const keepClaude = String(currentPrimary || '').startsWith(`${claudeProviderName}/`) || claudeIds.includes(currentId);
155
166
  const primary = keepDeepSeek
156
167
  ? deepSeekPrimary || codexPrimary || claudePrimary
@@ -158,16 +169,20 @@ export async function configure(ctx) {
158
169
  ? claudePrimary || codexPrimary || deepSeekPrimary
159
170
  : codexPrimary || deepSeekPrimary || claudePrimary;
160
171
  const usesClaude = Boolean(primary?.startsWith(`${claudeProviderName}/`));
161
- const usesDeepSeek = Boolean(primary?.startsWith(`${deepSeekProviderName}/`));
172
+ const usesDeepSeek = Boolean(primary?.startsWith(`${chatProviderName}/`));
162
173
  const nextModel = activatesScaleMax
163
174
  ? { ...asRecord(currentModel), ...(primary ? { primary } : {}) }
164
175
  : currentModel;
165
176
  const currentUtility = typeof defaults.utilityModel === 'string' ? defaults.utilityModel : undefined;
166
177
  const quick = usesClaude ? claudeQuick : usesDeepSeek ? deepSeekQuick : codexQuick;
167
- const utilityProvider = usesClaude ? claudeProviderName : usesDeepSeek ? deepSeekProviderName : codexProviderName;
178
+ const utilityProvider = usesClaude ? claudeProviderName : usesDeepSeek ? chatProviderName : codexProviderName;
168
179
  const utilityModel = quick && (activatesScaleMax || currentUtility?.startsWith('scalemax')) ? `${utilityProvider}/${quick}` : currentUtility;
169
180
  const nextProviders = { ...providers };
170
181
  delete nextProviders[legacyProviderName];
182
+ if (chatProviderName === glmProviderName)
183
+ delete nextProviders[deepSeekProviderName];
184
+ else
185
+ delete nextProviders[glmProviderName];
171
186
  if (codexIds.length)
172
187
  nextProviders[codexProviderName] = {
173
188
  baseUrl: openAiBaseUrl(ctx.baseUrl),
@@ -180,7 +195,7 @@ export async function configure(ctx) {
180
195
  else
181
196
  delete nextProviders[codexProviderName];
182
197
  if (deepSeekIds.length)
183
- nextProviders[deepSeekProviderName] = {
198
+ nextProviders[chatProviderName] = {
184
199
  baseUrl: openAiBaseUrl(ctx.baseUrl),
185
200
  apiKey: secretReference,
186
201
  api: 'openai-completions',
@@ -188,8 +203,10 @@ export async function configure(ctx) {
188
203
  request: { headers: { 'User-Agent': userAgentReference }, allowPrivateNetwork: false },
189
204
  models: entries(ctx, deepSeekIds, 'deepseek')
190
205
  };
191
- else
206
+ else {
192
207
  delete nextProviders[deepSeekProviderName];
208
+ delete nextProviders[glmProviderName];
209
+ }
193
210
  if (claudeIds.length)
194
211
  nextProviders[claudeProviderName] = {
195
212
  baseUrl: anthropicOrigin(ctx.baseUrl),
@@ -230,18 +247,20 @@ export async function configure(ctx) {
230
247
  }
231
248
  await removeLegacyArtifact(ctx);
232
249
  const scrubbed = await scrubGeneratedPlaintextCredentials(ctx);
233
- return { ...result, changed: result.changed || secret.changed || userAgentSecret.changed || scrubbed > 0, message: `configured native Responses (${codexIds.length}), OpenAI Chat Completions (${deepSeekIds.length}), and Anthropic Messages (${claudeIds.length}) providers with file-backed SecretRefs${scrubbed ? `; scrubbed ${scrubbed} generated credential ${scrubbed === 1 ? 'copy' : 'copies'}` : ''}` };
250
+ return { ...result, changed: result.changed || secret.changed || userAgentSecret.changed || scrubbed > 0, message: `configured native Responses (${codexIds.length}), ${chatLabel} (${deepSeekIds.length}), and Anthropic Messages (${claudeIds.length}) providers with file-backed SecretRefs${scrubbed ? `; scrubbed ${scrubbed} generated credential ${scrubbed === 1 ? 'copy' : 'copies'}` : ''}` };
234
251
  }
235
252
  export async function verify(ctx) {
236
253
  const file = clientConfigPath(id, ctx.home, ctx.platform);
237
254
  const config = await fs.readJson(file);
238
255
  const codex = config?.models?.providers?.[codexProviderName];
239
- const deepSeek = config?.models?.providers?.[deepSeekProviderName];
240
256
  const claude = config?.models?.providers?.[claudeProviderName];
241
257
  const secretProvider = config?.secrets?.providers?.[secretProviderId];
242
258
  const userAgentProvider = config?.secrets?.providers?.[userAgentProviderId];
243
259
  const codexIds = openAiResponsesModelIds(ctx);
244
260
  const deepSeekIds = deepSeekChatModelIds(ctx);
261
+ const chatProviderName = exactChatProviderName(deepSeekIds);
262
+ const chatLabel = exactChatLabel(deepSeekIds);
263
+ const deepSeek = config?.models?.providers?.[chatProviderName];
245
264
  const claudeIds = anthropicModelIds(ctx);
246
265
  const valid = (provider, baseUrl, api, ids) => {
247
266
  const userAgent = provider?.request?.headers?.['User-Agent'];
@@ -255,9 +274,14 @@ export async function verify(ctx) {
255
274
  const deepSeekModels = Array.isArray(deepSeek?.models) ? deepSeek.models : [];
256
275
  const deepSeekValid = valid(deepSeek, openAiBaseUrl(ctx.baseUrl), 'openai-completions', deepSeekIds)
257
276
  && deepSeekModels.every((model) => {
258
- const expected = modelSupportsTools(ctx, String(model?.id || ''));
259
- return model?.compat?.supportsTools === expected
260
- && (!expected || model?.compat?.requiresReasoningContentOnAssistantMessages === true);
277
+ const expected = authenticatedChatCapabilities(ctx, String(model?.id || ''));
278
+ return model?.reasoning === expected.reasoning
279
+ && model?.compat?.supportsTools === expected.tools
280
+ && model?.compat?.supportsToolChoice === expected.toolChoice
281
+ && model?.compat?.supportsReasoningEffort === expected.reasoningEffort
282
+ && model?.compat?.supportsUsageInStreaming === expected.streamingUsage
283
+ && model?.compat?.maxTokensField === 'max_tokens'
284
+ && model?.compat?.requiresReasoningContentOnAssistantMessages === expected.reasoningReplay;
261
285
  });
262
286
  if ((codexIds.length ? !valid(codex, openAiBaseUrl(ctx.baseUrl), 'openai-responses', codexIds) : Boolean(codex))
263
287
  || (deepSeekIds.length ? !deepSeekValid : Boolean(deepSeek))
@@ -270,7 +294,7 @@ export async function verify(ctx) {
270
294
  await verifyClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
271
295
  if (config?.models?.providers?.[legacyProviderName])
272
296
  throw new Error('OpenClaw still contains the obsolete mixed ScaleMax provider.');
273
- return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Responses, ${deepSeekIds.length} DeepSeek Chat Completions, and ${claudeIds.length} Claude models` };
297
+ return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Responses, ${deepSeekIds.length} ${chatLabel}, and ${claudeIds.length} Claude models` };
274
298
  }
275
299
  export async function remove(ctx) {
276
300
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -281,7 +305,7 @@ export async function remove(ctx) {
281
305
  }
282
306
  const config = await fs.readJson(file).catch(() => undefined);
283
307
  const providerRoot = asRecord(config?.models?.providers);
284
- const managedNames = [legacyProviderName, codexProviderName, deepSeekProviderName, claudeProviderName].filter(providerName => {
308
+ const managedNames = [legacyProviderName, codexProviderName, deepSeekProviderName, glmProviderName, claudeProviderName].filter(providerName => {
285
309
  const provider = asRecord(providerRoot[providerName]);
286
310
  return [anthropicOrigin(ctx.baseUrl), openAiBaseUrl(ctx.baseUrl)].includes(String(provider.baseUrl || ''));
287
311
  });
@@ -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, deepSeekChatModelIds, isDeepSeekChatModelId, modelSupportsTools, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredChatCompletionsModel, preferredOpenAiResponsesModel } from './models.js';
6
+ import { anthropicModelIds, deepSeekChatModelIds, isDeepSeekChatModelId, modelName, 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)';
@@ -12,37 +12,74 @@ const importSettingValue = '~/.scalemax/roo-code-import.json';
12
12
  const codexProfileName = 'ScaleMax Codex';
13
13
  const claudeProfileName = 'ScaleMax Claude';
14
14
  const deepSeekProfileName = 'ScaleMax DeepSeek';
15
+ const glmProfileName = 'ScaleMax GLM 5.2';
15
16
  const codexProfileId = 'scalemax-codex';
16
17
  const claudeProfileId = 'scalemax-claude';
17
18
  const deepSeekProfileId = 'scalemax-deepseek';
19
+ const glmProfileId = 'scalemax-glm-5-2';
20
+ function exactChatProfile(ctx, model) {
21
+ if (model.trim().toLowerCase() === 'glm-5.2')
22
+ return { name: glmProfileName, id: glmProfileId, model };
23
+ return { name: deepSeekProfileName, id: deepSeekProfileId, model };
24
+ }
18
25
  function importPath(home) { return path.join(home, '.scalemax', 'roo-code-import.json'); }
26
+ function positiveInteger(value, fallback) {
27
+ const parsed = Number(value);
28
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
29
+ }
30
+ function rooModelInfo(ctx, modelId) {
31
+ const model = (ctx.models || []).find(candidate => candidate.id.trim() === modelId.trim());
32
+ const contextWindow = positiveInteger(model?.context_window, 128_000);
33
+ const maxTokens = Math.min(contextWindow, positiveInteger(model?.max_output_tokens, 4_096));
34
+ const capabilities = model?.capabilities;
35
+ return {
36
+ maxTokens,
37
+ contextWindow,
38
+ // Missing authenticated capability metadata must fail closed. Roo would
39
+ // otherwise accept an image or cache workflow the active route may reject.
40
+ supportsImages: capabilities?.images === true || capabilities?.vision === true,
41
+ supportsPromptCache: capabilities?.prompt_cache === true,
42
+ // ScaleMax performs billing centrally; Roo should not estimate a second,
43
+ // potentially contradictory price from the client profile.
44
+ inputPrice: 0,
45
+ outputPrice: 0,
46
+ };
47
+ }
48
+ function toolCapableContext(ctx) {
49
+ return {
50
+ ...ctx,
51
+ models: (ctx.models || []).filter(model => modelSupportsTools(ctx, model.id)),
52
+ };
53
+ }
19
54
  function fallbackProfileFor(ctx) {
20
55
  const model = preferredChatCompletionsModel(ctx);
21
56
  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.');
57
+ if (!modelSupportsTools(ctx, model))
58
+ throw new Error(`Roo Code requires native task-completion tool calling, but the active ${modelName(ctx, model)} route is text-only. Use Continue Chat/Edit in VS Code or choose a tool-capable model. No configuration was changed.`);
24
59
  if (isDeepSeekChatModelId(model))
25
- return { name: deepSeekProfileName, id: deepSeekProfileId, model };
60
+ return exactChatProfile(ctx, model);
26
61
  return isAnthropicModel(metadata)
27
62
  ? { name: claudeProfileName, id: claudeProfileId, model }
28
63
  : { name: codexProfileName, id: codexProfileId, model };
29
64
  }
30
65
  function profilesFor(ctx) {
66
+ const compatible = toolCapableContext(ctx);
67
+ if (!compatible.models?.length) {
68
+ throw new Error('Roo Code requires native task-completion tool calling, but this API key has no tool-capable models. Use Continue Chat/Edit in VS Code or choose a tool-capable model. No configuration was changed.');
69
+ }
31
70
  const scope = ctx.providerScope || 'both';
32
71
  const profiles = [];
33
- const codexIds = openAiResponsesModelIds(ctx);
72
+ const codexIds = openAiResponsesModelIds(compatible);
34
73
  if (scope !== 'anthropic' && codexIds.length) {
35
- profiles.push({ name: codexProfileName, id: codexProfileId, model: preferredOpenAiResponsesModel(ctx) });
74
+ profiles.push({ name: codexProfileName, id: codexProfileId, model: preferredOpenAiResponsesModel(compatible) });
36
75
  }
37
- if (scope !== 'openai' && anthropicModelIds(ctx).length) {
38
- profiles.push({ name: claudeProfileName, id: claudeProfileId, model: preferredAnthropicModel(ctx) });
76
+ if (scope !== 'openai' && anthropicModelIds(compatible).length) {
77
+ profiles.push({ name: claudeProfileName, id: claudeProfileId, model: preferredAnthropicModel(compatible) });
39
78
  }
40
- for (const model of deepSeekChatModelIds(ctx)) {
41
- if (modelSupportsTools(ctx, model)) {
42
- profiles.push({ name: deepSeekProfileName, id: deepSeekProfileId, model });
43
- }
79
+ for (const model of deepSeekChatModelIds(compatible)) {
80
+ profiles.push(exactChatProfile(compatible, model));
44
81
  }
45
- return profiles.length ? profiles : [fallbackProfileFor(ctx)];
82
+ return profiles.length ? profiles : [fallbackProfileFor(compatible)];
46
83
  }
47
84
  function currentProfileFor(ctx, profiles = profilesFor(ctx)) {
48
85
  if (ctx.providerScope === 'anthropic')
@@ -51,6 +88,8 @@ function currentProfileFor(ctx, profiles = profilesFor(ctx)) {
51
88
  return profiles.find(profile => profile.id === codexProfileId) || profiles[0];
52
89
  if (ctx.providerScope === 'deepseek-v4-flash')
53
90
  return profiles.find(profile => profile.id === deepSeekProfileId) || profiles[0];
91
+ if (ctx.providerScope === 'glm-5.2')
92
+ return profiles.find(profile => profile.id === glmProfileId) || profiles[0];
54
93
  return profiles.find(profile => profile.id === codexProfileId) || profiles[0];
55
94
  }
56
95
  function parseSettings(text, file) {
@@ -69,15 +108,20 @@ function parseSettings(text, file) {
69
108
  function handoff(ctx) {
70
109
  const profiles = profilesFor(ctx);
71
110
  const current = currentProfileFor(ctx, profiles);
72
- const apiConfigs = Object.fromEntries(profiles.map(profile => [profile.name, {
73
- id: profile.id,
74
- apiProvider: 'openai',
75
- openAiBaseUrl: openAiBaseUrl(ctx.baseUrl),
76
- openAiApiKey: ctx.apiKey,
77
- openAiModelId: profile.model,
78
- openAiStreamingEnabled: true,
79
- includeMaxTokens: true
80
- }]));
111
+ const apiConfigs = Object.fromEntries(profiles.map(profile => {
112
+ const modelInfo = rooModelInfo(ctx, profile.model);
113
+ return [profile.name, {
114
+ id: profile.id,
115
+ apiProvider: 'openai',
116
+ openAiBaseUrl: openAiBaseUrl(ctx.baseUrl),
117
+ openAiApiKey: ctx.apiKey,
118
+ openAiModelId: profile.model,
119
+ openAiCustomModelInfo: modelInfo,
120
+ modelMaxTokens: modelInfo.maxTokens,
121
+ openAiStreamingEnabled: true,
122
+ includeMaxTokens: true
123
+ }];
124
+ }));
81
125
  return JSON.stringify({
82
126
  providerProfiles: {
83
127
  currentApiConfigName: current.name,
@@ -129,9 +173,11 @@ export async function verify(ctx) {
129
173
  }
130
174
  for (const expected of expectedProfiles) {
131
175
  const config = profile?.providerProfiles?.apiConfigs?.[expected.name];
176
+ const expectedModelInfo = rooModelInfo(ctx, expected.model);
132
177
  if (config?.id !== expected.id || config?.apiProvider !== 'openai' || config?.openAiBaseUrl !== openAiBaseUrl(ctx.baseUrl)
133
178
  || config?.openAiApiKey !== ctx.apiKey || config?.openAiModelId !== expected.model || config?.openAiStreamingEnabled !== true
134
- || config?.includeMaxTokens !== true) {
179
+ || config?.includeMaxTokens !== true || config?.modelMaxTokens !== expectedModelInfo.maxTokens
180
+ || JSON.stringify(config?.openAiCustomModelInfo) !== JSON.stringify(expectedModelInfo)) {
135
181
  throw new Error('Roo Code auto-import handoff is incomplete.');
136
182
  }
137
183
  }
@@ -159,7 +205,8 @@ export async function remove(ctx) {
159
205
  const managed = [
160
206
  configs?.[codexProfileName]?.id === codexProfileId,
161
207
  configs?.[claudeProfileName]?.id === claudeProfileId,
162
- configs?.[deepSeekProfileName]?.id === deepSeekProfileId
208
+ configs?.[deepSeekProfileName]?.id === deepSeekProfileId,
209
+ configs?.[glmProfileName]?.id === glmProfileId
163
210
  ].some(Boolean);
164
211
  if (managed) {
165
212
  await backupFile(profileFile, ctx.home);
@@ -1,11 +1,13 @@
1
1
  import { clientConfigPath } from './paths.js';
2
2
  import { writeManagedConfig, removeManagedConfig } from './common.js';
3
3
  import { jsonConfig } from './templates.js';
4
+ import { preferredChatCompletionsModel } from './models.js';
4
5
  const id = 'trae-solo';
5
6
  const name = 'TRAE SOLO';
6
7
  export async function configure(ctx) {
7
8
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", `Request URL: ${ctx.baseUrl}/chat/completions or base URL ${ctx.baseUrl}`, "Use only a model ID returned by the authenticated catalog"], "recommendedModel": "gpt-5.4-mini" });
9
+ const recommendedModel = preferredChatCompletionsModel(ctx);
10
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", `Request URL: ${ctx.baseUrl}/chat/completions or base URL ${ctx.baseUrl}`, "Use only a model ID returned by the authenticated catalog"], "recommendedModel": recommendedModel });
9
11
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
12
  return { ...result, message: 'setup guide written; enter these values in TRAE SOLO custom model settings' };
11
13
  }
@@ -1,5 +1,5 @@
1
1
  export type ClientId = 'codex' | 'cursor' | 'roo-code' | 'continue' | 'trae-solo' | 'claude' | 'opencode' | 'openclaw' | 'hermes' | 'cherry-studio' | 'api-code';
2
- export type ProviderScope = 'openai' | 'anthropic' | 'both' | 'deepseek-v4-flash';
2
+ export type ProviderScope = 'openai' | 'anthropic' | 'both' | 'deepseek-v4-flash' | 'glm-5.2';
3
3
  export interface ClientDefinition {
4
4
  id: ClientId;
5
5
  name: string;
@@ -45,6 +45,16 @@ export interface ConfigureContext extends DetectContext {
45
45
  max_output_tokens?: number | null;
46
46
  capabilities?: {
47
47
  tools?: boolean;
48
+ /** The model accepts an explicit OpenAI-compatible tool_choice control. */
49
+ tool_choice?: boolean;
50
+ /** The model exposes reasoning/thinking controls. */
51
+ reasoning?: boolean;
52
+ /** The model accepts an explicit reasoning_effort control. */
53
+ reasoning_effort?: boolean;
54
+ /** The model returns usage information in streaming responses. */
55
+ streaming_usage?: boolean;
56
+ /** Assistant reasoning_content must be replayed during a tool loop. */
57
+ reasoning_replay?: boolean;
48
58
  [key: string]: unknown;
49
59
  };
50
60
  }>;
@@ -1,15 +1,23 @@
1
1
  export declare const DEEPSEEK_V4_FLASH_SCOPE: "deepseek-v4-flash";
2
- export type ProviderScope = 'openai' | 'anthropic' | 'both' | typeof DEEPSEEK_V4_FLASH_SCOPE;
2
+ export declare const GLM_5_2_SCOPE: "glm-5.2";
3
+ export type ProviderScope = 'openai' | 'anthropic' | 'both' | typeof DEEPSEEK_V4_FLASH_SCOPE | typeof GLM_5_2_SCOPE;
3
4
  export interface ModelInfo {
4
5
  id: string;
5
6
  object?: string;
6
7
  owned_by?: string;
7
8
  provider?: string;
8
9
  display_name?: string;
10
+ availability?: 'available' | 'unavailable';
9
11
  context_window?: number | null;
10
12
  max_output_tokens?: number | null;
11
13
  capabilities?: {
12
14
  tools?: boolean;
15
+ tool_choice?: boolean;
16
+ route_available?: boolean;
17
+ reasoning?: boolean;
18
+ reasoning_effort?: boolean;
19
+ streaming_usage?: boolean;
20
+ reasoning_replay?: boolean;
13
21
  [key: string]: unknown;
14
22
  };
15
23
  }
@@ -47,6 +55,7 @@ export interface ChatCompletionResult {
47
55
  }
48
56
  export declare const RETIRED_MODEL_IDS: Set<string>;
49
57
  export declare function isRetiredModelId(id: string): boolean;
58
+ export declare function isModelRouteAvailable(model: ModelInfo): boolean;
50
59
  export declare function isAnthropicModel(model: ModelInfo): boolean;
51
60
  export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
52
61
  export declare function createChatCompletion(options: ChatCompletionOptions): Promise<ChatCompletionResult>;
package/dist/utils/api.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { SCALEMAX_BASE_URL, SCALEMAX_TOKEN_BASE_URL } from '../constants.js';
2
2
  import { redactApiKey, sanitizeApiKey } from './apiKey.js';
3
3
  export const DEEPSEEK_V4_FLASH_SCOPE = 'deepseek-v4-flash';
4
+ export const GLM_5_2_SCOPE = 'glm-5.2';
4
5
  // Keep only aliases that are no longer offered. Direct GPT-5.6 Sol is an
5
6
  // active Codex model and must remain available to all generated clients.
6
7
  export const RETIRED_MODEL_IDS = new Set(['fable-5-ultra']);
7
8
  export function isRetiredModelId(id) {
8
9
  return RETIRED_MODEL_IDS.has(id.trim());
9
10
  }
11
+ export function isModelRouteAvailable(model) {
12
+ return model.availability !== 'unavailable'
13
+ && model.capabilities?.route_available !== false;
14
+ }
10
15
  export function isAnthropicModel(model) {
11
16
  const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
12
17
  if (provider)
@@ -17,13 +22,23 @@ export function filterModelsForScope(models, scope) {
17
22
  return models.filter(model => {
18
23
  if (isRetiredModelId(model.id))
19
24
  return false;
20
- const isDeepSeekV4Flash = model.id.trim().toLowerCase() === DEEPSEEK_V4_FLASH_SCOPE;
25
+ // Availability is independent from feature support. Preserve truthful tool
26
+ // metadata while refusing to generate a client profile for a route the
27
+ // gateway has explicitly marked unhealthy.
28
+ if (!isModelRouteAvailable(model))
29
+ return false;
30
+ const modelId = model.id.trim().toLowerCase();
31
+ const exactScope = modelId === DEEPSEEK_V4_FLASH_SCOPE
32
+ ? DEEPSEEK_V4_FLASH_SCOPE
33
+ : modelId === GLM_5_2_SCOPE
34
+ ? GLM_5_2_SCOPE
35
+ : null;
21
36
  // This scope is an exact-model entitlement, not an OpenAI-compatible
22
37
  // provider family. Mirror the gateway's fail-closed authorization so an
23
38
  // unexpectedly broad catalog can never widen a customer's key locally.
24
- if (isDeepSeekV4Flash)
25
- return scope === DEEPSEEK_V4_FLASH_SCOPE;
26
- if (scope === DEEPSEEK_V4_FLASH_SCOPE)
39
+ if (exactScope)
40
+ return scope === exactScope;
41
+ if (scope === DEEPSEEK_V4_FLASH_SCOPE || scope === GLM_5_2_SCOPE)
27
42
  return false;
28
43
  return scope === 'both' || (scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
29
44
  });
@@ -33,6 +48,7 @@ function providerScope(value) {
33
48
  || value === 'anthropic'
34
49
  || value === 'both'
35
50
  || value === DEEPSEEK_V4_FLASH_SCOPE
51
+ || value === GLM_5_2_SCOPE
36
52
  ? value
37
53
  : null;
38
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {