preferences: add allowed_providers hard allowlist + plug 6 merge gaps

New feature: allowed_providers — hard allowlist of providers that
auto-mode can dispatch to. When set, models from any other provider
are invisible to selection BEFORE models.* resolution and dynamic
routing run. This prevents routing from silently picking providers
the user doesn't have keys for — the root cause of repeated
"400 The requested model is not supported" pauses observed in
dr-repo when routing picked gpt-5.2-codex despite no GPT being
configured.

Implementation is a single filter at the top of selectAndApplyModel:
  availableModels = rawAvailable.filter(m => allowed.includes(m.provider.toLowerCase()))
If the allowlist rejects everything, throw with a clear message
pointing at the pref (fail-closed — don't dispatch to whatever's
left).

While wiring this I found mergePreferences was silently dropping
six more validated fields — same latent-bug class as service_tier:

  - allowed_providers (new)       - flat_rate_providers
  - stale_commit_threshold_minutes - widget_mode
  - modelOverrides                - safety_harness

All added to the merge function. Now: if you set it in PREFERENCES,
consumers see it.

Verified end-to-end: loadEffectiveSFPreferences() reads
allowed_providers from dr-repo's .sf/PREFERENCES.md correctly, and
auto-mode model selection honors the filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-19 09:12:33 +02:00
parent 9f0723a7be
commit 58543fdae4
4 changed files with 68 additions and 1 deletions

View file

@ -100,7 +100,25 @@ export async function selectAndApplyModel(
let appliedModel: Model<Api> | 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<string> | null = null;

View file

@ -100,6 +100,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"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 {

View file

@ -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

View file

@ -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,
};
}