From 4320527785d382c419441ab6e3adff72e8d7fe35 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 27 Jul 2026 16:44:27 -0700 Subject: [PATCH] feat: support OpenRouter API key in config (#1714) * feat: support OpenRouter API key in config * fixup: dedupe openrouter_api_key vs master, drop no-op compile-guard test Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Garry Tan Co-authored-by: Claude Fable 5 --- docs/INSTALL.md | 3 ++- docs/guides/agent-to-gbrain.md | 3 ++- docs/guides/minions-shell-jobs.md | 1 + docs/integrations/embedding-providers.md | 2 +- src/core/ai/recipes/openrouter.ts | 2 +- test/config-set.test.ts | 1 + test/config.test.ts | 2 ++ test/minions-shell-inherit.test.ts | 6 ++++++ test/v0_37_fix_wave.serial.test.ts | 9 +++++++++ 9 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f3517161b..2033280af 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -39,10 +39,11 @@ gbrain migrate --to pglite # Postgres → PGLite (rare) For shared / large / multi-machine deployments (a team or company brain with multiple users hitting one server over HTTP MCP with OAuth scoping per user), follow the dedicated walkthrough: **[Tutorial: set up GBrain as your company brain](tutorials/company-brain.md)**. -API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI: +API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI: ```bash gbrain config set zeroentropy_api_key sk-... +gbrain config set openrouter_api_key sk-or-... gbrain config set anthropic_api_key sk-ant-... ``` diff --git a/docs/guides/agent-to-gbrain.md b/docs/guides/agent-to-gbrain.md index 20627b2ab..8454e1638 100644 --- a/docs/guides/agent-to-gbrain.md +++ b/docs/guides/agent-to-gbrain.md @@ -159,7 +159,8 @@ proxy for worker env. If a brain DB ever traverses a trust boundary, secrets stay out. - **Free-form names.** `inherit:` accepts any snake_case config-key on your worker — `database_url`, `anthropic_api_key`, `openai_api_key`, - `voyage_api_key`, `groq_api_key`, `zeroentropy_api_key`, or any custom + `openrouter_api_key`, `voyage_api_key`, `groq_api_key`, + `zeroentropy_api_key`, or any custom field you stuff into `~/.gbrain/config.json`. The agent picks what it needs. - **`env:` still works** for non-secret values, or for cases where you diff --git a/docs/guides/minions-shell-jobs.md b/docs/guides/minions-shell-jobs.md index 62496e890..a5cd26b4b 100644 --- a/docs/guides/minions-shell-jobs.md +++ b/docs/guides/minions-shell-jobs.md @@ -155,6 +155,7 @@ child-spawn time: - `inherit: ["database_url"]` → child env `GBRAIN_DATABASE_URL` - `inherit: ["anthropic_api_key"]` → child env `ANTHROPIC_API_KEY` - `inherit: ["openai_api_key"]` → child env `OPENAI_API_KEY` +- `inherit: ["openrouter_api_key"]` → child env `OPENROUTER_API_KEY` - `inherit: ["voyage_api_key"]` → child env `VOYAGE_API_KEY` - `inherit: ["groq_api_key", "zeroentropy_api_key"]` → both injected - Or any arbitrary config-key your worker has (`my_custom_field` → diff --git a/docs/integrations/embedding-providers.md b/docs/integrations/embedding-providers.md index 75e405f69..ffd8114bf 100644 --- a/docs/integrations/embedding-providers.md +++ b/docs/integrations/embedding-providers.md @@ -103,7 +103,7 @@ For GCP service-account / Vertex AI auth (production deployments), see the v0.32 ### OpenRouter -Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` and use `openrouter:/` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`). +Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` or `openrouter_api_key` in `~/.gbrain/config.json`, then use `openrouter:/` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`). **Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). OR's embedding catalog also includes `text-embedding-3-large`, `google/gemini-embedding-2-preview`, `qwen/qwen3-embedding-8b`, `bge-m3` — opt in via `--embedding-model openrouter:`. Pricing matches the upstream provider (OR adds a small markup). diff --git a/src/core/ai/recipes/openrouter.ts b/src/core/ai/recipes/openrouter.ts index bc19f5cca..4e4502aaa 100644 --- a/src/core/ai/recipes/openrouter.ts +++ b/src/core/ai/recipes/openrouter.ts @@ -231,6 +231,6 @@ export const openrouter: Recipe = { }, }, setup_hint: - 'Get an API key at https://openrouter.ai/settings/keys, then `export OPENROUTER_API_KEY=...` and use `openrouter:/`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).', + 'Get an API key at https://openrouter.ai/settings/keys, then `export OPENROUTER_API_KEY=...` or set `openrouter_api_key` in ~/.gbrain/config.json and use `openrouter:/`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).', compat: { fetch: openrouterCompatFetch }, }; diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 648d3b932..af2787b45 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -24,6 +24,7 @@ describe('KNOWN_CONFIG_KEYS', () => { expect(KNOWN_CONFIG_KEYS).toContain('embedding_disabled'); // v0.37 D9 expect(KNOWN_CONFIG_KEYS).toContain('expansion_model'); expect(KNOWN_CONFIG_KEYS).toContain('chat_model'); + expect(KNOWN_CONFIG_KEYS).toContain('openrouter_api_key'); expect(KNOWN_CONFIG_KEYS).toContain('provider_chat_options'); }); diff --git a/test/config.test.ts b/test/config.test.ts index cdfcbf116..79b373941 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -67,6 +67,7 @@ describe('isSensitiveConfigKey (v0.36.x #892 regression)', () => { test('matches common sensitive key shapes', () => { expect(isSensitiveConfigKey('openai_api_key')).toBe(true); expect(isSensitiveConfigKey('anthropic_api_key')).toBe(true); + expect(isSensitiveConfigKey('openrouter_api_key')).toBe(true); expect(isSensitiveConfigKey('voyage_api_key')).toBe(true); expect(isSensitiveConfigKey('admin_token')).toBe(true); expect(isSensitiveConfigKey('database.password')).toBe(true); @@ -93,6 +94,7 @@ describe('isSensitiveConfigKey (v0.36.x #892 regression)', () => { describe('redactConfigValue (v0.36.x #892 — set output regression)', () => { test('redacts sensitive keys to ***', () => { expect(redactConfigValue('openai_api_key', 'sk-test-123')).toBe('***'); + expect(redactConfigValue('openrouter_api_key', 'sk-or-test-123')).toBe('***'); expect(redactConfigValue('admin_token', 'eyJhbGciOiJIUzI1NiJ9')).toBe('***'); }); diff --git a/test/minions-shell-inherit.test.ts b/test/minions-shell-inherit.test.ts index dff66343d..e6f99c035 100644 --- a/test/minions-shell-inherit.test.ts +++ b/test/minions-shell-inherit.test.ts @@ -23,6 +23,7 @@ describe('INHERIT_NAME_RE', () => { 'database_url', 'anthropic_api_key', 'openai_api_key', + 'openrouter_api_key', 'voyage_api_key', 'groq_api_key', 'zeroentropy_api_key', @@ -61,6 +62,9 @@ describe('deriveEnvKey', () => { test('openai_api_key → OPENAI_API_KEY', () => { expect(deriveEnvKey('openai_api_key')).toBe('OPENAI_API_KEY'); }); + test('openrouter_api_key → OPENROUTER_API_KEY', () => { + expect(deriveEnvKey('openrouter_api_key')).toBe('OPENROUTER_API_KEY'); + }); test('voyage_api_key → VOYAGE_API_KEY', () => { expect(deriveEnvKey('voyage_api_key')).toBe('VOYAGE_API_KEY'); }); @@ -108,11 +112,13 @@ describe('integration: deriveEnvKey + resolveInheritValue work together', () => database_url: 'postgresql://x', anthropic_api_key: 'sk-ant-x', openai_api_key: 'sk-x', + openrouter_api_key: 'sk-or-x', }; test.each([ ['database_url', 'GBRAIN_DATABASE_URL', 'postgresql://x'], ['anthropic_api_key', 'ANTHROPIC_API_KEY', 'sk-ant-x'], ['openai_api_key', 'OPENAI_API_KEY', 'sk-x'], + ['openrouter_api_key', 'OPENROUTER_API_KEY', 'sk-or-x'], ])('name %s resolves to envKey %s with value %s', (name, expectedEnvKey, expectedValue) => { expect(deriveEnvKey(name)).toBe(expectedEnvKey); expect(resolveInheritValue(cfg, name)).toBe(expectedValue); diff --git a/test/v0_37_fix_wave.serial.test.ts b/test/v0_37_fix_wave.serial.test.ts index e97e91f21..500309570 100644 --- a/test/v0_37_fix_wave.serial.test.ts +++ b/test/v0_37_fix_wave.serial.test.ts @@ -141,6 +141,7 @@ describe('v0.37 Lane B — init paths', () => { process.env.GBRAIN_EMBEDDING_MODEL = 'voyage:voyage-3-large'; process.env.GBRAIN_EMBEDDING_DIMENSIONS = '2048'; process.env.OPENAI_API_KEY = 'sk-from-env'; + process.env.OPENROUTER_API_KEY = 'sk-or-from-env'; // Force re-import to pick up env state (the module-level resolver in // config.ts reads process.env at call time, so this is safe). @@ -152,16 +153,19 @@ describe('v0.37 Lane B — init paths', () => { expect(fileOnly?.embedding_dimensions).toBe(1536); // CDX-5 regression: env keys must NOT leak into file-only loader. expect(fileOnly?.openai_api_key).toBeUndefined(); + expect(fileOnly?.openrouter_api_key).toBeUndefined(); // Control: loadConfig() DOES merge env. const merged = loadConfig(); expect(merged?.embedding_model).toBe('voyage:voyage-3-large'); expect(merged?.embedding_dimensions).toBe(2048); expect(merged?.openai_api_key).toBe('sk-from-env'); + expect(merged?.openrouter_api_key).toBe('sk-or-from-env'); delete process.env.GBRAIN_EMBEDDING_MODEL; delete process.env.GBRAIN_EMBEDDING_DIMENSIONS; delete process.env.OPENAI_API_KEY; + delete process.env.OPENROUTER_API_KEY; }); test('B.4 / CDX-5: loadConfigFileOnly does NOT infer engine from DATABASE_URL', async () => { @@ -203,9 +207,11 @@ describe('v0.37 Lane C.3 — ZE key reaches buildGatewayConfig', () => { const savedZe = process.env.ZEROENTROPY_API_KEY; const savedOai = process.env.OPENAI_API_KEY; const savedAnth = process.env.ANTHROPIC_API_KEY; + const savedOr = process.env.OPENROUTER_API_KEY; delete process.env.ZEROENTROPY_API_KEY; delete process.env.OPENAI_API_KEY; delete process.env.ANTHROPIC_API_KEY; + delete process.env.OPENROUTER_API_KEY; try { const { buildGatewayConfig } = await import('../src/cli.ts'); const cfg = { @@ -213,16 +219,19 @@ describe('v0.37 Lane C.3 — ZE key reaches buildGatewayConfig', () => { zeroentropy_api_key: 'test-ze-key', openai_api_key: 'test-oai', anthropic_api_key: 'test-anth', + openrouter_api_key: 'test-or', }; const gwCfg = buildGatewayConfig(cfg as any); expect(gwCfg.env?.ZEROENTROPY_API_KEY).toBe('test-ze-key'); // Regression on the existing two keys. expect(gwCfg.env?.OPENAI_API_KEY).toBe('test-oai'); expect(gwCfg.env?.ANTHROPIC_API_KEY).toBe('test-anth'); + expect(gwCfg.env?.OPENROUTER_API_KEY).toBe('test-or'); } finally { if (savedZe !== undefined) process.env.ZEROENTROPY_API_KEY = savedZe; if (savedOai !== undefined) process.env.OPENAI_API_KEY = savedOai; if (savedAnth !== undefined) process.env.ANTHROPIC_API_KEY = savedAnth; + if (savedOr !== undefined) process.env.OPENROUTER_API_KEY = savedOr; } });