fix: initialize foreground chat gateway (#2590) (#3003)

Co-authored-by: Eddie Ash <119880+cazador481@users.noreply.github.com>
This commit is contained in:
Eddie Ash
2026-07-20 16:47:12 -07:00
committed by GitHub
co-authored by Eddie Ash
parent 706d3cea3d
commit ee45653a02
4 changed files with 99 additions and 5 deletions
+4 -2
View File
@@ -33,7 +33,7 @@ import type { BrainEngine } from '../core/engine.ts';
import type { EnrichCandidate, PageType } from '../core/types.ts';
import { operations } from '../core/operations.ts';
import type { OperationContext } from '../core/operations.ts';
import { isAvailable, chat, getChatModel, withBudgetTracker } from '../core/ai/gateway.ts';
import { configureGatewayIfUninitialized, isAvailable, chat, getChatModel, withBudgetTracker } from '../core/ai/gateway.ts';
import { BudgetTracker, BudgetExhausted } from '../core/budget/budget-tracker.ts';
import { hybridSearch } from '../core/search/hybrid.ts';
import { serializeMarkdown } from '../core/markdown.ts';
@@ -807,7 +807,9 @@ export async function runEnrich(engine: BrainEngine, args: string[]): Promise<vo
process.exit(1);
}
// Chat gateway required for non-dry-run.
// Chat gateway is required for non-dry-run. Recover a cold singleton before
// reporting an availability error (#2590).
if (!parsed.dryRun && !isAvailable('chat')) configureGatewayIfUninitialized();
if (!parsed.dryRun && !isAvailable('chat')) {
console.error('Chat gateway unavailable. Configure a chat model (e.g. `gbrain config set chat_model anthropic:claude-haiku-4-5`), or pass --dry-run to preview candidates.');
process.exit(1);
+4 -3
View File
@@ -71,7 +71,7 @@ import {
extractFactsFromTurn,
isFactsExtractionEnabled,
} from '../core/facts/extract.ts';
import { isAvailable, withBudgetTracker } from '../core/ai/gateway.ts';
import { configureGatewayIfUninitialized, isAvailable, withBudgetTracker } from '../core/ai/gateway.ts';
import { BudgetTracker, BudgetExhausted } from '../core/budget/budget-tracker.ts';
import { listSources } from '../core/sources-ops.ts';
import {
@@ -81,7 +81,6 @@ import {
} from '../core/op-checkpoint.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions, maybeBackground } from '../core/cli-options.ts';
import { loadConfig } from '../core/config.ts';
import { createHash } from 'crypto';
// v0.41.15.0 (T5): worker-pool primitive + per-source-clamp wrapper +
// per-page advisory lock + delete-orphans-first replay safety. See plan
@@ -1354,7 +1353,9 @@ export async function runExtractConversationFacts(
process.exit(1);
}
// Chat gateway is required for non-dry-run.
// Chat gateway is required for non-dry-run. Recover a cold singleton before
// reporting an availability error (#2590).
if (!parsed.dryRun && !isAvailable('chat')) configureGatewayIfUninitialized();
if (!parsed.dryRun && !isAvailable('chat')) {
console.error('Chat gateway unavailable. Configure an Anthropic or compatible chat model, or pass --dry-run to preview segmentation.');
process.exit(1);
+14
View File
@@ -53,6 +53,8 @@ import { dimsProviderOptions } from './dims.ts';
import { hasAnthropicKey } from './anthropic-key.ts';
import { AIConfigError, AITransientError, normalizeAIError } from './errors.ts';
import { runGuardrails, hasGuardrails, type GuardrailHook } from '../guardrails.ts';
import { loadConfig } from '../config.ts';
import { buildGatewayConfig } from './build-gateway-config.ts';
// ---- Gateway-wide AI-HTTP timeout (v0.42.20.0, #1762/#1775) ----
//
@@ -117,6 +119,18 @@ const DEFAULT_RERANKER_MODEL = 'zeroentropyai:zerank-2';
let _config: AIGatewayConfig | null = null;
const _modelCache = new Map<string, any>();
/**
* Recover the process-global gateway for foreground command entrypoints that
* were reached without cli.ts's normal engine-connect initialization (#2590).
* Existing configured gateways, including their DB-resolved model overrides,
* are deliberately left unchanged.
*/
export function configureGatewayIfUninitialized(): void {
if (_config) return;
const config = loadConfig();
if (config) configureGateway(buildGatewayConfig(config));
}
/**
* v0.31.12 recipe-models merge: per-gateway-instance set of model ids the
* user opted into via config. Keyed by provider id (`anthropic`, `openai`,
@@ -0,0 +1,77 @@
/**
* Foreground batch commands are normally reached after cli.ts configures the
* process-global gateway. Keep the command boundary defensive as well: an
* embedding/CLI loader can leave that singleton cold while the persisted chat
* configuration and provider credentials are valid (#2590).
*/
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isAvailable, resetGateway } from '../src/core/ai/gateway.ts';
import { runExtractConversationFacts } from '../src/commands/extract-conversation-facts.ts';
import { runEnrich } from '../src/commands/enrich.ts';
let home: string;
const originalHome = process.env.GBRAIN_HOME;
const originalOpenAiKey = process.env.OPENAI_API_KEY;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'gbrain-foreground-gateway-'));
mkdirSync(join(home, '.gbrain'));
writeFileSync(join(home, '.gbrain', 'config.json'), JSON.stringify({
engine: 'pglite',
database_path: join(home, '.gbrain', 'brain.pglite'),
chat_model: 'openai:example-chat-model',
}));
process.env.GBRAIN_HOME = home;
process.env.OPENAI_API_KEY = 'test-key';
resetGateway();
});
afterEach(() => {
resetGateway();
if (originalHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = originalHome;
if (originalOpenAiKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalOpenAiKey;
rmSync(home, { recursive: true, force: true });
});
describe('foreground chat gateway initialization (#2590)', () => {
test('extract-conversation-facts initializes a cold gateway from persisted config before its availability gate', async () => {
const exit = spyOn(process, 'exit').mockImplementation((() => {
throw new Error('unexpected process.exit');
}) as never);
try {
await runExtractConversationFacts({
executeRaw: async () => [],
} as never, []);
} finally {
exit.mockRestore();
}
expect(exit).not.toHaveBeenCalled();
expect(isAvailable('chat')).toBe(true);
});
test('enrich initializes a cold gateway from persisted config before its availability gate', async () => {
const exit = spyOn(process, 'exit').mockImplementation((() => {
throw new Error('unexpected process.exit');
}) as never);
try {
await runEnrich({
executeRaw: async () => [],
getConfig: async () => null,
} as never, ['--yes']);
} finally {
exit.mockRestore();
}
expect(exit).not.toHaveBeenCalled();
expect(isAvailable('chat')).toBe(true);
});
});