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:
parent
9f0723a7be
commit
58543fdae4
4 changed files with 68 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue