@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.
- package/README.md +188 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/local.js +28 -8
- package/public/js/pr.js +92 -7
- package/public/js/utils/provider-model.js +9 -2
- package/public/setup.html +8 -0
- package/src/ai/claude-provider.js +31 -30
- package/src/ai/codex-provider.js +4 -3
- package/src/ai/copilot-provider.js +15 -1
- package/src/ai/cursor-agent-provider.js +4 -3
- package/src/ai/executable-provider.js +12 -4
- package/src/ai/gemini-provider.js +15 -1
- package/src/ai/index.js +14 -0
- package/src/ai/opencode-provider.js +15 -1
- package/src/ai/pi-provider.js +4 -3
- package/src/ai/provider.js +294 -38
- package/src/chat/chat-providers.js +30 -9
- package/src/councils/headless-council.js +128 -0
- package/src/councils/resolve-council.js +167 -0
- package/src/database.js +52 -0
- package/src/git/worktree.js +7 -1
- package/src/interactive-analysis-config.js +152 -0
- package/src/local-review.js +54 -23
- package/src/main.js +1125 -25
- package/src/mcp-stdio.js +40 -5
- package/src/review-config.js +164 -0
- package/src/routes/bulk-analysis-configs.js +45 -15
- package/src/routes/config.js +9 -4
- package/src/routes/executable-analysis.js +10 -1
- package/src/routes/local.js +170 -109
- package/src/routes/mcp.js +28 -31
- package/src/routes/pr.js +118 -56
- package/src/single-port.js +131 -5
- package/src/utils/logger.js +25 -4
package/src/ai/provider.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
629
|
-
*
|
|
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
|
-
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
//
|
|
659
|
-
const
|
|
872
|
+
// Effective models: config merged with built-ins, minus disabled_models
|
|
873
|
+
const effectiveModels = applyModelOverrides(ProviderClass.getModels(), overrides);
|
|
660
874
|
|
|
661
|
-
// Resolve default model
|
|
662
|
-
const defaultModel = resolveDefaultModel(
|
|
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
|
|
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
|
-
|
|
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}
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
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,
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
//
|
|
260
|
-
|
|
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
|
|
328
|
+
timeout,
|
|
308
329
|
shell,
|
|
309
330
|
env: { ...process.env, ...(env || {}) },
|
|
310
331
|
});
|