coding-tool-x 3.7.7 → 3.7.9

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.
@@ -3,25 +3,31 @@ const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
4
  const yaml = require('js-yaml');
5
5
  const ompConfig = require('./omp-config');
6
+ const { MODEL_METADATA, MODEL_ALIASES } = require('../../config/model-metadata');
7
+ const {
8
+ MODEL_METADATA_MODES,
9
+ isPlainObject,
10
+ normalizeProviderConfig,
11
+ resolveModelDefinition,
12
+ serializeModelSelector,
13
+ splitModelSelector
14
+ } = require('./model-definition-schema');
6
15
 
7
16
  const MANAGED_PROVIDER_PREFIX = 'ctx-';
8
17
  const MODELS_BACKUP_MARKER = '.ctx-backup-';
9
18
  const VISIBILITY_STATE_VERSION = 1;
10
19
  const MANAGED_VISIBILITY_STATE_FILE = 'coding-tool-x-omp-managed-visibility.json';
11
20
  const NO_MANAGED_MODELS_SELECTOR = `${MANAGED_PROVIDER_PREFIX}coding-tool-x/__no_models_configured__`;
12
- const THINKING_EFFORT_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
13
- const THINKING_MODES = new Set([
14
- 'effort',
15
- 'budget',
16
- 'google-level',
17
- 'anthropic-adaptive',
18
- 'anthropic-budget-effort'
19
- ]);
20
-
21
21
  let lastManagedOmpSyncResult = null;
22
+ const OMP_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
23
+ const OMP_CATALOG_FAILURE_CACHE_TTL_MS = 30 * 1000;
24
+ const ompCatalogCache = new Map();
22
25
 
23
- function getOmpPaths(...args) {
24
- return ompConfig.getOmpPaths(...args);
26
+ function getOmpPaths(env = process.env, options = {}) {
27
+ // Channel CRUD only needs the native OMP file layout. Do not start OMP just
28
+ // to discover a path: OMP_CONFIG_DIR, OMP_PROFILE and OMP_CODING_AGENT_DIR
29
+ // are sufficient to derive it, and a blocked CLI must not block a save.
30
+ return ompConfig.getOmpPaths(env, { ...options, resolveRuntime: false });
25
31
  }
26
32
 
27
33
  function ensureOmpDir(...args) {
@@ -58,87 +64,6 @@ function normalizeProviderApi(value = '') {
58
64
  return normalized;
59
65
  }
60
66
 
61
- function defaultCost() {
62
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
63
- }
64
-
65
- function normalizeCost(value) {
66
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
67
- return defaultCost();
68
- }
69
- const numberOrZero = (item) => {
70
- const number = Number(item);
71
- return Number.isFinite(number) ? number : 0;
72
- };
73
- return {
74
- input: numberOrZero(value.input),
75
- output: numberOrZero(value.output),
76
- cacheRead: numberOrZero(value.cacheRead ?? value.cache_read),
77
- cacheWrite: numberOrZero(value.cacheWrite ?? value.cache_write)
78
- };
79
- }
80
-
81
- function normalizePositiveNumber(value, fallback) {
82
- const number = Number(value);
83
- return Number.isFinite(number) && number > 0 ? number : fallback;
84
- }
85
-
86
- function normalizeModelInput(value) {
87
- if (!Array.isArray(value)) return ['text', 'image'];
88
- const filtered = value.filter(item => item === 'text' || item === 'image');
89
- return filtered.length > 0 ? filtered : ['text', 'image'];
90
- }
91
-
92
- function normalizeThinkingEfforts(value) {
93
- if (!Array.isArray(value)) return undefined;
94
- const seen = new Set();
95
- const filtered = value
96
- .map(item => String(item || '').trim())
97
- .filter(item => THINKING_EFFORT_LEVELS.includes(item))
98
- .filter((item) => {
99
- if (seen.has(item)) return false;
100
- seen.add(item);
101
- return true;
102
- });
103
- return filtered.length > 0 ? filtered : undefined;
104
- }
105
-
106
- function normalizeThinking(value) {
107
- if (Array.isArray(value)) {
108
- const efforts = normalizeThinkingEfforts(value);
109
- return efforts ? { mode: 'effort', efforts } : undefined;
110
- }
111
- if (typeof value === 'string') {
112
- const trimmed = value.trim();
113
- if (THINKING_EFFORT_LEVELS.includes(trimmed)) {
114
- return { mode: 'effort', efforts: [trimmed] };
115
- }
116
- return undefined;
117
- }
118
- if (value && typeof value === 'object') {
119
- const mode = String(value.mode || '').trim();
120
- if (!THINKING_MODES.has(mode)) return undefined;
121
- const efforts = normalizeThinkingEfforts(value.efforts || value.levels);
122
- const minLevel = String(value.minLevel || '').trim();
123
- const maxLevel = String(value.maxLevel || '').trim();
124
- const defaultLevel = String(value.defaultLevel || '').trim();
125
- const hasRange = THINKING_EFFORT_LEVELS.includes(minLevel) && THINKING_EFFORT_LEVELS.includes(maxLevel);
126
- if (!efforts && !hasRange) return undefined;
127
- return compactObject({
128
- mode,
129
- efforts,
130
- defaultLevel: THINKING_EFFORT_LEVELS.includes(defaultLevel) ? defaultLevel : undefined,
131
- minLevel: efforts ? undefined : minLevel,
132
- maxLevel: efforts ? undefined : maxLevel,
133
- effortMap: value.effortMap && typeof value.effortMap === 'object' && !Array.isArray(value.effortMap)
134
- ? value.effortMap
135
- : undefined,
136
- supportsDisplay: typeof value.supportsDisplay === 'boolean' ? value.supportsDisplay : undefined
137
- });
138
- }
139
- return undefined;
140
- }
141
-
142
67
  function compactObject(value = {}) {
143
68
  const next = { ...value };
144
69
  Object.keys(next).forEach((key) => {
@@ -147,6 +72,15 @@ function compactObject(value = {}) {
147
72
  return next;
148
73
  }
149
74
 
75
+ function resolveBuiltinOmpMetadata(modelId) {
76
+ const id = String(modelId || '').trim().toLowerCase();
77
+ if (!id) return null;
78
+ const exactKey = Object.keys(MODEL_METADATA).find(key => key.toLowerCase() === id);
79
+ if (exactKey) return MODEL_METADATA[exactKey];
80
+ const aliasKey = Object.keys(MODEL_ALIASES).find(key => key.toLowerCase() === id);
81
+ return aliasKey ? (MODEL_METADATA[MODEL_ALIASES[aliasKey]] || null) : null;
82
+ }
83
+
150
84
  function uniqueStrings(values = []) {
151
85
  const seen = new Set();
152
86
  const result = [];
@@ -182,46 +116,33 @@ function getOwnProperty(object, key) {
182
116
  : false;
183
117
  }
184
118
 
185
- function mergeDefined(base = {}, override = {}) {
186
- const next = { ...base };
187
- Object.entries(override || {}).forEach(([key, value]) => {
188
- if (value !== undefined) {
189
- next[key] = value;
190
- }
191
- });
192
- return next;
193
- }
194
-
195
- function buildModelEntry(model = {}, fallbackId = '') {
196
- const id = String(model.id || model.name || fallbackId || '').trim();
197
- if (!id) return null;
198
- return compactObject({
199
- id,
200
- name: model.name || id,
201
- api: model.api ? normalizeProviderApi(model.api) : undefined,
202
- reasoning: model.reasoning === true,
203
- thinking: normalizeThinking(model.thinking),
204
- input: normalizeModelInput(model.input),
205
- cost: normalizeCost(model.cost),
206
- contextWindow: normalizePositiveNumber(model.contextWindow || model.context_window, 128000),
207
- maxTokens: normalizePositiveNumber(model.maxTokens || model.max_tokens, 4096),
208
- headers: model.headers && typeof model.headers === 'object' && !Array.isArray(model.headers)
209
- ? model.headers
210
- : undefined,
211
- compat: model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat)
212
- ? model.compat
213
- : undefined
214
- });
215
- }
216
-
217
119
  function normalizeModels(channel = {}, options = {}) {
218
120
  const models = [];
219
121
  const catalogIndex = options.catalogIndex instanceof Map ? options.catalogIndex : new Map();
122
+ const defaultMode = MODEL_METADATA_MODES.includes(channel.modelMetadataMode)
123
+ ? channel.modelMetadataMode
124
+ : 'auto';
220
125
  const add = (value) => {
221
- const id = String(value || '').trim();
222
- if (!id || models.some(model => model.id === id)) return;
223
- const catalogModel = catalogIndex.get(id.toLowerCase()) || {};
224
- const entry = buildModelEntry({ ...catalogModel, id }, id);
126
+ const raw = typeof value === 'string' ? { id: value } : value;
127
+ if (!raw || typeof raw !== 'object') return;
128
+ const selector = splitModelSelector(raw.id || raw.name || '');
129
+ const id = selector.modelId;
130
+ if (!id || models.some(model => model.id.toLowerCase() === id.toLowerCase())) return;
131
+ const definition = {
132
+ ...raw,
133
+ id,
134
+ metadataMode: MODEL_METADATA_MODES.includes(raw.metadataMode) ? raw.metadataMode : defaultMode
135
+ };
136
+ const resolved = resolveModelDefinition(definition, {
137
+ // OMP provider limits are provider-specific. Use exact built-in metadata
138
+ // only; prefix and generic-family fallbacks could silently invent limits.
139
+ builtin: resolveBuiltinOmpMetadata(id) || {},
140
+ catalog: catalogIndex.get(id.toLowerCase()) || {}
141
+ });
142
+ if (Array.isArray(options.warnings)) {
143
+ options.warnings.push(...resolved.warnings.map(message => `${id}: ${message}`));
144
+ }
145
+ const entry = resolved.spec;
225
146
  if (entry) models.push(entry);
226
147
  };
227
148
 
@@ -232,11 +153,7 @@ function normalizeModels(channel = {}, options = {}) {
232
153
  return;
233
154
  }
234
155
  if (!model || typeof model !== 'object') return;
235
- const id = String(model.id || model.name || '').trim();
236
- if (!id || models.some(item => item.id === id)) return;
237
- const catalogModel = catalogIndex.get(id.toLowerCase()) || {};
238
- const entry = buildModelEntry(mergeDefined(catalogModel, model), id);
239
- if (entry) models.push(entry);
156
+ add(model);
240
157
  });
241
158
  }
242
159
 
@@ -245,11 +162,21 @@ function normalizeModels(channel = {}, options = {}) {
245
162
  if (Array.isArray(channel.allowedModels)) {
246
163
  channel.allowedModels.forEach(add);
247
164
  }
165
+ if (Array.isArray(channel.modelBindings)) {
166
+ channel.modelBindings.forEach((binding) => add(binding?.modelId || binding?.id || binding));
167
+ }
248
168
 
249
169
  return models.map(compactObject);
250
170
  }
251
171
 
252
172
  function readOmpCatalogModels(command, providerId, options = {}) {
173
+ const cacheKey = `${command}\u0000${providerId}`;
174
+ const cached = ompCatalogCache.get(cacheKey);
175
+ const now = Date.now();
176
+ const cacheTtl = cached?.failed ? OMP_CATALOG_FAILURE_CACHE_TTL_MS : OMP_CATALOG_CACHE_TTL_MS;
177
+ if (options.forceCatalogRefresh !== true && cached && now - cached.cachedAt < cacheTtl) {
178
+ return cached.models.map(model => ({ ...model }));
179
+ }
253
180
  const runner = options.catalogRunner || options.modelsRunner || spawnSync;
254
181
  const result = runner(command, ['models', providerId, '--json'], {
255
182
  encoding: 'utf8',
@@ -257,24 +184,36 @@ function readOmpCatalogModels(command, providerId, options = {}) {
257
184
  ...process.env,
258
185
  ...(options.env || {})
259
186
  },
260
- timeout: options.catalogTimeout || 5000
187
+ timeout: options.catalogTimeout || 5000,
188
+ windowsHide: true
261
189
  });
262
190
  const status = result?.status === undefined || result?.status === null ? 0 : result.status;
263
- if (result?.error || status !== 0) return [];
191
+ if (result?.error || status !== 0) {
192
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models: [], failed: true });
193
+ return [];
194
+ }
264
195
  try {
265
196
  const parsed = JSON.parse(normalizeCommandOutput(result?.stdout).trim() || '{}');
266
- return Array.isArray(parsed.models) ? parsed.models : [];
197
+ const models = Array.isArray(parsed.models) ? parsed.models : [];
198
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models, failed: false });
199
+ return models.map(model => ({ ...model }));
267
200
  } catch {
201
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models: [], failed: true });
268
202
  return [];
269
203
  }
270
204
  }
271
205
 
206
+ function getOmpCatalogModels(providerId, options = {}) {
207
+ const normalizedProviderId = normalizeProviderId(providerId);
208
+ if (!normalizedProviderId) return [];
209
+ // Catalog sync is an explicit user action. Assume OMP is installed and call
210
+ // it directly instead of paying for a separate `omp --version` probe.
211
+ const command = options.command || options.runtime?.command || options.env?.OMP_COMMAND || process.env.OMP_COMMAND || 'omp';
212
+ return readOmpCatalogModels(command, normalizedProviderId, options);
213
+ }
214
+
272
215
  function buildCatalogIndex(channel = {}, options = {}) {
273
- const runtime = options.runtime
274
- || (typeof ompConfig.resolveOmpRuntime === 'function'
275
- ? ompConfig.resolveOmpRuntime(options.env || process.env, options.runtimeOptions || {})
276
- : null);
277
- if (!runtime || runtime.runtime !== 'omp' || !runtime.installed) {
216
+ if (options.catalogFromCli !== true) {
278
217
  return new Map();
279
218
  }
280
219
 
@@ -285,7 +224,7 @@ function buildCatalogIndex(channel = {}, options = {}) {
285
224
  if (!providerId) return new Map();
286
225
 
287
226
  const index = new Map();
288
- readOmpCatalogModels(runtime.command, providerId, options).forEach((model) => {
227
+ getOmpCatalogModels(providerId, options).forEach((model) => {
289
228
  const id = String(model?.id || model?.name || '').trim();
290
229
  if (id) {
291
230
  index.set(id.toLowerCase(), model);
@@ -298,21 +237,38 @@ function buildProviderEntry(channel = {}, options = {}) {
298
237
  const catalogIndex = options.catalogIndex instanceof Map
299
238
  ? options.catalogIndex
300
239
  : buildCatalogIndex(channel, options);
301
- const entry = {
302
- baseUrl: String(channel.baseUrl || '').trim(),
303
- api: normalizeProviderApi(channel.providerApi || channel.api || channel.wireApi),
304
- models: normalizeModels(channel, { catalogIndex })
305
- };
240
+ const providerConfig = isPlainObject(channel.providerConfig) ? channel.providerConfig : {};
241
+ const providerSource = { ...providerConfig };
242
+ for (const key of [
243
+ 'headers',
244
+ 'compat',
245
+ 'remoteCompaction',
246
+ 'modelOverrides',
247
+ 'authHeader',
248
+ 'auth',
249
+ 'discovery',
250
+ 'disableStrictTools',
251
+ 'transport'
252
+ ]) {
253
+ if (channel[key] !== undefined) providerSource[key] = channel[key];
254
+ }
255
+ const normalizedProvider = normalizeProviderConfig(providerSource);
256
+ if (Array.isArray(options.warnings)) {
257
+ const providerName = channel.providerKey || channel.provider || channel.name || channel.id || 'provider';
258
+ options.warnings.push(...normalizedProvider.warnings.map(message => `${providerName}: ${message}`));
259
+ }
260
+ const entry = compactObject({
261
+ ...normalizedProvider.config,
262
+ baseUrl: String(channel.baseUrl || providerConfig.baseUrl || '').trim(),
263
+ api: normalizeProviderApi(channel.providerApi || channel.api || channel.wireApi || providerConfig.api),
264
+ models: normalizeModels(channel, { catalogIndex, warnings: options.warnings })
265
+ });
306
266
 
307
- const apiKey = String(channel.apiKey || '').trim();
267
+ const apiKey = String(channel.apiKey || providerConfig.apiKey || '').trim();
308
268
  if (apiKey) {
309
269
  entry.apiKey = apiKey;
310
270
  }
311
271
 
312
- if (channel.headers && typeof channel.headers === 'object' && !Array.isArray(channel.headers)) {
313
- entry.headers = channel.headers;
314
- }
315
-
316
272
  return entry;
317
273
  }
318
274
 
@@ -499,7 +455,8 @@ function runModelsJson(command, env, options = {}) {
499
455
  ...process.env,
500
456
  ...env
501
457
  },
502
- timeout: options.timeout || 5000
458
+ timeout: options.timeout || 5000,
459
+ windowsHide: true
503
460
  });
504
461
  if (typeof result === 'string' || Buffer.isBuffer(result)) {
505
462
  return { status: 0, stdout: result, stderr: '' };
@@ -515,7 +472,8 @@ function collectVisibleProviderIds(command, env, options = {}) {
515
472
  ...process.env,
516
473
  ...env
517
474
  },
518
- timeout: options.visibilityTimeout || options.timeout || 5000
475
+ timeout: options.visibilityTimeout || options.timeout || 5000,
476
+ windowsHide: true
519
477
  });
520
478
  const status = result?.status === undefined || result?.status === null ? 0 : result.status;
521
479
  if (result?.error || status !== 0) return [];
@@ -635,6 +593,7 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
635
593
  const managedDisabledProviders = [];
636
594
  const warnings = [];
637
595
  const providers = modelsConfig.providers || {};
596
+ let managedDefaultModel = null;
638
597
 
639
598
  (channels || [])
640
599
  .filter(channel => channel && channel.enabled !== false && channel.baseUrl)
@@ -649,6 +608,20 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
649
608
  }
650
609
  });
651
610
 
611
+ const defaultSelection = splitModelSelector(channel.model || '');
612
+ if (!managedDefaultModel && defaultSelection.modelId) {
613
+ const hasDefaultModel = models.some((model) => {
614
+ const modelId = String(model?.id || model?.name || '').trim();
615
+ return modelId.toLowerCase() === defaultSelection.modelId.toLowerCase();
616
+ });
617
+ if (hasDefaultModel) {
618
+ managedDefaultModel = `${managedProviderId}/${serializeModelSelector(
619
+ defaultSelection.modelId,
620
+ defaultSelection.thinkingLevel
621
+ )}`;
622
+ }
623
+ }
624
+
652
625
  const originalProviderId = getOriginalProviderId(channel);
653
626
  if (originalProviderId && !isManagedProviderId(originalProviderId)) {
654
627
  pushUnique(managedDisabledProviders, originalProviderId);
@@ -658,15 +631,17 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
658
631
  }
659
632
  });
660
633
 
661
- const env = options.env || process.env;
662
- const runtime = options.runtime
663
- || (typeof ompConfig.resolveOmpRuntime === 'function'
664
- ? ompConfig.resolveOmpRuntime(env, options.runtimeOptions || {})
665
- : null);
666
- if (runtime && runtime.runtime === 'omp' && runtime.installed && options.discoverDisabledProviders !== false) {
667
- collectVisibleProviderIds(runtime.command, env, options)
668
- .filter(providerId => providerId && !isManagedProviderId(providerId))
669
- .forEach(providerId => pushUnique(managedDisabledProviders, providerId));
634
+ if (options.discoverDisabledProviders === true) {
635
+ const env = options.env || process.env;
636
+ const runtime = options.runtime
637
+ || (typeof ompConfig.resolveOmpRuntime === 'function'
638
+ ? ompConfig.resolveOmpRuntime(env, options.runtimeOptions || {})
639
+ : null);
640
+ if (runtime && runtime.runtime === 'omp' && runtime.installed) {
641
+ collectVisibleProviderIds(runtime.command, env, options)
642
+ .filter(providerId => providerId && !isManagedProviderId(providerId))
643
+ .forEach(providerId => pushUnique(managedDisabledProviders, providerId));
644
+ }
670
645
  }
671
646
 
672
647
  return {
@@ -675,7 +650,7 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
675
650
  ? managedEnabledModels
676
651
  : [NO_MANAGED_MODELS_SELECTOR],
677
652
  managedDisabledProviders,
678
- managedDefaultModel: managedEnabledModels[0] || null,
653
+ managedDefaultModel: managedDefaultModel || managedEnabledModels[0] || null,
679
654
  warnings
680
655
  };
681
656
  }
@@ -870,12 +845,19 @@ function removeManagedOmpVisibility(options = {}) {
870
845
 
871
846
  function writeManagedOmpProviders(channels = [], options = {}) {
872
847
  const paths = getOmpPaths();
873
- const config = buildManagedModelsConfig(channels, readModelsConfig(paths.modelsYml), options);
848
+ const buildWarnings = [];
849
+ const config = buildManagedModelsConfig(channels, readModelsConfig(paths.modelsYml), {
850
+ ...options,
851
+ warnings: buildWarnings
852
+ });
874
853
  const backupPath = createModelsBackupIfNeeded(paths.modelsYml, options);
875
854
  writeModelsConfig(config, paths.modelsYml);
876
855
  const visibility = syncManagedOmpVisibility(channels, config, options);
877
- const validation = validateOmpModelsConfig(options);
856
+ const validation = options.validateWithCli === true
857
+ ? validateOmpModelsConfig(options)
858
+ : { skipped: true, reason: 'cli-validation-disabled', warnings: [] };
878
859
  const warnings = [
860
+ ...buildWarnings,
879
861
  ...(visibility.warnings || []),
880
862
  ...(validation.warnings || [])
881
863
  ];
@@ -916,7 +898,9 @@ function removeManagedOmpProviders(options = {}) {
916
898
  if (before !== after) {
917
899
  backupPath = createModelsBackupIfNeeded(paths.modelsYml, options);
918
900
  writeModelsConfig(config, paths.modelsYml);
919
- validation = validateOmpModelsConfig(options);
901
+ validation = options.validateWithCli === true
902
+ ? validateOmpModelsConfig(options)
903
+ : { skipped: true, reason: 'cli-validation-disabled', warnings: [] };
920
904
  }
921
905
  recordManagedOmpSyncResult({
922
906
  path: paths.modelsYml,
@@ -960,12 +944,14 @@ module.exports = {
960
944
  buildManagedExtensionSource,
961
945
  buildManagedModelsConfig,
962
946
  buildProviderEntry,
947
+ getOmpCatalogModels,
963
948
  getManagedProviderId,
964
949
  getLastManagedOmpSyncResult,
965
950
  isManagedOmpProvidersActive,
966
951
  isManagedProviderExtensionActive: isManagedOmpProvidersActive,
967
952
  normalizeProviderId,
968
953
  normalizeProviderApi,
954
+ normalizeModels,
969
955
  readOmpSettingsConfig,
970
956
  readModelsConfig,
971
957
  removeManagedOmpProviders,