scalemax 0.1.47 → 0.1.48

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.
@@ -714,7 +714,7 @@ export function selectHelperModel(models, requested) {
714
714
  throw new Error(`Model "${requested}" is not available to this API key. Run \`scalemax models\` to see available models.`);
715
715
  return exact.id;
716
716
  }
717
- const preferred = ['gpt-5.4', 'gpt-5.4-mini', 'claude-haiku-4-5-20251001', 'fable-5', 'gpt-5.6-terra', 'gpt-5.6-sol'];
717
+ const preferred = ['gpt-5.6-luna', 'claude-haiku-4-5-20251001', 'fable-5', 'gpt-5.6-terra', 'gpt-5.6-sol'];
718
718
  for (const id of preferred) {
719
719
  const match = models.find(model => model.id.toLowerCase() === id);
720
720
  if (match)
@@ -6,7 +6,23 @@ export interface CodexVerificationResult {
6
6
  configPath: string;
7
7
  authPath: string;
8
8
  }
9
- export declare function mergeCodexConfig(existing: string, baseUrl: string, model?: string, authHash?: string): string;
9
+ export declare function mergeCodexConfig(existing: string, baseUrl: string, model?: string, authHash?: string, keyHash?: string): string;
10
+ /**
11
+ * Take ScaleMax OUT of a config.toml, keeping everything else the user has --
12
+ * including what Codex or the user added after setup (trusted projects,
13
+ * notices, other providers).
14
+ *
15
+ * Ownership is read from the CONTENT, not only from the generated header: a
16
+ * Codex rewrite that drops comments still leaves `model_provider = "scalemax"`
17
+ * and the `[model_providers.scalemax]` table, and those must go. The three
18
+ * top-level keys are removed only while ScaleMax is still the provider; if the
19
+ * user switched provider after setup they are the user's.
20
+ */
21
+ export declare function stripScaleMaxCodexConfig(existing: string): {
22
+ text: string;
23
+ found: boolean;
24
+ strippedTopLevel: boolean;
25
+ };
10
26
  export declare function verifyCodexConfiguration(ctx: ConfigureContext): Promise<CodexVerificationResult>;
11
27
  export declare function install(ctx: InstallContext): Promise<ConfigureResult>;
12
28
  export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -10,17 +10,29 @@ import { AUTO_MODEL_ID, preferredOpenAiResponsesModel } from './models.js';
10
10
  const id = 'codex';
11
11
  const name = 'Codex';
12
12
  const provider = 'scalemax';
13
- const fallbackModel = 'gpt-5.5';
13
+ const fallbackModel = 'gpt-5.6-luna'; // gpt-5.5 retired 2026-09-30; same price
14
14
  const reasoningEffort = 'medium';
15
15
  const managedAuthHashPrefix = '# ScaleMax managed auth.json SHA-256: ';
16
+ // Fingerprint of the API key setup wrote into auth.json. Unlike the auth.json
17
+ // hash above it survives Codex rewriting auth.json (a token refresh, its own
18
+ // serialisation), so remove() can still tell OUR key from one the user put
19
+ // there later.
20
+ const managedKeyHashPrefix = '# ScaleMax managed API key SHA-256: ';
21
+ const modelsComment = '# ScaleMax models are loaded from the authenticated live model catalog.';
22
+ // The top-level keys setup owns while ScaleMax is the provider. remove() puts
23
+ // the user's pre-ScaleMax values for exactly these back from the backup.
24
+ const managedTopLevelKey = /^(model|model_provider|model_reasoning_effort)\s*=/;
16
25
  function contentSha256(content) {
17
26
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
18
27
  }
19
- function managedAuthHash(config) {
20
- const marker = config.split(/\r?\n/).find(line => line.startsWith(managedAuthHashPrefix));
21
- const hash = marker?.slice(managedAuthHashPrefix.length).trim();
28
+ function markerHash(config, prefix) {
29
+ const marker = config.split(/\r?\n/).find(line => line.startsWith(prefix));
30
+ const hash = marker?.slice(prefix.length).trim();
22
31
  return hash && /^[a-f0-9]{64}$/.test(hash) ? hash : undefined;
23
32
  }
33
+ function managedAuthHash(config) {
34
+ return markerHash(config, managedAuthHashPrefix);
35
+ }
24
36
  function selectedModel(ctx) {
25
37
  if (!ctx.models) {
26
38
  // An auto-api key's ONLY model is `auto`; gpt-5.5 (the generic fallback
@@ -30,11 +42,22 @@ function selectedModel(ctx) {
30
42
  }
31
43
  return preferredOpenAiResponsesModel(ctx);
32
44
  }
33
- function scalemaxCodexBlock(baseUrl, model = fallbackModel, authHash) {
45
+ function scalemaxCodexBlock(baseUrl, model = fallbackModel, authHash, keyHash) {
34
46
  const authMarker = authHash ? `${managedAuthHashPrefix}${authHash}\n` : '';
35
- return `# ${GENERATED_HEADER}\n${authMarker}model = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n# ScaleMax models are loaded from the authenticated live model catalog.\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
47
+ const keyMarker = keyHash ? `${managedKeyHashPrefix}${keyHash}\n` : '';
48
+ return `# ${GENERATED_HEADER}\n${authMarker}${keyMarker}model = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n${modelsComment}\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
49
+ }
50
+ /** A line setup writes as a comment. Never user content, and never preserved. */
51
+ function isManagedCommentLine(line) {
52
+ return line === `# ${GENERATED_HEADER}`
53
+ || line === modelsComment
54
+ || line.startsWith(managedAuthHashPrefix)
55
+ || line.startsWith(managedKeyHashPrefix);
36
56
  }
37
- export function mergeCodexConfig(existing, baseUrl, model = fallbackModel, authHash) {
57
+ function isScaleMaxProviderSection(section) {
58
+ return section === `model_providers.${provider}` || section.startsWith(`model_providers.${provider}.`);
59
+ }
60
+ export function mergeCodexConfig(existing, baseUrl, model = fallbackModel, authHash, keyHash) {
38
61
  const preserved = [];
39
62
  let section = '';
40
63
  let skipScaleMaxProvider = false;
@@ -43,20 +66,94 @@ export function mergeCodexConfig(existing, baseUrl, model = fallbackModel, authH
43
66
  const sectionMatch = line.match(/^\[([^\]]+)\]$/);
44
67
  if (sectionMatch) {
45
68
  section = sectionMatch[1];
46
- skipScaleMaxProvider = section === `model_providers.${provider}`;
69
+ skipScaleMaxProvider = isScaleMaxProviderSection(section);
47
70
  if (skipScaleMaxProvider)
48
71
  continue;
49
72
  }
50
73
  if (skipScaleMaxProvider)
51
74
  continue;
52
- if (!section && /^(model|model_provider|model_reasoning_effort)\s*=/.test(line))
75
+ if (!section && managedTopLevelKey.test(line))
53
76
  continue;
54
- if (line === `# ${GENERATED_HEADER}` || line.startsWith(managedAuthHashPrefix))
77
+ // Includes the models comment: it used to be kept here, so every re-run of
78
+ // setup stacked one more copy of it into the preserved tail.
79
+ if (isManagedCommentLine(line))
55
80
  continue;
56
81
  preserved.push(raw);
57
82
  }
58
83
  const tail = preserved.join('\n').trim();
59
- return `${scalemaxCodexBlock(baseUrl, model, authHash)}${tail ? `\n${tail}\n` : ''}`;
84
+ return `${scalemaxCodexBlock(baseUrl, model, authHash, keyHash)}${tail ? `\n${tail}\n` : ''}`;
85
+ }
86
+ /**
87
+ * Take ScaleMax OUT of a config.toml, keeping everything else the user has --
88
+ * including what Codex or the user added after setup (trusted projects,
89
+ * notices, other providers).
90
+ *
91
+ * Ownership is read from the CONTENT, not only from the generated header: a
92
+ * Codex rewrite that drops comments still leaves `model_provider = "scalemax"`
93
+ * and the `[model_providers.scalemax]` table, and those must go. The three
94
+ * top-level keys are removed only while ScaleMax is still the provider; if the
95
+ * user switched provider after setup they are the user's.
96
+ */
97
+ export function stripScaleMaxCodexConfig(existing) {
98
+ const lines = existing.split(/\r?\n/);
99
+ let section = '';
100
+ let topProvider;
101
+ let found = false;
102
+ for (const raw of lines) {
103
+ const line = raw.trim();
104
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
105
+ if (sectionMatch) {
106
+ section = sectionMatch[1];
107
+ if (isScaleMaxProviderSection(section))
108
+ found = true;
109
+ continue;
110
+ }
111
+ if (isManagedCommentLine(line))
112
+ found = true;
113
+ if (!section) {
114
+ const providerMatch = line.match(/^model_provider\s*=\s*"([^"]*)"/);
115
+ if (providerMatch)
116
+ topProvider = providerMatch[1];
117
+ }
118
+ }
119
+ const strippedTopLevel = topProvider === provider;
120
+ if (strippedTopLevel)
121
+ found = true;
122
+ if (!found)
123
+ return { text: existing, found: false, strippedTopLevel: false };
124
+ const kept = [];
125
+ section = '';
126
+ let skip = false;
127
+ for (const raw of lines) {
128
+ const line = raw.trim();
129
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
130
+ if (sectionMatch) {
131
+ section = sectionMatch[1];
132
+ skip = isScaleMaxProviderSection(section);
133
+ if (skip)
134
+ continue;
135
+ }
136
+ if (skip)
137
+ continue;
138
+ if (!section && strippedTopLevel && managedTopLevelKey.test(line))
139
+ continue;
140
+ if (isManagedCommentLine(line))
141
+ continue;
142
+ kept.push(raw);
143
+ }
144
+ return { text: kept.join('\n').trim(), found: true, strippedTopLevel };
145
+ }
146
+ /** The pre-ScaleMax top-level model / model_provider / model_reasoning_effort lines of a backup. */
147
+ function originalTopLevelLines(backup) {
148
+ const out = [];
149
+ for (const raw of backup.split(/\r?\n/)) {
150
+ const line = raw.trim();
151
+ if (/^\[[^\]]+\]$/.test(line))
152
+ break;
153
+ if (managedTopLevelKey.test(line))
154
+ out.push(line);
155
+ }
156
+ return out;
60
157
  }
61
158
  function codexAuth(apiKey, existing = {}) {
62
159
  return JSON.stringify({ ...existing, auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2) + '\n';
@@ -111,22 +208,99 @@ async function latestRestorableBackup(file, home) {
111
208
  }
112
209
  return undefined;
113
210
  }
114
- async function restoreOrRemove(file, ctx, managedOverride) {
211
+ async function removeCodexConfig(file, ctx) {
115
212
  if (!(await fs.pathExists(file)))
116
213
  return { changed: false, message: 'no ScaleMax configuration found' };
117
214
  const current = await fs.readFile(file, 'utf8').catch(() => '');
118
- const structurallyManaged = current.includes(GENERATED_HEADER)
119
- || (file.endsWith('auth.json') && isManagedCodexAuth(current));
120
- const managed = managedOverride ?? structurallyManaged;
121
- if (!managed)
215
+ const stripped = stripScaleMaxCodexConfig(current);
216
+ if (!stripped.found)
217
+ return { changed: false, message: 'left unmanaged configuration unchanged' };
218
+ // Put back the user's own model settings from before setup (the first
219
+ // pre-ScaleMax backup) -- only when ScaleMax's were actually taken out.
220
+ let restoredTop = [];
221
+ if (stripped.strippedTopLevel) {
222
+ const backup = await latestRestorableBackup(file, ctx.home);
223
+ if (backup)
224
+ restoredTop = originalTopLevelLines(await fs.readFile(backup, 'utf8').catch(() => ''));
225
+ }
226
+ const next = [restoredTop.join('\n'), stripped.text].filter(Boolean).join('\n\n');
227
+ if (!next.trim()) {
228
+ await fs.remove(file);
229
+ return { changed: true, message: 'removed ScaleMax configuration' };
230
+ }
231
+ await atomicWrite(file, `${next}\n`, 0o600, ctx.platform);
232
+ return {
233
+ changed: true,
234
+ message: restoredTop.length
235
+ ? 'removed ScaleMax settings and restored your previous model settings'
236
+ : 'removed ScaleMax settings; your other settings were kept',
237
+ };
238
+ }
239
+ /**
240
+ * Take the ScaleMax credential OUT of auth.json.
241
+ *
242
+ * Owned when ANY of: the file is byte-identical to what setup wrote (auth
243
+ * hash marker); its key is the key the CLI has saved; its key matches the
244
+ * key fingerprint setup wrote into config.toml. The last two survive Codex
245
+ * rewriting the file -- a ChatGPT token refresh or its own serialisation --
246
+ * which the exact hash alone did not, and which left the ScaleMax key in
247
+ * place after "remove". A DIFFERENT key the user put there after setup is
248
+ * none of these, and is left alone.
249
+ *
250
+ * Only the two fields setup sets are touched: auth_mode and OPENAI_API_KEY go
251
+ * back to their pre-setup values from the backup, or away if there were none.
252
+ * Tokens and refresh times stay as Codex last wrote them (they are fresher
253
+ * than the backup). A file left with nothing in it is deleted.
254
+ */
255
+ async function removeCodexAuth(file, ctx, ownership) {
256
+ if (!(await fs.pathExists(file)))
257
+ return { changed: false, message: 'no ScaleMax configuration found' };
258
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
259
+ let current;
260
+ try {
261
+ const parsed = JSON.parse(text);
262
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
263
+ throw new Error('not an object');
264
+ current = parsed;
265
+ }
266
+ catch {
122
267
  return { changed: false, message: 'left unmanaged configuration unchanged' };
123
- const restore = await latestRestorableBackup(file, ctx.home);
124
- if (restore) {
125
- await atomicWrite(file, await fs.readFile(restore, 'utf8'), 0o600, ctx.platform);
126
- return { changed: true, message: `restored backup ${restore}` };
127
268
  }
128
- await fs.remove(file);
129
- return { changed: true, message: 'removed ScaleMax configuration' };
269
+ const key = typeof current.OPENAI_API_KEY === 'string' ? current.OPENAI_API_KEY : undefined;
270
+ const owned = (ownership.authHash !== undefined && contentSha256(text) === ownership.authHash)
271
+ || (key !== undefined && typeof ctx.apiKey === 'string' && ctx.apiKey.length > 0 && key === ctx.apiKey)
272
+ || (key !== undefined && ownership.keyHash !== undefined && contentSha256(key) === ownership.keyHash)
273
+ || (ownership.authHash === undefined && ownership.keyHash === undefined && ownership.legacyStructural && isManagedCodexAuth(text));
274
+ if (!owned)
275
+ return { changed: false, message: 'left unmanaged configuration unchanged' };
276
+ let original;
277
+ const backup = await latestRestorableBackup(file, ctx.home);
278
+ if (backup) {
279
+ try {
280
+ const parsed = JSON.parse(await fs.readFile(backup, 'utf8'));
281
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
282
+ original = parsed;
283
+ }
284
+ catch { /* an unreadable backup restores nothing */ }
285
+ }
286
+ const next = { ...current };
287
+ for (const field of ['auth_mode', 'OPENAI_API_KEY']) {
288
+ if (original && Object.prototype.hasOwnProperty.call(original, field))
289
+ next[field] = original[field];
290
+ else
291
+ delete next[field];
292
+ }
293
+ const meaningful = Object.values(next).some(value => value !== null && value !== undefined
294
+ && !(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0));
295
+ if (!original && !meaningful) {
296
+ await fs.remove(file);
297
+ return { changed: true, message: 'removed ScaleMax configuration' };
298
+ }
299
+ await atomicWrite(file, JSON.stringify(next, null, 2) + '\n', 0o600, ctx.platform);
300
+ return {
301
+ changed: true,
302
+ message: original ? 'removed the ScaleMax key and restored your previous sign-in' : 'removed the ScaleMax key',
303
+ };
130
304
  }
131
305
  export async function verifyCodexConfiguration(ctx) {
132
306
  const configPath = codexConfigPath(ctx.home);
@@ -261,7 +435,7 @@ export async function configure(ctx) {
261
435
  }
262
436
  catch { }
263
437
  const nextAuth = codexAuth(ctx.apiKey, existingAuth);
264
- const nextConfig = mergeCodexConfig(originalConfig, ctx.baseUrl, selectedModel(ctx), contentSha256(nextAuth));
438
+ const nextConfig = mergeCodexConfig(originalConfig, ctx.baseUrl, selectedModel(ctx), contentSha256(nextAuth), contentSha256(ctx.apiKey));
265
439
  const changed = originalConfig !== nextConfig || originalAuth !== nextAuth;
266
440
  const alreadyManaged = originalConfig.includes(GENERATED_HEADER);
267
441
  let verification;
@@ -312,24 +486,18 @@ export async function configure(ctx) {
312
486
  export async function remove(ctx) {
313
487
  const configPath = codexConfigPath(ctx.home);
314
488
  const authPath = codexAuthPath(ctx.home);
315
- const [configText, authText] = await Promise.all([
316
- fs.readFile(configPath, 'utf8').catch(() => ''),
317
- fs.readFile(authPath, 'utf8').catch(() => ''),
318
- ]);
319
- const expectedAuthHash = managedAuthHash(configText);
320
- // New generated configs bind ownership to the exact auth content. A hash
321
- // mismatch means the user signed in or edited auth.json after setup, so
322
- // removal must leave that current authentication state untouched. Legacy
323
- // configs without the marker retain the conservative structural fallback.
324
- const authManagedOverride = expectedAuthHash
325
- ? contentSha256(authText) === expectedAuthHash
326
- : configText.includes(GENERATED_HEADER)
327
- ? undefined
328
- : false;
489
+ // Read the ownership markers BEFORE config.toml is rewritten below.
490
+ const configText = await fs.readFile(configPath, 'utf8').catch(() => '');
491
+ const ownership = {
492
+ authHash: managedAuthHash(configText),
493
+ keyHash: markerHash(configText, managedKeyHashPrefix),
494
+ // Configs written before either marker existed: the original structural rule.
495
+ legacyStructural: configText.includes(GENERATED_HEADER),
496
+ };
329
497
  const snapshots = await captureFileSnapshots([configPath, authPath]);
330
498
  try {
331
- const config = await restoreOrRemove(configPath, ctx);
332
- const auth = await restoreOrRemove(authPath, ctx, authManagedOverride);
499
+ const config = await removeCodexConfig(configPath, ctx);
500
+ const auth = await removeCodexAuth(authPath, ctx, ownership);
333
501
  return {
334
502
  id,
335
503
  name,
@@ -15,9 +15,8 @@ const preferredOpenAi = [
15
15
  'gpt-5.6-sol',
16
16
  'gpt-6-sol',
17
17
  'gpt-6-luna',
18
- 'gpt-5.5',
19
- 'gpt-5.4',
20
- 'gpt-5.4-mini',
18
+ // gpt-5.5 / gpt-5.4 / gpt-5.4-mini retired 2026-09-30.
19
+ 'gpt-5.6-luna',
21
20
  'gpt-5.3-codex',
22
21
  'codex-auto-review',
23
22
  // Gemini / Grok / Qwen ride the same OpenAI-compatible route, so they are
@@ -126,7 +125,8 @@ export function fastModel(ctx) {
126
125
  }
127
126
  export function fastOpenAiModel(ctx) {
128
127
  const ids = openAiModelIds(ctx);
129
- return ids.find(id => id === 'gpt-5.4-mini')
128
+ return ids.find(id => id === 'gpt-5.6-luna')
129
+ || ids.find(id => id === 'gpt-6-luna')
130
130
  || ids.find(id => /(^|[-.])(mini|nano)([-.]|$)/i.test(id));
131
131
  }
132
132
  /** The lightweight model for an OpenAI-compatible chat client, if one exists. */
@@ -11,7 +11,7 @@ export function jsonConfig(client, apiKey, baseUrl, extra = {}) {
11
11
  }, null, 2) + '\n';
12
12
  }
13
13
  export function continueConfig(apiKey, baseUrl) {
14
- const models = ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'codex-auto-review', 'deepseek-v4-flash'];
14
+ const models = ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.3-codex', 'codex-auto-review', 'deepseek-v4-flash'];
15
15
  return JSON.stringify({
16
16
  models: models.map(model => ({
17
17
  title: model === 'deepseek-v4-flash' ? 'ScaleMax DeepSeek V4 Flash' : `ScaleMax ${model}`,
@@ -21,7 +21,7 @@ export function continueConfig(apiKey, baseUrl) {
21
21
  apiKey,
22
22
  ...(model === 'deepseek-v4-flash' ? { useResponsesApi: false } : {})
23
23
  })),
24
- tabAutocompleteModel: { title: 'ScaleMax gpt-5.4-mini', provider: 'openai', model: 'gpt-5.4-mini', apiBase: baseUrl, apiKey },
24
+ tabAutocompleteModel: { title: 'ScaleMax gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna', apiBase: baseUrl, apiKey },
25
25
  contextProviders: [{ name: 'code' }, { name: 'docs' }, { name: 'diff' }, { name: 'terminal' }]
26
26
  }, null, 2) + '\n';
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {