tokenmix 1.5.1 → 1.5.2

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.
@@ -2,6 +2,7 @@ import os from 'os';
2
2
  import path from 'path';
3
3
  import fs from 'fs-extra';
4
4
  import { run } from '../utils/exec.js';
5
+ import { writeFileAtomic } from '../utils/fs.js';
5
6
  import { npmInstallCheck, npmInstallGlobal } from './helpers.js';
6
7
  import { t } from '../i18n/index.js';
7
8
  const CLAUDE_BIN = 'claude';
@@ -16,11 +17,16 @@ async function configure(apiKey, baseUrl, _defaultModel) {
16
17
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
17
18
  let existing = {};
18
19
  try {
19
- const raw = await fs.readFile(settingsPath, 'utf-8');
20
- existing = JSON.parse(raw);
20
+ const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
21
+ // Valid JSON that isn't a plain object (null, array, string) must be treated as
22
+ // "start fresh" — otherwise `existing.env` below throws (JSON.parse("null") →
23
+ // null → null.env → TypeError, which would block `tokenmix claude` entirely).
24
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
25
+ existing = parsed;
26
+ }
21
27
  }
22
28
  catch {
23
- // first run
29
+ // first run, or unreadable/corrupt — start fresh
24
30
  }
25
31
  const existingEnv = existing.env || {};
26
32
  // Detect that we're about to overwrite a user's OWN (non-tokenmix) Anthropic
@@ -42,11 +48,17 @@ async function configure(apiKey, baseUrl, _defaultModel) {
42
48
  // Stash the user's original Anthropic env creds on the FIRST overwrite, so
43
49
  // cleanup() can put them back instead of leaving Claude Code broken. Once the
44
50
  // stored key is ours, replacingForeign is false — so we never clobber the backup.
45
- const prevTm = existing.tokenmix;
46
- const alreadyBackedUp = !!(prevTm && typeof prevTm === 'object' && 'claudeEnvBackup' in prevTm);
51
+ // Guard: `existing.tokenmix` could be any JSON (a user or another tool might set
52
+ // it to a string/array). Only spread it when it's a real object otherwise a
53
+ // string would explode into numeric-index keys and pollute settings.json.
54
+ const prevTmRaw = existing.tokenmix;
55
+ const prevTm = prevTmRaw && typeof prevTmRaw === 'object' && !Array.isArray(prevTmRaw)
56
+ ? prevTmRaw
57
+ : {};
58
+ const alreadyBackedUp = 'claudeEnvBackup' in prevTm;
47
59
  if (replacingForeign && !alreadyBackedUp) {
48
60
  next.tokenmix = {
49
- ...(prevTm ?? {}),
61
+ ...prevTm,
50
62
  claudeEnvBackup: {
51
63
  ANTHROPIC_API_KEY: prevKey ?? null,
52
64
  ANTHROPIC_BASE_URL: prevBase ?? null,
@@ -54,13 +66,7 @@ async function configure(apiKey, baseUrl, _defaultModel) {
54
66
  };
55
67
  }
56
68
  await fs.ensureDir(path.dirname(settingsPath));
57
- await fs.writeFile(settingsPath, JSON.stringify(next, null, 2));
58
- try {
59
- await fs.chmod(settingsPath, 0o600);
60
- }
61
- catch {
62
- // ignore
63
- }
69
+ await writeFileAtomic(settingsPath, JSON.stringify(next, null, 2), 0o600);
64
70
  // Claude Pro/Max users sign in via OAuth (creds live in ~/.claude/.credentials.json
65
71
  // or the OS keychain — NOT in settings.json env). Claude Code prefers
66
72
  // ANTHROPIC_API_KEY over the OAuth subscription, so injecting our key silently
@@ -101,7 +107,11 @@ async function cleanup() {
101
107
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
102
108
  let existing;
103
109
  try {
104
- existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
110
+ const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
111
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
112
+ return { reverted: false, configPath: settingsPath };
113
+ }
114
+ existing = parsed;
105
115
  }
106
116
  catch {
107
117
  return { reverted: false };
@@ -148,13 +158,7 @@ async function cleanup() {
148
158
  else
149
159
  existing.tokenmix = tm;
150
160
  }
151
- await fs.writeFile(settingsPath, JSON.stringify(existing, null, 2));
152
- try {
153
- await fs.chmod(settingsPath, 0o600);
154
- }
155
- catch {
156
- // ignore
157
- }
161
+ await writeFileAtomic(settingsPath, JSON.stringify(existing, null, 2), 0o600);
158
162
  return {
159
163
  reverted: true,
160
164
  configPath: settingsPath,
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
4
4
  import { run } from '../utils/exec.js';
5
5
  import { npmInstallCheck, npmInstallGlobal } from './helpers.js';
6
6
  import { v1Url } from '../config/store.js';
7
+ import { writeFileAtomic } from '../utils/fs.js';
7
8
  import { t } from '../i18n/index.js';
8
9
  const OPENCODE_BIN = 'opencode';
9
10
  const OPENCODE_NPM_PACKAGE = 'opencode-ai';
@@ -22,11 +23,13 @@ async function configure(apiKey, baseUrl, defaultModel) {
22
23
  const filePath = configPath();
23
24
  let existing = {};
24
25
  try {
25
- const raw = await fs.readFile(filePath, 'utf-8');
26
- existing = JSON.parse(raw);
26
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
27
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
28
+ existing = parsed;
29
+ }
27
30
  }
28
31
  catch {
29
- // not present yet
32
+ // not present yet, or corrupt — start fresh
30
33
  }
31
34
  // Register tokenmix as an OpenAI-compatible provider.
32
35
  // OpenCode uses @ai-sdk/openai-compatible under the hood for custom providers.
@@ -52,7 +55,7 @@ async function configure(apiKey, baseUrl, defaultModel) {
52
55
  },
53
56
  };
54
57
  await fs.ensureDir(path.dirname(filePath));
55
- await fs.writeFile(filePath, JSON.stringify(next, null, 2));
58
+ await writeFileAtomic(filePath, JSON.stringify(next, null, 2));
56
59
  return {
57
60
  configPath: filePath,
58
61
  notes: [t('opencode.noteModel', { model: defaultModel }), t('opencode.noteSwitch')],
@@ -67,7 +70,11 @@ async function cleanup() {
67
70
  const filePath = configPath();
68
71
  let existing;
69
72
  try {
70
- existing = JSON.parse(await fs.readFile(filePath, 'utf-8'));
73
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
74
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
75
+ return { reverted: false, configPath: filePath };
76
+ }
77
+ existing = parsed;
71
78
  }
72
79
  catch {
73
80
  return { reverted: false };
@@ -86,7 +93,7 @@ async function cleanup() {
86
93
  }
87
94
  if (!changed)
88
95
  return { reverted: false, configPath: filePath };
89
- await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
96
+ await writeFileAtomic(filePath, JSON.stringify(existing, null, 2));
90
97
  return { reverted: true, configPath: filePath };
91
98
  }
92
99
  export const OpenCodeAgent = {
@@ -9,7 +9,10 @@ export class ApiError extends Error {
9
9
  }
10
10
  export function unwrap(resp) {
11
11
  const body = resp.data;
12
- if (body && typeof body.code === 'number' && body.code !== 0) {
12
+ // Normalize `code` with Number() a backend/proxy could serialize it as a string
13
+ // ("1"), which a strict `typeof === 'number'` check would miss, silently swallowing
14
+ // a real error and passing the error body downstream as if it were data.
15
+ if (body && body.code != null && Number(body.code) !== 0) {
13
16
  throw new ApiError(0, body.message || 'API error');
14
17
  }
15
18
  // Standard success envelope → return the inner `data`. A body with no envelope
@@ -31,7 +34,8 @@ function handleAxios(err) {
31
34
  // timeout (override with TOKENMIX_TIMEOUT_MS) and automatic retry of transient
32
35
  // TRANSPORT failures (no HTTP response) with exponential backoff. HTTP errors
33
36
  // (4xx/5xx) are real answers and are NEVER retried.
34
- export const REQUEST_TIMEOUT_MS = Number(process.env.TOKENMIX_TIMEOUT_MS) || 20000;
37
+ const envTimeout = Number(process.env.TOKENMIX_TIMEOUT_MS);
38
+ export const REQUEST_TIMEOUT_MS = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 20000;
35
39
  const MAX_RETRIES = 2;
36
40
  export async function withRetry(fn) {
37
41
  let lastErr;
@@ -78,9 +82,19 @@ export async function verifyApiKey(apiKey, baseUrl) {
78
82
  timeout: REQUEST_TIMEOUT_MS,
79
83
  validateStatus: () => true,
80
84
  }));
81
- return r.status === 200;
85
+ if (r.status === 200)
86
+ return true;
87
+ // 401/403 = a genuinely invalid/expired key → false. But 5xx/429 are server-side
88
+ // problems, NOT a bad key — throw so callers report "API unavailable" instead of
89
+ // falsely telling the user to re-create a perfectly good key.
90
+ if (r.status >= 500 || r.status === 429) {
91
+ throw new ApiError(r.status, `TokenMix API is temporarily unavailable (HTTP ${r.status}). Please try again in a moment.`);
92
+ }
93
+ return false;
82
94
  }
83
95
  catch (err) {
96
+ if (err instanceof ApiError)
97
+ throw err;
84
98
  handleAxios(err);
85
99
  }
86
100
  }
@@ -121,18 +135,38 @@ const DEFAULT_EXPIRES_IN_S = 900;
121
135
  // Throws DeviceFlowError with code ∈ {expired_token, access_denied, api_key_limit_reached, timeout} on terminal failures.
122
136
  // onTick is called once per polling iteration with the seconds remaining (for progress display).
123
137
  export async function pollDeviceToken(baseUrl, auth, onTick) {
124
- let intervalMs = Math.max(1, Number(auth.interval) || DEFAULT_POLL_INTERVAL_S) * 1000;
125
- const expiresIn = Number(auth.expires_in) > 0 ? Number(auth.expires_in) : DEFAULT_EXPIRES_IN_S;
138
+ // Clamp the server-provided interval/deadline so a malicious or buggy server can't
139
+ // drive a ~1ms busy-loop Infinity/NaN/huge values slip past a bare Math.max.
140
+ // Interval ∈ [1s, 60s]; the overall deadline is capped at 1h.
141
+ const clampIntervalMs = (s) => {
142
+ const ms = (Number.isFinite(s) && s > 0 ? s : DEFAULT_POLL_INTERVAL_S) * 1000;
143
+ return Math.min(60_000, Math.max(1000, ms));
144
+ };
145
+ let intervalMs = clampIntervalMs(Number(auth.interval));
146
+ const expiresInRaw = Number(auth.expires_in);
147
+ const expiresIn = Number.isFinite(expiresInRaw) && expiresInRaw > 0
148
+ ? Math.min(expiresInRaw, 3600)
149
+ : DEFAULT_EXPIRES_IN_S;
126
150
  const deadline = Date.now() + expiresIn * 1000;
127
151
  while (Date.now() < deadline) {
128
152
  await new Promise((r) => setTimeout(r, intervalMs));
129
153
  if (onTick) {
130
- onTick(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
154
+ try {
155
+ onTick(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
156
+ }
157
+ catch {
158
+ // a progress callback must never be able to abort the login flow
159
+ }
131
160
  }
132
161
  try {
133
162
  const r = await axios.post(`${baseUrl}/api/auth/device/token`, { device_code: auth.device_code }, { timeout: REQUEST_TIMEOUT_MS, validateStatus: () => true });
134
163
  if (r.status === 200 && r.data?.code === 0) {
135
164
  const body = r.data.data;
165
+ // A 200/code:0 with no usable token would otherwise be written to config as
166
+ // `apiKey: undefined` and look like a successful login. Treat it as an error.
167
+ if (!body || typeof body.access_token !== 'string') {
168
+ throw new DeviceFlowError('unknown', 'Server returned a success response without a token.');
169
+ }
136
170
  return {
137
171
  apiKey: body.access_token,
138
172
  apiKeyId: body.api_key_id,
@@ -147,7 +181,7 @@ export async function pollDeviceToken(baseUrl, auth, onTick) {
147
181
  case 'slow_down': {
148
182
  const ra = parseInt(String(r.headers['retry-after'] ?? '5'), 10);
149
183
  if (Number.isFinite(ra) && ra > 0)
150
- intervalMs = ra * 1000;
184
+ intervalMs = clampIntervalMs(ra);
151
185
  continue;
152
186
  }
153
187
  case 'expired_token':
@@ -1,5 +1,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import { configDir, configFile } from './paths.js';
3
+ import { writeFileAtomic } from '../utils/fs.js';
4
+ import { logger } from '../utils/logger.js';
5
+ import { t } from '../i18n/index.js';
3
6
  export const DEFAULT_API_BASE = 'https://api.tokenmix.ai';
4
7
  // The model agents default to when the user hasn't chosen one (overridable via
5
8
  // the TOKENMIX_DEFAULT_MODEL env var or stored config). Single source of truth.
@@ -10,24 +13,28 @@ export function v1Url(baseUrl) {
10
13
  return `${baseUrl.replace(/\/+$/, '')}/v1`;
11
14
  }
12
15
  export async function readConfig() {
16
+ let raw;
17
+ try {
18
+ raw = await fs.readFile(configFile(), 'utf-8');
19
+ }
20
+ catch {
21
+ return {}; // not logged in yet — the config file simply doesn't exist
22
+ }
13
23
  try {
14
- const raw = await fs.readFile(configFile(), 'utf-8');
15
24
  return JSON.parse(raw);
16
25
  }
17
26
  catch {
27
+ // The file exists but is corrupt (e.g. a crash truncated it mid-write). Don't
28
+ // silently treat it as "logged out" — warn so the user knows to re-login.
29
+ logger.warn(t('config.corrupt'));
18
30
  return {};
19
31
  }
20
32
  }
21
33
  export async function writeConfig(cfg) {
22
34
  await fs.ensureDir(configDir());
23
- await fs.writeFile(configFile(), JSON.stringify(cfg, null, 2));
24
- // Restrict to owner read/write only (best-effort on Windows).
25
- try {
26
- await fs.chmod(configFile(), 0o600);
27
- }
28
- catch {
29
- // ignore on filesystems that don't support chmod
30
- }
35
+ // Atomic write so a crash or a concurrent writer can't truncate the config to
36
+ // 0 bytes and silently lose the apiKey. 0600 = owner read/write only.
37
+ await writeFileAtomic(configFile(), JSON.stringify(cfg, null, 2), 0o600);
31
38
  }
32
39
  export async function updateConfig(patch) {
33
40
  const current = await readConfig();
@@ -6,6 +6,7 @@
6
6
  // `{name}`-style placeholders are filled by t() at call time.
7
7
  export const en = {
8
8
  'common.notLoggedIn': 'Not logged in. Run `tokenmix login` first.',
9
+ 'config.corrupt': 'Your TokenMix config file was corrupt and has been ignored — run `tokenmix login` to re-create it.',
9
10
  // welcome screen (bare `tokenmix`, no args — onboarding for first-time users)
10
11
  'welcome.tagline': 'one account, 160+ models, every open-source coding agent',
11
12
  'welcome.why': 'The model you pick is the model you get — no silent swaps, billed at real usage.',
@@ -184,6 +185,7 @@ export const en = {
184
185
  };
185
186
  export const zh = {
186
187
  'common.notLoggedIn': '未登录,请先运行 `tokenmix login`。',
188
+ 'config.corrupt': 'TokenMix 配置文件已损坏,已忽略 —— 请运行 `tokenmix login` 重新创建。',
187
189
  'welcome.tagline': '一个账户、160+ 模型、对接所有开源编程 agent',
188
190
  'welcome.why': '你选的模型就是你用的模型 —— 不偷换、不降级,按真实用量计费。',
189
191
  'welcome.start': '快速开始:',
@@ -354,6 +356,7 @@ export const zh = {
354
356
  };
355
357
  export const ja = {
356
358
  'common.notLoggedIn': 'ログインしていません。まず `tokenmix login` を実行してください。',
359
+ 'config.corrupt': 'TokenMix の設定ファイルが破損していたため無視しました —— `tokenmix login` を実行して再作成してください。',
357
360
  'welcome.tagline': '1 つのアカウントで 160+ モデル、あらゆるオープンソース・コーディング agent に対応',
358
361
  'welcome.why': '選んだモデルがそのまま使われます —— すり替えなし、実使用量で課金。',
359
362
  'welcome.start': 'はじめに:',
@@ -509,6 +512,7 @@ export const ja = {
509
512
  };
510
513
  export const ko = {
511
514
  'common.notLoggedIn': '로그인되어 있지 않습니다. 먼저 `tokenmix login`을 실행하세요.',
515
+ 'config.corrupt': 'TokenMix 설정 파일이 손상되어 무시했습니다 —— `tokenmix login`을 실행해 다시 만드세요.',
512
516
  'welcome.tagline': '하나의 계정으로 160+ 모델, 모든 오픈소스 코딩 agent 지원',
513
517
  'welcome.why': '선택한 모델 그대로 실행됩니다 —— 몰래 바꾸지 않고 실사용량으로 과금합니다.',
514
518
  'welcome.start': '시작하기:',
@@ -664,6 +668,7 @@ export const ko = {
664
668
  };
665
669
  export const es = {
666
670
  'common.notLoggedIn': 'No has iniciado sesión. Ejecuta `tokenmix login` primero.',
671
+ 'config.corrupt': 'Tu archivo de configuración de TokenMix estaba dañado y se ha ignorado: ejecuta `tokenmix login` para volver a crearlo.',
667
672
  'welcome.tagline': 'una cuenta, 160+ modelos, todos los agentes de programación de código abierto',
668
673
  'welcome.why': 'El modelo que eliges es el que usas: sin cambios ocultos, facturado por uso real.',
669
674
  'welcome.start': 'Empezar:',
@@ -819,6 +824,7 @@ export const es = {
819
824
  };
820
825
  export const fr = {
821
826
  'common.notLoggedIn': 'Non connecté. Exécutez d’abord `tokenmix login`.',
827
+ 'config.corrupt': 'Votre fichier de configuration TokenMix était corrompu et a été ignoré — exécutez `tokenmix login` pour le recréer.',
822
828
  'welcome.tagline': 'un seul compte, 160+ modèles, tous les agents de codage open source',
823
829
  'welcome.why': 'Le modèle que vous choisissez est celui que vous utilisez — sans substitution, facturé à l’usage réel.',
824
830
  'welcome.start': 'Démarrer :',
@@ -2,15 +2,24 @@ import open from 'open';
2
2
  import { logger } from './logger.js';
3
3
  import { t } from '../i18n/index.js';
4
4
  export async function openInBrowser(url) {
5
- await open(url);
5
+ const child = await open(url);
6
+ // open() spawns a detached, unref'd child; if the launcher binary is missing its
7
+ // async 'error' event would otherwise be uncaught (→ process crash). Swallow it —
8
+ // callers print the URL and treat a browser failure as non-fatal.
9
+ child.on('error', () => { });
6
10
  }
7
- // Try to open the browser; on failure (headless / SSH / no desktop environment),
8
- // print the URL so the user can open it manually instead of crashing.
11
+ // Print the URL, then best-effort open the browser. We print UNCONDITIONALLY and
12
+ // first: without `{ wait: true }`, open() resolves immediately even when no browser
13
+ // actually launches (headless / SSH / container / no desktop), so a catch-based
14
+ // fallback would never fire and the user would be left staring at nothing. Printing
15
+ // first guarantees they can always copy the link.
9
16
  export async function openOrHint(url) {
17
+ logger.info(t('browser.manual', { url }));
10
18
  try {
11
- await open(url);
19
+ const child = await open(url);
20
+ child.on('error', () => { }); // swallow async spawn errors; the URL is already shown
12
21
  }
13
22
  catch {
14
- logger.info(t('browser.manual', { url }));
23
+ // best-effort the URL is already shown above
15
24
  }
16
25
  }
@@ -0,0 +1,31 @@
1
+ import fs from 'fs-extra';
2
+ // Atomically write a file: write to a temp file alongside the target, then rename
3
+ // it over the target. Rename is atomic on the same filesystem, so a crash or a
4
+ // concurrent writer can never observe a half-written / 0-byte target — which is how
5
+ // a plain `fs.writeFile` (open with O_TRUNC) would otherwise corrupt user config
6
+ // (e.g. truncating ~/.claude/settings.json or our own config.json to 0 bytes).
7
+ export async function writeFileAtomic(filePath, data, mode) {
8
+ const tmp = `${filePath}.${process.pid}.tmp`;
9
+ try {
10
+ await fs.writeFile(tmp, data);
11
+ if (mode !== undefined) {
12
+ try {
13
+ await fs.chmod(tmp, mode);
14
+ }
15
+ catch {
16
+ // chmod unsupported (e.g. Windows) — non-fatal, keep going.
17
+ }
18
+ }
19
+ await fs.rename(tmp, filePath);
20
+ }
21
+ catch (err) {
22
+ // Best-effort cleanup of the temp file if the rename never happened.
23
+ try {
24
+ await fs.remove(tmp);
25
+ }
26
+ catch {
27
+ // ignore
28
+ }
29
+ throw err;
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmix",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Zero-config CLI to use any open-source coding agent with TokenMix as the unified LLM backend.",
5
5
  "type": "module",
6
6
  "bin": {