scalemax 0.1.19 → 0.1.20

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.
@@ -42,7 +42,10 @@ async function maybeLaunchCodex(chosen, successful, baseUrl = SCALEMAX_BASE_URL)
42
42
  export function clientAllowedForModels(clientId, models) {
43
43
  if (!models.length)
44
44
  return false;
45
- const crossProtocolClients = new Set(['claude', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
45
+ // These clients use either a native family-specific adapter or the gateway's
46
+ // normalized OpenAI-compatible Chat Completions route, which supports both
47
+ // authenticated model families. Codex and TRAE remain OpenAI-only.
48
+ const crossProtocolClients = new Set(['claude', 'cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
46
49
  if (crossProtocolClients.has(clientId))
47
50
  return true;
48
51
  return models.some(model => !isAnthropicModel(model));
@@ -5,7 +5,7 @@ const id = 'api-code';
5
5
  const name = 'API Code';
6
6
  export async function configure(ctx) {
7
7
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "environment": { "SCALEMAX_API_KEY": "your ScaleMax API key", "SCALEMAX_BASE_URL": "https://api.scalemax.pro/v1" }, "node": "import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: 'https://api.scalemax.pro/v1' });", "python": "from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='https://api.scalemax.pro/v1')" });
8
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { environment: { SCALEMAX_API_KEY: 'your ScaleMax API key', SCALEMAX_BASE_URL: ctx.baseUrl }, node: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: '${ctx.baseUrl}' });`, python: `from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='${ctx.baseUrl}')` });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'API code examples written' };
11
11
  }
@@ -1,11 +1,12 @@
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 = 'cherry-studio';
5
6
  const name = 'Cherry Studio';
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 > Model Service / Model Providers", "Provider type: OpenAI or OpenAI Compatible", "API host/base URL: https://api.scalemax.pro/v1", "Add ScaleMax model IDs or fetch from /v1/models"], "recommendedModel": "fable-5" });
9
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Model Service / Model Providers", "Provider type: OpenAI or OpenAI Compatible", `API host/base URL: ${ctx.baseUrl}`, `Add only model IDs returned by ${ctx.baseUrl}/models`], "recommendedModel": preferredChatCompletionsModel(ctx) });
9
10
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
11
  return { ...result, message: 'setup guide written; enter these values in Cherry Studio model provider settings' };
11
12
  }
@@ -4,9 +4,18 @@ export declare function anthropicModelIds(ctx: ConfigureContext): string[];
4
4
  export declare function openAiModelIds(ctx: ConfigureContext): string[];
5
5
  export declare function preferredAnthropicModel(ctx: ConfigureContext): string;
6
6
  export declare function preferredOpenAiModel(ctx: ConfigureContext): string;
7
+ /**
8
+ * OpenAI-compatible chat clients can use the gateway's normalized chat route
9
+ * for either family. Keep an OpenAI model as the default when one is present
10
+ * so existing mixed-catalog configurations do not unexpectedly switch model
11
+ * families; a Claude-only catalog naturally selects its preferred Claude model.
12
+ */
13
+ export declare function preferredChatCompletionsModel(ctx: ConfigureContext): string;
7
14
  export declare function preferredModel(ctx: ConfigureContext): string;
8
15
  export declare function fastModel(ctx: ConfigureContext): string | undefined;
9
16
  export declare function fastOpenAiModel(ctx: ConfigureContext): string | undefined;
17
+ /** The lightweight model for an OpenAI-compatible chat client, if one exists. */
18
+ export declare function fastChatCompletionsModel(ctx: ConfigureContext): string | undefined;
10
19
  export declare function modelName(ctx: ConfigureContext, id: string): string;
11
20
  export declare function anthropicOrigin(baseUrl: string): string;
12
21
  export declare function openAiBaseUrl(baseUrl: string): string;
@@ -1,4 +1,4 @@
1
- import { isAnthropicModel } from '../utils/api.js';
1
+ import { isAnthropicModel, isRetiredModelId } from '../utils/api.js';
2
2
  const preferredAnthropic = [
3
3
  'claude-opus-4-8',
4
4
  'claude-sonnet-5',
@@ -9,23 +9,21 @@ const preferredAnthropic = [
9
9
  const preferredOpenAi = [
10
10
  'gpt-5.6-terra',
11
11
  'gpt-5.6-luna',
12
- 'gpt-5.6-sol',
13
12
  'gpt-5.5',
14
13
  'gpt-5.4',
15
14
  'gpt-5.4-mini',
16
15
  'gpt-5.3-codex',
17
16
  'fable-5',
18
- 'fable-5-ultra',
19
17
  'codex-auto-review'
20
18
  ];
21
19
  export function modelIds(ctx) {
22
- return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(Boolean))];
20
+ return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
23
21
  }
24
22
  export function anthropicModelIds(ctx) {
25
- return [...new Set((ctx.models || []).filter(isAnthropicModel).map(model => model.id.trim()).filter(Boolean))];
23
+ return [...new Set((ctx.models || []).filter(isAnthropicModel).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
26
24
  }
27
25
  export function openAiModelIds(ctx) {
28
- return [...new Set((ctx.models || []).filter(model => !isAnthropicModel(model)).map(model => model.id.trim()).filter(Boolean))];
26
+ return [...new Set((ctx.models || []).filter(model => !isAnthropicModel(model)).map(model => model.id.trim()).filter(id => id && !isRetiredModelId(id)))];
29
27
  }
30
28
  function preferredFrom(ids, candidates, family) {
31
29
  for (const candidate of candidates)
@@ -41,6 +39,15 @@ export function preferredAnthropicModel(ctx) {
41
39
  export function preferredOpenAiModel(ctx) {
42
40
  return preferredFrom(openAiModelIds(ctx), preferredOpenAi, 'Codex/OpenAI');
43
41
  }
42
+ /**
43
+ * OpenAI-compatible chat clients can use the gateway's normalized chat route
44
+ * for either family. Keep an OpenAI model as the default when one is present
45
+ * so existing mixed-catalog configurations do not unexpectedly switch model
46
+ * families; a Claude-only catalog naturally selects its preferred Claude model.
47
+ */
48
+ export function preferredChatCompletionsModel(ctx) {
49
+ return openAiModelIds(ctx).length ? preferredOpenAiModel(ctx) : preferredAnthropicModel(ctx);
50
+ }
44
51
  export function preferredModel(ctx) {
45
52
  const ids = modelIds(ctx);
46
53
  for (const candidate of [...preferredAnthropic, ...preferredOpenAi])
@@ -60,6 +67,10 @@ export function fastOpenAiModel(ctx) {
60
67
  return ids.find(id => id === 'gpt-5.4-mini')
61
68
  || ids.find(id => /(^|[-.])(mini|nano)([-.]|$)/i.test(id));
62
69
  }
70
+ /** The lightweight model for an OpenAI-compatible chat client, if one exists. */
71
+ export function fastChatCompletionsModel(ctx) {
72
+ return fastOpenAiModel(ctx) || fastModel(ctx);
73
+ }
63
74
  export function modelName(ctx, id) {
64
75
  const model = (ctx.models || []).find(candidate => candidate.id === id);
65
76
  if (model?.display_name)
@@ -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 { writeMergedJsonConfig } from './common.js';
6
- import { fastOpenAiModel, modelName, openAiBaseUrl, openAiModelIds, preferredOpenAiModel } from './models.js';
6
+ import { fastChatCompletionsModel, modelIds, modelName, openAiBaseUrl, preferredChatCompletionsModel } from './models.js';
7
7
  import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
8
8
  const id = 'opencode';
9
9
  const name = 'OpenCode';
@@ -13,7 +13,7 @@ function asRecord(value) {
13
13
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
14
  }
15
15
  function providerConfig(ctx) {
16
- const models = Object.fromEntries(openAiModelIds(ctx).map(modelId => {
16
+ const models = Object.fromEntries(modelIds(ctx).map(modelId => {
17
17
  const metadata = (ctx.models || []).find(model => model.id === modelId);
18
18
  return [modelId, {
19
19
  name: modelName(ctx, modelId),
@@ -32,8 +32,8 @@ function providerConfig(ctx) {
32
32
  }
33
33
  export async function configure(ctx) {
34
34
  const file = clientConfigPath(id, ctx.home, ctx.platform);
35
- const selected = `scalemax/${preferredOpenAiModel(ctx)}`;
36
- const quick = fastOpenAiModel(ctx);
35
+ const selected = `scalemax/${preferredChatCompletionsModel(ctx)}`;
36
+ const quick = fastChatCompletionsModel(ctx);
37
37
  const secret = await writeClientSecret(secretPath(ctx.home), ctx);
38
38
  let result;
39
39
  try {
@@ -55,7 +55,7 @@ export async function configure(ctx) {
55
55
  await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
56
56
  throw error;
57
57
  }
58
- return { ...result, changed: result.changed || secret.changed, message: `${result.message}; file-backed credential${quick ? `; ${quick} handles lightweight work` : ''}` };
58
+ return { ...result, changed: result.changed || secret.changed, message: `${result.message}; file-backed credential and ${modelIds(ctx).length} authenticated chat model(s)${quick ? `; ${quick} handles lightweight work` : ''}` };
59
59
  }
60
60
  export async function verify(ctx) {
61
61
  const file = clientConfigPath(id, ctx.home, ctx.platform);
@@ -66,11 +66,11 @@ export async function verify(ctx) {
66
66
  }
67
67
  await verifyClientSecret(secretPath(ctx.home), ctx);
68
68
  const configured = Object.keys(asRecord(provider.models));
69
- const expected = openAiModelIds(ctx);
69
+ const expected = modelIds(ctx);
70
70
  const missing = expected.filter(model => !configured.includes(model));
71
71
  const unexpected = configured.filter(model => !expected.includes(model));
72
72
  if (missing.length || unexpected.length)
73
- throw new Error(`OpenCode model catalog is not restricted to the ${expected.length} authenticated Codex/OpenAI model(s).`);
73
+ throw new Error(`OpenCode model catalog is not restricted to the ${expected.length} authenticated chat model(s).`);
74
74
  return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
75
75
  }
76
76
  export async function remove(ctx) {
@@ -3,14 +3,24 @@ 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 { openAiBaseUrl, openAiModelIds, preferredOpenAiModel } from './models.js';
6
+ import { openAiBaseUrl, preferredChatCompletionsModel } from './models.js';
7
+ import { isAnthropicModel } from '../utils/api.js';
7
8
  const id = 'roo-code';
8
9
  const name = 'Roo Code (VS Code)';
9
10
  const importSetting = 'roo-cline.autoImportSettingsPath';
10
11
  const importSettingValue = '~/.scalemax/roo-code-import.json';
11
- const profileName = 'ScaleMax Codex';
12
- const profileId = 'scalemax-codex';
12
+ const codexProfileName = 'ScaleMax Codex';
13
+ const claudeProfileName = 'ScaleMax Claude';
14
+ const codexProfileId = 'scalemax-codex';
15
+ const claudeProfileId = 'scalemax-claude';
13
16
  function importPath(home) { return path.join(home, '.scalemax', 'roo-code-import.json'); }
17
+ function profileFor(ctx) {
18
+ const model = preferredChatCompletionsModel(ctx);
19
+ const metadata = (ctx.models || []).find(candidate => candidate.id === model) || { id: model };
20
+ return isAnthropicModel(metadata)
21
+ ? { name: claudeProfileName, id: claudeProfileId, model }
22
+ : { name: codexProfileName, id: codexProfileId, model };
23
+ }
14
24
  function parseSettings(text, file) {
15
25
  if (!text)
16
26
  return {};
@@ -25,17 +35,17 @@ function parseSettings(text, file) {
25
35
  }
26
36
  }
27
37
  function handoff(ctx) {
28
- const selected = preferredOpenAiModel(ctx);
38
+ const profile = profileFor(ctx);
29
39
  return JSON.stringify({
30
40
  providerProfiles: {
31
- currentApiConfigName: profileName,
41
+ currentApiConfigName: profile.name,
32
42
  apiConfigs: {
33
- [profileName]: {
34
- id: profileId,
43
+ [profile.name]: {
44
+ id: profile.id,
35
45
  apiProvider: 'openai',
36
46
  openAiBaseUrl: openAiBaseUrl(ctx.baseUrl),
37
47
  openAiApiKey: ctx.apiKey,
38
- openAiModelId: selected,
48
+ openAiModelId: profile.model,
39
49
  openAiStreamingEnabled: true,
40
50
  includeMaxTokens: true
41
51
  }
@@ -46,8 +56,6 @@ function handoff(ctx) {
46
56
  }, null, 2) + '\n';
47
57
  }
48
58
  export async function configure(ctx) {
49
- if (!openAiModelIds(ctx).length)
50
- throw new Error('Roo Code OpenAI Compatible setup requires at least one Codex/OpenAI model.');
51
59
  const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
52
60
  const profileFile = importPath(ctx.home);
53
61
  const originalSettings = await fs.readFile(settingsFile, 'utf8').catch(() => '');
@@ -82,10 +90,11 @@ export async function verify(ctx) {
82
90
  const profileFile = importPath(ctx.home);
83
91
  const settings = parseSettings(await fs.readFile(settingsFile, 'utf8'), settingsFile);
84
92
  const profile = await fs.readJson(profileFile).catch(() => undefined);
85
- const config = profile?.providerProfiles?.apiConfigs?.[profileName];
86
- if (settings[importSetting] !== importSettingValue || profile?.providerProfiles?.currentApiConfigName !== profileName
87
- || config?.id !== profileId || config?.apiProvider !== 'openai' || config?.openAiBaseUrl !== openAiBaseUrl(ctx.baseUrl)
88
- || config?.openAiApiKey !== ctx.apiKey || config?.openAiModelId !== preferredOpenAiModel(ctx) || config?.openAiStreamingEnabled !== true
93
+ const expected = profileFor(ctx);
94
+ const config = profile?.providerProfiles?.apiConfigs?.[expected.name];
95
+ if (settings[importSetting] !== importSettingValue || profile?.providerProfiles?.currentApiConfigName !== expected.name
96
+ || config?.id !== expected.id || config?.apiProvider !== 'openai' || config?.openAiBaseUrl !== openAiBaseUrl(ctx.baseUrl)
97
+ || config?.openAiApiKey !== ctx.apiKey || config?.openAiModelId !== expected.model || config?.openAiStreamingEnabled !== true
89
98
  || config?.includeMaxTokens !== true) {
90
99
  throw new Error('Roo Code auto-import handoff is incomplete.');
91
100
  }
@@ -109,7 +118,12 @@ export async function remove(ctx) {
109
118
  }
110
119
  if (await fs.pathExists(profileFile)) {
111
120
  const profile = await fs.readJson(profileFile).catch(() => undefined);
112
- if (profile?.providerProfiles?.apiConfigs?.[profileName]?.id === profileId) {
121
+ const configs = profile?.providerProfiles?.apiConfigs;
122
+ const managed = [
123
+ configs?.[codexProfileName]?.id === codexProfileId,
124
+ configs?.[claudeProfileName]?.id === claudeProfileId
125
+ ].some(Boolean);
126
+ if (managed) {
113
127
  await backupFile(profileFile, ctx.home);
114
128
  await fs.remove(profileFile);
115
129
  changed = true;
@@ -5,7 +5,7 @@ const id = 'trae-solo';
5
5
  const name = 'TRAE SOLO';
6
6
  export async function configure(ctx) {
7
7
  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: https://api.scalemax.pro/v1/chat/completions or base URL https://api.scalemax.pro/v1", "Model ID: fable-5 or another ScaleMax model"], "recommendedModel": "fable-5" });
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": "fable-5" });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'setup guide written; enter these values in TRAE SOLO custom model settings' };
11
11
  }
@@ -22,6 +22,8 @@ export interface ApiValidation {
22
22
  error?: string;
23
23
  surfaceMismatch?: boolean;
24
24
  }
25
+ export declare const RETIRED_MODEL_IDS: Set<string>;
26
+ export declare function isRetiredModelId(id: string): boolean;
25
27
  export declare function isAnthropicModel(model: ModelInfo): boolean;
26
28
  export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
27
29
  export declare function validateApiKey(apiKey: string): Promise<ApiValidation>;
package/dist/utils/api.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { SCALEMAX_BASE_URL, SCALEMAX_TOKEN_BASE_URL } from '../constants.js';
2
2
  import { redactApiKey, sanitizeApiKey } from './apiKey.js';
3
+ // These aliases are intentionally unavailable because their upstream routes
4
+ // were retired after repeated 5xx responses. Keep a stale catalog response
5
+ // from selecting or advertising a model that cannot complete requests.
6
+ export const RETIRED_MODEL_IDS = new Set(['gpt-5.6-sol', 'fable-5-ultra']);
7
+ export function isRetiredModelId(id) {
8
+ return RETIRED_MODEL_IDS.has(id.trim());
9
+ }
3
10
  export function isAnthropicModel(model) {
4
11
  const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
5
12
  if (provider)
@@ -7,9 +14,8 @@ export function isAnthropicModel(model) {
7
14
  return model.id.startsWith('claude-') && !model.id.startsWith('claude-fable-');
8
15
  }
9
16
  export function filterModelsForScope(models, scope) {
10
- if (scope === 'both')
11
- return models;
12
- return models.filter(model => scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
17
+ return models.filter(model => !isRetiredModelId(model.id)
18
+ && (scope === 'both' || (scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model))));
13
19
  }
14
20
  async function friendlyError(res) {
15
21
  let body = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {