fix(dream): gate patterns phase on gateway provider reachability, not ANTHROPIC_API_KEY (takeover of #2279)

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: brettdavies <brettdavies@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-17 14:12:31 -07:00
co-authored by brettdavies Claude Fable 5
parent 3aeb622dc7
commit c46b457978
3 changed files with 36 additions and 21 deletions
+16 -3
View File
@@ -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();
+8 -3
View File
@@ -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', () => {
+12 -15
View File
@@ -9,7 +9,9 @@
* Anthropic call:
* - disabled: dream.patterns.enabled=false → skipped
* - insufficient_evidence: <min_evidence reflections → skipped
* - no_api_key: enough reflections, no ANTHROPIC_API_KEY → skipped
* - no_provider: enough reflections, no reachable provider for the
* resolved patterns model (default: Anthropic with no key in env OR
* config) → skipped
* - dry-run: passes through with reflections_considered + zero pages
*
* The Sonnet detection path is structurally covered in
@@ -23,6 +25,7 @@
import { describe, test, expect } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { runPhasePatterns } from '../../src/core/cycle/patterns.ts';
import { withoutAnthropicKey } from '../helpers/no-anthropic-key.ts';
interface TestRig {
engine: PGLiteEngine;
@@ -43,17 +46,6 @@ async function setupRig(): Promise<TestRig> {
};
}
async function withoutAnthropicKey<T>(body: () => Promise<T>): Promise<T> {
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();