Skip to content

Commit 1b6190b

Browse files
feat(web): add optional LLM user email header
1 parent af6f1e6 commit 1b6190b

11 files changed

Lines changed: 161 additions & 46 deletions

File tree

docs/docs/configuration/environment-variables.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ The following environment variables allow you to configure your Sourcebot deploy
4242
| `ALWAYS_INDEX_FILE_PATTERNS` | - | <p>A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.</p> |
4343
| `SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES` | `10485760` (10 MiB) | <p>Maximum size in bytes of a single image attachment uploaded to Ask Sourcebot. Enforced server-side at upload time.</p> |
4444
| `SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS` | `24` | <p>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.</p> |
45+
| `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` | `false` | <p>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.</p> |
4546
| `NODE_USE_ENV_PROXY` | `0` | <p>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.</p> |
4647
| `HTTP_PROXY` | - | <p>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`.</p> |
4748
| `HTTPS_PROXY` | - | <p>HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.</p> |
@@ -71,4 +72,4 @@ The following environment variables allow you to configure your Sourcebot deploy
7172

7273
### Overriding environment variables from the config
7374

74-
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.
75+
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.

packages/shared/src/env.server.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,25 @@ describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => {
8282
await expect(import('./env.server.js')).rejects.toThrow();
8383
});
8484
});
85+
86+
describe('SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED', () => {
87+
beforeEach(() => {
88+
vi.resetModules();
89+
delete process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED;
90+
});
91+
92+
afterEach(() => {
93+
delete process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED;
94+
});
95+
96+
test('defaults to false', async () => {
97+
const { env } = await import('./env.server.js');
98+
expect(env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED).toBe('false');
99+
});
100+
101+
test.each(['true', 'false'])('accepts %s', async (enabled) => {
102+
process.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = enabled;
103+
const { env } = await import('./env.server.js');
104+
expect(env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED).toBe(enabled);
105+
});
106+
});

packages/shared/src/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ const options = {
313313

314314
SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
315315
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
316+
SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: booleanSchema.default('false'),
316317
/** TTL for the static block. The moving tail marker always uses the 5m default. */
317318
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
318319
/**

packages/web/src/app/api/(server)/ee/chat/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ export const POST = apiHandler(async (req: NextRequest) => {
135135
return attachmentError;
136136
}
137137

138-
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
138+
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(
139+
languageModelConfig,
140+
user?.email,
141+
);
139142

140143
// Authoritative, server-side resolution of the model's input
141144
// modalities. The agent's multimodal content builder and degrade

packages/web/src/ee/features/chat/actions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageMod
4343
} satisfies ServiceError;
4444
}
4545

46-
const name = await generateChatNameFromMessage({ message, languageModelConfig });
46+
const name = await generateChatNameFromMessage({
47+
message,
48+
languageModelConfig,
49+
userEmail: user?.email,
50+
});
4751

4852
await prisma.chat.update({
4953
where: {

packages/web/src/ee/features/chat/llm.server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ import { LanguageModel } from '@sourcebot/schemas/v3/languageModel.type';
44
import { generateText } from "ai";
55
import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server";
66

7-
export const generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
8-
const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig);
7+
export const generateChatNameFromMessage = async ({
8+
message,
9+
languageModelConfig,
10+
userEmail,
11+
}: {
12+
message: string,
13+
languageModelConfig: LanguageModel,
14+
userEmail?: string,
15+
}) => {
16+
const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig, userEmail);
917

1018
const prompt = `Convert this question into a short topic title (max 50 characters).
1119

packages/web/src/ee/features/mcp/askCodebase.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul
8484
languageModelConfig = matchingModel;
8585
}
8686

87-
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
87+
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(
88+
languageModelConfig,
89+
user?.email,
90+
);
8891
const modelName = languageModelConfig.displayName ?? languageModelConfig.model;
8992
const contextWindow = await resolveContextWindow(languageModelConfig);
9093
const { inputModalities, supportedDocumentTypes } = await resolveModelCapabilities(languageModelConfig);
@@ -211,6 +214,7 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul
211214
generateChatNameFromMessage({
212215
message: query,
213216
languageModelConfig,
217+
userEmail: user?.email,
214218
})
215219
]);
216220

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
env: {
5+
SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: 'false',
6+
},
7+
getTokenFromConfig: vi.fn(),
8+
}));
9+
10+
vi.mock('@sourcebot/shared', () => mocks);
11+
vi.mock('server-only', () => ({}));
12+
13+
import {
14+
resolveLanguageModelHeaders,
15+
SOURCEBOT_USER_EMAIL_HEADER,
16+
} from './languageModelHeaders.server';
17+
18+
describe('resolveLanguageModelHeaders', () => {
19+
beforeEach(() => {
20+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false';
21+
mocks.getTokenFromConfig.mockReset();
22+
});
23+
24+
test('does not add the user email header by default', async () => {
25+
await expect(resolveLanguageModelHeaders(undefined, 'User@Example.com')).resolves.toBeUndefined();
26+
});
27+
28+
test('adds a lower-cased user email when enabled', async () => {
29+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
30+
31+
await expect(resolveLanguageModelHeaders(undefined, 'User@Example.COM')).resolves.toEqual({
32+
[SOURCEBOT_USER_EMAIL_HEADER]: 'user@example.com',
33+
});
34+
});
35+
36+
test('omits the user email header for anonymous requests', async () => {
37+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
38+
39+
await expect(resolveLanguageModelHeaders(undefined, undefined)).resolves.toBeUndefined();
40+
});
41+
42+
test('preserves configured headers and overrides a case-insensitive email header', async () => {
43+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
44+
45+
await expect(resolveLanguageModelHeaders({
46+
'x-sourcebot-user-email': 'configured@example.com',
47+
'X-Custom-Header': 'custom-value',
48+
}, 'Authenticated@Example.com')).resolves.toEqual({
49+
'X-Custom-Header': 'custom-value',
50+
[SOURCEBOT_USER_EMAIL_HEADER]: 'authenticated@example.com',
51+
});
52+
});
53+
54+
test('resolves token-backed configured headers', async () => {
55+
const token = { env: 'CUSTOM_HEADER' };
56+
mocks.getTokenFromConfig.mockResolvedValue('resolved-value');
57+
58+
await expect(resolveLanguageModelHeaders({ Authorization: token }, undefined)).resolves.toEqual({
59+
Authorization: 'resolved-value',
60+
});
61+
expect(mocks.getTokenFromConfig).toHaveBeenCalledWith(token);
62+
});
63+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'server-only';
2+
3+
import { Token } from '@sourcebot/schemas/v3/shared.type';
4+
import { env, getTokenFromConfig } from '@sourcebot/shared';
5+
6+
export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email';
7+
8+
export const resolveLanguageModelHeaders = async (
9+
configuredHeaders: Record<string, string | Token> | undefined,
10+
userEmail: string | undefined,
11+
): Promise<Record<string, string> | undefined> => {
12+
const headers: Record<string, string> = {};
13+
14+
for (const [key, value] of Object.entries(configuredHeaders ?? {})) {
15+
headers[key] = typeof value === 'string'
16+
? value
17+
: await getTokenFromConfig(value);
18+
}
19+
20+
if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' && userEmail) {
21+
// Header names are case-insensitive. Remove any configured variant so
22+
// the authenticated user's email is always the authoritative value.
23+
for (const key of Object.keys(headers)) {
24+
if (key.toLowerCase() === SOURCEBOT_USER_EMAIL_HEADER.toLowerCase()) {
25+
delete headers[key];
26+
}
27+
}
28+
29+
headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase();
30+
}
31+
32+
return Object.keys(headers).length > 0 ? headers : undefined;
33+
};

packages/web/src/features/chat/llm.server.ts

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Token } from "@sourcebot/schemas/v3/shared.type";
2323
import { env, getTokenFromConfig } from '@sourcebot/shared';
2424
import { extractReasoningMiddleware, JSONValue, wrapLanguageModel } from "ai";
2525
import * as Sentry from "@sentry/nextjs";
26+
import { resolveLanguageModelHeaders } from './languageModelHeaders.server';
2627

2728
// @note: This module resolves a configured language model into an AI SDK
2829
// provider object. It is intentionally FSL (open source) provider plumbing —
@@ -31,12 +32,13 @@ import * as Sentry from "@sentry/nextjs";
3132
// review agent). The re-licensed Ask logic (prompts, tools, threads, chat
3233
// name generation) lives in `@/ee/features/chat`.
3334

34-
export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Promise<{
35+
export const getAISDKLanguageModelAndOptions = async (config: LanguageModel, userEmail?: string): Promise<{
3536
model: AISDKLanguageModelV3,
3637
providerOptions?: Record<string, Record<string, JSONValue>>,
3738
temperature?: number,
3839
}> => {
3940
const { provider, model: modelId } = config;
41+
const headers = await resolveLanguageModelHeaders(config.headers, userEmail);
4042

4143
const { model: _model, providerOptions } = await (async (): Promise<{
4244
model: AISDKLanguageModelV3,
@@ -56,9 +58,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
5658
sessionToken: config.sessionToken
5759
? await getTokenFromConfig(config.sessionToken)
5860
: env.AWS_SESSION_TOKEN,
59-
headers: config.headers
60-
? await extractLanguageModelKeyValuePairs(config.headers)
61-
: undefined,
61+
headers,
6262
// Fallback to the default Node.js credential provider chain if no credentials are provided.
6363
// See: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain
6464
credentialProvider: !config.accessKeyId && !config.accessKeySecret && !config.sessionToken
@@ -77,10 +77,6 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
7777
const authToken = config.authToken
7878
? await getTokenFromConfig(config.authToken)
7979
: env.ANTHROPIC_AUTH_TOKEN;
80-
const headers = config.headers
81-
? await extractLanguageModelKeyValuePairs(config.headers)
82-
: undefined;
83-
8480
const anthropic = createAnthropic({
8581
baseURL: config.baseUrl,
8682
apiKey,
@@ -111,9 +107,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
111107
apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.AZURE_API_KEY,
112108
apiVersion: config.apiVersion,
113109
resourceName: config.resourceName ?? env.AZURE_RESOURCE_NAME,
114-
headers: config.headers
115-
? await extractLanguageModelKeyValuePairs(config.headers)
116-
: undefined,
110+
headers,
117111
});
118112

119113
const reasoningSummary = config.reasoningSummary ?? 'auto';
@@ -131,9 +125,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
131125
const deepseek = createDeepSeek({
132126
baseURL: config.baseUrl,
133127
apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.DEEPSEEK_API_KEY,
134-
headers: config.headers
135-
? await extractLanguageModelKeyValuePairs(config.headers)
136-
: undefined,
128+
headers,
137129
});
138130

139131
return {
@@ -146,9 +138,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
146138
apiKey: config.token
147139
? await getTokenFromConfig(config.token)
148140
: env.GOOGLE_GENERATIVE_AI_API_KEY,
149-
headers: config.headers
150-
? await extractLanguageModelKeyValuePairs(config.headers)
151-
: undefined,
141+
headers,
152142
});
153143

154144
return {
@@ -173,9 +163,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
173163
keyFilename: await getTokenFromConfig(config.credentials),
174164
}
175165
} : {}),
176-
headers: config.headers
177-
? await extractLanguageModelKeyValuePairs(config.headers)
178-
: undefined,
166+
headers,
179167
});
180168

181169
return {
@@ -202,9 +190,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
202190
keyFilename: await getTokenFromConfig(config.credentials),
203191
}
204192
} : {}),
205-
headers: config.headers
206-
? await extractLanguageModelKeyValuePairs(config.headers)
207-
: undefined,
193+
headers,
208194
});
209195

210196
return {
@@ -217,9 +203,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
217203
apiKey: config.token
218204
? await getTokenFromConfig(config.token)
219205
: env.MISTRAL_API_KEY,
220-
headers: config.headers
221-
? await extractLanguageModelKeyValuePairs(config.headers)
222-
: undefined,
206+
headers,
223207
});
224208

225209
return {
@@ -232,9 +216,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
232216
apiKey: config.token
233217
? await getTokenFromConfig(config.token)
234218
: env.OPENAI_API_KEY,
235-
headers: config.headers
236-
? await extractLanguageModelKeyValuePairs(config.headers)
237-
: undefined,
219+
headers,
238220
});
239221

240222
const reasoningSummary = config.reasoningSummary ?? 'auto';
@@ -255,9 +237,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
255237
apiKey: config.token
256238
? await getTokenFromConfig(config.token)
257239
: undefined,
258-
headers: config.headers
259-
? await extractLanguageModelKeyValuePairs(config.headers)
260-
: undefined,
240+
headers,
261241
queryParams: config.queryParams
262242
? await extractLanguageModelKeyValuePairs(config.queryParams)
263243
: undefined,
@@ -282,9 +262,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
282262
apiKey: config.token
283263
? await getTokenFromConfig(config.token)
284264
: env.OPENROUTER_API_KEY,
285-
headers: config.headers
286-
? await extractLanguageModelKeyValuePairs(config.headers)
287-
: undefined,
265+
headers,
288266
});
289267

290268
return {
@@ -297,9 +275,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
297275
apiKey: config.token
298276
? await getTokenFromConfig(config.token)
299277
: env.XAI_API_KEY,
300-
headers: config.headers
301-
? await extractLanguageModelKeyValuePairs(config.headers)
302-
: undefined,
278+
headers,
303279
});
304280

305281
return {

0 commit comments

Comments
 (0)