scalemax 0.1.34 → 0.1.36

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
@@ -205,7 +205,7 @@ npx scalemax models
205
205
  ```
206
206
 
207
207
  The model list is fetched live from `https://api.scalemax.pro/v1/models` every time, so disabled or unsupported models disappear immediately without manual cache cleanup.
208
- Key metadata and the authenticated model catalog are fetched concurrently on the normal path. Safe GET checks receive one short, jittered retry on transient network, rate-limit, or server failures; model-generation POST requests are never retried by the installer.
208
+ The CLI authenticates the key before loading its model catalog. Safe GET checks receive one short, jittered retry on transient network, rate-limit, or server failures; model-generation POST requests are never retried by the installer. CLI requests honor `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` and automatically try the usable IPv4 or IPv6 route.
209
209
 
210
210
  Run diagnostics:
211
211
 
@@ -289,6 +289,20 @@ Or clear the npm cache:
289
289
  npm cache verify
290
290
  ```
291
291
 
292
+ ### `Could not reach the ScaleMax API`
293
+
294
+ Use the latest CLI first:
295
+
296
+ ```bash
297
+ npx scalemax@latest
298
+ ```
299
+
300
+ ScaleMax automatically honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
301
+ when a workplace or ISP requires an outbound proxy. It also falls back between
302
+ IPv4 and IPv6. If the error specifically reports DNS or TLS, correct the
303
+ device DNS/system clock or disable HTTPS inspection for `api.scalemax.pro`,
304
+ then rerun the command. Never disable TLS verification.
305
+
292
306
  ### Client config looks wrong
293
307
 
294
308
  Run:
@@ -16,6 +16,23 @@ const preferredOpenAi = [
16
16
  'gpt-5.4-mini',
17
17
  'gpt-5.3-codex',
18
18
  'codex-auto-review',
19
+ // Gemini / Grok / Qwen ride the same OpenAI-compatible route, so they are
20
+ // ranked in the same list. Ordering across families is harmless: a key is
21
+ // scoped to ONE family, so filterModelsForScope has already reduced the
22
+ // candidates to that family before this runs. Listing them means each family
23
+ // picks its own flagship rather than whatever the catalog happened to return
24
+ // first — preferredFrom's fallback made it work, but arbitrarily.
25
+ 'gemini-3-pro-preview',
26
+ 'gemini-3.1-pro-preview',
27
+ 'gemini-3.6-flash',
28
+ 'gemini-3.5-flash',
29
+ 'gemini-3-flash-preview',
30
+ 'gemini-3.1-flash-lite-preview',
31
+ 'gemini-2.5-flash',
32
+ 'gemini-2.5-flash-lite',
33
+ 'grok-4.5',
34
+ 'qwen3.7-plus',
35
+ 'qwen3.6-plus',
19
36
  'glm-5.2',
20
37
  'deepseek-v4-flash'
21
38
  ];
@@ -1,5 +1,5 @@
1
1
  export type ClientId = 'codex' | 'cursor' | 'roo-code' | 'cline' | 'continue' | 'trae-solo' | 'claude' | 'opencode' | 'openclaw' | 'hermes' | 'cherry-studio' | 'api-code';
2
- export type ProviderScope = 'openai' | 'anthropic' | 'both' | 'deepseek-v4-flash' | 'glm-5.2';
2
+ export type ProviderScope = 'openai' | 'anthropic' | 'both' | 'gemini' | 'grok' | 'qwen' | 'deepseek-v4-flash' | 'glm-5.2';
3
3
  export interface ClientDefinition {
4
4
  id: ClientId;
5
5
  name: string;
@@ -1,6 +1,6 @@
1
1
  export declare const DEEPSEEK_V4_FLASH_SCOPE: "deepseek-v4-flash";
2
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
+ export type ProviderScope = 'openai' | 'anthropic' | 'both' | 'gemini' | 'grok' | 'qwen' | typeof DEEPSEEK_V4_FLASH_SCOPE | typeof GLM_5_2_SCOPE;
4
4
  export interface ModelInfo {
5
5
  id: string;
6
6
  object?: string;
package/dist/utils/api.js CHANGED
@@ -1,7 +1,20 @@
1
1
  import { SCALEMAX_BASE_URL, SCALEMAX_TOKEN_BASE_URL } from '../constants.js';
2
2
  import { redactApiKey, sanitizeApiKey } from './apiKey.js';
3
+ import { scaleMaxFetch } from './network.js';
3
4
  export const DEEPSEEK_V4_FLASH_SCOPE = 'deepseek-v4-flash';
4
5
  export const GLM_5_2_SCOPE = 'glm-5.2';
6
+ /**
7
+ * Provider FAMILIES sold as their own scope. Each reaches only its own models;
8
+ * `both` deliberately does not inherit them, matching the gateway.
9
+ */
10
+ const FAMILY_SCOPES = ['gemini', 'grok', 'qwen'];
11
+ function isFamilyScope(scope) {
12
+ return FAMILY_SCOPES.includes(scope);
13
+ }
14
+ /** The family a model belongs to, from the catalog's own owned_by/provider. */
15
+ function modelFamily(model) {
16
+ return String(model.provider || model.owned_by || '').trim().toLowerCase();
17
+ }
5
18
  // Keep only aliases that are no longer offered. Direct GPT-5.6 Sol is an
6
19
  // active Codex model and must remain available to all generated clients.
7
20
  export const RETIRED_MODEL_IDS = new Set(['fable-5-ultra']);
@@ -40,6 +53,18 @@ export function filterModelsForScope(models, scope) {
40
53
  return scope === exactScope;
41
54
  if (scope === DEEPSEEK_V4_FLASH_SCOPE || scope === GLM_5_2_SCOPE)
42
55
  return false;
56
+ // A family scope reaches ONLY its own family. This must be an allowlist:
57
+ // the old final line fell through to `!isAnthropicModel(model)` for any
58
+ // unrecognised scope, so a gemini key was offered every non-Anthropic model
59
+ // — GPT, Codex, Grok, Qwen — and `scalemax setup` wrote a client profile
60
+ // whose every model 403s at the gateway.
61
+ if (isFamilyScope(scope))
62
+ return modelFamily(model) === scope;
63
+ // Conversely, a family model must not leak into the broad scopes: an
64
+ // openai/both key cannot call them (the gateway prices those families
65
+ // separately), so offering them here would be the same lie in reverse.
66
+ if (FAMILY_SCOPES.includes(modelFamily(model)))
67
+ return false;
43
68
  return scope === 'both' || (scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
44
69
  });
45
70
  }
@@ -77,9 +102,21 @@ function retryableStatus(status) {
77
102
  }
78
103
  function safeTransportError(error, timeoutSeconds) {
79
104
  const message = error instanceof Error ? error.message : String(error);
105
+ const cause = error && typeof error === 'object' && 'cause' in error
106
+ ? error.cause
107
+ : undefined;
108
+ const code = cause && typeof cause === 'object' && 'code' in cause
109
+ ? String(cause.code || '').toUpperCase()
110
+ : '';
80
111
  if (/abort|timeout|timed?\s*out/i.test(message)) {
81
112
  return `ScaleMax request timed out after ${timeoutSeconds} seconds. Please try again.`;
82
113
  }
114
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
115
+ return 'Could not resolve the ScaleMax API hostname. Check this device’s DNS settings, then retry.';
116
+ }
117
+ if (/CERT|TLS|SSL|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code)) {
118
+ return 'The ScaleMax TLS certificate could not be verified. Check the system clock and any HTTPS-inspecting proxy or antivirus, then retry.';
119
+ }
83
120
  // Native fetch intentionally hides DNS/TLS/proxy details behind the generic
84
121
  // "fetch failed" message. It is not an authentication verdict and may
85
122
  // contain environment-specific detail that is not useful in an installer.
@@ -90,6 +127,8 @@ async function request(path, apiKey, init = {}, retryGet = false, baseUrl = SCAL
90
127
  if (apiKey)
91
128
  headers.set('Authorization', `Bearer ${sanitizeApiKey(apiKey)}`);
92
129
  headers.set('Cache-Control', 'no-cache');
130
+ headers.set('Accept', 'application/json');
131
+ headers.set('User-Agent', 'ScaleMax-CLI');
93
132
  if (!headers.has('Content-Type') && init.body)
94
133
  headers.set('Content-Type', 'application/json');
95
134
  const attempts = retryGet && (!init.method || init.method === 'GET') ? 2 : 1;
@@ -98,7 +137,7 @@ async function request(path, apiKey, init = {}, retryGet = false, baseUrl = SCAL
98
137
  const timeout = AbortSignal.timeout(timeoutMs);
99
138
  const signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, timeout]) : timeout;
100
139
  try {
101
- const response = await fetch(`${baseUrl}${path}`, { ...init, headers, signal });
140
+ const response = await scaleMaxFetch(`${baseUrl}${path}`, { ...init, headers, signal }, attempt > 0);
102
141
  if (attempt + 1 === attempts || !retryableStatus(response.status))
103
142
  return response;
104
143
  await response.body?.cancel().catch(() => undefined);
package/dist/utils/fs.js CHANGED
@@ -7,7 +7,15 @@ export async function exists(p) { return fs.pathExists(p); }
7
7
  export async function syncParentDirectory(directory, platform = process.platform) {
8
8
  // Windows does not support fsync on directory handles and commonly returns EPERM.
9
9
  // The file itself is still fsynced before the atomic rename on every platform.
10
- if (platform === 'win32')
10
+ //
11
+ // BOTH the injected platform and the REAL host are checked. The parameter
12
+ // exists so a test can simulate another OS's config layout, but fsync on a
13
+ // directory handle is a property of the machine actually running, not of the
14
+ // OS being simulated — so simulating linux on a Windows host made 49 tests
15
+ // fail with EPERM on a syscall that host can never support. Guarding on the
16
+ // host too costs Linux nothing: there process.platform is not 'win32', so the
17
+ // durability fsync still runs exactly as before.
18
+ if (platform === 'win32' || process.platform === 'win32')
11
19
  return;
12
20
  const dir = await fsp.open(directory, 'r');
13
21
  try {
@@ -0,0 +1,5 @@
1
+ import { type Dispatcher } from 'undici';
2
+ export type ScaleMaxFetchInit = RequestInit & {
3
+ dispatcher: Dispatcher;
4
+ };
5
+ export declare function scaleMaxFetch(input: string | URL, init?: RequestInit, allowProxyFallback?: boolean): Promise<Response>;
@@ -0,0 +1,91 @@
1
+ import { Agent, ProxyAgent } from 'undici';
2
+ /*
3
+ * Node's built-in fetch does not consistently honor HTTP(S)_PROXY/NO_PROXY
4
+ * across the supported Node 18-22 range. It can also wait on an unusable
5
+ * IPv6 route instead of trying the working IPv4 address. The CLI is a
6
+ * network installer, so use an explicit dispatcher that handles both cases.
7
+ *
8
+ * RFC 8305-style family selection races the available A/AAAA routes instead
9
+ * of assuming the first DNS result is usable. ProxyAgent is used explicitly
10
+ * rather than Undici's experimental EnvHttpProxyAgent so customers do not see
11
+ * runtime stability warnings.
12
+ */
13
+ const directDispatcher = new Agent({
14
+ autoSelectFamily: true,
15
+ autoSelectFamilyAttemptTimeout: 250
16
+ });
17
+ const proxyDispatchers = new Map();
18
+ const proxyPreferredOrigins = new Set();
19
+ function environmentValue(lowercase, uppercase) {
20
+ return String(process.env[lowercase] || process.env[uppercase] || '').trim();
21
+ }
22
+ function defaultPort(url) {
23
+ return url.port || (url.protocol === 'https:' ? '443' : '80');
24
+ }
25
+ function splitNoProxyEntry(entry) {
26
+ const value = entry.trim().toLowerCase();
27
+ if (value.startsWith('[')) {
28
+ const end = value.indexOf(']');
29
+ if (end >= 0) {
30
+ const port = value.slice(end + 1).replace(/^:/, '') || undefined;
31
+ return { hostname: value.slice(1, end), port };
32
+ }
33
+ }
34
+ const colon = value.lastIndexOf(':');
35
+ if (colon > -1 && value.indexOf(':') === colon && /^\d+$/.test(value.slice(colon + 1))) {
36
+ return { hostname: value.slice(0, colon), port: value.slice(colon + 1) };
37
+ }
38
+ return { hostname: value };
39
+ }
40
+ function bypassesProxy(url) {
41
+ const raw = environmentValue('no_proxy', 'NO_PROXY');
42
+ if (!raw)
43
+ return false;
44
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
45
+ const port = defaultPort(url);
46
+ return raw.split(/[,\s]+/).filter(Boolean).some(candidate => {
47
+ if (candidate === '*')
48
+ return true;
49
+ const parsed = splitNoProxyEntry(candidate);
50
+ if (parsed.port && parsed.port !== port)
51
+ return false;
52
+ const rule = parsed.hostname.replace(/^\*\./, '').replace(/^\./, '');
53
+ return hostname === rule || hostname.endsWith(`.${rule}`);
54
+ });
55
+ }
56
+ function proxyFor(url) {
57
+ if (bypassesProxy(url))
58
+ return undefined;
59
+ if (url.protocol === 'https:') {
60
+ return environmentValue('https_proxy', 'HTTPS_PROXY')
61
+ || environmentValue('http_proxy', 'HTTP_PROXY')
62
+ || undefined;
63
+ }
64
+ return environmentValue('http_proxy', 'HTTP_PROXY') || undefined;
65
+ }
66
+ function dispatcherFor(url) {
67
+ const proxy = proxyFor(url);
68
+ if (!proxy)
69
+ return directDispatcher;
70
+ let dispatcher = proxyDispatchers.get(proxy);
71
+ if (!dispatcher) {
72
+ dispatcher = new ProxyAgent({
73
+ uri: proxy,
74
+ autoSelectFamily: true,
75
+ autoSelectFamilyAttemptTimeout: 250
76
+ });
77
+ proxyDispatchers.set(proxy, dispatcher);
78
+ }
79
+ return dispatcher;
80
+ }
81
+ export async function scaleMaxFetch(input, init = {}, allowProxyFallback = false) {
82
+ const url = input instanceof URL ? input : new URL(input);
83
+ const proxy = proxyFor(url);
84
+ const useProxy = Boolean(proxy)
85
+ && (allowProxyFallback || proxyPreferredOrigins.has(url.origin));
86
+ const dispatcher = useProxy ? dispatcherFor(url) : directDispatcher;
87
+ const response = await fetch(input, { ...init, dispatcher });
88
+ if (useProxy)
89
+ proxyPreferredOrigins.add(url.origin);
90
+ return response;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "fs-extra": "^11.2.0",
34
34
  "json5": "^2.2.3",
35
35
  "ora": "^8.1.1",
36
+ "undici": "^6.28.0",
36
37
  "yaml": "^2.9.0"
37
38
  },
38
39
  "devDependencies": {