From cf9f4ecd52e9bc11bd2c36c70a41bf1ea46aeb0e Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 16:19:50 +0200 Subject: [PATCH] fix(llm): per-task tier override bypasses global allowedTiers gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../{eventstream => activity}/ListView.svelte | 0 .../{cycles => period}/ListView.svelte | 0 .../lib/modules/{cycles => period}/ROADMAP.md | 0 .../modules/{cycles => period}/collections.ts | 0 .../components/CycleCalendar.svelte | 0 .../components/SymptomManager.svelte | 0 .../lib/modules/{cycles => period}/index.ts | 0 .../{cycles => period}/module.config.ts | 0 .../lib/modules/{cycles => period}/queries.ts | 0 .../stores/cycles.integration.test.ts | 0 .../stores/cycles.svelte.ts | 0 .../stores/dayLogs.svelte.ts | 0 .../stores/symptoms.svelte.ts | 0 .../lib/modules/{cycles => period}/tools.ts | 0 .../lib/modules/{cycles => period}/types.ts | 0 .../utils/auto-detect.test.ts | 0 .../{cycles => period}/utils/auto-detect.ts | 0 .../{cycles => period}/utils/phase.test.ts | 0 .../modules/{cycles => period}/utils/phase.ts | 0 .../utils/prediction.test.ts | 0 .../{cycles => period}/utils/prediction.ts | 0 .../(app)/{cycles => period}/+page.svelte | 0 packages/shared-llm/src/orchestrator.ts | 42 ++++++++++++------- 23 files changed, 28 insertions(+), 14 deletions(-) rename apps/mana/apps/web/src/lib/modules/{eventstream => activity}/ListView.svelte (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/ListView.svelte (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/ROADMAP.md (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/collections.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/components/CycleCalendar.svelte (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/components/SymptomManager.svelte (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/index.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/module.config.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/queries.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/stores/cycles.integration.test.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/stores/cycles.svelte.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/stores/dayLogs.svelte.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/stores/symptoms.svelte.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/tools.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/types.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/utils/auto-detect.test.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/utils/auto-detect.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/utils/phase.test.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/utils/phase.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/utils/prediction.test.ts (100%) rename apps/mana/apps/web/src/lib/modules/{cycles => period}/utils/prediction.ts (100%) rename apps/mana/apps/web/src/routes/(app)/{cycles => period}/+page.svelte (100%) diff --git a/apps/mana/apps/web/src/lib/modules/eventstream/ListView.svelte b/apps/mana/apps/web/src/lib/modules/activity/ListView.svelte similarity index 100% rename from apps/mana/apps/web/src/lib/modules/eventstream/ListView.svelte rename to apps/mana/apps/web/src/lib/modules/activity/ListView.svelte diff --git a/apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte b/apps/mana/apps/web/src/lib/modules/period/ListView.svelte similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte rename to apps/mana/apps/web/src/lib/modules/period/ListView.svelte diff --git a/apps/mana/apps/web/src/lib/modules/cycles/ROADMAP.md b/apps/mana/apps/web/src/lib/modules/period/ROADMAP.md similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/ROADMAP.md rename to apps/mana/apps/web/src/lib/modules/period/ROADMAP.md diff --git a/apps/mana/apps/web/src/lib/modules/cycles/collections.ts b/apps/mana/apps/web/src/lib/modules/period/collections.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/collections.ts rename to apps/mana/apps/web/src/lib/modules/period/collections.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/components/CycleCalendar.svelte b/apps/mana/apps/web/src/lib/modules/period/components/CycleCalendar.svelte similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/components/CycleCalendar.svelte rename to apps/mana/apps/web/src/lib/modules/period/components/CycleCalendar.svelte diff --git a/apps/mana/apps/web/src/lib/modules/cycles/components/SymptomManager.svelte b/apps/mana/apps/web/src/lib/modules/period/components/SymptomManager.svelte similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/components/SymptomManager.svelte rename to apps/mana/apps/web/src/lib/modules/period/components/SymptomManager.svelte diff --git a/apps/mana/apps/web/src/lib/modules/cycles/index.ts b/apps/mana/apps/web/src/lib/modules/period/index.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/index.ts rename to apps/mana/apps/web/src/lib/modules/period/index.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/module.config.ts b/apps/mana/apps/web/src/lib/modules/period/module.config.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/module.config.ts rename to apps/mana/apps/web/src/lib/modules/period/module.config.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/queries.ts b/apps/mana/apps/web/src/lib/modules/period/queries.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/queries.ts rename to apps/mana/apps/web/src/lib/modules/period/queries.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.integration.test.ts b/apps/mana/apps/web/src/lib/modules/period/stores/cycles.integration.test.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.integration.test.ts rename to apps/mana/apps/web/src/lib/modules/period/stores/cycles.integration.test.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts b/apps/mana/apps/web/src/lib/modules/period/stores/cycles.svelte.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts rename to apps/mana/apps/web/src/lib/modules/period/stores/cycles.svelte.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts b/apps/mana/apps/web/src/lib/modules/period/stores/dayLogs.svelte.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts rename to apps/mana/apps/web/src/lib/modules/period/stores/dayLogs.svelte.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/symptoms.svelte.ts b/apps/mana/apps/web/src/lib/modules/period/stores/symptoms.svelte.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/stores/symptoms.svelte.ts rename to apps/mana/apps/web/src/lib/modules/period/stores/symptoms.svelte.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/tools.ts b/apps/mana/apps/web/src/lib/modules/period/tools.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/tools.ts rename to apps/mana/apps/web/src/lib/modules/period/tools.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/types.ts b/apps/mana/apps/web/src/lib/modules/period/types.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/types.ts rename to apps/mana/apps/web/src/lib/modules/period/types.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.test.ts b/apps/mana/apps/web/src/lib/modules/period/utils/auto-detect.test.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.test.ts rename to apps/mana/apps/web/src/lib/modules/period/utils/auto-detect.test.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.ts b/apps/mana/apps/web/src/lib/modules/period/utils/auto-detect.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.ts rename to apps/mana/apps/web/src/lib/modules/period/utils/auto-detect.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts b/apps/mana/apps/web/src/lib/modules/period/utils/phase.test.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts rename to apps/mana/apps/web/src/lib/modules/period/utils/phase.test.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts b/apps/mana/apps/web/src/lib/modules/period/utils/phase.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts rename to apps/mana/apps/web/src/lib/modules/period/utils/phase.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.test.ts b/apps/mana/apps/web/src/lib/modules/period/utils/prediction.test.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.test.ts rename to apps/mana/apps/web/src/lib/modules/period/utils/prediction.test.ts diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.ts b/apps/mana/apps/web/src/lib/modules/period/utils/prediction.ts similarity index 100% rename from apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.ts rename to apps/mana/apps/web/src/lib/modules/period/utils/prediction.ts diff --git a/apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte b/apps/mana/apps/web/src/routes/(app)/period/+page.svelte similarity index 100% rename from apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte rename to apps/mana/apps/web/src/routes/(app)/period/+page.svelte diff --git a/packages/shared-llm/src/orchestrator.ts b/packages/shared-llm/src/orchestrator.ts index 7176f6a08..505c1b139 100644 --- a/packages/shared-llm/src/orchestrator.ts +++ b/packages/shared-llm/src/orchestrator.ts @@ -101,7 +101,8 @@ export class LlmOrchestrator { if (task.minTier === 'none') 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) => { const backend = this.backendsByTier.get(t); return backend?.isAvailable() ?? false; @@ -117,9 +118,17 @@ export class LlmOrchestrator { const start = performance.now(); const attempted: LlmTier[] = []; - // Rule 1: tier-too-low check - const userMaxTier = this.userMaxTier(); - if (TIER_RANK[task.minTier] > TIER_RANK[userMaxTier]) { + // Rule 1: tier-too-low check. + // An explicit per-task override counts as opting-in to that tier + // 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) { const value = await task.runRules(input); return { @@ -129,12 +138,11 @@ export class LlmOrchestrator { 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 - const candidates = this.candidateTiers(task); - const override = this.settings.taskOverrides[task.name]; + const candidates = this.candidateTiers(task, override); const orderedTiers = override ? [override].filter((t) => candidates.includes(t)) : candidates; // Rule 4-5: try the first runnable tier @@ -236,17 +244,23 @@ export class LlmOrchestrator { /** Candidate tier list after applying rules 1 + 2. * - 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. */ - private candidateTiers(task: LlmTask): LlmTier[] { - // Sort by privacy gradient (most private first) so the browser tier - // always wins over mana-server when both are enabled, regardless of - // the order the user toggled them in settings. - let tiers = this.settings.allowedTiers + private candidateTiers(task: LlmTask, override?: LlmTier): LlmTier[] { + // Start with the user's allowed tiers, plus the override if set + // (the override is an explicit per-task opt-in even if the user + // hasn't enabled that tier globally). + 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]) .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') { tiers = tiers.filter((t) => t === 'browser'); }