diff --git a/src/core/config.ts b/src/core/config.ts index 4ca00cdc9..5eedbbfdf 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -620,7 +620,10 @@ export function loadConfig(): GBrainConfig | null { * size the schema and must be stable across engine connect. */ export async function loadConfigWithEngine( - engine: { getConfig(key: string): Promise }, + engine: { + getConfig(key: string): Promise; + listConfigKeys?(prefix: string): Promise; + }, base?: GBrainConfig | null, ): Promise { // Codex /ship finding #3: when there's no file config AND no env DB URL, @@ -669,10 +672,36 @@ export async function loadConfigWithEngine( const dbEmbeddingColumns = await dbStr('embedding_columns'); const dbSearchEmbeddingColumn = await dbStr('search_embedding_column'); + // `gbrain config set provider_base_urls. ` writes DB plane (the + // prefix is advertised in KNOWN_CONFIG_KEY_PREFIXES) but pre-fix nothing + // ever merged it back, so the gateway never saw the configured proxy. + const dbProviderBaseUrls: Record = {}; + if (typeof engine.listConfigKeys === 'function') { + try { + const keys = await engine.listConfigKeys('provider_base_urls.'); + for (const key of keys) { + const provider = key.slice('provider_base_urls.'.length).trim(); + if (!provider) continue; + const value = await dbStr(key); + if (value !== undefined && value.trim()) dbProviderBaseUrls[provider] = value.trim(); + } + } catch { + // Minimal engine shims (tests) may not support prefix listing; keep + // file/env behavior. + } + } + // DB applies only when env did NOT win. Env presence is detected by the // sync loadConfig() already setting the field. For each flag, prefer the // existing fileConfig value when defined; otherwise fall through to DB. const merged: GBrainConfig = { ...fileConfig }; + if (Object.keys(dbProviderBaseUrls).length > 0) { + // File-plane entries win over DB values per the documented precedence. + merged.provider_base_urls = { + ...dbProviderBaseUrls, + ...(merged.provider_base_urls ?? {}), + }; + } if (merged.embedding_multimodal === undefined && dbMultimodal !== undefined) { merged.embedding_multimodal = dbMultimodal; } diff --git a/src/core/facts/extract.ts b/src/core/facts/extract.ts index 0ee9930ec..97e02b8a2 100644 --- a/src/core/facts/extract.ts +++ b/src/core/facts/extract.ts @@ -173,16 +173,19 @@ export async function extractFactsFromTurn(input: ExtractInput): Promise\n${cleaned}\n\n\nExtract up to ${cap} facts.${ input.entityHints && input.entityHints.length ? ` Known entity slugs the user already mentioned: ${input.entityHints.slice(0, 5).join(', ')}.` diff --git a/test/facts-extract-silent-no-op.test.ts b/test/facts-extract-silent-no-op.test.ts index 4272bb5d6..8b1e68a3c 100644 --- a/test/facts-extract-silent-no-op.test.ts +++ b/test/facts-extract-silent-no-op.test.ts @@ -24,6 +24,7 @@ import { isAvailable, resetGateway, __setChatTransportForTests, + __setGenerateTextTransportForTests, getChatModel, } from '../src/core/ai/gateway.ts'; import { extractFactsFromTurn } from '../src/core/facts/extract.ts'; @@ -116,6 +117,48 @@ describe('facts extract — silent-no-op regression (v0.31.6 bug class)', () => expect(facts).toEqual([]); }); + test('facts model override availability is independent of the global chat model', () => { + // A brain can leave the global chat model on Anthropic while routing only + // facts extraction through an OpenAI-compatible/private endpoint. The + // extractor must probe the EFFECTIVE facts model, not the global chat model. + configureGateway({ + chat_model: 'anthropic:claude-sonnet-4-6', + base_urls: { openrouter: 'http://127.0.0.1:8806/v1' }, + env: { OPENROUTER_API_KEY: 'unused' }, + }); + expect(isAvailable('chat')).toBe(false); // no ANTHROPIC_API_KEY + expect(isAvailable('chat', 'openrouter:private/gemma4-31b')).toBe(true); + }); + + test('extractFactsFromTurn extracts via the per-call facts model even when the global chat model is unavailable', async () => { + // Pre-fix, extract probed isAvailable('chat') with NO model — so a brain + // whose facts model differed from the (unavailable) global chat model + // silently extracted zero facts (takeover of #2233). + configureGateway({ + chat_model: 'anthropic:claude-sonnet-4-6', + base_urls: { openrouter: 'http://127.0.0.1:8806/v1' }, + env: { OPENROUTER_API_KEY: 'unused' }, + }); + expect(isAvailable('chat')).toBe(false); + __setGenerateTextTransportForTests(async () => ({ + content: [{ type: 'text', text: JSON.stringify({ facts: [ + { fact: 'The user prefers short answers', kind: 'preference', confidence: 0.9, notability: 'medium' }, + ] }) }], + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1 }, + }) as any); + try { + const facts = await extractFactsFromTurn({ + turnText: 'I prefer short answers, always.', + source: 'test:facts-model-override', + model: 'openrouter:private/gemma4-31b', + }); + expect(facts.length).toBeGreaterThan(0); + } finally { + __setGenerateTextTransportForTests(null); + } + }); + test('extractFactsFromTurn USES the chat transport when available — does NOT silently return []', async () => { // The smoking-gun test: when chat IS available, extract MUST actually call // the chat transport. If it silently returns [] without calling chat, the diff --git a/test/loadConfig-merge.test.ts b/test/loadConfig-merge.test.ts index 7a8ab08af..a2b4ea804 100644 --- a/test/loadConfig-merge.test.ts +++ b/test/loadConfig-merge.test.ts @@ -9,6 +9,7 @@ import { loadConfigWithEngine, type GBrainConfig } from '../src/core/config.ts'; interface FakeEngine { getConfig(key: string): Promise; + listConfigKeys?(prefix: string): Promise; } function makeEngine(map: Record): FakeEngine { @@ -16,6 +17,9 @@ function makeEngine(map: Record): FakeEngine async getConfig(key: string) { return map[key]; }, + async listConfigKeys(prefix: string) { + return Object.keys(map).filter(k => k.startsWith(prefix)); + }, }; } @@ -47,6 +51,40 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => { expect(merged?.embedding_columns?.embedding_voyage?.dimensions).toBe(1024); }); + test('DB-plane provider_base_urls. merge reaches runtime config', async () => { + // `gbrain config set provider_base_urls.openrouter ` writes DB plane + // (KNOWN_CONFIG_KEY_PREFIXES advertises it) — pre-fix nothing merged it + // back, so the gateway never saw the configured proxy. + const base: GBrainConfig = { engine: 'pglite' }; + const engine = makeEngine({ + 'provider_base_urls.openrouter': 'http://127.0.0.1:8806/v1', + 'provider_base_urls.llama-server': 'http://127.0.0.1:8081/v1', + }); + const merged = await loadConfigWithEngine(engine, base); + expect(merged?.provider_base_urls?.openrouter).toBe('http://127.0.0.1:8806/v1'); + expect(merged?.provider_base_urls?.['llama-server']).toBe('http://127.0.0.1:8081/v1'); + }); + + test('file-plane provider_base_urls win over DB-plane provider overrides', async () => { + const base: GBrainConfig = { + engine: 'pglite', + provider_base_urls: { openrouter: 'http://file-plane/v1' }, + }; + const engine = makeEngine({ + 'provider_base_urls.openrouter': 'http://db-plane/v1', + }); + const merged = await loadConfigWithEngine(engine, base); + expect(merged?.provider_base_urls?.openrouter).toBe('http://file-plane/v1'); + }); + + test('engine without listConfigKeys keeps working (older shims)', async () => { + const base: GBrainConfig = { engine: 'pglite' }; + const engine = { async getConfig() { return undefined; } }; + const merged = await loadConfigWithEngine(engine, base); + expect(merged?.engine).toBe('pglite'); + expect(merged?.provider_base_urls).toBeUndefined(); + }); + test('DB flag fills in when file/env did not set it', async () => { const base: GBrainConfig = { engine: 'pglite' }; const engine = makeEngine({