From 1b6190b35f8e765cf2f795232b58036097128e4a Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 17:12:54 -0700 Subject: [PATCH 1/9] feat(web): add optional LLM user email header --- .../configuration/environment-variables.mdx | 3 +- packages/shared/src/env.server.test.ts | 22 +++++++ packages/shared/src/env.server.ts | 1 + .../web/src/app/api/(server)/ee/chat/route.ts | 5 +- packages/web/src/ee/features/chat/actions.ts | 6 +- .../web/src/ee/features/chat/llm.server.ts | 12 +++- .../web/src/ee/features/mcp/askCodebase.ts | 6 +- .../chat/languageModelHeaders.server.test.ts | 63 +++++++++++++++++++ .../chat/languageModelHeaders.server.ts | 33 ++++++++++ packages/web/src/features/chat/llm.server.ts | 52 +++++---------- .../web/src/features/searchAssist/actions.ts | 4 +- 11 files changed, 161 insertions(+), 46 deletions(-) create mode 100644 packages/web/src/features/chat/languageModelHeaders.server.test.ts create mode 100644 packages/web/src/features/chat/languageModelHeaders.server.ts diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 68e94c19b..30d8d642e 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -42,6 +42,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `ALWAYS_INDEX_FILE_PATTERNS` | - |

A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.

| | `SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES` | `10485760` (10 MiB) |

Maximum size in bytes of a single image attachment uploaded to Ask Sourcebot. Enforced server-side at upload time.

| | `SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS` | `24` |

How long in hours an uploaded-but-unsent attachment is retained before being deleted by the orphan sweep. Set to `0` to disable the sweep.

| +| `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` | `false` |

When enabled, Sourcebot sends the authenticated user's lower-cased email address to configured language model providers in the `X-Sourcebot-User-Email` request header. Anonymous and automated requests omit the header.

| | `NODE_USE_ENV_PROXY` | `0` |

Enables Node.js to automatically use `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for network requests. Set to `1` to enable or `0` to disable. See [this doc](https://nodejs.org/en/learn/http/enterprise-network-configuration) for more info.

| | `HTTP_PROXY` | - |

HTTP proxy URL for routing non-SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.

| | `HTTPS_PROXY` | - |

HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.

| @@ -71,4 +72,4 @@ The following environment variables allow you to configure your Sourcebot deploy ### Overriding environment variables from the config -You can override environment variables from the config file by using the `environmentOverrides` property. See [this doc](/docs/configuration/config-file#overriding-environment-variables-from-the-config) for more info. \ No newline at end of file +You can override environment variables from the config file by using the `environmentOverrides` property. See [this doc](/docs/configuration/config-file#overriding-environment-variables-from-the-config) for more info. diff --git a/packages/shared/src/env.server.test.ts b/packages/shared/src/env.server.test.ts index 7f9bf0bca..b8a928c39 100644 --- a/packages/shared/src/env.server.test.ts +++ b/packages/shared/src/env.server.test.ts @@ -82,3 +82,25 @@ describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => { await expect(import('./env.server.js')).rejects.toThrow(); }); }); + +describe('SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED; + }); + + afterEach(() => { + delete process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED; + }); + + test('defaults to false', async () => { + const { env } = await import('./env.server.js'); + expect(env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED).toBe('false'); + }); + + test.each(['true', 'false'])('accepts %s', async (enabled) => { + process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = enabled; + const { env } = await import('./env.server.js'); + expect(env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED).toBe(enabled); + }); +}); diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 392a18517..8e082fe63 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -313,6 +313,7 @@ const options = { SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100), SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'), + SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: booleanSchema.default('false'), /** TTL for the static block. The moving tail marker always uses the 5m default. */ SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'), /** diff --git a/packages/web/src/app/api/(server)/ee/chat/route.ts b/packages/web/src/app/api/(server)/ee/chat/route.ts index c8a34aa8f..5d15ba587 100644 --- a/packages/web/src/app/api/(server)/ee/chat/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/route.ts @@ -135,7 +135,10 @@ export const POST = apiHandler(async (req: NextRequest) => { return attachmentError; } - const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig); + const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions( + languageModelConfig, + user?.email, + ); // Authoritative, server-side resolution of the model's input // modalities. The agent's multimodal content builder and degrade diff --git a/packages/web/src/ee/features/chat/actions.ts b/packages/web/src/ee/features/chat/actions.ts index fc2b7243f..23abc8b72 100644 --- a/packages/web/src/ee/features/chat/actions.ts +++ b/packages/web/src/ee/features/chat/actions.ts @@ -43,7 +43,11 @@ export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageMod } satisfies ServiceError; } - const name = await generateChatNameFromMessage({ message, languageModelConfig }); + const name = await generateChatNameFromMessage({ + message, + languageModelConfig, + userEmail: user?.email, + }); await prisma.chat.update({ where: { diff --git a/packages/web/src/ee/features/chat/llm.server.ts b/packages/web/src/ee/features/chat/llm.server.ts index 2880fded9..8acf94ad5 100644 --- a/packages/web/src/ee/features/chat/llm.server.ts +++ b/packages/web/src/ee/features/chat/llm.server.ts @@ -4,8 +4,16 @@ import { LanguageModel } from '@sourcebot/schemas/v3/languageModel.type'; import { generateText } from "ai"; import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server"; -export const generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => { - const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig); +export const generateChatNameFromMessage = async ({ + message, + languageModelConfig, + userEmail, +}: { + message: string, + languageModelConfig: LanguageModel, + userEmail?: string, +}) => { + const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig, userEmail); const prompt = `Convert this question into a short topic title (max 50 characters). diff --git a/packages/web/src/ee/features/mcp/askCodebase.ts b/packages/web/src/ee/features/mcp/askCodebase.ts index 35337d29f..20c26143e 100644 --- a/packages/web/src/ee/features/mcp/askCodebase.ts +++ b/packages/web/src/ee/features/mcp/askCodebase.ts @@ -84,7 +84,10 @@ export const askCodebase = (params: AskCodebaseParams): Promise ({ + env: { + SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: 'false', + }, + getTokenFromConfig: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => mocks); +vi.mock('server-only', () => ({})); + +import { + resolveLanguageModelHeaders, + SOURCEBOT_USER_EMAIL_HEADER, +} from './languageModelHeaders.server'; + +describe('resolveLanguageModelHeaders', () => { + beforeEach(() => { + mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false'; + mocks.getTokenFromConfig.mockReset(); + }); + + test('does not add the user email header by default', async () => { + await expect(resolveLanguageModelHeaders(undefined, 'User@Example.com')).resolves.toBeUndefined(); + }); + + test('adds a lower-cased user email when enabled', async () => { + mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; + + await expect(resolveLanguageModelHeaders(undefined, 'User@Example.COM')).resolves.toEqual({ + [SOURCEBOT_USER_EMAIL_HEADER]: 'user@example.com', + }); + }); + + test('omits the user email header for anonymous requests', async () => { + mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; + + await expect(resolveLanguageModelHeaders(undefined, undefined)).resolves.toBeUndefined(); + }); + + test('preserves configured headers and overrides a case-insensitive email header', async () => { + mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; + + await expect(resolveLanguageModelHeaders({ + 'x-sourcebot-user-email': 'configured@example.com', + 'X-Custom-Header': 'custom-value', + }, 'Authenticated@Example.com')).resolves.toEqual({ + 'X-Custom-Header': 'custom-value', + [SOURCEBOT_USER_EMAIL_HEADER]: 'authenticated@example.com', + }); + }); + + test('resolves token-backed configured headers', async () => { + const token = { env: 'CUSTOM_HEADER' }; + mocks.getTokenFromConfig.mockResolvedValue('resolved-value'); + + await expect(resolveLanguageModelHeaders({ Authorization: token }, undefined)).resolves.toEqual({ + Authorization: 'resolved-value', + }); + expect(mocks.getTokenFromConfig).toHaveBeenCalledWith(token); + }); +}); diff --git a/packages/web/src/features/chat/languageModelHeaders.server.ts b/packages/web/src/features/chat/languageModelHeaders.server.ts new file mode 100644 index 000000000..26365981f --- /dev/null +++ b/packages/web/src/features/chat/languageModelHeaders.server.ts @@ -0,0 +1,33 @@ +import 'server-only'; + +import { Token } from '@sourcebot/schemas/v3/shared.type'; +import { env, getTokenFromConfig } from '@sourcebot/shared'; + +export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email'; + +export const resolveLanguageModelHeaders = async ( + configuredHeaders: Record | undefined, + userEmail: string | undefined, +): Promise | undefined> => { + const headers: Record = {}; + + for (const [key, value] of Object.entries(configuredHeaders ?? {})) { + headers[key] = typeof value === 'string' + ? value + : await getTokenFromConfig(value); + } + + if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' && userEmail) { + // Header names are case-insensitive. Remove any configured variant so + // the authenticated user's email is always the authoritative value. + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === SOURCEBOT_USER_EMAIL_HEADER.toLowerCase()) { + delete headers[key]; + } + } + + headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase(); + } + + return Object.keys(headers).length > 0 ? headers : undefined; +}; diff --git a/packages/web/src/features/chat/llm.server.ts b/packages/web/src/features/chat/llm.server.ts index 07dad6f0c..8c235d15d 100644 --- a/packages/web/src/features/chat/llm.server.ts +++ b/packages/web/src/features/chat/llm.server.ts @@ -23,6 +23,7 @@ import { Token } from "@sourcebot/schemas/v3/shared.type"; import { env, getTokenFromConfig } from '@sourcebot/shared'; import { extractReasoningMiddleware, JSONValue, wrapLanguageModel } from "ai"; import * as Sentry from "@sentry/nextjs"; +import { resolveLanguageModelHeaders } from './languageModelHeaders.server'; // @note: This module resolves a configured language model into an AI SDK // provider object. It is intentionally FSL (open source) provider plumbing — @@ -31,12 +32,13 @@ import * as Sentry from "@sentry/nextjs"; // review agent). The re-licensed Ask logic (prompts, tools, threads, chat // name generation) lives in `@/ee/features/chat`. -export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Promise<{ +export const getAISDKLanguageModelAndOptions = async (config: LanguageModel, userEmail?: string): Promise<{ model: AISDKLanguageModelV3, providerOptions?: Record>, temperature?: number, }> => { const { provider, model: modelId } = config; + const headers = await resolveLanguageModelHeaders(config.headers, userEmail); const { model: _model, providerOptions } = await (async (): Promise<{ model: AISDKLanguageModelV3, @@ -56,9 +58,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr sessionToken: config.sessionToken ? await getTokenFromConfig(config.sessionToken) : env.AWS_SESSION_TOKEN, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, // Fallback to the default Node.js credential provider chain if no credentials are provided. // See: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain credentialProvider: !config.accessKeyId && !config.accessKeySecret && !config.sessionToken @@ -77,10 +77,6 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr const authToken = config.authToken ? await getTokenFromConfig(config.authToken) : env.ANTHROPIC_AUTH_TOKEN; - const headers = config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined; - const anthropic = createAnthropic({ baseURL: config.baseUrl, apiKey, @@ -111,9 +107,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.AZURE_API_KEY, apiVersion: config.apiVersion, resourceName: config.resourceName ?? env.AZURE_RESOURCE_NAME, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); const reasoningSummary = config.reasoningSummary ?? 'auto'; @@ -131,9 +125,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr const deepseek = createDeepSeek({ baseURL: config.baseUrl, apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.DEEPSEEK_API_KEY, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { @@ -146,9 +138,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? await getTokenFromConfig(config.token) : env.GOOGLE_GENERATIVE_AI_API_KEY, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { @@ -173,9 +163,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr keyFilename: await getTokenFromConfig(config.credentials), } } : {}), - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { @@ -202,9 +190,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr keyFilename: await getTokenFromConfig(config.credentials), } } : {}), - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { @@ -217,9 +203,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? await getTokenFromConfig(config.token) : env.MISTRAL_API_KEY, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { @@ -232,9 +216,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? await getTokenFromConfig(config.token) : env.OPENAI_API_KEY, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); const reasoningSummary = config.reasoningSummary ?? 'auto'; @@ -255,9 +237,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? await getTokenFromConfig(config.token) : undefined, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, queryParams: config.queryParams ? await extractLanguageModelKeyValuePairs(config.queryParams) : undefined, @@ -282,9 +262,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? await getTokenFromConfig(config.token) : env.OPENROUTER_API_KEY, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { @@ -297,9 +275,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr apiKey: config.token ? await getTokenFromConfig(config.token) : env.XAI_API_KEY, - headers: config.headers - ? await extractLanguageModelKeyValuePairs(config.headers) - : undefined, + headers, }); return { diff --git a/packages/web/src/features/searchAssist/actions.ts b/packages/web/src/features/searchAssist/actions.ts index cf02e6087..3d89f2bf1 100644 --- a/packages/web/src/features/searchAssist/actions.ts +++ b/packages/web/src/features/searchAssist/actions.ts @@ -26,7 +26,7 @@ ${SEARCH_SYNTAX_DESCRIPTION} `; export const translateSearchQuery = async ({ prompt }: { prompt: string }) => sew(() => - withOptionalAuth(async () => { + withOptionalAuth(async ({ user }) => { const models = await getConfiguredLanguageModels(); if (models.length === 0) { @@ -37,7 +37,7 @@ export const translateSearchQuery = async ({ prompt }: { prompt: string }) => se } satisfies ServiceError; } - const { model } = await getAISDKLanguageModelAndOptions(models[0]); + const { model } = await getAISDKLanguageModelAndOptions(models[0], user?.email); const { object } = await generateObject({ model, From 6346cfd2f19ef1445886671166716a8dfd62e94a Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 17:13:37 -0700 Subject: [PATCH 2/9] docs: add changelog entry for PR 1455 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c46bf4f..31da4d167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added an opt-in `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` environment variable that sends the authenticated user's lower-cased email to language model providers in the `X-Sourcebot-User-Email` header. [#1455](https://github.com/sourcebot-dev/sourcebot/pull/1455) + ### Fixed - [EE] Verified signed online license assertions before granting paid feature entitlements. [#1442](https://github.com/sourcebot-dev/sourcebot/pull/1442) - [EE] Fixed worker startup races that could disable GitHub App authentication and permission syncing until restart after an online license refresh. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454) From 712b0d64572c9dca72d1cc92d49f5e00742e2d84 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 17:17:04 -0700 Subject: [PATCH 3/9] test: remove redundant environment schema coverage --- packages/shared/src/env.server.test.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/shared/src/env.server.test.ts b/packages/shared/src/env.server.test.ts index b8a928c39..7f9bf0bca 100644 --- a/packages/shared/src/env.server.test.ts +++ b/packages/shared/src/env.server.test.ts @@ -82,25 +82,3 @@ describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => { await expect(import('./env.server.js')).rejects.toThrow(); }); }); - -describe('SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED', () => { - beforeEach(() => { - vi.resetModules(); - delete process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED; - }); - - afterEach(() => { - delete process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED; - }); - - test('defaults to false', async () => { - const { env } = await import('./env.server.js'); - expect(env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED).toBe('false'); - }); - - test.each(['true', 'false'])('accepts %s', async (enabled) => { - process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = enabled; - const { env } = await import('./env.server.js'); - expect(env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED).toBe(enabled); - }); -}); From 665a2da23574660b6baaba2adc1e5962347bd7dc Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 17:24:51 -0700 Subject: [PATCH 4/9] refactor(web): resolve LLM user from auth context --- .../web/src/app/api/(server)/ee/chat/route.ts | 5 +-- packages/web/src/ee/features/chat/actions.ts | 1 - .../web/src/ee/features/chat/llm.server.ts | 4 +-- .../web/src/ee/features/mcp/askCodebase.ts | 6 +--- .../chat/languageModelHeaders.server.test.ts | 33 +++++++++++++++---- .../chat/languageModelHeaders.server.ts | 10 ++++-- packages/web/src/features/chat/llm.server.ts | 4 +-- .../web/src/features/searchAssist/actions.ts | 4 +-- packages/web/src/lib/currentUserContext.ts | 12 +++++++ packages/web/src/middleware/withAuth.test.ts | 5 ++- packages/web/src/middleware/withAuth.ts | 5 +-- 11 files changed, 60 insertions(+), 29 deletions(-) create mode 100644 packages/web/src/lib/currentUserContext.ts diff --git a/packages/web/src/app/api/(server)/ee/chat/route.ts b/packages/web/src/app/api/(server)/ee/chat/route.ts index 5d15ba587..c8a34aa8f 100644 --- a/packages/web/src/app/api/(server)/ee/chat/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/route.ts @@ -135,10 +135,7 @@ export const POST = apiHandler(async (req: NextRequest) => { return attachmentError; } - const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions( - languageModelConfig, - user?.email, - ); + const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig); // Authoritative, server-side resolution of the model's input // modalities. The agent's multimodal content builder and degrade diff --git a/packages/web/src/ee/features/chat/actions.ts b/packages/web/src/ee/features/chat/actions.ts index 23abc8b72..671dd9a5c 100644 --- a/packages/web/src/ee/features/chat/actions.ts +++ b/packages/web/src/ee/features/chat/actions.ts @@ -46,7 +46,6 @@ export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageMod const name = await generateChatNameFromMessage({ message, languageModelConfig, - userEmail: user?.email, }); await prisma.chat.update({ diff --git a/packages/web/src/ee/features/chat/llm.server.ts b/packages/web/src/ee/features/chat/llm.server.ts index 8acf94ad5..10e4cecc3 100644 --- a/packages/web/src/ee/features/chat/llm.server.ts +++ b/packages/web/src/ee/features/chat/llm.server.ts @@ -7,13 +7,11 @@ import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server"; export const generateChatNameFromMessage = async ({ message, languageModelConfig, - userEmail, }: { message: string, languageModelConfig: LanguageModel, - userEmail?: string, }) => { - const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig, userEmail); + const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig); const prompt = `Convert this question into a short topic title (max 50 characters). diff --git a/packages/web/src/ee/features/mcp/askCodebase.ts b/packages/web/src/ee/features/mcp/askCodebase.ts index 20c26143e..35337d29f 100644 --- a/packages/web/src/ee/features/mcp/askCodebase.ts +++ b/packages/web/src/ee/features/mcp/askCodebase.ts @@ -84,10 +84,7 @@ export const askCodebase = (params: AskCodebaseParams): Promise ({ env: { @@ -15,6 +17,15 @@ import { SOURCEBOT_USER_EMAIL_HEADER, } from './languageModelHeaders.server'; +const resolveHeadersForUser = ( + email: string, + configuredHeaders?: Parameters[0], +) => + runWithCurrentUser( + { email } as unknown as UserWithAccounts, + () => resolveLanguageModelHeaders(configuredHeaders), + ); + describe('resolveLanguageModelHeaders', () => { beforeEach(() => { mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false'; @@ -22,13 +33,13 @@ describe('resolveLanguageModelHeaders', () => { }); test('does not add the user email header by default', async () => { - await expect(resolveLanguageModelHeaders(undefined, 'User@Example.com')).resolves.toBeUndefined(); + await expect(resolveHeadersForUser('User@Example.com')).resolves.toBeUndefined(); }); - test('adds a lower-cased user email when enabled', async () => { + test('adds the current user email in lower case when enabled', async () => { mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; - await expect(resolveLanguageModelHeaders(undefined, 'User@Example.COM')).resolves.toEqual({ + await expect(resolveHeadersForUser('User@Example.COM')).resolves.toEqual({ [SOURCEBOT_USER_EMAIL_HEADER]: 'user@example.com', }); }); @@ -36,16 +47,24 @@ describe('resolveLanguageModelHeaders', () => { test('omits the user email header for anonymous requests', async () => { mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; - await expect(resolveLanguageModelHeaders(undefined, undefined)).resolves.toBeUndefined(); + await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined(); + }); + + test('omits synthetic placeholder emails', async () => { + mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; + + await expect(resolveHeadersForUser( + 'placeholder-internal-user-id@no-email.invalid', + )).resolves.toBeUndefined(); }); test('preserves configured headers and overrides a case-insensitive email header', async () => { mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; - await expect(resolveLanguageModelHeaders({ + await expect(resolveHeadersForUser('Authenticated@Example.com', { 'x-sourcebot-user-email': 'configured@example.com', 'X-Custom-Header': 'custom-value', - }, 'Authenticated@Example.com')).resolves.toEqual({ + })).resolves.toEqual({ 'X-Custom-Header': 'custom-value', [SOURCEBOT_USER_EMAIL_HEADER]: 'authenticated@example.com', }); @@ -55,7 +74,7 @@ describe('resolveLanguageModelHeaders', () => { const token = { env: 'CUSTOM_HEADER' }; mocks.getTokenFromConfig.mockResolvedValue('resolved-value'); - await expect(resolveLanguageModelHeaders({ Authorization: token }, undefined)).resolves.toEqual({ + await expect(resolveLanguageModelHeaders({ Authorization: token })).resolves.toEqual({ Authorization: 'resolved-value', }); expect(mocks.getTokenFromConfig).toHaveBeenCalledWith(token); diff --git a/packages/web/src/features/chat/languageModelHeaders.server.ts b/packages/web/src/features/chat/languageModelHeaders.server.ts index 26365981f..c067b2804 100644 --- a/packages/web/src/features/chat/languageModelHeaders.server.ts +++ b/packages/web/src/features/chat/languageModelHeaders.server.ts @@ -2,12 +2,13 @@ import 'server-only'; import { Token } from '@sourcebot/schemas/v3/shared.type'; import { env, getTokenFromConfig } from '@sourcebot/shared'; +import { getCurrentUser } from '@/lib/currentUserContext'; export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email'; +const PLACEHOLDER_EMAIL_PATTERN = /^placeholder-.+@no-email\.invalid$/i; export const resolveLanguageModelHeaders = async ( configuredHeaders: Record | undefined, - userEmail: string | undefined, ): Promise | undefined> => { const headers: Record = {}; @@ -17,7 +18,12 @@ export const resolveLanguageModelHeaders = async ( : await getTokenFromConfig(value); } - if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' && userEmail) { + const userEmail = getCurrentUser()?.email; + if ( + env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' && + userEmail && + !PLACEHOLDER_EMAIL_PATTERN.test(userEmail) + ) { // Header names are case-insensitive. Remove any configured variant so // the authenticated user's email is always the authoritative value. for (const key of Object.keys(headers)) { diff --git a/packages/web/src/features/chat/llm.server.ts b/packages/web/src/features/chat/llm.server.ts index 8c235d15d..d6e232002 100644 --- a/packages/web/src/features/chat/llm.server.ts +++ b/packages/web/src/features/chat/llm.server.ts @@ -32,13 +32,13 @@ import { resolveLanguageModelHeaders } from './languageModelHeaders.server'; // review agent). The re-licensed Ask logic (prompts, tools, threads, chat // name generation) lives in `@/ee/features/chat`. -export const getAISDKLanguageModelAndOptions = async (config: LanguageModel, userEmail?: string): Promise<{ +export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Promise<{ model: AISDKLanguageModelV3, providerOptions?: Record>, temperature?: number, }> => { const { provider, model: modelId } = config; - const headers = await resolveLanguageModelHeaders(config.headers, userEmail); + const headers = await resolveLanguageModelHeaders(config.headers); const { model: _model, providerOptions } = await (async (): Promise<{ model: AISDKLanguageModelV3, diff --git a/packages/web/src/features/searchAssist/actions.ts b/packages/web/src/features/searchAssist/actions.ts index 3d89f2bf1..cf02e6087 100644 --- a/packages/web/src/features/searchAssist/actions.ts +++ b/packages/web/src/features/searchAssist/actions.ts @@ -26,7 +26,7 @@ ${SEARCH_SYNTAX_DESCRIPTION} `; export const translateSearchQuery = async ({ prompt }: { prompt: string }) => sew(() => - withOptionalAuth(async ({ user }) => { + withOptionalAuth(async () => { const models = await getConfiguredLanguageModels(); if (models.length === 0) { @@ -37,7 +37,7 @@ export const translateSearchQuery = async ({ prompt }: { prompt: string }) => se } satisfies ServiceError; } - const { model } = await getAISDKLanguageModelAndOptions(models[0], user?.email); + const { model } = await getAISDKLanguageModelAndOptions(models[0]); const { object } = await generateObject({ model, diff --git a/packages/web/src/lib/currentUserContext.ts b/packages/web/src/lib/currentUserContext.ts new file mode 100644 index 000000000..9f43cb635 --- /dev/null +++ b/packages/web/src/lib/currentUserContext.ts @@ -0,0 +1,12 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { UserWithAccounts } from '@sourcebot/db'; + +const currentUserStorage = new AsyncLocalStorage(); + +export function runWithCurrentUser(user: UserWithAccounts | undefined, fn: () => T): T { + return currentUserStorage.run(user, fn); +} + +export function getCurrentUser(): UserWithAccounts | undefined { + return currentUserStorage.getStore(); +} diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 74af00b0e..e967adead 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -9,6 +9,7 @@ import { ErrorCode } from '../lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { userScopedPrismaClientExtension } from '@/prisma'; import { runWithRequestContext } from '@/lib/requestContext'; +import { getCurrentUser } from '@/lib/currentUserContext'; const TEST_OAUTH_SCOPE = 'read'; @@ -481,7 +482,9 @@ describe('getAuthContext', () => { prisma.userToOrg.updateMany.mockResolvedValue({ count: 1 }); setMockSession(createMockSession({ user: { id: userId } })); - const cb = vi.fn(); + const cb = vi.fn(async () => { + expect(getCurrentUser()).toMatchObject({ id: userId }); + }); const result = await withAuth(cb); expect(result).toBeUndefined(); diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index ba2d5876e..907b27f9c 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -13,6 +13,7 @@ import { activatePendingMembership } from "@/features/membership/membership.serv import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; +import { runWithCurrentUser } from "@/lib/currentUserContext"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -49,7 +50,7 @@ export const withAuth = async (fn: (params: RequiredAuthContext) => Promise fn({ user, org, role, prisma })); }; export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => Promise, options: AuthOptions = {}) => { @@ -65,7 +66,7 @@ export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => P return notAuthenticated(); } - return fn(authContext); + return runWithCurrentUser(authContext.user, () => fn(authContext)); }; export const getAuthContext = async (options: AuthOptions = {}): Promise => { From a20f84955c51af1e8920633448d42db8ef700808 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 17:30:07 -0700 Subject: [PATCH 5/9] refactor(web): use existing auth context for LLM headers --- .../configuration/environment-variables.mdx | 2 +- packages/web/src/ee/features/chat/actions.ts | 5 +---- .../web/src/ee/features/chat/llm.server.ts | 8 +------- .../chat/languageModelHeaders.server.test.ts | 18 +++++++++++------- .../chat/languageModelHeaders.server.ts | 12 +++++++++--- packages/web/src/lib/currentUserContext.ts | 12 ------------ packages/web/src/middleware/withAuth.test.ts | 5 +---- packages/web/src/middleware/withAuth.ts | 5 ++--- 8 files changed, 26 insertions(+), 41 deletions(-) delete mode 100644 packages/web/src/lib/currentUserContext.ts diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 30d8d642e..43d1e324b 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -72,4 +72,4 @@ The following environment variables allow you to configure your Sourcebot deploy ### Overriding environment variables from the config -You can override environment variables from the config file by using the `environmentOverrides` property. See [this doc](/docs/configuration/config-file#overriding-environment-variables-from-the-config) for more info. +You can override environment variables from the config file by using the `environmentOverrides` property. See [this doc](/docs/configuration/config-file#overriding-environment-variables-from-the-config) for more info. \ No newline at end of file diff --git a/packages/web/src/ee/features/chat/actions.ts b/packages/web/src/ee/features/chat/actions.ts index 671dd9a5c..fc2b7243f 100644 --- a/packages/web/src/ee/features/chat/actions.ts +++ b/packages/web/src/ee/features/chat/actions.ts @@ -43,10 +43,7 @@ export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageMod } satisfies ServiceError; } - const name = await generateChatNameFromMessage({ - message, - languageModelConfig, - }); + const name = await generateChatNameFromMessage({ message, languageModelConfig }); await prisma.chat.update({ where: { diff --git a/packages/web/src/ee/features/chat/llm.server.ts b/packages/web/src/ee/features/chat/llm.server.ts index 10e4cecc3..2880fded9 100644 --- a/packages/web/src/ee/features/chat/llm.server.ts +++ b/packages/web/src/ee/features/chat/llm.server.ts @@ -4,13 +4,7 @@ import { LanguageModel } from '@sourcebot/schemas/v3/languageModel.type'; import { generateText } from "ai"; import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server"; -export const generateChatNameFromMessage = async ({ - message, - languageModelConfig, -}: { - message: string, - languageModelConfig: LanguageModel, -}) => { +export const generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => { const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig); const prompt = `Convert this question into a short topic title (max 50 characters). diff --git a/packages/web/src/features/chat/languageModelHeaders.server.test.ts b/packages/web/src/features/chat/languageModelHeaders.server.test.ts index 953ab8f88..80119d2f8 100644 --- a/packages/web/src/features/chat/languageModelHeaders.server.test.ts +++ b/packages/web/src/features/chat/languageModelHeaders.server.test.ts @@ -1,16 +1,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import type { UserWithAccounts } from '@sourcebot/db'; -import { runWithCurrentUser } from '@/lib/currentUserContext'; const mocks = vi.hoisted(() => ({ env: { SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: 'false', }, getTokenFromConfig: vi.fn(), + getAuthContext: vi.fn(), })); vi.mock('@sourcebot/shared', () => mocks); vi.mock('server-only', () => ({})); +vi.mock('@/middleware/withAuth', () => ({ + getAuthContext: mocks.getAuthContext, +})); import { resolveLanguageModelHeaders, @@ -20,20 +22,22 @@ import { const resolveHeadersForUser = ( email: string, configuredHeaders?: Parameters[0], -) => - runWithCurrentUser( - { email } as unknown as UserWithAccounts, - () => resolveLanguageModelHeaders(configuredHeaders), - ); +) => { + mocks.getAuthContext.mockResolvedValue({ user: { email } }); + return resolveLanguageModelHeaders(configuredHeaders); +}; describe('resolveLanguageModelHeaders', () => { beforeEach(() => { mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false'; mocks.getTokenFromConfig.mockReset(); + mocks.getAuthContext.mockReset(); + mocks.getAuthContext.mockResolvedValue({ user: undefined }); }); test('does not add the user email header by default', async () => { await expect(resolveHeadersForUser('User@Example.com')).resolves.toBeUndefined(); + expect(mocks.getAuthContext).not.toHaveBeenCalled(); }); test('adds the current user email in lower case when enabled', async () => { diff --git a/packages/web/src/features/chat/languageModelHeaders.server.ts b/packages/web/src/features/chat/languageModelHeaders.server.ts index c067b2804..cb4828b22 100644 --- a/packages/web/src/features/chat/languageModelHeaders.server.ts +++ b/packages/web/src/features/chat/languageModelHeaders.server.ts @@ -2,7 +2,7 @@ import 'server-only'; import { Token } from '@sourcebot/schemas/v3/shared.type'; import { env, getTokenFromConfig } from '@sourcebot/shared'; -import { getCurrentUser } from '@/lib/currentUserContext'; +import { getAuthContext } from '@/middleware/withAuth'; export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email'; const PLACEHOLDER_EMAIL_PATTERN = /^placeholder-.+@no-email\.invalid$/i; @@ -18,9 +18,15 @@ export const resolveLanguageModelHeaders = async ( : await getTokenFromConfig(value); } - const userEmail = getCurrentUser()?.email; + const userEmail = await (async () => { + if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED !== 'true') { + return undefined; + } + + const authContext = await getAuthContext(); + return 'statusCode' in authContext ? undefined : authContext.user?.email; + })(); if ( - env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' && userEmail && !PLACEHOLDER_EMAIL_PATTERN.test(userEmail) ) { diff --git a/packages/web/src/lib/currentUserContext.ts b/packages/web/src/lib/currentUserContext.ts deleted file mode 100644 index 9f43cb635..000000000 --- a/packages/web/src/lib/currentUserContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { UserWithAccounts } from '@sourcebot/db'; - -const currentUserStorage = new AsyncLocalStorage(); - -export function runWithCurrentUser(user: UserWithAccounts | undefined, fn: () => T): T { - return currentUserStorage.run(user, fn); -} - -export function getCurrentUser(): UserWithAccounts | undefined { - return currentUserStorage.getStore(); -} diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index e967adead..74af00b0e 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -9,7 +9,6 @@ import { ErrorCode } from '../lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { userScopedPrismaClientExtension } from '@/prisma'; import { runWithRequestContext } from '@/lib/requestContext'; -import { getCurrentUser } from '@/lib/currentUserContext'; const TEST_OAUTH_SCOPE = 'read'; @@ -482,9 +481,7 @@ describe('getAuthContext', () => { prisma.userToOrg.updateMany.mockResolvedValue({ count: 1 }); setMockSession(createMockSession({ user: { id: userId } })); - const cb = vi.fn(async () => { - expect(getCurrentUser()).toMatchObject({ id: userId }); - }); + const cb = vi.fn(); const result = await withAuth(cb); expect(result).toBeUndefined(); diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 907b27f9c..ba2d5876e 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -13,7 +13,6 @@ import { activatePendingMembership } from "@/features/membership/membership.serv import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; -import { runWithCurrentUser } from "@/lib/currentUserContext"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -50,7 +49,7 @@ export const withAuth = async (fn: (params: RequiredAuthContext) => Promise fn({ user, org, role, prisma })); + return fn({ user, org, role, prisma }); }; export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => Promise, options: AuthOptions = {}) => { @@ -66,7 +65,7 @@ export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => P return notAuthenticated(); } - return runWithCurrentUser(authContext.user, () => fn(authContext)); + return fn(authContext); }; export const getAuthContext = async (options: AuthOptions = {}): Promise => { From b872f26738a5b8ee28bf1a3512c8bb5ae2911316 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 17:36:29 -0700 Subject: [PATCH 6/9] refactor(web): colocate LLM header resolution --- .../chat/languageModelHeaders.server.ts | 45 ------------------- ...ders.server.test.ts => llm.server.test.ts} | 28 +++++++++--- packages/web/src/features/chat/llm.server.ts | 39 +++++++++++++++- 3 files changed, 61 insertions(+), 51 deletions(-) delete mode 100644 packages/web/src/features/chat/languageModelHeaders.server.ts rename packages/web/src/features/chat/{languageModelHeaders.server.test.ts => llm.server.test.ts} (77%) diff --git a/packages/web/src/features/chat/languageModelHeaders.server.ts b/packages/web/src/features/chat/languageModelHeaders.server.ts deleted file mode 100644 index cb4828b22..000000000 --- a/packages/web/src/features/chat/languageModelHeaders.server.ts +++ /dev/null @@ -1,45 +0,0 @@ -import 'server-only'; - -import { Token } from '@sourcebot/schemas/v3/shared.type'; -import { env, getTokenFromConfig } from '@sourcebot/shared'; -import { getAuthContext } from '@/middleware/withAuth'; - -export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email'; -const PLACEHOLDER_EMAIL_PATTERN = /^placeholder-.+@no-email\.invalid$/i; - -export const resolveLanguageModelHeaders = async ( - configuredHeaders: Record | undefined, -): Promise | undefined> => { - const headers: Record = {}; - - for (const [key, value] of Object.entries(configuredHeaders ?? {})) { - headers[key] = typeof value === 'string' - ? value - : await getTokenFromConfig(value); - } - - const userEmail = await (async () => { - if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED !== 'true') { - return undefined; - } - - const authContext = await getAuthContext(); - return 'statusCode' in authContext ? undefined : authContext.user?.email; - })(); - if ( - userEmail && - !PLACEHOLDER_EMAIL_PATTERN.test(userEmail) - ) { - // Header names are case-insensitive. Remove any configured variant so - // the authenticated user's email is always the authoritative value. - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === SOURCEBOT_USER_EMAIL_HEADER.toLowerCase()) { - delete headers[key]; - } - } - - headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase(); - } - - return Object.keys(headers).length > 0 ? headers : undefined; -}; diff --git a/packages/web/src/features/chat/languageModelHeaders.server.test.ts b/packages/web/src/features/chat/llm.server.test.ts similarity index 77% rename from packages/web/src/features/chat/languageModelHeaders.server.test.ts rename to packages/web/src/features/chat/llm.server.test.ts index 80119d2f8..b937302e0 100644 --- a/packages/web/src/features/chat/languageModelHeaders.server.test.ts +++ b/packages/web/src/features/chat/llm.server.test.ts @@ -6,18 +6,32 @@ const mocks = vi.hoisted(() => ({ }, getTokenFromConfig: vi.fn(), getAuthContext: vi.fn(), + isServiceError: vi.fn(), })); vi.mock('@sourcebot/shared', () => mocks); vi.mock('server-only', () => ({})); +vi.mock('@/lib/posthog', () => ({ + createPostHogClient: vi.fn(), + tryGetPostHogDistinctId: vi.fn(), +})); +vi.mock('./logger', () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + }, +})); vi.mock('@/middleware/withAuth', () => ({ getAuthContext: mocks.getAuthContext, })); +vi.mock('@/lib/utils', () => ({ + isServiceError: mocks.isServiceError, +})); import { resolveLanguageModelHeaders, SOURCEBOT_USER_EMAIL_HEADER, -} from './languageModelHeaders.server'; +} from './llm.server'; const resolveHeadersForUser = ( email: string, @@ -33,6 +47,8 @@ describe('resolveLanguageModelHeaders', () => { mocks.getTokenFromConfig.mockReset(); mocks.getAuthContext.mockReset(); mocks.getAuthContext.mockResolvedValue({ user: undefined }); + mocks.isServiceError.mockReset(); + mocks.isServiceError.mockReturnValue(false); }); test('does not add the user email header by default', async () => { @@ -54,12 +70,14 @@ describe('resolveLanguageModelHeaders', () => { await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined(); }); - test('omits synthetic placeholder emails', async () => { + test('omits the user email header when auth context resolution fails', async () => { + const authError = { statusCode: 401 }; mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; + mocks.getAuthContext.mockResolvedValue(authError); + mocks.isServiceError.mockReturnValue(true); - await expect(resolveHeadersForUser( - 'placeholder-internal-user-id@no-email.invalid', - )).resolves.toBeUndefined(); + await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined(); + expect(mocks.isServiceError).toHaveBeenCalledWith(authError); }); test('preserves configured headers and overrides a case-insensitive email header', async () => { diff --git a/packages/web/src/features/chat/llm.server.ts b/packages/web/src/features/chat/llm.server.ts index d6e232002..161d69944 100644 --- a/packages/web/src/features/chat/llm.server.ts +++ b/packages/web/src/features/chat/llm.server.ts @@ -23,7 +23,8 @@ import { Token } from "@sourcebot/schemas/v3/shared.type"; import { env, getTokenFromConfig } from '@sourcebot/shared'; import { extractReasoningMiddleware, JSONValue, wrapLanguageModel } from "ai"; import * as Sentry from "@sentry/nextjs"; -import { resolveLanguageModelHeaders } from './languageModelHeaders.server'; +import { getAuthContext } from '@/middleware/withAuth'; +import { isServiceError } from '@/lib/utils'; // @note: This module resolves a configured language model into an AI SDK // provider object. It is intentionally FSL (open source) provider plumbing — @@ -329,6 +330,42 @@ const extractLanguageModelKeyValuePairs = async ( return resolvedPairs; }; +export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email'; + +export const resolveLanguageModelHeaders = async ( + configuredHeaders: Record | undefined, +): Promise | undefined> => { + const headers: Record = {}; + + for (const [key, value] of Object.entries(configuredHeaders ?? {})) { + headers[key] = typeof value === 'string' + ? value + : await getTokenFromConfig(value); + } + + const userEmail = await (async () => { + if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED !== 'true') { + return undefined; + } + + const authContext = await getAuthContext(); + return isServiceError(authContext) ? undefined : authContext.user?.email; + })(); + if (userEmail) { + // Header names are case-insensitive. Remove any configured variant so + // the authenticated user's email is always the authoritative value. + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === SOURCEBOT_USER_EMAIL_HEADER.toLowerCase()) { + delete headers[key]; + } + } + + headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase(); + } + + return Object.keys(headers).length > 0 ? headers : undefined; +}; + type AnthropicThinkingConfig = NonNullable; const anthropicThinkingConfigCache = new Map(); From 60220c0937ba7567530e00a14914ef912c8a5f2b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 18:36:39 -0700 Subject: [PATCH 7/9] refactor(web): reuse LLM key-value resolver --- packages/web/src/features/chat/llm.server.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/web/src/features/chat/llm.server.ts b/packages/web/src/features/chat/llm.server.ts index 161d69944..b7fd68159 100644 --- a/packages/web/src/features/chat/llm.server.ts +++ b/packages/web/src/features/chat/llm.server.ts @@ -309,7 +309,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr const extractLanguageModelKeyValuePairs = async ( pairs: { [k: string]: string | Token; - } + } | undefined ): Promise> => { const resolvedPairs: Record = {}; @@ -335,13 +335,7 @@ export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email'; export const resolveLanguageModelHeaders = async ( configuredHeaders: Record | undefined, ): Promise | undefined> => { - const headers: Record = {}; - - for (const [key, value] of Object.entries(configuredHeaders ?? {})) { - headers[key] = typeof value === 'string' - ? value - : await getTokenFromConfig(value); - } + const headers = await extractLanguageModelKeyValuePairs(configuredHeaders); const userEmail = await (async () => { if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED !== 'true') { From 33551e22614348424bdd088baf47789d62c8e3ce Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 16 Jul 2026 12:16:06 -0700 Subject: [PATCH 8/9] refactor(web): simplify LLM user email header --- packages/web/src/features/chat/llm.server.test.ts | 3 +-- packages/web/src/features/chat/llm.server.ts | 8 -------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/web/src/features/chat/llm.server.test.ts b/packages/web/src/features/chat/llm.server.test.ts index b937302e0..79abcfbc1 100644 --- a/packages/web/src/features/chat/llm.server.test.ts +++ b/packages/web/src/features/chat/llm.server.test.ts @@ -80,11 +80,10 @@ describe('resolveLanguageModelHeaders', () => { expect(mocks.isServiceError).toHaveBeenCalledWith(authError); }); - test('preserves configured headers and overrides a case-insensitive email header', async () => { + test('preserves configured headers when adding the user email header', async () => { mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true'; await expect(resolveHeadersForUser('Authenticated@Example.com', { - 'x-sourcebot-user-email': 'configured@example.com', 'X-Custom-Header': 'custom-value', })).resolves.toEqual({ 'X-Custom-Header': 'custom-value', diff --git a/packages/web/src/features/chat/llm.server.ts b/packages/web/src/features/chat/llm.server.ts index b7fd68159..af13fecf9 100644 --- a/packages/web/src/features/chat/llm.server.ts +++ b/packages/web/src/features/chat/llm.server.ts @@ -346,14 +346,6 @@ export const resolveLanguageModelHeaders = async ( return isServiceError(authContext) ? undefined : authContext.user?.email; })(); if (userEmail) { - // Header names are case-insensitive. Remove any configured variant so - // the authenticated user's email is always the authoritative value. - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === SOURCEBOT_USER_EMAIL_HEADER.toLowerCase()) { - delete headers[key]; - } - } - headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase(); } From 9ad379794725e3f6e08c62ca7d6fcaf847555bf1 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 16 Jul 2026 12:17:50 -0700 Subject: [PATCH 9/9] nit --- docs/docs/configuration/environment-variables.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 43d1e324b..9bbe31de4 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -42,7 +42,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `ALWAYS_INDEX_FILE_PATTERNS` | - |

A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.

| | `SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES` | `10485760` (10 MiB) |

Maximum size in bytes of a single image attachment uploaded to Ask Sourcebot. Enforced server-side at upload time.

| | `SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS` | `24` |

How long in hours an uploaded-but-unsent attachment is retained before being deleted by the orphan sweep. Set to `0` to disable the sweep.

| -| `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` | `false` |

When enabled, Sourcebot sends the authenticated user's lower-cased email address to configured language model providers in the `X-Sourcebot-User-Email` request header. Anonymous and automated requests omit the header.

| +| `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` | `false` |

When enabled, Sourcebot sends the authenticated user's lower-cased email address to configured language model providers in the `X-Sourcebot-User-Email` request header. Anonymous requests omit the header.

| | `NODE_USE_ENV_PROXY` | `0` |

Enables Node.js to automatically use `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for network requests. Set to `1` to enable or `0` to disable. See [this doc](https://nodejs.org/en/learn/http/enterprise-network-configuration) for more info.

| | `HTTP_PROXY` | - |

HTTP proxy URL for routing non-SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.

| | `HTTPS_PROXY` | - |

HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.

|