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 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-07-27 16:44:27 -07:00
committed by GitHub
co-authored by Claude Fable 5 Garry Tan
parent 3a28d2612a
commit 4320527785
9 changed files with 25 additions and 4 deletions
+2 -1
View File
@@ -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-...
```
+2 -1
View File
@@ -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
+1
View File
@@ -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`
+1 -1
View File
@@ -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:<provider>/<model>` (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:<provider>/<model>` (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:<id>`. Pricing matches the upstream provider (OR adds a small markup).
+1 -1
View File
@@ -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:<provider>/<model>`. 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:<provider>/<model>`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).',
compat: { fetch: openrouterCompatFetch },
};
+1
View File
@@ -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');
});
+2
View File
@@ -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('***');
});
+6
View File
@@ -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);
+9
View File
@@ -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;
}
});