fix(llm): per-task tier override bypasses global allowedTiers gate

Bug: setting taskOverrides['companion.chat'] = 'byok' didn't work
when the user's allowedTiers was empty/['none']. The tier-too-low
check in run() compared task.minTier ('browser') against userMaxTier
('none') and threw TierTooLowError before the override was even read.

Same issue in canRun() and candidateTiers().

Fix: when a per-task override exists, treat it as opt-in to that tier
even if not in the global allowedTiers. The override is the user's
explicit per-task signal — overriding the global default is exactly
what an override is for.

- run(): effectiveMaxTier = max(override, userMaxTier)
- candidateTiers(task, override): adds override to baseTiers
- canRun(): now passes the override to candidateTiers

The Companion chat now correctly uses BYOK when selected from the
toolbar, even if the user hasn't enabled BYOK in their global LLM
settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 16:19:50 +02:00
parent e95d0487b9
commit cf9f4ecd52
23 changed files with 28 additions and 14 deletions

View file

@ -101,7 +101,8 @@ export class LlmOrchestrator {
if (task.minTier === 'none') return true; if (task.minTier === 'none') return true;
if (task.runRules) return true; if (task.runRules) return true;
const candidates = this.candidateTiers(task); const override = this.settings.taskOverrides[task.name];
const candidates = this.candidateTiers(task, override);
return candidates.some((t) => { return candidates.some((t) => {
const backend = this.backendsByTier.get(t); const backend = this.backendsByTier.get(t);
return backend?.isAvailable() ?? false; return backend?.isAvailable() ?? false;
@ -117,9 +118,17 @@ export class LlmOrchestrator {
const start = performance.now(); const start = performance.now();
const attempted: LlmTier[] = []; const attempted: LlmTier[] = [];
// Rule 1: tier-too-low check // Rule 1: tier-too-low check.
const userMaxTier = this.userMaxTier(); // An explicit per-task override counts as opting-in to that tier
if (TIER_RANK[task.minTier] > TIER_RANK[userMaxTier]) { // even if it isn't in the user's global allowedTiers — that's the
// whole point of overrides (e.g. "use BYOK just for the Companion").
const override = this.settings.taskOverrides[task.name];
const effectiveMaxTier = override
? TIER_RANK[override] > TIER_RANK[this.userMaxTier()]
? override
: this.userMaxTier()
: this.userMaxTier();
if (TIER_RANK[task.minTier] > TIER_RANK[effectiveMaxTier]) {
if (task.runRules) { if (task.runRules) {
const value = await task.runRules(input); const value = await task.runRules(input);
return { return {
@ -129,12 +138,11 @@ export class LlmOrchestrator {
attempted: ['none'], attempted: ['none'],
}; };
} }
throw new TierTooLowError(task.name, task.minTier, userMaxTier); throw new TierTooLowError(task.name, task.minTier, effectiveMaxTier);
} }
// Rules-2-3: candidate tier list and per-task override // Rules-2-3: candidate tier list and per-task override
const candidates = this.candidateTiers(task); const candidates = this.candidateTiers(task, override);
const override = this.settings.taskOverrides[task.name];
const orderedTiers = override ? [override].filter((t) => candidates.includes(t)) : candidates; const orderedTiers = override ? [override].filter((t) => candidates.includes(t)) : candidates;
// Rule 4-5: try the first runnable tier // Rule 4-5: try the first runnable tier
@ -236,17 +244,23 @@ export class LlmOrchestrator {
/** Candidate tier list after applying rules 1 + 2. /** Candidate tier list after applying rules 1 + 2.
* - Rule 1: only tiers >= task.minTier * - Rule 1: only tiers >= task.minTier
* - Rule 2: sensitive content excludes mana-server + cloud * - Rule 2: sensitive content excludes mana-server + cloud + byok
* - If a per-task override is given, it's allowed even if not in
* settings.allowedTiers (explicit per-task opt-in beats global)
* Also always includes 'none' at the end if the task has runRules. */ * Also always includes 'none' at the end if the task has runRules. */
private candidateTiers<TIn, TOut>(task: LlmTask<TIn, TOut>): LlmTier[] { private candidateTiers<TIn, TOut>(task: LlmTask<TIn, TOut>, override?: LlmTier): LlmTier[] {
// Sort by privacy gradient (most private first) so the browser tier // Start with the user's allowed tiers, plus the override if set
// always wins over mana-server when both are enabled, regardless of // (the override is an explicit per-task opt-in even if the user
// the order the user toggled them in settings. // hasn't enabled that tier globally).
let tiers = this.settings.allowedTiers const baseTiers = override
? Array.from(new Set([...this.settings.allowedTiers, override]))
: this.settings.allowedTiers;
let tiers = baseTiers
.filter((t) => TIER_RANK[t] >= TIER_RANK[task.minTier]) .filter((t) => TIER_RANK[t] >= TIER_RANK[task.minTier])
.sort((a, b) => TIER_RANK[a] - TIER_RANK[b]); .sort((a, b) => TIER_RANK[a] - TIER_RANK[b]);
// Rule 2: sensitive content backstop // Rule 2: sensitive content backstop — only browser-local stays
if (task.contentClass === 'sensitive') { if (task.contentClass === 'sensitive') {
tiers = tiers.filter((t) => t === 'browser'); tiers = tiers.filter((t) => t === 'browser');
} }