@in-the-loop-labs/pair-review 3.7.2 → 3.9.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.
@@ -113,9 +113,13 @@ class AIProvider {
113
113
 
114
114
  /**
115
115
  * Test if the provider's CLI is available
116
+ * @param {number} [timeoutMs] - Optional timeout in milliseconds for the
117
+ * availability probe. Subclasses that spawn a child process should use this
118
+ * to bound the check (the value is resolved per-provider by
119
+ * testProviderAvailability). Defaults to DEFAULT_AVAILABILITY_TIMEOUT_MS.
116
120
  * @returns {Promise<boolean>}
117
121
  */
118
- async testAvailability() {
122
+ async testAvailability(timeoutMs) {
119
123
  throw new Error('testAvailability() must be implemented by subclass');
120
124
  }
121
125
 
@@ -166,7 +170,8 @@ class AIProvider {
166
170
  * @returns {string} Model ID for extraction
167
171
  */
168
172
  getFastTierModel() {
169
- const models = this.constructor.getModels();
173
+ const overrides = providerConfigOverrides.get(this.constructor.getProviderId());
174
+ const models = applyModelOverrides(this.constructor.getModels(), overrides);
170
175
  const fastModel = models.find(m => m.tier === 'fast');
171
176
  if (fastModel) {
172
177
  return fastModel.id;
@@ -353,6 +358,28 @@ const providerRegistry = new Map();
353
358
  */
354
359
  const providerConfigOverrides = new Map();
355
360
 
361
+ /**
362
+ * Default timeout (ms) for a provider availability probe when no
363
+ * per-provider `availability_timeout_seconds` is configured.
364
+ */
365
+ const DEFAULT_AVAILABILITY_TIMEOUT_MS = 10000;
366
+
367
+ /**
368
+ * Convert a configured `availability_timeout_seconds` value to milliseconds.
369
+ * Single source of truth for the "valid positive seconds" predicate shared by
370
+ * every availability-probe timeout (AI providers, executable providers, and
371
+ * chat providers); falls back to `defaultMs` when the value is unset,
372
+ * non-numeric, non-finite, or <= 0.
373
+ * @param {*} seconds - Raw config value, expected to be a number of seconds
374
+ * @param {number} [defaultMs=DEFAULT_AVAILABILITY_TIMEOUT_MS] - Fallback in ms
375
+ * @returns {number} Timeout in milliseconds
376
+ */
377
+ function secondsToTimeoutMs(seconds, defaultMs = DEFAULT_AVAILABILITY_TIMEOUT_MS) {
378
+ return (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0)
379
+ ? seconds * 1000
380
+ : defaultMs;
381
+ }
382
+
356
383
  /**
357
384
  * Whether yolo mode is enabled (skips fine-grained provider permissions)
358
385
  */
@@ -420,17 +447,53 @@ function inferModelDefaults(model) {
420
447
  }
421
448
 
422
449
  /**
423
- * Resolve the default model from an array of model definitions
424
- * Priority: model with default:true > first balanced tier model > first model
425
- * @param {Array<Object>} models - Array of model definitions
450
+ * Match a model definition against a selector that may be the model's canonical
451
+ * id OR one of its aliases. Used by config-driven selectors (`default_model`,
452
+ * `disabled_models`, `models` overrides) so legacy config naming an alias (e.g.
453
+ * `opus`, an alias of the canonical `opus-4.8-xhigh`) keeps working.
454
+ *
455
+ * Optional-chaining is intentional: a model with no `aliases` short-circuits to
456
+ * undefined/falsy, so no Array.isArray guard is needed.
457
+ *
458
+ * @param {Object} model - Model definition (must have an `id`; may have `aliases`)
459
+ * @param {string} selector - Selector to match against id or aliases
460
+ * @returns {boolean}
461
+ */
462
+ function modelMatches(model, selector) {
463
+ return model.id === selector || model.aliases?.includes(selector);
464
+ }
465
+
466
+ /**
467
+ * Resolve the default model from an array of model definitions.
468
+ * Priority: provider-level `default_model` (preferredId) > legacy model with
469
+ * `default:true` > first balanced tier model > first model.
470
+ *
471
+ * `preferredId` is the provider-level `default_model` config value. It is the
472
+ * preferred way to pick a default; the per-model `default:true` flag is the
473
+ * deprecated legacy mechanism, kept for backward compatibility. When
474
+ * `preferredId` names a model that isn't present (e.g. it was disabled or
475
+ * mistyped), resolution falls through to the legacy/automatic logic — the
476
+ * mismatch is warned about once at config-apply time, not here.
477
+ *
478
+ * @param {Array<Object>} models - Array of model definitions (already filtered)
479
+ * @param {string|null} [preferredId] - Provider-level `default_model` id
426
480
  * @returns {string|null} - Default model ID or null if no models
427
481
  */
428
- function resolveDefaultModel(models) {
482
+ function resolveDefaultModel(models, preferredId = null) {
429
483
  if (!models || models.length === 0) {
430
484
  return null;
431
485
  }
432
486
 
433
- // First, look for a model explicitly marked as default
487
+ // Provider-level `default_model` wins when it names an available model
488
+ // (by canonical id or alias). Returns the canonical id either way.
489
+ if (preferredId) {
490
+ const preferred = models.find(m => modelMatches(m, preferredId));
491
+ if (preferred) {
492
+ return preferred.id;
493
+ }
494
+ }
495
+
496
+ // Legacy: model explicitly marked default:true (deprecated in favor of default_model)
434
497
  const explicitDefault = models.find(m => m.default === true);
435
498
  if (explicitDefault) {
436
499
  return explicitDefault.id;
@@ -467,7 +530,7 @@ function createAliasedProviderClass(aliasId, BaseClass, aliasConfig) {
467
530
  AliasedProvider.getProviderId = () => aliasId;
468
531
  if (processedModels) {
469
532
  AliasedProvider.getModels = () => processedModels;
470
- AliasedProvider.getDefaultModel = () => resolveDefaultModel(processedModels);
533
+ AliasedProvider.getDefaultModel = () => resolveDefaultModel(processedModels, aliasConfig.default_model || null);
471
534
  }
472
535
  if (aliasConfig.installInstructions) {
473
536
  AliasedProvider.getInstallInstructions = () => aliasConfig.installInstructions;
@@ -513,7 +576,15 @@ function applyConfigOverrides(config) {
513
576
  const { createExecutableProviderClass } = require('./executable-provider');
514
577
  const ExecClass = createExecutableProviderClass(providerId, providerConfig);
515
578
  registerProvider(providerId, ExecClass);
516
- providerConfigOverrides.set(providerId, { ...providerConfig, models: ExecClass.getModels() });
579
+ const execDisabled = normalizeDisabledModels(providerId, providerConfig.disabled_models);
580
+ const execDefault = providerConfig.default_model || null;
581
+ validateModelSelectors(providerId, ExecClass.getModels(), null, execDisabled, execDefault);
582
+ providerConfigOverrides.set(providerId, {
583
+ ...providerConfig,
584
+ models: ExecClass.getModels(),
585
+ disabled_models: execDisabled,
586
+ default_model: execDefault
587
+ });
517
588
  logger.debug(`Registered executable provider: ${providerId}`);
518
589
  continue;
519
590
  }
@@ -524,6 +595,10 @@ function applyConfigOverrides(config) {
524
595
  const AliasClass = createAliasedProviderClass(providerId, BaseClass, providerConfig);
525
596
  registerProvider(providerId, AliasClass);
526
597
 
598
+ const aliasDisabled = normalizeDisabledModels(providerId, providerConfig.disabled_models);
599
+ const aliasDefault = providerConfig.default_model || null;
600
+ validateModelSelectors(providerId, AliasClass.getModels(), null, aliasDisabled, aliasDefault);
601
+
527
602
  // Aliases reuse the base provider's implementation class, not its config.
528
603
  // Only universal override fields are forwarded; provider-specific fields
529
604
  // (e.g. codex args) must be explicitly set in the alias config.
@@ -534,7 +609,10 @@ function applyConfigOverrides(config) {
534
609
  env: providerConfig.env,
535
610
  load_skills: providerConfig.load_skills,
536
611
  app_extensions: providerConfig.app_extensions,
537
- models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null
612
+ availability_timeout_seconds: providerConfig.availability_timeout_seconds,
613
+ models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null,
614
+ disabled_models: aliasDisabled,
615
+ default_model: aliasDefault
538
616
  });
539
617
  logger.debug(`Registered aliased provider: ${providerId} (base: ${providerConfig.type})`);
540
618
  continue;
@@ -551,8 +629,36 @@ function applyConfigOverrides(config) {
551
629
  if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
552
630
  processedModels = providerConfig.models.map(inferModelDefaults);
553
631
  logger.debug(`Configured ${processedModels.length} models for ${providerId}`);
632
+ // Deprecation: per-model `default: true` is superseded by provider-level `default_model`.
633
+ // Only warn when the user didn't already adopt the new field.
634
+ if (providerConfig.default_model == null && processedModels.some(m => m.default === true)) {
635
+ logger.warn(`Provider "${providerId}": per-model "default: true" is deprecated. Set provider-level "default_model": "<id>" instead.`);
636
+ }
637
+ }
638
+
639
+ const builtInModels = providerRegistry.get(providerId)?.getModels();
640
+
641
+ // Canonicalize alias-keyed override ids to their built-in canonical id.
642
+ // A config entry may key a model by a short alias (e.g. `opus` for the
643
+ // canonical `opus-4.8-xhigh`). mergeModels() already resolves aliases for the
644
+ // display/metadata path, but the runtime path forwards this raw `models` array
645
+ // to the provider instance, where per-model config is looked up by EXACT id
646
+ // (e.g. `configOverrides.models.find(m => m.id === model)`). The frontend
647
+ // submits the canonical id, so an alias-keyed entry would never match and its
648
+ // cli_model/env/extra_args would be silently dropped. Rewriting the id here —
649
+ // the single point where the override is stored — keeps metadata and runtime
650
+ // execution in agreement.
651
+ if (processedModels && builtInModels) {
652
+ processedModels = processedModels.map(cm => {
653
+ const builtIn = builtInModels.find(bm => modelMatches(bm, cm.id));
654
+ return builtIn && builtIn.id !== cm.id ? { ...cm, id: builtIn.id } : cm;
655
+ });
554
656
  }
555
657
 
658
+ const disabledModels = normalizeDisabledModels(providerId, providerConfig.disabled_models);
659
+ const defaultModel = providerConfig.default_model || null;
660
+ validateModelSelectors(providerId, builtInModels, processedModels, disabledModels, defaultModel);
661
+
556
662
  // Store the overrides
557
663
  providerConfigOverrides.set(providerId, {
558
664
  command: providerConfig.command,
@@ -561,7 +667,10 @@ function applyConfigOverrides(config) {
561
667
  env: providerConfig.env,
562
668
  load_skills: providerConfig.load_skills,
563
669
  app_extensions: providerConfig.app_extensions,
564
- models: processedModels
670
+ availability_timeout_seconds: providerConfig.availability_timeout_seconds,
671
+ models: processedModels,
672
+ disabled_models: disabledModels,
673
+ default_model: defaultModel
565
674
  });
566
675
  }
567
676
 
@@ -625,8 +734,12 @@ function resolveNonExecutableProviderId(preferredId) {
625
734
 
626
735
  /**
627
736
  * Merge config-override models with a provider's built-in models.
628
- * Config models with matching IDs replace built-ins; config models with new IDs
629
- * are appended. If no config models exist, returns built-ins unchanged.
737
+ * A config model whose id matches a built-in's canonical id OR one of its
738
+ * aliases replaces that built-in in place (preserving display order). The
739
+ * canonical built-in id is preserved as the internal source of truth, and the
740
+ * built-in's aliases are kept unless the override supplies its own. Config
741
+ * models that match no built-in are appended. If no config models exist,
742
+ * returns built-ins unchanged.
630
743
  *
631
744
  * @param {Array<Object>} builtInModels - Models from ProviderClass.getModels()
632
745
  * @param {Array<Object>|undefined} configModels - Models from config overrides
@@ -636,13 +749,114 @@ function mergeModels(builtInModels, configModels) {
636
749
  if (!configModels || configModels.length === 0) {
637
750
  return builtInModels;
638
751
  }
639
- const configById = new Map(configModels.map(m => [m.id, m]));
640
- // Replace overridden built-ins in-place to preserve display order
641
- const merged = builtInModels.map(m => configById.get(m.id) || m);
642
- // Append config models with new IDs not present in built-ins
643
- const builtInIds = new Set(builtInModels.map(m => m.id));
644
- const newModels = configModels.filter(m => !builtInIds.has(m.id));
645
- return [...merged, ...newModels];
752
+ // Replace overridden built-ins in-place to preserve display order. An override
753
+ // matched by alias (e.g. config `{ id: 'opus' }` against built-in
754
+ // `opus-4.8-xhigh`) still replaces, but keeps the canonical built-in id.
755
+ const matched = new Set();
756
+ const merged = builtInModels.map(bm => {
757
+ const override = configModels.find(cm => modelMatches(bm, cm.id));
758
+ if (override) {
759
+ matched.add(override);
760
+ return { ...override, id: bm.id, aliases: override.aliases ?? bm.aliases };
761
+ }
762
+ return bm;
763
+ });
764
+ // Append config models that matched no built-in (genuinely new ids)
765
+ for (const cm of configModels) {
766
+ if (!matched.has(cm)) {
767
+ merged.push(cm);
768
+ }
769
+ }
770
+ return merged;
771
+ }
772
+
773
+ /**
774
+ * Compute the effective model list for a provider: built-in models merged with
775
+ * config-override models, then with any `disabled_models` IDs removed.
776
+ *
777
+ * This is the single source of truth for "which models does this provider
778
+ * actually expose" — every call site that surfaces or selects a model should go
779
+ * through here so a disabled model is hidden consistently (UI, default
780
+ * resolution, instance creation).
781
+ *
782
+ * If `disabled_models` would remove every model, the filter is ignored (a
783
+ * provider with zero models is unusable). Unknown/empty-list validation and
784
+ * warnings happen once at config-apply time in {@link applyConfigOverrides};
785
+ * this function is intentionally silent so it can run on every request.
786
+ *
787
+ * @param {Array<Object>} builtInModels - Models from ProviderClass.getModels()
788
+ * @param {Object} [overrides] - Stored config overrides for the provider
789
+ * @returns {Array<Object>} Effective model list
790
+ */
791
+ function applyModelOverrides(builtInModels, overrides) {
792
+ const merged = mergeModels(builtInModels, overrides?.models);
793
+ const disabled = overrides?.disabled_models;
794
+ if (!Array.isArray(disabled) || disabled.length === 0) {
795
+ return merged;
796
+ }
797
+ // Drop a model if ANY disabled selector matches it by canonical id or alias.
798
+ const filtered = merged.filter(m => !disabled.some(d => modelMatches(m, d)));
799
+ // Never strip a provider down to zero models — fall back to the unfiltered set.
800
+ return filtered.length > 0 ? filtered : merged;
801
+ }
802
+
803
+ /**
804
+ * Normalize a `disabled_models` config value into a clean array of string IDs
805
+ * (or null when absent/empty). Warns on malformed input.
806
+ * @param {string} providerId - Provider ID (for log messages)
807
+ * @param {*} raw - Raw config value
808
+ * @returns {string[]|null}
809
+ */
810
+ function normalizeDisabledModels(providerId, raw) {
811
+ if (raw == null) {
812
+ return null;
813
+ }
814
+ if (!Array.isArray(raw)) {
815
+ logger.warn(`Provider "${providerId}": "disabled_models" must be an array of model IDs; ignoring.`);
816
+ return null;
817
+ }
818
+ const ids = raw.filter(id => typeof id === 'string' && id.length > 0);
819
+ if (ids.length !== raw.length) {
820
+ logger.warn(`Provider "${providerId}": "disabled_models" contained non-string entries which were ignored.`);
821
+ }
822
+ return ids.length > 0 ? ids : null;
823
+ }
824
+
825
+ /**
826
+ * Validate provider-level model selectors against the effective model set and
827
+ * warn about mistakes. Never throws — bad selectors degrade gracefully at
828
+ * resolution time (a disabled-everything list is ignored, an unknown
829
+ * default_model falls back to automatic selection).
830
+ * @param {string} providerId - Provider ID (for log messages)
831
+ * @param {Array<Object>} builtInModels - Provider's built-in models
832
+ * @param {Array<Object>|null} configModels - Processed config-override models
833
+ * @param {string[]|null} disabledModels - Normalized disabled list
834
+ * @param {string|null} defaultModel - Provider-level default_model id
835
+ */
836
+ function validateModelSelectors(providerId, builtInModels, configModels, disabledModels, defaultModel) {
837
+ const merged = mergeModels(builtInModels || [], configModels);
838
+ // A selector is "known" if it matches some model by canonical id OR alias.
839
+ const isKnown = (sel) => merged.some(m => modelMatches(m, sel));
840
+
841
+ if (disabledModels) {
842
+ for (const id of disabledModels) {
843
+ if (!isKnown(id)) {
844
+ logger.warn(`Provider "${providerId}": disabled_models references unknown model "${id}".`);
845
+ }
846
+ }
847
+ const remaining = merged.filter(m => !disabledModels.some(d => modelMatches(m, d)));
848
+ if (merged.length > 0 && remaining.length === 0) {
849
+ logger.warn(`Provider "${providerId}": disabled_models removes every model; the filter will be ignored.`);
850
+ }
851
+ }
852
+
853
+ if (defaultModel != null) {
854
+ if (!isKnown(defaultModel)) {
855
+ logger.warn(`Provider "${providerId}": default_model "${defaultModel}" is not a known model; falling back to automatic default.`);
856
+ } else if (disabledModels && disabledModels.includes(defaultModel)) {
857
+ logger.warn(`Provider "${providerId}": default_model "${defaultModel}" is also listed in disabled_models; falling back to automatic default.`);
858
+ }
859
+ }
646
860
  }
647
861
 
648
862
  /**
@@ -655,11 +869,17 @@ function getAllProvidersInfo() {
655
869
  for (const [id, ProviderClass] of providerRegistry) {
656
870
  const overrides = providerConfigOverrides.get(id);
657
871
 
658
- // Merge config models with built-in models (config wins on ID collision)
659
- const models = mergeModels(ProviderClass.getModels(), overrides?.models);
872
+ // Effective models: config merged with built-ins, minus disabled_models
873
+ const effectiveModels = applyModelOverrides(ProviderClass.getModels(), overrides);
660
874
 
661
- // Resolve default model from merged models array
662
- const defaultModel = resolveDefaultModel(models) || ProviderClass.getDefaultModel();
875
+ // Resolve default model: provider-level default_model wins, then legacy/auto
876
+ const defaultModel = resolveDefaultModel(effectiveModels, overrides?.default_model) || ProviderClass.getDefaultModel();
877
+
878
+ // Normalize per-model `default` flags to agree with the resolved defaultModel.
879
+ // Many frontend consumers derive the default via models.find(m => m.default),
880
+ // so the payload's flags must reflect the new source of truth (default_model).
881
+ // Produce NEW objects — never mutate the shared built-in model objects.
882
+ const models = effectiveModels.map(m => ({ ...m, default: m.id === defaultModel }));
663
883
 
664
884
  // Use overridden install instructions if available
665
885
  const installInstructions = overrides?.installInstructions || ProviderClass.getInstallInstructions();
@@ -708,11 +928,13 @@ function createProvider(providerId, model = null, overrides = {}) {
708
928
  // Determine the actual model to use
709
929
  let actualModel = model;
710
930
  if (!actualModel) {
711
- // Resolve default from merged models (config + built-in).
931
+ // Resolve default from effective models (config + built-in, minus disabled).
712
932
  // Checks both sources because some providers (e.g., Pi) define built-in
713
- // modes with default:true that aren't in config overrides.
933
+ // modes with default:true that aren't in config overrides. Honors the
934
+ // provider-level `default_model` selector.
714
935
  if (configOverrides?.models || ProviderClass.getModels().length > 0) {
715
- actualModel = resolveDefaultModel(mergeModels(ProviderClass.getModels(), configOverrides?.models));
936
+ const effectiveModels = applyModelOverrides(ProviderClass.getModels(), configOverrides);
937
+ actualModel = resolveDefaultModel(effectiveModels, configOverrides?.default_model);
716
938
  }
717
939
  // Fall back to provider's built-in default
718
940
  if (!actualModel) {
@@ -724,27 +946,54 @@ function createProvider(providerId, model = null, overrides = {}) {
724
946
  return new ProviderClass(actualModel, { ...(configOverrides || {}), ...overrides, yolo: yoloMode });
725
947
  }
726
948
 
949
+ /**
950
+ * Resolve the availability-probe timeout (ms) for a provider.
951
+ * Reads the per-provider `availability_timeout_seconds` config override
952
+ * (in seconds, mirroring `checkout_timeout_seconds`); falls back to
953
+ * DEFAULT_AVAILABILITY_TIMEOUT_MS when unset, non-numeric, or <= 0.
954
+ * @param {string} providerId - Provider ID
955
+ * @returns {number} Timeout in milliseconds
956
+ */
957
+ function resolveAvailabilityTimeoutMs(providerId) {
958
+ const seconds = providerConfigOverrides.get(providerId)?.availability_timeout_seconds;
959
+ return secondsToTimeoutMs(seconds);
960
+ }
961
+
727
962
  /**
728
963
  * Test availability of a provider with timeout
729
964
  * @param {string} providerId - Provider ID
730
- * @param {number} timeout - Timeout in milliseconds (default 10 seconds)
965
+ * @param {number} [timeoutMs] - Timeout in milliseconds. When omitted, resolved
966
+ * per-provider from `availability_timeout_seconds` (default 10 seconds).
731
967
  * @returns {Promise<{available: boolean, error?: string}>}
732
968
  */
733
- async function testProviderAvailability(providerId, timeout = 10000) {
969
+ async function testProviderAvailability(providerId, timeoutMs) {
970
+ const effectiveTimeoutMs = timeoutMs != null ? timeoutMs : resolveAvailabilityTimeoutMs(providerId);
734
971
  try {
735
972
  const provider = createProvider(providerId);
736
973
 
737
- // Race between availability test and timeout
974
+ // Race between availability test and timeout. The provider's own probe
975
+ // receives the same timeout so it can kill its child process on expiry;
976
+ // this race is the only guard for providers without an internal timeout.
977
+ // Capture the timer handle so we can clear it once the race settles —
978
+ // otherwise a fast probe with a large configured timeout would keep the
979
+ // Node event loop alive (and its rejection unobserved) until the timer fires.
980
+ let timeoutId;
738
981
  const timeoutPromise = new Promise((_, reject) => {
739
- setTimeout(() => reject(new Error('Provider test timed out')), timeout);
982
+ timeoutId = setTimeout(
983
+ () => reject(new Error(`Provider test timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`)),
984
+ effectiveTimeoutMs
985
+ );
740
986
  });
741
987
 
742
- const available = await Promise.race([
743
- provider.testAvailability(),
744
- timeoutPromise
745
- ]);
746
-
747
- return { available };
988
+ try {
989
+ const available = await Promise.race([
990
+ provider.testAvailability(effectiveTimeoutMs),
991
+ timeoutPromise
992
+ ]);
993
+ return { available };
994
+ } finally {
995
+ clearTimeout(timeoutId);
996
+ }
748
997
  } catch (error) {
749
998
  const ProviderClass = providerRegistry.get(providerId);
750
999
  const installInstructions = ProviderClass?.getInstallInstructions() || 'Check the provider documentation.';
@@ -776,7 +1025,7 @@ function getTierForModel(providerId, modelId) {
776
1025
  const overrides = providerConfigOverrides.get(providerId);
777
1026
  const models = mergeModels(ProviderClass.getModels(), overrides?.models);
778
1027
 
779
- const model = models.find(m => m.id === modelId || m.aliases?.includes(modelId));
1028
+ const model = models.find(m => modelMatches(m, modelId));
780
1029
  return model?.tier || null;
781
1030
  }
782
1031
 
@@ -791,12 +1040,19 @@ module.exports = {
791
1040
  getAllProvidersInfo,
792
1041
  createProvider,
793
1042
  testProviderAvailability,
1043
+ resolveAvailabilityTimeoutMs,
1044
+ secondsToTimeoutMs,
1045
+ DEFAULT_AVAILABILITY_TIMEOUT_MS,
794
1046
  // Config override support
795
1047
  applyConfigOverrides,
796
1048
  createAliasedProviderClass,
797
1049
  getProviderConfigOverrides,
798
1050
  inferModelDefaults,
799
1051
  resolveDefaultModel,
1052
+ modelMatches,
1053
+ mergeModels,
1054
+ applyModelOverrides,
1055
+ normalizeDisabledModels,
800
1056
  prettifyModelId,
801
1057
  getTierForModel
802
1058
  };
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  const { spawn } = require('child_process');
10
- const { getCachedAvailability } = require('../ai');
10
+ const { getCachedAvailability, secondsToTimeoutMs, DEFAULT_AVAILABILITY_TIMEOUT_MS } = require('../ai');
11
11
  const logger = require('../utils/logger');
12
12
 
13
13
  // Default dependencies (overridable for testing)
@@ -123,6 +123,9 @@ function getChatProvider(id) {
123
123
  if (overrides.availability_command !== undefined) {
124
124
  provider.availability_command = overrides.availability_command;
125
125
  }
126
+ if (overrides.availability_timeout_seconds !== undefined) {
127
+ provider.availability_timeout_seconds = overrides.availability_timeout_seconds;
128
+ }
126
129
  if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
127
130
  provider.args = [...provider.args, ...overrides.extra_args];
128
131
  }
@@ -147,6 +150,9 @@ function getChatProvider(id) {
147
150
  if (overrides.availability_command !== undefined) {
148
151
  merged.availability_command = overrides.availability_command;
149
152
  }
153
+ if (overrides.availability_timeout_seconds !== undefined) {
154
+ merged.availability_timeout_seconds = overrides.availability_timeout_seconds;
155
+ }
150
156
  if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
151
157
  if (overrides.args) {
152
158
  merged.args = overrides.args;
@@ -230,9 +236,10 @@ function isCodexProvider(id) {
230
236
  /**
231
237
  * Check availability of a single chat provider.
232
238
  * Providers with `availability_command` run that command first.
233
- * Without an availability command, Pi delegates to the existing AI provider
234
- * availability cache and other providers spawn `<command> --version` to verify
235
- * the binary exists.
239
+ * Without an availability command, the built-in Pi provider (no `command`
240
+ * override) delegates to the existing AI provider availability cache; every
241
+ * other provider — including custom `type: 'pi'` providers and built-in Pi with
242
+ * a `command` override — spawns `<command> --version` to verify the binary exists.
236
243
  * @param {string} id - Provider ID
237
244
  * @param {Object} [_deps] - Dependency overrides for testing
238
245
  * @returns {Promise<{available: boolean, error?: string}>}
@@ -245,6 +252,12 @@ async function checkChatProviderAvailability(id, _deps) {
245
252
 
246
253
  const deps = { ...defaults, ..._deps };
247
254
 
255
+ // Per-provider availability-probe timeout. Configured in seconds (mirrors
256
+ // checkout_timeout_seconds); falls back to the shared default when
257
+ // unset/invalid. Build-based availability commands can raise this via
258
+ // `availability_timeout_seconds`.
259
+ const timeout = secondsToTimeoutMs(provider.availability_timeout_seconds);
260
+
248
261
  if (provider.availability_command) {
249
262
  return runCommandAvailabilityCheck({
250
263
  deps,
@@ -253,11 +266,18 @@ async function checkChatProviderAvailability(id, _deps) {
253
266
  displayCommand: 'availability command',
254
267
  shell: true,
255
268
  env: provider.env,
269
+ timeout,
256
270
  });
257
271
  }
258
272
 
259
- // Pi delegates to existing AI provider availability
260
- if (provider.type === 'pi') {
273
+ // Delegate to the AI provider's cached availability only for the built-in Pi
274
+ // chat provider with no command override — i.e. the same binary the AI
275
+ // provider already probed. The built-in `pi` definition intentionally omits
276
+ // `command` (PiBridge resolves its own default at runtime), so `!provider.command`
277
+ // cleanly distinguishes it. Custom `type: 'pi'` providers and built-in Pi
278
+ // overridden with a different `command` point at a different binary, so they
279
+ // fall through to the `<command> --version` probe below (which honors `timeout`).
280
+ if (provider.type === 'pi' && !provider.command) {
261
281
  const cached = getCachedAvailability('pi');
262
282
  return { available: cached?.available || false, error: cached?.error };
263
283
  }
@@ -278,6 +298,7 @@ async function checkChatProviderAvailability(id, _deps) {
278
298
  displayCommand: `${command} --version`,
279
299
  shell: useShell,
280
300
  env: provider.env,
301
+ timeout,
281
302
  });
282
303
  }
283
304
 
@@ -296,15 +317,15 @@ async function checkChatProviderAvailability(id, _deps) {
296
317
  * - `displayCommand` is used in error messages so user-configured shell strings
297
318
  * do not need to be printed verbatim.
298
319
  *
299
- * @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object}} opts
320
+ * @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object, timeout?: number}} opts
300
321
  * @returns {Promise<{available: boolean, error?: string}>}
301
322
  */
302
- function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env }) {
323
+ function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env, timeout = DEFAULT_AVAILABILITY_TIMEOUT_MS }) {
303
324
  return new Promise((resolve) => {
304
325
  try {
305
326
  const proc = deps.spawn(command, args, {
306
327
  stdio: ['ignore', 'ignore', 'ignore'],
307
- timeout: 10000,
328
+ timeout,
308
329
  shell,
309
330
  env: { ...process.env, ...(env || {}) },
310
331
  });