@kikkimo/claude-launcher 2.5.0 → 3.1.0

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.
@@ -6,49 +6,210 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const { encrypt, decrypt } = require('./crypto');
9
- const { validateBaseUrl, validateAuthToken, validateModel } = require('./validators');
9
+ const { validateBaseUrl, validateAuthToken, validateModel, validateApiName } = require('./validators');
10
+ const screen = require('./ui/screen');
11
+
12
+ class DuplicateApiError extends Error {
13
+ constructor(existingApi) {
14
+ super(`Duplicate API: ${existingApi.name}`);
15
+ this.name = 'DuplicateApiError';
16
+ this.code = 'DUPLICATE_API';
17
+ this.existingApiId = existingApi.id;
18
+ this.existingApiName = existingApi.name;
19
+ }
20
+ }
21
+
22
+ const MODEL_CONFIG_LABELS = {
23
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'Regular Model (Sonnet)',
24
+ ANTHROPIC_DEFAULT_OPUS_MODEL: 'Heavy Model (Opus)',
25
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'Fast Model (Haiku)',
26
+ CLAUDE_CODE_SUBAGENT_MODEL: 'Subagent Model',
27
+ ANTHROPIC_CUSTOM_MODEL_OPTION: 'Custom Model',
28
+ ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: 'Custom Model Name',
29
+ };
30
+
31
+ const RUNTIME_CONFIG_LABELS = {
32
+ API_TIMEOUT_MS: 'Request Timeout',
33
+ CLAUDE_CODE_ATTRIBUTION_HEADER: 'Output Attribution',
34
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 'Reduce Non-Essential Traffic',
35
+ CLAUDE_CODE_EFFORT_LEVEL: 'Reasoning Effort',
36
+ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: 'Experimental Features',
37
+ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: 'Disable Non-Streaming Fallback',
38
+ };
10
39
 
11
40
  class ApiManager {
12
41
  constructor() {
13
42
  this.configFile = path.join(os.homedir(), '.claude-launcher-apis.json');
14
- this.config = this.loadConfig();
43
+ const { config, migrated } = this.loadConfig();
44
+ this.config = config;
45
+ if (migrated) {
46
+ this.saveConfig();
47
+ }
15
48
  }
16
49
 
17
50
  /**
18
51
  * Load configuration from encrypted file
19
52
  */
20
53
  loadConfig() {
54
+ let migrated = false;
21
55
  try {
22
56
  if (fs.existsSync(this.configFile)) {
23
57
  const encryptedData = fs.readFileSync(this.configFile, 'utf8');
24
58
  const decrypted = decrypt(encryptedData);
25
59
  if (decrypted.success) {
26
60
  const config = JSON.parse(decrypted.value);
27
- // Ensure required fields exist
28
- if (!config.hasOwnProperty('exportPassword')) {
29
- config.exportPassword = null;
30
- }
31
- if (!config.hasOwnProperty('passwordSkipped')) {
32
- config.passwordSkipped = false;
33
- }
61
+ if (!config.hasOwnProperty('exportPassword')) config.exportPassword = null;
62
+ if (!config.hasOwnProperty('passwordSkipped')) config.passwordSkipped = false;
34
63
 
35
- return config;
64
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS } = require('./validators');
65
+ for (const api of config.apis || []) {
66
+ migrated = this._migrateApiEntry(api, PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS) || migrated;
67
+ }
68
+ return { config, migrated };
36
69
  }
37
70
  }
38
71
  } catch (error) {
39
- console.error(`[!] Could not load API config: ${error.message}`);
72
+ screen.debug(`[!] Could not load API config: ${error.message}`);
40
73
  }
41
74
 
42
75
  return {
43
- apis: [],
44
- activeIndex: -1,
45
- version: '2.0.0',
46
- createdAt: new Date().toISOString(),
47
- exportPassword: null, // Hashed export password for validation
48
- passwordSkipped: false // Whether user permanently skipped password setup
76
+ config: {
77
+ apis: [],
78
+ activeIndex: -1,
79
+ version: '2.0.0',
80
+ createdAt: new Date().toISOString(),
81
+ exportPassword: null,
82
+ passwordSkipped: false,
83
+ },
84
+ migrated: false,
49
85
  };
50
86
  }
51
87
 
88
+ _migrateApiEntry(api, MODEL_KEYS, RUNTIME_KEYS) {
89
+ const before = JSON.stringify({
90
+ modelEnvVars: api.modelEnvVars,
91
+ _autoModelEnvVars: api._autoModelEnvVars,
92
+ runtimeEnvVars: api.runtimeEnvVars,
93
+ _runtimeEnvSources: api._runtimeEnvSources,
94
+ customEnvVars: api.customEnvVars,
95
+ smallFastModel: api.smallFastModel,
96
+ _autoFilledModel: api._autoFilledModel,
97
+ });
98
+ this._normalizeApiFields(api);
99
+ const after = JSON.stringify({
100
+ modelEnvVars: api.modelEnvVars,
101
+ _autoModelEnvVars: api._autoModelEnvVars,
102
+ runtimeEnvVars: api.runtimeEnvVars,
103
+ _runtimeEnvSources: api._runtimeEnvSources,
104
+ customEnvVars: api.customEnvVars,
105
+ smallFastModel: api.smallFastModel,
106
+ _autoFilledModel: api._autoFilledModel,
107
+ });
108
+ return before !== after;
109
+ }
110
+
111
+ _normalizeApiFields(api) {
112
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS } = require('./validators');
113
+ const { getProvider } = require('./presets/providers');
114
+
115
+ const effectiveModel = api._autoFilledModel || api.model;
116
+ const hadAutoModelEnvVars = !!api._autoModelEnvVars;
117
+
118
+ const providerConfig = getProvider(api.provider);
119
+ let template;
120
+ if (providerConfig && providerConfig.modelEnvTemplate) {
121
+ template = providerConfig.modelEnvTemplate.getValues(effectiveModel);
122
+ } else {
123
+ template = {};
124
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) template[k] = effectiveModel;
125
+ template.smallFastModel = effectiveModel;
126
+ }
127
+
128
+ let smallFastWasFixed = false;
129
+
130
+ // _autoModelEnvVars — full rebuild via template
131
+ if (!api._autoModelEnvVars) {
132
+ api._autoModelEnvVars = { ...template };
133
+ } else {
134
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
135
+ if (!(k in api._autoModelEnvVars) || typeof api._autoModelEnvVars[k] !== 'string') {
136
+ api._autoModelEnvVars[k] = template[k] || '';
137
+ }
138
+ }
139
+ for (const k of Object.keys(api._autoModelEnvVars)) {
140
+ if (!PREDEFINED_MODEL_ENV_KEYS.includes(k) && k !== 'smallFastModel') {
141
+ delete api._autoModelEnvVars[k];
142
+ }
143
+ }
144
+ if (!('smallFastModel' in api._autoModelEnvVars) || typeof api._autoModelEnvVars.smallFastModel !== 'string') {
145
+ api._autoModelEnvVars.smallFastModel = template.smallFastModel;
146
+ smallFastWasFixed = true;
147
+ }
148
+ }
149
+
150
+ // modelEnvVars — fill with template actual values (NOT "")
151
+ if (!api.modelEnvVars) {
152
+ api.modelEnvVars = {};
153
+ }
154
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
155
+ if (!(k in api.modelEnvVars) || typeof api.modelEnvVars[k] !== 'string') {
156
+ api.modelEnvVars[k] = template[k] || '';
157
+ }
158
+ }
159
+ for (const k of Object.keys(api.modelEnvVars)) {
160
+ if (!PREDEFINED_MODEL_ENV_KEYS.includes(k)) {
161
+ delete api.modelEnvVars[k];
162
+ }
163
+ }
164
+
165
+ // smallFastModel — sync with template
166
+ if (!api.smallFastModel || typeof api.smallFastModel !== 'string'
167
+ || !hadAutoModelEnvVars || smallFastWasFixed) {
168
+ api.smallFastModel = template.smallFastModel;
169
+ }
170
+
171
+ // runtimeEnvVars — fill "" not provider values
172
+ if (!api.runtimeEnvVars) {
173
+ api.runtimeEnvVars = {};
174
+ }
175
+ for (const k of PREDEFINED_RUNTIME_KEYS) {
176
+ if (!(k in api.runtimeEnvVars) || typeof api.runtimeEnvVars[k] !== 'string') {
177
+ api.runtimeEnvVars[k] = '';
178
+ }
179
+ }
180
+
181
+ // _runtimeEnvSources — missing → "auto"
182
+ if (!api._runtimeEnvSources) {
183
+ api._runtimeEnvSources = {};
184
+ }
185
+ for (const k of PREDEFINED_RUNTIME_KEYS) {
186
+ if (!(k in api._runtimeEnvSources)) {
187
+ api._runtimeEnvSources[k] = 'auto';
188
+ }
189
+ if (api._runtimeEnvSources[k] !== 'auto' && api._runtimeEnvSources[k] !== 'manual') {
190
+ api._runtimeEnvSources[k] = 'auto';
191
+ }
192
+ }
193
+
194
+ // runtime/source conflict resolution
195
+ for (const k of PREDEFINED_RUNTIME_KEYS) {
196
+ if (api.runtimeEnvVars[k] !== '' && api._runtimeEnvSources[k] === 'auto') {
197
+ api.runtimeEnvVars[k] = '';
198
+ }
199
+ }
200
+
201
+ // customEnvVars
202
+ if (!api.customEnvVars) {
203
+ api.customEnvVars = {};
204
+ }
205
+
206
+ if (api._autoFilledModel) {
207
+ delete api._autoFilledModel;
208
+ }
209
+
210
+ return api;
211
+ }
212
+
52
213
  /**
53
214
  * Save configuration to encrypted file
54
215
  */
@@ -60,11 +221,11 @@ class ApiManager {
60
221
  fs.writeFileSync(this.configFile, encrypted.value);
61
222
  return true;
62
223
  } else {
63
- console.error(`[!] Failed to save API config: ${encrypted.error}`);
224
+ screen.debug(`[!] Failed to save API config: ${encrypted.error}`);
64
225
  return false;
65
226
  }
66
227
  } catch (error) {
67
- console.error(`[!] Error saving API config: ${error.message}`);
228
+ screen.debug(`[!] Error saving API config: ${error.message}`);
68
229
  return false;
69
230
  }
70
231
  }
@@ -96,6 +257,10 @@ class ApiManager {
96
257
  * Add a new API configuration
97
258
  */
98
259
  addApi(baseUrl, authToken, model, name, provider = 'custom') {
260
+ if (this.config.apis.length >= 99) {
261
+ throw new Error('Maximum 99 APIs supported. Remove unused APIs before adding new ones.');
262
+ }
263
+
99
264
  // Validate inputs
100
265
  const urlValidation = validateBaseUrl(baseUrl);
101
266
  if (!urlValidation.valid) {
@@ -115,7 +280,7 @@ class ApiManager {
115
280
  // Check for duplicates
116
281
  const duplicate = this.checkDuplicate(baseUrl, authToken, model);
117
282
  if (duplicate.isDuplicate) {
118
- throw new Error(`${duplicate.type} already exists for API: ${duplicate.existing.name}`);
283
+ throw new DuplicateApiError(duplicate.existing);
119
284
  }
120
285
 
121
286
  // Encrypt the auth token before storing
@@ -124,6 +289,32 @@ class ApiManager {
124
289
  throw new Error(`Failed to encrypt auth token: ${encryptedToken.error}`);
125
290
  }
126
291
 
292
+ // Compute model env template values
293
+ const { getProvider } = require('./presets/providers');
294
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS } = require('./validators');
295
+ const providerConfig = getProvider(provider);
296
+ let templateValues;
297
+ if (providerConfig && providerConfig.modelEnvTemplate) {
298
+ templateValues = providerConfig.modelEnvTemplate.getValues(modelValidation.value);
299
+ } else {
300
+ templateValues = {};
301
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) templateValues[k] = modelValidation.value;
302
+ templateValues.smallFastModel = modelValidation.value;
303
+ }
304
+
305
+ const modelEnvVars = {};
306
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
307
+ modelEnvVars[k] = templateValues[k] || '';
308
+ }
309
+ const _autoModelEnvVars = { ...templateValues };
310
+
311
+ const runtimeEnvVars = {};
312
+ const _runtimeEnvSources = {};
313
+ for (const k of PREDEFINED_RUNTIME_KEYS) {
314
+ runtimeEnvVars[k] = '';
315
+ _runtimeEnvSources[k] = 'auto';
316
+ }
317
+
127
318
  const newApi = {
128
319
  id: Date.now().toString(),
129
320
  name: name || `API-${this.config.apis.length + 1}`,
@@ -131,15 +322,18 @@ class ApiManager {
131
322
  baseUrl: urlValidation.value,
132
323
  authToken: encryptedToken.value,
133
324
  model: modelValidation.value,
134
- smallFastModel: modelValidation.value, // Same as model as requested
325
+ smallFastModel: templateValues.smallFastModel,
135
326
  createdAt: new Date().toISOString(),
136
- // Existing statistics fields
137
327
  lastUsed: null,
138
328
  usageCount: 0,
139
- // New statistics fields
140
329
  successCount: 0,
141
330
  failCount: 0,
142
- lastError: null
331
+ lastError: null,
332
+ modelEnvVars,
333
+ _autoModelEnvVars,
334
+ runtimeEnvVars,
335
+ _runtimeEnvSources,
336
+ customEnvVars: {},
143
337
  };
144
338
 
145
339
  this.config.apis.push(newApi);
@@ -241,17 +435,227 @@ class ApiManager {
241
435
  * @returns {Object} The updated API object
242
436
  */
243
437
  updateApiModel(apiId, newModel) {
438
+ return this.updateApiField(apiId, 'model', newModel);
439
+ }
440
+
441
+ /**
442
+ * Update a single field of an API configuration with validation
443
+ * @param {string} apiId - The API id
444
+ * @param {string} field - Field name: 'name', 'provider', 'baseUrl', 'model'
445
+ * @param {string} value - New value
446
+ * @returns {Object} The updated API object
447
+ */
448
+ updateApiField(apiId, field, value) {
449
+ const allowedFields = ['name', 'provider', 'baseUrl', 'model'];
450
+ if (!allowedFields.includes(field)) {
451
+ throw new Error(`Field '${field}' is not allowed. Allowed: ${allowedFields.join(', ')}`);
452
+ }
453
+
244
454
  const index = this.config.apis.findIndex(api => api.id === apiId);
245
455
  if (index === -1) {
246
456
  throw new Error(`API not found: ${apiId}`);
247
457
  }
248
458
 
249
- this.config.apis[index].model = newModel;
250
- this.config.apis[index].smallFastModel = newModel;
459
+ const api = this.config.apis[index];
460
+
461
+ // Manager-level validation
462
+ switch (field) {
463
+ case 'name': {
464
+ if (!value || value.trim() === '') {
465
+ throw new Error('Name cannot be empty when editing');
466
+ }
467
+ const nameValidation = validateApiName(value);
468
+ if (!nameValidation.valid) {
469
+ throw new Error(`Invalid name: ${nameValidation.error}`);
470
+ }
471
+ break;
472
+ }
473
+ case 'provider': {
474
+ const { getAllProviders } = require('./presets/providers');
475
+ const validIds = getAllProviders().map(p => p.id);
476
+ if (!validIds.includes(value)) {
477
+ throw new Error(`Unknown provider: ${value}. Valid: ${validIds.join(', ')}`);
478
+ }
479
+ return this.updateApiProvider(apiId, value).api;
480
+ }
481
+ case 'baseUrl': {
482
+ const urlValidation = validateBaseUrl(value);
483
+ if (!urlValidation.valid) {
484
+ throw new Error(`Invalid URL: ${urlValidation.error}`);
485
+ }
486
+ break;
487
+ }
488
+ case 'model': {
489
+ const modelValidation = validateModel(value);
490
+ if (!modelValidation.valid) {
491
+ throw new Error(`Invalid model: ${modelValidation.error}`);
492
+ }
493
+ break;
494
+ }
495
+ }
496
+
497
+ // Duplicate check for uniqueness-affecting fields
498
+ if (field === 'baseUrl' || field === 'model') {
499
+ const checkUrl = field === 'baseUrl' ? value : api.baseUrl;
500
+ const checkModel = field === 'model' ? value : api.model;
501
+ const decryptedToken = decrypt(api.authToken);
502
+ const tokenValue = decryptedToken.success ? decryptedToken.value : '';
503
+
504
+ // Check against all OTHER apis (exclude self)
505
+ const duplicate = this.config.apis.find((other, idx) => {
506
+ if (idx === index) return false;
507
+ const otherToken = decrypt(other.authToken);
508
+ const otherTokenValue = otherToken.success ? otherToken.value : '';
509
+ return other.baseUrl === checkUrl &&
510
+ otherTokenValue === tokenValue &&
511
+ other.model === checkModel;
512
+ });
513
+
514
+ if (duplicate) {
515
+ throw new Error(`Duplicate configuration: URL + Token + Model already exists for API '${duplicate.name}'`);
516
+ }
517
+ }
518
+
519
+ // Apply update
520
+ api[field] = value.trim();
521
+ if (field === 'model') {
522
+ const { getProvider } = require('./presets/providers');
523
+ const { PREDEFINED_MODEL_ENV_KEYS } = require('./validators');
524
+ const providerConfig = getProvider(api.provider);
525
+ let templateVals;
526
+ if (providerConfig && providerConfig.modelEnvTemplate) {
527
+ templateVals = providerConfig.modelEnvTemplate.getValues(value.trim());
528
+ } else {
529
+ templateVals = {};
530
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) templateVals[k] = value.trim();
531
+ templateVals.smallFastModel = value.trim();
532
+ }
533
+ if (api._autoModelEnvVars) {
534
+ if (!api.modelEnvVars) api.modelEnvVars = {};
535
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
536
+ if (api.modelEnvVars[k] === api._autoModelEnvVars[k]) {
537
+ api.modelEnvVars[k] = templateVals[k] || '';
538
+ }
539
+ }
540
+ if (api.smallFastModel === api._autoModelEnvVars.smallFastModel) {
541
+ api.smallFastModel = templateVals.smallFastModel;
542
+ }
543
+ } else {
544
+ // No snapshot means all fields are auto — overwrite all
545
+ if (!api.modelEnvVars) api.modelEnvVars = {};
546
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
547
+ api.modelEnvVars[k] = templateVals[k] || '';
548
+ }
549
+ api.smallFastModel = templateVals.smallFastModel;
550
+ }
551
+ api._autoModelEnvVars = { ...templateVals };
552
+ }
553
+
554
+ this.saveConfig();
555
+ return api;
556
+ }
557
+
558
+ updateModelEnvVar(apiId, key, value) {
559
+ const { PREDEFINED_MODEL_ENV_KEYS } = require('./validators');
560
+ if (!PREDEFINED_MODEL_ENV_KEYS.includes(key)) throw new Error(`"${key}" is not a predefined model env key`);
561
+ if (typeof value !== 'string') throw new Error('model env value must be a string');
562
+ const index = this.config.apis.findIndex(a => a.id === apiId);
563
+ if (index === -1) throw new Error(`API not found: ${apiId}`);
564
+ const api = this.config.apis[index];
565
+ const autoValue = api._autoModelEnvVars && typeof api._autoModelEnvVars[key] === 'string'
566
+ ? api._autoModelEnvVars[key]
567
+ : '';
568
+ api.modelEnvVars[key] = value === '' ? autoValue : value;
569
+ this.saveConfig();
570
+ return api;
571
+ }
572
+
573
+ updateRuntimeEnvVar(apiId, key, value, options = {}) {
574
+ const { PREDEFINED_RUNTIME_KEYS, validateRuntimeEnvValue } = require('./validators');
575
+ if (!PREDEFINED_RUNTIME_KEYS.includes(key)) throw new Error(`"${key}" is not a predefined runtime env key`);
576
+ const validation = validateRuntimeEnvValue(key, value);
577
+ if (!validation.valid) throw new Error(`Invalid value for ${key}: ${validation.error}`);
578
+ const index = this.config.apis.findIndex(a => a.id === apiId);
579
+ if (index === -1) throw new Error(`API not found: ${apiId}`);
580
+ if (options.source === 'auto') {
581
+ this.config.apis[index].runtimeEnvVars[key] = '';
582
+ this.config.apis[index]._runtimeEnvSources[key] = 'auto';
583
+ } else if (options.source === 'manual') {
584
+ this.config.apis[index].runtimeEnvVars[key] = value;
585
+ this.config.apis[index]._runtimeEnvSources[key] = 'manual';
586
+ } else {
587
+ this.config.apis[index].runtimeEnvVars[key] = value;
588
+ this.config.apis[index]._runtimeEnvSources[key] = (value === '') ? 'auto' : 'manual';
589
+ }
590
+ this.saveConfig();
591
+ return this.config.apis[index];
592
+ }
593
+
594
+ setCustomEnvVar(apiId, key, value) {
595
+ const { validateEnvKey } = require('./validators');
596
+ const kv = validateEnvKey(key);
597
+ if (!kv.valid) throw new Error(`Custom env key "${key}" is reserved or invalid`);
598
+ if (typeof value !== 'string') throw new Error('Custom env value must be a string');
599
+ const index = this.config.apis.findIndex(a => a.id === apiId);
600
+ if (index === -1) throw new Error(`API not found: ${apiId}`);
601
+ this.config.apis[index].customEnvVars[key] = value;
602
+ this.saveConfig();
603
+ return this.config.apis[index];
604
+ }
605
+
606
+ deleteCustomEnvVar(apiId, key) {
607
+ const index = this.config.apis.findIndex(a => a.id === apiId);
608
+ if (index === -1) throw new Error(`API not found: ${apiId}`);
609
+ delete this.config.apis[index].customEnvVars[key];
251
610
  this.saveConfig();
252
611
  return this.config.apis[index];
253
612
  }
254
613
 
614
+ updateApiProvider(apiId, newProviderId) {
615
+ const { getProvider, getAllProviders, detectProvider } = require('./presets/providers');
616
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS } = require('./validators');
617
+ const validIds = getAllProviders().map(p => p.id);
618
+ if (!validIds.includes(newProviderId)) throw new Error(`Unknown provider: ${newProviderId}`);
619
+ const index = this.config.apis.findIndex(a => a.id === apiId);
620
+ if (index === -1) throw new Error(`API not found: ${apiId}`);
621
+ const api = this.config.apis[index];
622
+ const newProvider = getProvider(newProviderId);
623
+ const warnings = [];
624
+ if (!newProvider.models.includes(api.model)) {
625
+ warnings.push({ code: 'MODEL_NOT_IN_PROVIDER', messageArgs: { model: api.model, providerName: newProvider.name } });
626
+ }
627
+ if (detectProvider(api.baseUrl) !== newProviderId) {
628
+ warnings.push({ code: 'BASE_URL_NOT_UPDATED', messageArgs: { baseUrl: api.baseUrl } });
629
+ }
630
+ if (api._runtimeEnvSources) {
631
+ for (const [key, source] of Object.entries(api._runtimeEnvSources)) {
632
+ if (source === 'auto' && PREDEFINED_RUNTIME_KEYS.includes(key)) api.runtimeEnvVars[key] = '';
633
+ }
634
+ }
635
+ let templateValues;
636
+ if (newProvider.modelEnvTemplate) {
637
+ templateValues = newProvider.modelEnvTemplate.getValues(api.model);
638
+ } else {
639
+ templateValues = {};
640
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) templateValues[k] = api.model;
641
+ templateValues.smallFastModel = api.model;
642
+ }
643
+ if (api._autoModelEnvVars) {
644
+ if (!api.modelEnvVars) api.modelEnvVars = {};
645
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
646
+ if (api.modelEnvVars[k] === api._autoModelEnvVars[k]) api.modelEnvVars[k] = templateValues[k] || '';
647
+ }
648
+ if (api.smallFastModel === api._autoModelEnvVars.smallFastModel) api.smallFastModel = templateValues.smallFastModel;
649
+ }
650
+ api._autoModelEnvVars = { ...templateValues };
651
+ if (detectProvider(api.baseUrl) !== newProviderId && warnings.some(w => w.code === 'MODEL_NOT_IN_PROVIDER')) {
652
+ warnings.push({ code: 'MIXED_PROVIDER_CONFIG', messageArgs: { providerId: newProviderId, baseUrl: api.baseUrl, model: api.model } });
653
+ }
654
+ api.provider = newProviderId;
655
+ this.saveConfig();
656
+ return { api, warnings };
657
+ }
658
+
255
659
  /**
256
660
  * Record a successful API launch
257
661
  * @returns {Object|null} The updated API object or null
@@ -289,6 +693,40 @@ class ApiManager {
289
693
  return null;
290
694
  }
291
695
 
696
+ /**
697
+ * Record a launch attempt (optimistic success)
698
+ * Call rollbackLaunchAttempt() if a pre-launch sync error occurs
699
+ * @returns {Object|null} The updated API object or null
700
+ */
701
+ recordLaunchAttempt() {
702
+ const activeApi = this.getActiveApi();
703
+ if (activeApi) {
704
+ const index = this.config.activeIndex;
705
+ this.config.apis[index].lastUsed = new Date().toISOString();
706
+ this.config.apis[index].usageCount = (this.config.apis[index].usageCount || 0) + 1;
707
+ this.config.apis[index].successCount = (this.config.apis[index].successCount || 0) + 1;
708
+ this.config.apis[index].lastError = null;
709
+ this.saveConfig();
710
+ return this.config.apis[index];
711
+ }
712
+ return null;
713
+ }
714
+
715
+ /**
716
+ * Rollback an optimistic launch attempt on pre-launch sync error
717
+ * @param {string} errorMessage - The error message
718
+ */
719
+ rollbackLaunchAttempt(errorMessage) {
720
+ const activeApi = this.getActiveApi();
721
+ if (activeApi) {
722
+ const index = this.config.activeIndex;
723
+ this.config.apis[index].successCount = Math.max(0, (this.config.apis[index].successCount || 0) - 1);
724
+ this.config.apis[index].failCount = (this.config.apis[index].failCount || 0) + 1;
725
+ this.config.apis[index].lastError = errorMessage;
726
+ this.saveConfig();
727
+ }
728
+ }
729
+
292
730
  /**
293
731
  * Get statistics about API usage
294
732
  */
@@ -456,14 +894,16 @@ class ApiManager {
456
894
  * Export configuration as plaintext JSON (already authenticated)
457
895
  */
458
896
  exportConfigAuthenticated() {
459
- // Create export data with plaintext API keys
460
897
  const exportData = {
898
+ configVersion: 2,
461
899
  version: this.config.version,
900
+ warning: 'This file contains plaintext API keys and custom environment variables. Handle with care.',
462
901
  exportedAt: new Date().toISOString(),
463
902
  apis: this.config.apis.map(api => {
464
903
  const decrypted = decrypt(api.authToken);
904
+ const { _autoFilledModel, ...safe } = api;
465
905
  return {
466
- ...api,
906
+ ...safe,
467
907
  authToken: decrypted.success ? decrypted.value : '***DECRYPTION_FAILED***'
468
908
  };
469
909
  }),
@@ -500,84 +940,203 @@ class ApiManager {
500
940
  processImportData(configData) {
501
941
  let imported = 0;
502
942
  let skipped = 0;
943
+ const warnings = [];
944
+ const skippedItems = [];
503
945
 
504
946
  if (!configData.apis || !Array.isArray(configData.apis)) {
505
947
  throw new Error('Invalid configuration format - no APIs found');
506
948
  }
507
949
 
950
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS, validateRuntimeEnvValue, RESERVED_ENV_KEYS } = require('./validators');
951
+
508
952
  configData.apis.forEach(importApi => {
509
- // Validate the API configuration before importing
953
+ if (this.config.apis.length >= 99) {
954
+ skipped++;
955
+ skippedItems.push({ apiName: importApi.name || 'Unknown', reason: 'Maximum 99 APIs reached' });
956
+ return;
957
+ }
958
+
510
959
  try {
511
- // Validate Base URL
512
960
  const urlValidation = validateBaseUrl(importApi.baseUrl);
513
961
  if (!urlValidation.valid) {
514
- console.warn(`Skipping API "${importApi.name || 'Unknown'}" - Invalid Base URL: ${urlValidation.error}`);
515
- skipped++;
516
- return;
962
+ skipped++; skippedItems.push({ apiName: importApi.name || 'Unknown', reason: urlValidation.error }); return;
517
963
  }
518
964
 
519
- // Validate Auth Token (skip validation for placeholder tokens)
520
965
  if (importApi.authToken !== '***REQUIRES_MANUAL_INPUT***') {
521
966
  const tokenValidation = validateAuthToken(importApi.authToken);
522
967
  if (!tokenValidation.valid) {
523
- console.warn(`Skipping API "${importApi.name || 'Unknown'}" - Invalid Auth Token: ${tokenValidation.error}`);
524
- skipped++;
525
- return;
968
+ skipped++; skippedItems.push({ apiName: importApi.name || 'Unknown', reason: tokenValidation.error }); return;
526
969
  }
527
970
  }
528
971
 
529
- // Validate Model
530
972
  const modelValidation = validateModel(importApi.model);
531
973
  if (!modelValidation.valid) {
532
- console.warn(`Skipping API "${importApi.name || 'Unknown'}" - Invalid Model: ${modelValidation.error}`);
533
- skipped++;
534
- return;
974
+ skipped++; skippedItems.push({ apiName: importApi.name || 'Unknown', reason: modelValidation.error }); return;
535
975
  }
536
976
 
537
- // Check for duplicates using the same logic as addApi
538
977
  const importToken = importApi.authToken === '***REQUIRES_MANUAL_INPUT***' ? '' : importApi.authToken;
539
978
  const duplicate = this.checkDuplicate(importApi.baseUrl, importToken, importApi.model);
540
979
 
541
980
  if (duplicate.isDuplicate) {
542
981
  skipped++;
543
- } else {
544
- // Encrypt the auth token if it's not already encrypted or masked
545
- let encryptedToken;
546
- if (importApi.authToken === '***REQUIRES_MANUAL_INPUT***') {
547
- encryptedToken = encrypt('').value; // Empty encrypted token
548
- } else {
549
- encryptedToken = encrypt(importApi.authToken).value;
982
+ skippedItems.push({ apiName: importApi.name || 'Unknown', reason: 'Duplicate configuration' });
983
+ return;
984
+ }
985
+
986
+ // Clean modelEnvVars: whitelist only
987
+ const cleanedModelEnvVars = {};
988
+ if (importApi.modelEnvVars) {
989
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) {
990
+ const v = importApi.modelEnvVars[k];
991
+ cleanedModelEnvVars[k] = (typeof v === 'string') ? v : '';
992
+ }
993
+ for (const k of Object.keys(importApi.modelEnvVars)) {
994
+ if (!PREDEFINED_MODEL_ENV_KEYS.includes(k)) {
995
+ warnings.push({ code: 'UNKNOWN_MODEL_ENV_KEY', apiName: importApi.name || 'Unknown', key: k });
996
+ }
997
+ }
998
+ }
999
+
1000
+ // Clean runtimeEnvVars: whitelist + validate
1001
+ const cleanedRuntimeEnvVars = {};
1002
+ const cleanedRuntimeEnvSources = {};
1003
+ if (importApi.runtimeEnvVars) {
1004
+ for (const k of PREDEFINED_RUNTIME_KEYS) {
1005
+ let v = importApi.runtimeEnvVars[k];
1006
+ if (typeof v !== 'string') v = '';
1007
+ if (v !== '' && !validateRuntimeEnvValue(k, v).valid) {
1008
+ warnings.push({ code: 'INVALID_RUNTIME_ENV_VALUE', apiName: importApi.name || 'Unknown', key: k });
1009
+ v = '';
1010
+ }
1011
+ cleanedRuntimeEnvVars[k] = v;
1012
+ const src = (importApi._runtimeEnvSources || {})[k];
1013
+ cleanedRuntimeEnvSources[k] = (src === 'manual' && v !== '') ? 'manual' : 'auto';
1014
+ }
1015
+ for (const k of Object.keys(importApi.runtimeEnvVars)) {
1016
+ if (!PREDEFINED_RUNTIME_KEYS.includes(k)) {
1017
+ warnings.push({ code: 'UNKNOWN_RUNTIME_ENV_KEY', apiName: importApi.name || 'Unknown', key: k });
1018
+ }
550
1019
  }
1020
+ }
551
1021
 
552
- const newApi = {
553
- id: Date.now() + Math.random(),
554
- name: importApi.name || `Imported API ${this.config.apis.length + 1}`,
555
- baseUrl: urlValidation.value,
556
- authToken: encryptedToken,
557
- model: modelValidation.value,
558
- provider: importApi.provider || 'custom',
559
- createdAt: new Date().toISOString(),
560
- lastUsed: null,
561
- usageCount: 0
562
- };
563
-
564
- this.config.apis.push(newApi);
565
- imported++;
566
-
567
- // Set as active if this is the first API
568
- if (this.config.apis.length === 1) {
569
- this.config.activeIndex = 0;
1022
+ // Clean customEnvVars: skip reserved/predefined
1023
+ const cleanedCustomEnvVars = {};
1024
+ if (importApi.customEnvVars) {
1025
+ const allP = new Set([...RESERVED_ENV_KEYS, ...PREDEFINED_RUNTIME_KEYS, ...PREDEFINED_MODEL_ENV_KEYS]);
1026
+ for (const [k, v] of Object.entries(importApi.customEnvVars)) {
1027
+ if (allP.has(k)) {
1028
+ warnings.push({ code: 'CUSTOM_ENV_KEY_RESERVED', apiName: importApi.name || 'Unknown', key: k });
1029
+ continue;
1030
+ }
1031
+ if (typeof v === 'string') cleanedCustomEnvVars[k] = v;
570
1032
  }
571
1033
  }
1034
+
1035
+ const tokenToEncrypt =
1036
+ importApi.authToken === '***REQUIRES_MANUAL_INPUT***' ? '' : importApi.authToken;
1037
+ const encryptedResult = encrypt(tokenToEncrypt);
1038
+ if (!encryptedResult.success) {
1039
+ skipped++;
1040
+ skippedItems.push({
1041
+ apiName: importApi.name || 'Unknown',
1042
+ reason: `Failed to encrypt auth token: ${encryptedResult.error}`
1043
+ });
1044
+ return;
1045
+ }
1046
+ const encryptedToken = encryptedResult.value;
1047
+
1048
+ const newApi = {
1049
+ id: Date.now() + Math.random(),
1050
+ name: importApi.name || `Imported API ${this.config.apis.length + 1}`,
1051
+ baseUrl: urlValidation.value,
1052
+ authToken: encryptedToken,
1053
+ model: modelValidation.value,
1054
+ provider: importApi.provider || 'custom',
1055
+ smallFastModel: importApi.smallFastModel || importApi.model,
1056
+ createdAt: new Date().toISOString(),
1057
+ lastUsed: null,
1058
+ usageCount: 0,
1059
+ modelEnvVars: cleanedModelEnvVars,
1060
+ runtimeEnvVars: cleanedRuntimeEnvVars,
1061
+ _runtimeEnvSources: cleanedRuntimeEnvSources,
1062
+ customEnvVars: cleanedCustomEnvVars,
1063
+ _autoFilledModel: importApi._autoFilledModel,
1064
+ };
1065
+ if (importApi._autoModelEnvVars) {
1066
+ newApi._autoModelEnvVars = importApi._autoModelEnvVars;
1067
+ }
1068
+ if (importApi.successCount !== undefined) newApi.successCount = importApi.successCount;
1069
+ if (importApi.failCount !== undefined) newApi.failCount = importApi.failCount;
1070
+ if (importApi.lastError !== undefined) newApi.lastError = importApi.lastError;
1071
+
1072
+ this._normalizeApiFields(newApi);
1073
+ this.config.apis.push(newApi);
1074
+ imported++;
1075
+ if (this.config.apis.length === 1) this.config.activeIndex = 0;
572
1076
  } catch (error) {
573
- console.warn(`Skipping API "${importApi.name || 'Unknown'}" - Validation error: ${error.message}`);
574
1077
  skipped++;
1078
+ skippedItems.push({ apiName: importApi.name || 'Unknown', reason: error.message });
575
1079
  }
576
1080
  });
577
1081
 
578
1082
  this.saveConfig();
579
- return { imported, skipped };
1083
+ return { imported, skipped, warnings, skippedItems };
1084
+ }
1085
+
1086
+ // --- Draft methods (no persistence, for pre-create config editing) ---
1087
+
1088
+ static buildApiDraft(provider, baseUrl, authToken, model, name) {
1089
+ const { getProvider } = require('./presets/providers');
1090
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS } = require('./validators');
1091
+ const providerConfig = getProvider(provider);
1092
+ let templateValues;
1093
+ if (providerConfig && providerConfig.modelEnvTemplate) {
1094
+ templateValues = providerConfig.modelEnvTemplate.getValues(model);
1095
+ } else {
1096
+ templateValues = {};
1097
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) templateValues[k] = model;
1098
+ templateValues.smallFastModel = model;
1099
+ }
1100
+ const modelEnvVars = {};
1101
+ for (const k of PREDEFINED_MODEL_ENV_KEYS) modelEnvVars[k] = templateValues[k] || '';
1102
+ const _autoModelEnvVars = { ...templateValues };
1103
+ const runtimeEnvVars = {};
1104
+ const _runtimeEnvSources = {};
1105
+ for (const k of PREDEFINED_RUNTIME_KEYS) { runtimeEnvVars[k] = ''; _runtimeEnvSources[k] = 'auto'; }
1106
+ return { provider, baseUrl, authToken, model, name,
1107
+ smallFastModel: templateValues.smallFastModel,
1108
+ modelEnvVars, _autoModelEnvVars,
1109
+ runtimeEnvVars, _runtimeEnvSources, customEnvVars: {} };
1110
+ }
1111
+
1112
+ static applyDraftEnvChange(draft, section, key, value) {
1113
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS, validateRuntimeEnvValue, validateEnvKey } = require('./validators');
1114
+ if (section === 'model') {
1115
+ if (!PREDEFINED_MODEL_ENV_KEYS.includes(key)) throw new Error(`Unknown model env key: ${key}`);
1116
+ if (typeof value !== 'string') throw new Error('model env value must be string');
1117
+ draft.modelEnvVars[key] = (value === '') ? (draft._autoModelEnvVars[key] || '') : value;
1118
+ } else if (section === 'runtime') {
1119
+ if (!PREDEFINED_RUNTIME_KEYS.includes(key)) throw new Error(`Unknown runtime env key: ${key}`);
1120
+ const v = validateRuntimeEnvValue(key, value);
1121
+ if (!v.valid) throw new Error(`Invalid: ${v.error}`);
1122
+ draft.runtimeEnvVars[key] = value;
1123
+ draft._runtimeEnvSources[key] = (value === '') ? 'auto' : 'manual';
1124
+ } else if (section === 'custom') {
1125
+ const kv = validateEnvKey(key);
1126
+ if (!kv.valid) throw new Error(`Invalid custom key: ${kv.error}`);
1127
+ if (typeof value !== 'string') throw new Error('custom env value must be string');
1128
+ draft.customEnvVars[key] = value;
1129
+ }
1130
+ return draft;
1131
+ }
1132
+
1133
+ static deleteDraftCustomEnvVar(draft, key) {
1134
+ delete draft.customEnvVars[key];
1135
+ return draft;
580
1136
  }
581
1137
  }
582
1138
 
583
- module.exports = ApiManager;
1139
+ module.exports = ApiManager;
1140
+ module.exports.DuplicateApiError = DuplicateApiError;
1141
+ module.exports.MODEL_CONFIG_LABELS = MODEL_CONFIG_LABELS;
1142
+ module.exports.RUNTIME_CONFIG_LABELS = RUNTIME_CONFIG_LABELS;