Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/memory-cloudflare-embeddings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@roomote/web': minor
---

Memory embeddings auto-wire to Cloudflare Workers AI when its token and account are configured, so semantic recall no longer needs a separate OpenAI or OpenRouter key.
10 changes: 10 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ DEFAULT_COMPUTE_PROVIDER=docker
# R_BRAIN_INFERENCE_UPSTREAM_API_KEY=
# R_BRAIN_EMBEDDING_MODEL=BAAI/bge-m3
# R_BRAIN_EMBEDDING_DIMENSIONS=1024
# Cloudflare Workers AI embeddings (no OpenAI/OpenRouter key required).
# Auto-wires only when those keys are unset. Leave
# R_BRAIN_EMBEDDINGS_UPSTREAM_URL unset so Roomote uses the Workers AI /ai
# base (it appends /v1/embeddings). Do not paste the embeddings path into
# R_BRAIN_EMBEDDINGS_UPSTREAM_URL.
# The embedding pair is create-time: set it BEFORE Memory's first boot.
# CLOUDFLARE_WORKERS_AI_API_TOKEN=
# CLOUDFLARE_WORKERS_AI_ACCOUNT_ID=
# R_BRAIN_EMBEDDING_MODEL=@cf/baai/bge-m3
# R_BRAIN_EMBEDDING_DIMENSIONS=1024
# Self-run judgment model for routing and triage decisions. Any server that
# answers the typed decisions request at /v1/decisions works; the bundled one
# is COMPOSE_PROFILES=judgment with R_JUDGMENT_UPSTREAM_URL=http://judgment:8080
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions apps/api/src/handlers/brain-inference/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { timingSafeEqual } from 'node:crypto';

export function presentedToken(header: string | undefined): string | null {
if (!header) {
return null;
}

const match = header.match(/^Bearer\s+(.+)$/i);

return match?.[1]?.trim() || null;
}

/** Constant-time compare that does not leak length through early return. */
export function tokenMatches(presented: string, expected: string): boolean {
const a = Buffer.from(presented);
const b = Buffer.from(expected);

if (a.length !== b.length) {
timingSafeEqual(a, a);
return false;
}

return timingSafeEqual(a, b);
}
51 changes: 51 additions & 0 deletions apps/api/src/handlers/brain-inference/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export const LOG_PREFIX = '[Brain Inference]';

/**
* Sentinel chat-model id the Brain requests in gateway mode. It is not a
* real provider model: the gateway answers it through the deployment's
* helper model. An operator's `R_BRAIN_MODEL` still wins.
*/
export const BRAIN_HELPER_MODEL_ID = 'roomote/helper';

export const HELPER_SYNTHESIS_TIMEOUT_MS = 120_000;
export const HELPER_SYNTHESIS_DEFAULT_MAX_OUTPUT_TOKENS = 2048;

/**
* Embeddings for recall and chat for sourced synthesis. Narrower than the
* task-sandbox gateway because this credential is a static deployment secret.
*/
export const BRAIN_ALLOWED_PATHS = new Set([
'/v1/embeddings',
'/v1/chat/completions',
'/v1/responses',
]);

/** Never forwarded upstream: the Brain's gateway token is not a provider key. */
export const REQUEST_HEADER_DENYLIST = new Set([
'authorization',
'x-api-key',
'cookie',
'host',
'connection',
'content-length',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
'expect',
'forwarded',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
'x-real-ip',
]);

export const RESPONSE_HEADER_DENYLIST = new Set([
'content-encoding',
'content-length',
'transfer-encoding',
'connection',
]);
14 changes: 14 additions & 0 deletions apps/api/src/handlers/brain-inference/copy-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function copyAllowedHeaders(
source: Headers,
denylist: Set<string>,
): Headers {
const headers = new Headers();

for (const [name, value] of source.entries()) {
if (!denylist.has(name.toLowerCase())) {
headers.set(name, value);
}
}

return headers;
}
92 changes: 92 additions & 0 deletions apps/api/src/handlers/brain-inference/forward-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Context } from 'hono';
import { formatSingleLineLog } from '@roomote/types';
import { resolveBrainEmbeddingsUpstream } from '@roomote/sdk/server';

import type { Variables } from '../../types';
import {
LOG_PREFIX,
REQUEST_HEADER_DENYLIST,
RESPONSE_HEADER_DENYLIST,
} from './constants';
import { copyAllowedHeaders } from './copy-headers';

type GatewayContext = Context<{ Variables: Variables }>;

function embeddingModelFromBody(body: string): string | undefined {
try {
const parsed: unknown = JSON.parse(body);

if (
parsed &&
typeof parsed === 'object' &&
'model' in parsed &&
typeof parsed.model === 'string'
) {
return parsed.model.trim() || undefined;
}
} catch {
// Forward the body unchanged; a missing model only affects routing.
}

return undefined;
}

export async function forwardLocalEmbeddings(
c: GatewayContext,
upstreamPath: string,
requestUrl: URL,
): Promise<Response | null> {
if (upstreamPath !== '/v1/embeddings') {
return null;
}

const body = await c.req.text();
const localUpstream = await resolveBrainEmbeddingsUpstream(
embeddingModelFromBody(body),
);

if (!localUpstream) {
return null;
}

const headers = copyAllowedHeaders(
c.req.raw.headers,
REQUEST_HEADER_DENYLIST,
);

if (localUpstream.apiKey) {
headers.set('authorization', `Bearer ${localUpstream.apiKey}`);
}

let upstream: Response;

try {
upstream = await fetch(
`${localUpstream.baseUrl}${upstreamPath}${requestUrl.search}`,
{ method: 'POST', headers, body },
);
} catch (error) {
console.warn(
formatSingleLineLog(`${LOG_PREFIX} Local upstream request failed`, {
upstreamPath,
error: error instanceof Error ? error.message : String(error),
}),
);

return c.json({ error: 'Brain inference upstream is unreachable' }, 502);
}

if (!upstream.ok) {
console.warn(
formatSingleLineLog(`${LOG_PREFIX} Local upstream returned an error`, {
upstreamPath,
status: upstream.status,
}),
);
}

return new Response(upstream.body, {
status: upstream.status,
headers: copyAllowedHeaders(upstream.headers, RESPONSE_HEADER_DENYLIST),
});
}
Loading
Loading