coding-tool-x 3.7.8 → 3.7.10

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,13 +162,27 @@ 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 requestedModelIds = uniqueStrings(options.requestedModelIds);
174
+ const requested = new Set(requestedModelIds.map(modelId => modelId.toLowerCase()));
175
+ const useFullCatalog = requested.size > 0;
176
+ const cacheKey = `${command}\u0000${useFullCatalog ? `requested:${providerId}:${[...requested].sort().join(',')}` : providerId}`;
177
+ const cached = ompCatalogCache.get(cacheKey);
178
+ const now = Date.now();
179
+ const cacheTtl = cached?.failed ? OMP_CATALOG_FAILURE_CACHE_TTL_MS : OMP_CATALOG_CACHE_TTL_MS;
180
+ if (options.forceCatalogRefresh !== true && cached && now - cached.cachedAt < cacheTtl) {
181
+ return cached.models.map(model => ({ ...model }));
182
+ }
256
183
  const runner = options.catalogRunner || options.modelsRunner || spawnSync;
257
- const result = runner(command, ['models', providerId, '--json'], {
184
+ const args = useFullCatalog ? ['models', '--json'] : ['models', providerId, '--json'];
185
+ const result = runner(command, args, {
258
186
  encoding: 'utf8',
259
187
  env: {
260
188
  ...process.env,
@@ -264,26 +192,50 @@ function readOmpCatalogModels(command, providerId, options = {}) {
264
192
  windowsHide: true
265
193
  });
266
194
  const status = result?.status === undefined || result?.status === null ? 0 : result.status;
267
- if (result?.error || status !== 0) return [];
195
+ if (result?.error || status !== 0) {
196
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models: [], failed: true });
197
+ return [];
198
+ }
268
199
  try {
269
200
  const parsed = JSON.parse(normalizeCommandOutput(result?.stdout).trim() || '{}');
270
- return Array.isArray(parsed.models) ? parsed.models : [];
201
+ const rawModels = Array.isArray(parsed.models) ? parsed.models : [];
202
+ let models = rawModels;
203
+ if (useFullCatalog) {
204
+ const providerCandidates = new Set([providerId, `${MANAGED_PROVIDER_PREFIX}${providerId}`]);
205
+ const selected = new Map();
206
+ for (const model of rawModels) {
207
+ const id = String(model?.id || '').trim().toLowerCase();
208
+ if (!requested.has(id)) continue;
209
+ const provider = normalizeProviderId(model?.provider || '');
210
+ const previous = selected.get(id);
211
+ if (!previous || (providerCandidates.has(provider) && !providerCandidates.has(normalizeProviderId(previous.provider || '')))) {
212
+ selected.set(id, model);
213
+ }
214
+ }
215
+ models = [...selected.values()];
216
+ }
217
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models, failed: false });
218
+ return models.map(model => ({ ...model }));
271
219
  } catch {
220
+ ompCatalogCache.set(cacheKey, { cachedAt: now, models: [], failed: true });
272
221
  return [];
273
222
  }
274
223
  }
275
224
 
225
+
226
+ function getOmpCatalogModels(providerId, options = {}) {
227
+ const normalizedProviderId = normalizeProviderId(providerId);
228
+ if (!normalizedProviderId) return [];
229
+ // Catalog sync is an explicit user action. Assume OMP is installed and call
230
+ // it directly instead of paying for a separate `omp --version` probe.
231
+ const command = options.command || options.runtime?.command || options.env?.OMP_COMMAND || process.env.OMP_COMMAND || 'omp';
232
+ return readOmpCatalogModels(command, normalizedProviderId, options);
233
+ }
234
+
276
235
  function buildCatalogIndex(channel = {}, options = {}) {
277
236
  if (options.catalogFromCli !== true) {
278
237
  return new Map();
279
238
  }
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
239
 
288
240
  const rawProviderId = normalizeProviderId(channel.providerKey || channel.provider || channel.name || '');
289
241
  const providerId = rawProviderId.startsWith(MANAGED_PROVIDER_PREFIX)
@@ -292,7 +244,7 @@ function buildCatalogIndex(channel = {}, options = {}) {
292
244
  if (!providerId) return new Map();
293
245
 
294
246
  const index = new Map();
295
- readOmpCatalogModels(runtime.command, providerId, options).forEach((model) => {
247
+ getOmpCatalogModels(providerId, options).forEach((model) => {
296
248
  const id = String(model?.id || model?.name || '').trim();
297
249
  if (id) {
298
250
  index.set(id.toLowerCase(), model);
@@ -305,21 +257,38 @@ function buildProviderEntry(channel = {}, options = {}) {
305
257
  const catalogIndex = options.catalogIndex instanceof Map
306
258
  ? options.catalogIndex
307
259
  : 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
- };
260
+ const providerConfig = isPlainObject(channel.providerConfig) ? channel.providerConfig : {};
261
+ const providerSource = { ...providerConfig };
262
+ for (const key of [
263
+ 'headers',
264
+ 'compat',
265
+ 'remoteCompaction',
266
+ 'modelOverrides',
267
+ 'authHeader',
268
+ 'auth',
269
+ 'discovery',
270
+ 'disableStrictTools',
271
+ 'transport'
272
+ ]) {
273
+ if (channel[key] !== undefined) providerSource[key] = channel[key];
274
+ }
275
+ const normalizedProvider = normalizeProviderConfig(providerSource);
276
+ if (Array.isArray(options.warnings)) {
277
+ const providerName = channel.providerKey || channel.provider || channel.name || channel.id || 'provider';
278
+ options.warnings.push(...normalizedProvider.warnings.map(message => `${providerName}: ${message}`));
279
+ }
280
+ const entry = compactObject({
281
+ ...normalizedProvider.config,
282
+ baseUrl: String(channel.baseUrl || providerConfig.baseUrl || '').trim(),
283
+ api: normalizeProviderApi(channel.providerApi || channel.api || channel.wireApi || providerConfig.api),
284
+ models: normalizeModels(channel, { catalogIndex, warnings: options.warnings })
285
+ });
313
286
 
314
- const apiKey = String(channel.apiKey || '').trim();
287
+ const apiKey = String(channel.apiKey || providerConfig.apiKey || '').trim();
315
288
  if (apiKey) {
316
289
  entry.apiKey = apiKey;
317
290
  }
318
291
 
319
- if (channel.headers && typeof channel.headers === 'object' && !Array.isArray(channel.headers)) {
320
- entry.headers = channel.headers;
321
- }
322
-
323
292
  return entry;
324
293
  }
325
294
 
@@ -644,6 +613,7 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
644
613
  const managedDisabledProviders = [];
645
614
  const warnings = [];
646
615
  const providers = modelsConfig.providers || {};
616
+ let managedDefaultModel = null;
647
617
 
648
618
  (channels || [])
649
619
  .filter(channel => channel && channel.enabled !== false && channel.baseUrl)
@@ -658,6 +628,20 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
658
628
  }
659
629
  });
660
630
 
631
+ const defaultSelection = splitModelSelector(channel.model || '');
632
+ if (!managedDefaultModel && defaultSelection.modelId) {
633
+ const hasDefaultModel = models.some((model) => {
634
+ const modelId = String(model?.id || model?.name || '').trim();
635
+ return modelId.toLowerCase() === defaultSelection.modelId.toLowerCase();
636
+ });
637
+ if (hasDefaultModel) {
638
+ managedDefaultModel = `${managedProviderId}/${serializeModelSelector(
639
+ defaultSelection.modelId,
640
+ defaultSelection.thinkingLevel
641
+ )}`;
642
+ }
643
+ }
644
+
661
645
  const originalProviderId = getOriginalProviderId(channel);
662
646
  if (originalProviderId && !isManagedProviderId(originalProviderId)) {
663
647
  pushUnique(managedDisabledProviders, originalProviderId);
@@ -686,7 +670,7 @@ function collectManagedVisibility(channels = [], modelsConfig = { providers: {}
686
670
  ? managedEnabledModels
687
671
  : [NO_MANAGED_MODELS_SELECTOR],
688
672
  managedDisabledProviders,
689
- managedDefaultModel: managedEnabledModels[0] || null,
673
+ managedDefaultModel: managedDefaultModel || managedEnabledModels[0] || null,
690
674
  warnings
691
675
  };
692
676
  }
@@ -881,7 +865,11 @@ function removeManagedOmpVisibility(options = {}) {
881
865
 
882
866
  function writeManagedOmpProviders(channels = [], options = {}) {
883
867
  const paths = getOmpPaths();
884
- const config = buildManagedModelsConfig(channels, readModelsConfig(paths.modelsYml), options);
868
+ const buildWarnings = [];
869
+ const config = buildManagedModelsConfig(channels, readModelsConfig(paths.modelsYml), {
870
+ ...options,
871
+ warnings: buildWarnings
872
+ });
885
873
  const backupPath = createModelsBackupIfNeeded(paths.modelsYml, options);
886
874
  writeModelsConfig(config, paths.modelsYml);
887
875
  const visibility = syncManagedOmpVisibility(channels, config, options);
@@ -889,6 +877,7 @@ function writeManagedOmpProviders(channels = [], options = {}) {
889
877
  ? validateOmpModelsConfig(options)
890
878
  : { skipped: true, reason: 'cli-validation-disabled', warnings: [] };
891
879
  const warnings = [
880
+ ...buildWarnings,
892
881
  ...(visibility.warnings || []),
893
882
  ...(validation.warnings || [])
894
883
  ];
@@ -975,12 +964,14 @@ module.exports = {
975
964
  buildManagedExtensionSource,
976
965
  buildManagedModelsConfig,
977
966
  buildProviderEntry,
967
+ getOmpCatalogModels,
978
968
  getManagedProviderId,
979
969
  getLastManagedOmpSyncResult,
980
970
  isManagedOmpProvidersActive,
981
971
  isManagedProviderExtensionActive: isManagedOmpProvidersActive,
982
972
  normalizeProviderId,
983
973
  normalizeProviderApi,
974
+ normalizeModels,
984
975
  readOmpSettingsConfig,
985
976
  readModelsConfig,
986
977
  removeManagedOmpProviders,