From a0ef9515867945c5cb5dfd13ccb35cb37c1e8081 Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:18:56 -0700 Subject: [PATCH] fix(dream): gate patterns phase on gateway provider reachability, not ANTHROPIC_API_KEY (takeover of #2279) (#2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absorbs PR #2279's intent (drop the hardcoded ANTHROPIC_API_KEY gate) with the end-state the gateway world actually wants: the patterns phase now probes the RESOLVED patterns model through probeChatModel(normalizeModelId) — the same semantics as think/index.ts and synthesize's makeJudgeClient. Fixes two misclassifications of the old env gate: - Non-Anthropic stacks (litellm, deepseek, openrouter, ...) were skipped as "no upstream" even though the subagent routes them through the gateway (agent.use_gateway_loop). They now pass the gate; their auth is checked lazily at dispatch and surfaces in the job outcome. - Anthropic keys set via `gbrain config set anthropic_api_key` (stdio MCP launches without shell env) were treated as missing. hasAnthropicKey inside probeChatModel reads both sources. Skip reason renames no_api_key → no_provider (carrying the probe's detail). Both pinning tests updated: the structural test asserts the probe wiring; the PGLite E2E swaps its env-only helper for the shared hermetic withoutAnthropicKey (env + config file) so it can't flip to a live LLM call on a dev machine with a config-file key. Co-authored-by: Sinabina Co-authored-by: brettdavies Co-authored-by: Claude Fable 5 --- src/core/cycle/patterns.ts | 19 +++++++++++++++--- test/cycle-patterns.test.ts | 11 ++++++++--- test/e2e/dream-patterns-pglite.test.ts | 27 ++++++++++++-------------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/core/cycle/patterns.ts b/src/core/cycle/patterns.ts index 75e798ea4..5edb28f7e 100644 --- a/src/core/cycle/patterns.ts +++ b/src/core/cycle/patterns.ts @@ -27,6 +27,8 @@ import { waitForCompletion, TimeoutError } from '../minions/wait-for-completion. import type { MinionJobInput, SubagentHandlerData } from '../minions/types.ts'; import { serializeMarkdown } from '../markdown.ts'; import type { Page, PageType } from '../types.ts'; +import { probeChatModel } from '../ai/gateway.ts'; +import { normalizeModelId } from '../model-id.ts'; export interface PatternsPhaseOpts { brainDir: string; @@ -63,9 +65,20 @@ export async function runPhasePatterns( }); } - // Submit one subagent for pattern detection. - if (!process.env.ANTHROPIC_API_KEY) { - return skipped('no_api_key', 'ANTHROPIC_API_KEY unset; pattern detection skipped'); + // Submit one subagent for pattern detection. The subagent dispatches via + // the gateway model-tier resolver, so gate on "is the resolved model's + // provider reachable" rather than ANTHROPIC_API_KEY specifically — a + // hardcoded env gate misclassified non-Anthropic stacks (litellm, + // deepseek, openrouter, ...) as "no upstream" even though the subagent + // routes them through the gateway (agent.use_gateway_loop), and it missed + // Anthropic keys set via `gbrain config set anthropic_api_key`. Same + // probe semantics as think/index.ts + synthesize's makeJudgeClient: + // unknown provider/model or Anthropic-without-key skips cheaply; other + // providers' auth is checked lazily at dispatch and surfaces in the job + // outcome. (Takeover of PR #2279's intent by @brettdavies.) + const probe = probeChatModel(normalizeModelId(config.model)); + if (!probe.ok) { + return skipped('no_provider', `pattern detection skipped: ${probe.detail}`); } const allowedSlugPrefixes = await loadAllowedSlugPrefixes(); diff --git a/test/cycle-patterns.test.ts b/test/cycle-patterns.test.ts index 4e8753132..1ea368d89 100644 --- a/test/cycle-patterns.test.ts +++ b/test/cycle-patterns.test.ts @@ -39,9 +39,14 @@ describe('patterns phase wiring', () => { expect(patternsSrc).toContain("tool_name = 'brain_put_page'"); }); - test('skips when ANTHROPIC_API_KEY missing', () => { - expect(patternsSrc).toContain('ANTHROPIC_API_KEY'); - expect(patternsSrc).toContain('no_api_key'); + test('gates on gateway provider reachability, not ANTHROPIC_API_KEY (PR #2279)', () => { + // The gate must probe the RESOLVED patterns model through the gateway + // (any configured provider can run patterns), not hardcode the Anthropic + // env var — that misclassified non-Anthropic stacks as "no upstream". + expect(patternsSrc).toContain('probeChatModel'); + expect(patternsSrc).toContain('normalizeModelId'); + expect(patternsSrc).toContain('no_provider'); + expect(patternsSrc).not.toContain('process.env.ANTHROPIC_API_KEY'); }); test('skips when reflections below min_evidence', () => { diff --git a/test/e2e/dream-patterns-pglite.test.ts b/test/e2e/dream-patterns-pglite.test.ts index 88474f791..8d55f4a5e 100644 --- a/test/e2e/dream-patterns-pglite.test.ts +++ b/test/e2e/dream-patterns-pglite.test.ts @@ -9,7 +9,9 @@ * Anthropic call: * - disabled: dream.patterns.enabled=false → skipped * - insufficient_evidence: { }; } -async function withoutAnthropicKey(body: () => Promise): Promise { - const saved = process.env.ANTHROPIC_API_KEY; - delete process.env.ANTHROPIC_API_KEY; - try { - return await body(); - } finally { - if (saved === undefined) delete process.env.ANTHROPIC_API_KEY; - else process.env.ANTHROPIC_API_KEY = saved; - } -} - /** * Insert N reflection pages directly via engine.putPage so the patterns * gather query has data without going through the synthesize phase. @@ -141,18 +133,23 @@ describe('E2E patterns — insufficient_evidence', () => { }, 30_000); }); -describe('E2E patterns — no API key', () => { - test('enough reflections, no ANTHROPIC_API_KEY → skipped no_api_key', async () => { +describe('E2E patterns — no reachable provider', () => { + test('enough reflections, no Anthropic key in env OR config → skipped no_provider', async () => { const rig = await setupRig(); try { await seedReflections(rig.engine, 5); // above default min_evidence (3) + // Default patterns model resolves to Anthropic; with no key reachable + // from EITHER source (env + config file — the shared helper neuters + // both) the gateway probe reports the provider unavailable. A + // non-Anthropic stack (litellm, deepseek, ...) passes this gate and + // dispatches through the gateway instead (PR #2279). await withoutAnthropicKey(async () => { const result = await runPhasePatterns(rig.engine, { brainDir: rig.brainDir, dryRun: false, }); expect(result.status).toBe('skipped'); - expect((result.details as { reason?: string }).reason).toBe('no_api_key'); + expect((result.details as { reason?: string }).reason).toBe('no_provider'); }); } finally { await rig.cleanup();