diff --git a/src/resources/extensions/sf/auto-model-selection.ts b/src/resources/extensions/sf/auto-model-selection.ts index 2e96f7ce8..031b6103f 100644 --- a/src/resources/extensions/sf/auto-model-selection.ts +++ b/src/resources/extensions/sf/auto-model-selection.ts @@ -100,7 +100,25 @@ export async function selectAndApplyModel( let appliedModel: Model | null = null; if (modelConfig) { - const availableModels = ctx.modelRegistry.getAvailable(); + // ─── Provider Allowlist (outer gate) ────────────────────────────── + // When `allowed_providers` is set in preferences, filter the candidate + // set BEFORE any other selection logic runs — both models.* resolution + // and dynamic routing will only see providers in the allowlist. This + // prevents routing from silently picking a provider the user doesn't + // have keys for (or has explicitly excluded), which caused repeated + // 400 "model not supported" dispatch failures in dr-repo. + const rawAvailable = ctx.modelRegistry.getAvailable(); + const allowed = prefs?.allowed_providers; + const availableModels = (allowed && allowed.length > 0) + ? rawAvailable.filter(m => allowed.includes(m.provider.toLowerCase())) + : rawAvailable; + if (allowed && allowed.length > 0 && availableModels.length === 0) { + throw new Error( + `allowed_providers filter rejected every available model. ` + + `Configured providers: [${allowed.join(", ")}]. ` + + `Either add a provider to allowed_providers or remove the pref.`, + ); + } const modelPolicyTraceId = `model:${ctx.sessionManager.getSessionId()}:${Date.now()}`; const modelPolicyTurnId = `${unitType}:${unitId}`; let policyAllowedModelKeys: Set | null = null; diff --git a/src/resources/extensions/sf/preferences-types.ts b/src/resources/extensions/sf/preferences-types.ts index 084e29353..21a8e28da 100644 --- a/src/resources/extensions/sf/preferences-types.ts +++ b/src/resources/extensions/sf/preferences-types.ts @@ -100,6 +100,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "gate_evaluation", "github", "service_tier", + "allowed_providers", "forensics_dedup", "show_token_cost", "stale_commit_threshold_minutes", @@ -424,6 +425,21 @@ export interface SFPreferences { * same regardless of model. Case-insensitive. */ flat_rate_providers?: string[]; + /** + * Hard allowlist of providers that auto-mode is permitted to dispatch to. + * Case-insensitive provider IDs (e.g. ["kimi-coding", "minimax", "zai", + * "ollama-cloud"]). When set, SF filters `modelRegistry.getAvailable()` + * down to only these providers before resolving `models.*` roles, running + * dynamic routing, or applying fallback chains — models from any other + * provider are invisible to selection logic. + * + * Undefined (default) = no filtering, every installed provider is in play. + * + * Pair with `models.*` using `provider/model-id` syntax to pin specific + * choices; the allowlist is the outer gate, the `models.*` block picks + * within it, and dynamic routing's `tier_models` stays inside the gate. + */ + allowed_providers?: string[]; } export interface LoadedSFPreferences { diff --git a/src/resources/extensions/sf/preferences-validation.ts b/src/resources/extensions/sf/preferences-validation.ts index 111eb105e..455ef6997 100644 --- a/src/resources/extensions/sf/preferences-validation.ts +++ b/src/resources/extensions/sf/preferences-validation.ts @@ -392,6 +392,26 @@ export function validatePreferences(preferences: SFPreferences): { } } + // ─── Allowed Providers (hard allowlist) ───────────────────────────── + // When set, model selection is gated to these providers only — any + // model from any other provider is filtered out of the candidate set + // before models.* resolution and dynamic routing. Case-insensitive. + if (preferences.allowed_providers !== undefined) { + if (Array.isArray(preferences.allowed_providers)) { + const allStrings = preferences.allowed_providers.every((s: unknown) => typeof s === "string"); + if (allStrings) { + const cleaned = preferences.allowed_providers + .map((s: string) => s.trim().toLowerCase()) + .filter((s: string) => s.length > 0); + if (cleaned.length > 0) validated.allowed_providers = cleaned; + } else { + errors.push("allowed_providers must be an array of strings (provider IDs)"); + } + } else { + errors.push("allowed_providers must be an array of strings"); + } + } + // ─── Flat-rate Providers ──────────────────────────────────────────── // User-declared flat-rate providers for dynamic routing suppression. // Built-in providers (github-copilot, copilot, claude-code) and any diff --git a/src/resources/extensions/sf/preferences.ts b/src/resources/extensions/sf/preferences.ts index bc0672af1..eb04f20d2 100644 --- a/src/resources/extensions/sf/preferences.ts +++ b/src/resources/extensions/sf/preferences.ts @@ -503,6 +503,19 @@ function mergePreferences(base: SFPreferences, override: SFPreferences): SFPrefe slice_parallel: (base.slice_parallel || override.slice_parallel) ? { ...(base.slice_parallel ?? {}), ...(override.slice_parallel ?? {}) } : undefined, + // Fields previously validated but silently dropped here — same class + // of latent bug as service_tier (fixed separately). Each gets a simple + // override-wins merge so the preference actually reaches consumers. + allowed_providers: mergeStringLists(base.allowed_providers, override.allowed_providers), + flat_rate_providers: mergeStringLists(base.flat_rate_providers, override.flat_rate_providers), + stale_commit_threshold_minutes: override.stale_commit_threshold_minutes ?? base.stale_commit_threshold_minutes, + widget_mode: override.widget_mode ?? base.widget_mode, + modelOverrides: (base.modelOverrides || override.modelOverrides) + ? { ...(base.modelOverrides ?? {}), ...(override.modelOverrides ?? {}) } + : undefined, + safety_harness: (base.safety_harness || override.safety_harness) + ? { ...(base.safety_harness ?? {}), ...(override.safety_harness ?? {}) } + : undefined, }; }