coding-tool-x 3.7.8 → 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,22 +3,25 @@ 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
26
  function getOmpPaths(env = process.env, options = {}) {
24
27
  // Channel CRUD only needs the native OMP file layout. Do not start OMP just
@@ -61,87 +64,6 @@ function normalizeProviderApi(value = '') {
61
64
  return normalized;
62
65
  }
63
66
 
64
- function defaultCost() {
65
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
66
- }
67
-
68
- function normalizeCost(value) {
69
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
70
- return defaultCost();
71
- }
72
- const numberOrZero = (item) => {
73
- const number = Number(item);
74
- return Number.isFinite(number) ? number : 0;
75
- };
76
- return {
77
- input: numberOrZero(value.input),
78
- output: numberOrZero(value.output),
79
- cacheRead: numberOrZero(value.cacheRead ?? value.cache_read),
80
- cacheWrite: numberOrZero(value.cacheWrite ?? value.cache_write)
81
- };
82
- }
83
-
84
- function normalizePositiveNumber(value, fallback) {
85
- const number = Number(value);
86
- return Number.isFinite(number) && number > 0 ? number : fallback;
87
- }
88
-
89
- function normalizeModelInput(value) {
90
- if (!Array.isArray(value)) return ['text', 'image'];
91
- const filtered = value.filter(item => item === 'text' || item === 'image');
92
- return filtered.length > 0 ? filtered : ['text', 'image'];
93
- }
94
-
95
- function normalizeThinkingEfforts(value) {
96
- if (!Array.isArray(value)) return undefined;
97
- const seen = new Set();
98
- const filtered = value
99
- .map(item => String(item || '').trim())
100
- .filter(item => THINKING_EFFORT_LEVELS.includes(item))
101
- .filter((item) => {
102
- if (seen.has(item)) return false;
103
- seen.add(item);
104
- return true;
105
- });
106
- return filtered.length > 0 ? filtered : undefined;
107
- }
108
-
109
- function normalizeThinking(value) {
110
- if (Array.isArray(value)) {
111
- const efforts = normalizeThinkingEfforts(value);
112
- return efforts ? { mode: 'effort', efforts } : undefined;
113
- }
114
- if (typeof value === 'string') {
115
- const trimmed = value.trim();
116
- if (THINKING_EFFORT_LEVELS.includes(trimmed)) {
117
- return { mode: 'effort', efforts: [trimmed] };
118
- }
119
- return undefined;
120
- }
121
- if (value && typeof value === 'object') {
122
- const mode = String(value.mode || '').trim();
123
- if (!THINKING_MODES.has(mode)) return undefined;
124
- const efforts = normalizeThinkingEfforts(value.efforts || value.levels);
125
- const minLevel = String(value.minLevel || '').trim();
126
- const maxLevel = String(value.maxLevel || '').trim();
127
- const defaultLevel = String(value.defaultLevel || '').trim();
128
- const hasRange = THINKING_EFFORT_LEVELS.includes(minLevel) && THINKING_EFFORT_LEVELS.includes(maxLevel);
129
- if (!efforts && !hasRange) return undefined;
130
- return compactObject({
131
- mode,
132
- efforts,
133
- defaultLevel: THINKING_EFFORT_LEVELS.includes(defaultLevel) ? defaultLevel : undefined,
134
- minLevel: efforts ? undefined : minLevel,
135
- maxLevel: efforts ? undefined : maxLevel,
136
- effortMap: value.effortMap && typeof value.effortMap === 'object' && !Array.isArray(value.effortMap)
137
- ? value.effortMap
138
- : undefined,
139
- supportsDisplay: typeof value.supportsDisplay === 'boolean' ? value.supportsDisplay : undefined
140
- });
141
- }
142
- return undefined;
143
- }
144
-
145
67
  function compactObject(value = {}) {
146
68
  const next = { ...value };
147
69
  Object.keys(next).forEach((key) => {
@@ -150,6 +72,15 @@ function compactObject(value = {}) {
150
72
  return next;
151
73
  }
152
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
+
153
84
  function uniqueStrings(values = []) {
154
85
  const seen = new Set();
155
86
  const result = [];
@@ -185,46 +116,33 @@ function getOwnProperty(object, key) {
185
116
  : false;
186
117
  }
187
118
 
188
- function mergeDefined(base = {}, override = {}) {
189
- const next = { ...base };
190
- Object.entries(override || {}).forEach(([key, value]) => {
191
- if (value !== undefined) {
192
- next[key] = value;
193
- }
194
- });
195
- return next;
196
- }
197
-
198
- function buildModelEntry(model = {}, fallbackId = '') {
199
- const id = String(model.id || model.name || fallbackId || '').trim();
200
- if (!id) return null;
201
- return compactObject({
202
- id,
203
- name: model.name || id,
204
- api: model.api ? normalizeProviderApi(model.api) : undefined,
205
- reasoning: model.reasoning === true,
206
- thinking: normalizeThinking(model.thinking),
207
- input: normalizeModelInput(model.input),
208
- cost: normalizeCost(model.cost),
209
- contextWindow: normalizePositiveNumber(model.contextWindow || model.context_window, 128000),
210
- maxTokens: normalizePositiveNumber(model.maxTokens || model.max_tokens, 4096),
211
- headers: model.headers && typeof model.headers === 'object' && !Array.isArray(model.headers)
212
- ? model.headers
213
- : undefined,
214
- compat: model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat)
215
- ? model.compat
216
- : undefined
217
- });
218
- }
219
-
220
119
  function normalizeModels(channel = {}, options = {}) {
221
120
  const models = [];
222
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';
223
125
  const add = (value) => {
224
- const id = String(value || '').trim();
225
- if (!id || models.some(model => model.id === id)) return;
226
- const catalogModel = catalogIndex.get(id.toLowerCase()) || {};
227
- 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;
228
146
  if (entry) models.push(entry);
229
147
  };
230
148
 
@@ -235,11 +153,7 @@ function normalizeModels(channel = {}, options = {}) {
235
153
  return;
236
154
  }
237
155
  if (!model || typeof model !== 'object') return;
238
- const id = String(model.id || model.name || '').trim();
239
- if (!id || models.some(item => item.id === id)) return;
240
- const catalogModel = catalogIndex.get(id.toLowerCase()) || {};
241
- const entry = buildModelEntry(mergeDefined(catalogModel, model), id);
242
- if (entry) models.push(entry);
156
+ add(model);
243
157
  });
244
158
  }
245
159
 
@@ -248,11 +162,21 @@ function normalizeModels(channel = {}, options = {}) {
248
162
  if (Array.isArray(channel.allowedModels)) {
249
163
  channel.allowedModels.forEach(add);
250
164
  }
165
+ if (Array.isArray(channel.modelBindings)) {
166
+ channel.modelBindings.forEach((binding) => add(binding?.modelId || binding?.id || binding));
167
+ }
251
168
 
252
169
  return models.map(compactObject);
253
170
  }
254
171
 
255
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
+ }
256
180
  const runner = options.catalogRunner || options.modelsRunner || spawnSync;
257
181
  const result = runner(command, ['models', providerId, '--json'], {
258
182
  encoding: 'utf8',
@@ -264,26 +188,34 @@ function readOmpCatalogModels(command, providerId, options = {}) {
264
188
  windowsHide: true
265
189
  });
266
190
  const status = result?.status === undefined || result?.status === null ? 0 : result.status;
267
- if (result?.error || status !== 0) return [];
191
+ if (result?.error || status !== 0) {
192
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models: [], failed: true });
193
+ return [];
194
+ }
268
195
  try {
269
196
  const parsed = JSON.parse(normalizeCommandOutput(result?.stdout).trim() || '{}');
270
- 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 }));
271
200
  } catch {
201
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models: [], failed: true });
272
202
  return [];
273
203
  }
274
204
  }
275
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
+
276
215
  function buildCatalogIndex(channel = {}, options = {}) {
277
216
  if (options.catalogFromCli !== true) {
278
217
  return new Map();
279
218
  }
280
- const runtime = options.runtime
281
- || (typeof ompConfig.resolveOmpRuntime === 'function'
282
- ? ompConfig.resolveOmpRuntime(options.env || process.env, options.runtimeOptions || {})
283
- : null);
284
- if (!runtime || runtime.runtime !== 'omp' || !runtime.installed) {
285
- return new Map();
286
- }
287
219
 
288
220
  const rawProviderId = normalizeProviderId(channel.providerKey || channel.provider || channel.name || '');
289
221
  const providerId = rawProviderId.startsWith(MANAGED_PROVIDER_PREFIX)
@@ -292,7 +224,7 @@ function buildCatalogIndex(channel = {}, options = {}) {
292
224
  if (!providerId) return new Map();
293
225
 
294
226
  const index = new Map();
295
- readOmpCatalogModels(runtime.command, providerId, options).forEach((model) => {
227
+ getOmpCatalogModels(providerId, options).forEach((model) => {
296
228
  const id = String(model?.id || model?.name || '').trim();
297
229
  if (id) {
298
230
  index.set(id.toLowerCase(), model);
@@ -305,21 +237,38 @@ function buildProviderEntry(channel = {}, options = {}) {
305
237
  const catalogIndex = options.catalogIndex instanceof Map
306
238
  ? options.catalogIndex
307
239
  : buildCatalogIndex(channel, options);
308
- const entry = {
309
- baseUrl: String(channel.baseUrl || '').trim(),
310
- api: normalizeProviderApi(channel.providerApi || channel.api || channel.wireApi),
311
- models: normalizeModels(channel, { catalogIndex })
312
- };
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
+ });
313
266
 
314
- const apiKey = String(channel.apiKey || '').trim();
267
+ const apiKey = String(channel.apiKey || providerConfig.apiKey || '').trim();
315
268
  if (apiKey) {
316
269
  entry.apiKey = apiKey;
317
270
  }
318
271
 
319
- if (channel.headers && typeof channel.headers === 'object' && !Array.isArray(channel.headers)) {
320
- entry.headers = channel.headers;
321
- }
322
-
323
272
  return entry;
324
273
  }
325
274
 
@@ -644,6 +593,7 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
644
593
  const managedDisabledProviders = [];
645
594
  const warnings = [];
646
595
  const providers = modelsConfig.providers || {};
596
+ let managedDefaultModel = null;
647
597
 
648
598
  (channels || [])
649
599
  .filter(channel => channel && channel.enabled !== false && channel.baseUrl)
@@ -658,6 +608,20 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
658
608
  }
659
609
  });
660
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
+
661
625
  const originalProviderId = getOriginalProviderId(channel);
662
626
  if (originalProviderId && !isManagedProviderId(originalProviderId)) {
663
627
  pushUnique(managedDisabledProviders, originalProviderId);
@@ -686,7 +650,7 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
686
650
  ? managedEnabledModels
687
651
  : [NO_MANAGED_MODELS_SELECTOR],
688
652
  managedDisabledProviders,
689
- managedDefaultModel: managedEnabledModels[0] || null,
653
+ managedDefaultModel: managedDefaultModel || managedEnabledModels[0] || null,
690
654
  warnings
691
655
  };
692
656
  }
@@ -881,7 +845,11 @@ function removeManagedOmpVisibility(options = {}) {
881
845
 
882
846
  function writeManagedOmpProviders(channels = [], options = {}) {
883
847
  const paths = getOmpPaths();
884
- 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
+ });
885
853
  const backupPath = createModelsBackupIfNeeded(paths.modelsYml, options);
886
854
  writeModelsConfig(config, paths.modelsYml);
887
855
  const visibility = syncManagedOmpVisibility(channels, config, options);
@@ -889,6 +857,7 @@ function writeManagedOmpProviders(channels = [], options = {}) {
889
857
  ? validateOmpModelsConfig(options)
890
858
  : { skipped: true, reason: 'cli-validation-disabled', warnings: [] };
891
859
  const warnings = [
860
+ ...buildWarnings,
892
861
  ...(visibility.warnings || []),
893
862
  ...(validation.warnings || [])
894
863
  ];
@@ -975,12 +944,14 @@ module.exports = {
975
944
  buildManagedExtensionSource,
976
945
  buildManagedModelsConfig,
977
946
  buildProviderEntry,
947
+ getOmpCatalogModels,
978
948
  getManagedProviderId,
979
949
  getLastManagedOmpSyncResult,
980
950
  isManagedOmpProvidersActive,
981
951
  isManagedProviderExtensionActive: isManagedOmpProvidersActive,
982
952
  normalizeProviderId,
983
953
  normalizeProviderApi,
954
+ normalizeModels,
984
955
  readOmpSettingsConfig,
985
956
  readModelsConfig,
986
957
  removeManagedOmpProviders,