diff --git a/.docker/gbrain/entrypoint.sh b/.docker/gbrain/entrypoint.sh index 07e69fbd7..e0ecba2e7 100644 --- a/.docker/gbrain/entrypoint.sh +++ b/.docker/gbrain/entrypoint.sh @@ -218,6 +218,36 @@ if [ ! -s "$CONFIG_FILE" ]; then fi fi +# Route gbrain's OpenRouter reranker through the same Roomote credential +# gateway as embeddings and chat. Do this after initialization so exposing an +# OpenRouter-compatible endpoint does not change which provider gbrain chooses +# when it creates the Brain. An empty forwarded setting restores the default, +# including after a deployment previously selected another reranker. +GBRAIN_RERANKER_MODEL="${GBRAIN_RERANKER_MODEL:-openrouter:voyageai/rerank-2.5-lite}" +case "$GBRAIN_RERANKER_MODEL" in + openrouter:*) + if [ -z "${OPENROUTER_BASE_URL:-}" ] && [ -n "${OPENAI_BASE_URL:-}" ]; then + OPENROUTER_BASE_URL="${OPENAI_BASE_URL%/}" + case "$OPENROUTER_BASE_URL" in + */v1) ;; + *) OPENROUTER_BASE_URL="$OPENROUTER_BASE_URL/v1" ;; + esac + export OPENROUTER_BASE_URL + fi + if [ -z "${OPENROUTER_API_KEY:-}" ] && [ -n "${OPENAI_API_KEY:-}" ]; then + OPENROUTER_API_KEY="$OPENAI_API_KEY" + export OPENROUTER_API_KEY + fi + if [ -z "${OPENROUTER_BASE_URL:-}" ] || [ -z "${OPENROUTER_API_KEY:-}" ]; then + echo "[gbrain-entrypoint] WARNING: $GBRAIN_RERANKER_MODEL needs OPENROUTER_BASE_URL and OPENROUTER_API_KEY." + echo "[gbrain-entrypoint] WARNING: reranking will remain fail-open until the gateway is configured." + fi + ;; +esac + +gbrain config set search.reranker.model "$GBRAIN_RERANKER_MODEL" >/dev/null +echo "[gbrain-entrypoint] reranker: $GBRAIN_RERANKER_MODEL" + # Adding a key to a brain created without one is a first-class flow rather # than an edge case: on hosts whose compose parser ignores `profiles` the # service always runs, so a keyless first deploy followed by filling the key diff --git a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts index f5bcc3a93..9c8fc6f9c 100644 --- a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts +++ b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts @@ -139,6 +139,57 @@ describe('brain inference gateway', () => { }); }); + it('routes reranking through OpenRouter without exposing its key to gbrain', async () => { + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => + new Response(JSON.stringify({ results: [] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const body = { + model: 'cohere/rerank-v3.5', + query: 'Which result is relevant?', + documents: ['relevant', 'unrelated'], + top_n: 2, + }; + const response = await post('/v1/rerank', { + token: GATEWAY_TOKEN, + body, + }); + + expect(response.status).toBe(200); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://openrouter.ai/api/v1/rerank'); + expect((init.headers as Headers).get('authorization')).toBe( + `Bearer ${OPENROUTER.apiKey}`, + ); + expect(JSON.parse(init.body as string)).toEqual(body); + }); + + it('reports reranking as unavailable when only OpenAI is configured', async () => { + mockResolveBrainInferenceProvider.mockResolvedValue({ + providerId: 'openai', + apiKey: 'sk-openai-provider-key', + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/rerank', { + token: GATEWAY_TOKEN, + body: { + model: 'cohere/rerank-v3.5', + query: 'query', + documents: ['document'], + }, + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('OpenRouter'), + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('surfaces an unreachable provider as 502 rather than a crash', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/api/src/handlers/brain-inference/index.ts b/apps/api/src/handlers/brain-inference/index.ts index d918baf76..4075b411f 100644 --- a/apps/api/src/handlers/brain-inference/index.ts +++ b/apps/api/src/handlers/brain-inference/index.ts @@ -18,13 +18,14 @@ import type { Variables } from '../../types'; const LOG_PREFIX = '[Brain Inference]'; /** - * The Brain's whole inference surface: embeddings for recall, and one chat - * path for sourced synthesis and query expansion. Deliberately narrower than - * the task-sandbox gateway's allowlist, because this credential is a static - * deployment secret rather than a short-lived run token. + * The Brain's whole inference surface: embeddings for recall, reranking for + * precision, and chat for sourced synthesis and query expansion. Deliberately + * narrower than the task-sandbox gateway's allowlist, because this credential + * is a static deployment secret rather than a short-lived run token. */ const BRAIN_ALLOWED_PATHS = new Set([ '/v1/embeddings', + '/v1/rerank', '/v1/chat/completions', '/v1/responses', ]); @@ -176,6 +177,20 @@ brainInference.post('/*', async (c) => { ); } + // gbrain's OpenRouter reranker speaks the same authenticated gateway + // contract as embeddings and chat, but OpenAI itself has no compatible + // rerank endpoint. Fail explicitly instead of forwarding a doomed request + // to api.openai.com and obscuring the missing capability as a 404. + if (upstreamPath === '/v1/rerank' && resolved.providerId !== 'openrouter') { + return c.json( + { + error: + 'Brain reranking requires an OpenRouter provider configured in Settings.', + }, + 503, + ); + } + const provider = getInferenceGatewayProvider(resolved.providerId); if (!provider?.authHeader) { diff --git a/apps/docs/brain.mdx b/apps/docs/brain.mdx index c906dee43..a9771462d 100644 --- a/apps/docs/brain.mdx +++ b/apps/docs/brain.mdx @@ -46,9 +46,9 @@ up a provider for tasks, the Brain has what it needs and there is nothing else to do. Changing that key later takes effect on the Brain's next request, with no redeploy. -OpenRouter and OpenAI are both supported, and the Brain runs the same OpenAI -models either way, so the choice is about routing and billing rather than -capability. To bill the Brain separately from task inference, set +OpenRouter and OpenAI both support the Brain's embedding and synthesis calls, +but search reranking requires OpenRouter. To bill the Brain separately from +task inference, set `R_BRAIN_OPENROUTER_API_KEY` or `R_BRAIN_OPENAI_API_KEY`; those take precedence over the deployment's general provider keys. @@ -82,16 +82,23 @@ in staging is distinguishable from one written against production. ## Choosing models -Two settings pick the Brain's models: +Three settings pick the Brain's models: -| Variable | What it does | Written as | Changeable | -| -------------------------- | ----------------- | ------------------------------ | --------------------- | -| `R_BRAIN_MODEL` | Sourced synthesis | your provider's naming | any time | -| `R_BRAIN_EMBEDDING_MODEL` | Semantic recall | a plain model id | before the first boot | +| Variable | What it does | Written as | Changeable | +| ----------------------------- | ----------------- | --------------------------------- | --------------------- | +| `R_BRAIN_MODEL` | Sourced synthesis | your provider's naming | any time | +| `R_BRAIN_EMBEDDING_MODEL` | Semantic recall | a plain model id | before the first boot | +| `R_BRAIN_RERANKER_MODEL` | Search precision | `openrouter:/` | after a restart | -Leave both unset and the Brain uses OpenAI's `gpt-5.6-luna` and +Leave the first two unset and the Brain uses OpenAI's `gpt-5.6-luna` and `text-embedding-3-small` through whichever provider you configured. +The reranker defaults to OpenRouter's `voyageai/rerank-2.5-lite`. Set +`R_BRAIN_RERANKER_MODEL` to choose another model from +OpenRouter's reranker catalog. Reranking requires an OpenRouter key; with only +OpenAI configured, gbrain keeps the unreranked results instead of failing the +search. + The synthesis model is applied by Roomote when it forwards the call and passed to the provider as written, so use that provider's naming (`openai/gpt-5.6-mini` on OpenRouter, `gpt-5.6-mini` on OpenAI). Changing diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 9c355f823..cabdcba9f 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -24,6 +24,7 @@ x-roomote-base-env: &roomote-base-env R_BRAIN_MODEL: ${R_BRAIN_MODEL:-} R_BRAIN_EMBEDDING_MODEL: ${R_BRAIN_EMBEDDING_MODEL:-} R_BRAIN_EMBEDDING_DIMENSIONS: ${R_BRAIN_EMBEDDING_DIMENSIONS:-} + R_BRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} R_GBRAIN_URL: ${R_GBRAIN_URL:-http://gbrain:8931} R_GBRAIN_ADMIN_TOKEN_FILE: /gbrain-data/admin-bootstrap-token # Written by the Brain on first boot when no token was supplied, so a stack @@ -534,6 +535,7 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://api:3001/api/brain/inference} OPENAI_API_KEY: ${R_BRAIN_GATEWAY_TOKEN:-} + GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/deploy/coolify/README.md b/deploy/coolify/README.md index 3986a4b90..cb52f0c6c 100644 --- a/deploy/coolify/README.md +++ b/deploy/coolify/README.md @@ -278,9 +278,10 @@ Two operational notes: it. `gbrain_data` only holds service configuration and generated bootstrap credentials. - **Model choice is a variable, not a rebuild.** `R_BRAIN_MODEL` selects the - synthesis model and `R_BRAIN_EMBEDDING_MODEL` the embedding model, both in - your provider's own naming, both set on the app services. Leave them empty for the - defaults. The synthesis model can change at any time; the embedding model + synthesis model, `R_BRAIN_EMBEDDING_MODEL` the embedding model, and + `R_BRAIN_RERANKER_MODEL` the reranker. Set them on the app services. Leave + them empty for the defaults. The synthesis model can change at any time; + the reranker changes after a gbrain restart; the embedding model sizes the Brain's vector storage when it is first created, so set it (with `R_BRAIN_EMBEDDING_DIMENSIONS`) before first boot or not at all. A later change is ignored and reported in the Brain's logs rather than silently diff --git a/deploy/coolify/docker-compose.yaml b/deploy/coolify/docker-compose.yaml index 33dee1a02..75592f21b 100644 --- a/deploy/coolify/docker-compose.yaml +++ b/deploy/coolify/docker-compose.yaml @@ -82,6 +82,7 @@ x-roomote-shared-env: &roomote-shared-env R_BRAIN_MODEL: ${R_BRAIN_MODEL:-} R_BRAIN_EMBEDDING_MODEL: ${R_BRAIN_EMBEDDING_MODEL:-} R_BRAIN_EMBEDDING_DIMENSIONS: ${R_BRAIN_EMBEDDING_DIMENSIONS:-} + R_BRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} R_GBRAIN_URL: http://gbrain:8931 # Roomote uses this only to register its own scoped clients against the # Brain. Coolify's SERVICE_PASSWORD_64_* generates an @@ -165,6 +166,7 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://api:3001/api/brain/inference} OPENAI_API_KEY: ${SERVICE_PASSWORD_64_BRAINGATEWAY} + GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/deploy/railway/README.md b/deploy/railway/README.md index d081d726f..c51f65d5d 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -429,9 +429,10 @@ Two operational notes: re-registers its clients automatically when the Brain no longer recognizes them — but the deployment starts cold until that finishes. - **Model choice is a variable, not a rebuild.** `R_BRAIN_MODEL` selects the - synthesis model and `R_BRAIN_EMBEDDING_MODEL` the embedding model, both in - your provider's own naming, both set on **api**. Leave them empty for the - defaults. The synthesis model can change at any time; the embedding model + synthesis model, `R_BRAIN_EMBEDDING_MODEL` the embedding model, and + `R_BRAIN_RERANKER_MODEL` the reranker, all set on **api**. Leave them empty + for the defaults. The synthesis model can change at any time; the reranker + changes after a gbrain restart; the embedding model sizes the Brain's vector storage when it is first created, so set it (with `R_BRAIN_EMBEDDING_DIMENSIONS`) before first boot or not at all. A later change is ignored and reported in the Brain's logs rather than silently diff --git a/deploy/railway/template.yaml b/deploy/railway/template.yaml index c85b8dd91..f1a54afc3 100644 --- a/deploy/railway/template.yaml +++ b/deploy/railway/template.yaml @@ -143,6 +143,7 @@ services: # goes over the public origin, exactly as TRPC_URL already does. OPENAI_BASE_URL: https://${{api.RAILWAY_PUBLIC_DOMAIN}}/api/brain/inference OPENAI_API_KEY: ${{api.R_BRAIN_GATEWAY_TOKEN}} + GBRAIN_RERANKER_MODEL: ${{api.R_BRAIN_RERANKER_MODEL}} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. @@ -212,6 +213,7 @@ services: R_BRAIN_MODEL: '' R_BRAIN_EMBEDDING_MODEL: '' R_BRAIN_EMBEDDING_DIMENSIONS: '' + R_BRAIN_RERANKER_MODEL: '' # Railway's private network is IPv6-only and never leaves the project, # so the Brain is unreachable from the internet by construction. R_GBRAIN_URL: http://${{gbrain.RAILWAY_PRIVATE_DOMAIN}}:8931 @@ -259,6 +261,7 @@ services: R_BRAIN_GATEWAY_TOKEN: ${{api.R_BRAIN_GATEWAY_TOKEN}} R_BRAIN_MODEL: ${{api.R_BRAIN_MODEL}} R_BRAIN_EMBEDDING_MODEL: ${{api.R_BRAIN_EMBEDDING_MODEL}} + R_BRAIN_RERANKER_MODEL: ${{api.R_BRAIN_RERANKER_MODEL}} R_GBRAIN_URL: ${{api.R_GBRAIN_URL}} R_GBRAIN_ADMIN_TOKEN: ${{api.R_GBRAIN_ADMIN_TOKEN}} R_APP_ENV: ${{api.R_APP_ENV}} diff --git a/deploy/render/README.md b/deploy/render/README.md index bd480330a..936b9295d 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -392,9 +392,10 @@ Two operational notes: longer recognizes them — but the deployment starts cold until that finishes. - **Model choice is a variable, not a rebuild.** `R_BRAIN_MODEL` selects the - synthesis model and `R_BRAIN_EMBEDDING_MODEL` the embedding model, both in - your provider's own naming, both set on the app services. Leave them empty for the - defaults. The synthesis model can change at any time; the embedding model + synthesis model, `R_BRAIN_EMBEDDING_MODEL` the embedding model, and + `R_BRAIN_RERANKER_MODEL` the reranker. Set them on the api service. Leave + them empty for the defaults. The synthesis model can change at any time; + the reranker changes after a gbrain restart; the embedding model sizes the Brain's vector storage when it is first created, so set it (with `R_BRAIN_EMBEDDING_DIMENSIONS`) before first boot or not at all. A later change is ignored and reported in the Brain's logs rather than silently diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 3631c29c8..59691603d 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -36,6 +36,7 @@ x-roomote-env: &roomote-env R_BRAIN_MODEL: ${R_BRAIN_MODEL:-} R_BRAIN_EMBEDDING_MODEL: ${R_BRAIN_EMBEDDING_MODEL:-} R_BRAIN_EMBEDDING_DIMENSIONS: ${R_BRAIN_EMBEDDING_DIMENSIONS:-} + R_BRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} R_GBRAIN_URL: ${R_GBRAIN_URL:-http://gbrain:8931} # Roomote reads the brain's bootstrap token once to register its own # scoped clients; api and bullmq mount the brain volume read-only. @@ -297,6 +298,7 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://api:3001/api/brain/inference} OPENAI_API_KEY: ${R_BRAIN_GATEWAY_TOKEN:-} + GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/docker-compose.yml b/docker-compose.yml index f860be001..74509c7cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,6 +105,7 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://host.docker.internal:3001/api/brain/inference} OPENAI_API_KEY: ${R_BRAIN_GATEWAY_TOKEN:-} + GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/render.yaml b/render.yaml index 8f12fad5c..ff7ab12db 100644 --- a/render.yaml +++ b/render.yaml @@ -162,6 +162,11 @@ services: type: web name: roomote-api envVarKey: R_BRAIN_GATEWAY_TOKEN + - key: GBRAIN_RERANKER_MODEL + fromService: + type: web + name: roomote-api + envVarKey: R_BRAIN_RERANKER_MODEL # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. @@ -248,6 +253,8 @@ services: sync: false - key: R_BRAIN_EMBEDDING_MODEL sync: false + - key: R_BRAIN_RERANKER_MODEL + value: '' - key: ROOMOTE_GBRAIN_HOSTPORT fromService: type: pserv