Skip to content

Commit 69a9b47

Browse files
feat(web): add optional LLM user email header (#1455)
* feat(web): add optional LLM user email header * docs: add changelog entry for PR 1455 * test: remove redundant environment schema coverage * refactor(web): resolve LLM user from auth context * refactor(web): use existing auth context for LLM headers * refactor(web): colocate LLM header resolution * refactor(web): reuse LLM key-value resolver * refactor(web): simplify LLM user email header * nit
1 parent af6f1e6 commit 69a9b47

5 files changed

Lines changed: 145 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- 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)
12+
1013
### Fixed
1114
- [EE] Verified signed online license assertions before granting paid feature entitlements. [#1442](https://github.com/sourcebot-dev/sourcebot/pull/1442)
1215
- [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)

docs/docs/configuration/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
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 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> |

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
/**
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
getAuthContext: vi.fn(),
9+
isServiceError: vi.fn(),
10+
}));
11+
12+
vi.mock('@sourcebot/shared', () => mocks);
13+
vi.mock('server-only', () => ({}));
14+
vi.mock('@/lib/posthog', () => ({
15+
createPostHogClient: vi.fn(),
16+
tryGetPostHogDistinctId: vi.fn(),
17+
}));
18+
vi.mock('./logger', () => ({
19+
logger: {
20+
error: vi.fn(),
21+
warn: vi.fn(),
22+
},
23+
}));
24+
vi.mock('@/middleware/withAuth', () => ({
25+
getAuthContext: mocks.getAuthContext,
26+
}));
27+
vi.mock('@/lib/utils', () => ({
28+
isServiceError: mocks.isServiceError,
29+
}));
30+
31+
import {
32+
resolveLanguageModelHeaders,
33+
SOURCEBOT_USER_EMAIL_HEADER,
34+
} from './llm.server';
35+
36+
const resolveHeadersForUser = (
37+
email: string,
38+
configuredHeaders?: Parameters<typeof resolveLanguageModelHeaders>[0],
39+
) => {
40+
mocks.getAuthContext.mockResolvedValue({ user: { email } });
41+
return resolveLanguageModelHeaders(configuredHeaders);
42+
};
43+
44+
describe('resolveLanguageModelHeaders', () => {
45+
beforeEach(() => {
46+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false';
47+
mocks.getTokenFromConfig.mockReset();
48+
mocks.getAuthContext.mockReset();
49+
mocks.getAuthContext.mockResolvedValue({ user: undefined });
50+
mocks.isServiceError.mockReset();
51+
mocks.isServiceError.mockReturnValue(false);
52+
});
53+
54+
test('does not add the user email header by default', async () => {
55+
await expect(resolveHeadersForUser('User@Example.com')).resolves.toBeUndefined();
56+
expect(mocks.getAuthContext).not.toHaveBeenCalled();
57+
});
58+
59+
test('adds the current user email in lower case when enabled', async () => {
60+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
61+
62+
await expect(resolveHeadersForUser('User@Example.COM')).resolves.toEqual({
63+
[SOURCEBOT_USER_EMAIL_HEADER]: 'user@example.com',
64+
});
65+
});
66+
67+
test('omits the user email header for anonymous requests', async () => {
68+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
69+
70+
await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined();
71+
});
72+
73+
test('omits the user email header when auth context resolution fails', async () => {
74+
const authError = { statusCode: 401 };
75+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
76+
mocks.getAuthContext.mockResolvedValue(authError);
77+
mocks.isServiceError.mockReturnValue(true);
78+
79+
await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined();
80+
expect(mocks.isServiceError).toHaveBeenCalledWith(authError);
81+
});
82+
83+
test('preserves configured headers when adding the user email header', async () => {
84+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
85+
86+
await expect(resolveHeadersForUser('Authenticated@Example.com', {
87+
'X-Custom-Header': 'custom-value',
88+
})).resolves.toEqual({
89+
'X-Custom-Header': 'custom-value',
90+
[SOURCEBOT_USER_EMAIL_HEADER]: 'authenticated@example.com',
91+
});
92+
});
93+
94+
test('resolves token-backed configured headers', async () => {
95+
const token = { env: 'CUSTOM_HEADER' };
96+
mocks.getTokenFromConfig.mockResolvedValue('resolved-value');
97+
98+
await expect(resolveLanguageModelHeaders({ Authorization: token })).resolves.toEqual({
99+
Authorization: 'resolved-value',
100+
});
101+
expect(mocks.getTokenFromConfig).toHaveBeenCalledWith(token);
102+
});
103+
});

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

Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ 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 { getAuthContext } from '@/middleware/withAuth';
27+
import { isServiceError } from '@/lib/utils';
2628

2729
// @note: This module resolves a configured language model into an AI SDK
2830
// provider object. It is intentionally FSL (open source) provider plumbing —
@@ -37,6 +39,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
3739
temperature?: number,
3840
}> => {
3941
const { provider, model: modelId } = config;
42+
const headers = await resolveLanguageModelHeaders(config.headers);
4043

4144
const { model: _model, providerOptions } = await (async (): Promise<{
4245
model: AISDKLanguageModelV3,
@@ -56,9 +59,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
5659
sessionToken: config.sessionToken
5760
? await getTokenFromConfig(config.sessionToken)
5861
: env.AWS_SESSION_TOKEN,
59-
headers: config.headers
60-
? await extractLanguageModelKeyValuePairs(config.headers)
61-
: undefined,
62+
headers,
6263
// Fallback to the default Node.js credential provider chain if no credentials are provided.
6364
// See: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain
6465
credentialProvider: !config.accessKeyId && !config.accessKeySecret && !config.sessionToken
@@ -77,10 +78,6 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
7778
const authToken = config.authToken
7879
? await getTokenFromConfig(config.authToken)
7980
: env.ANTHROPIC_AUTH_TOKEN;
80-
const headers = config.headers
81-
? await extractLanguageModelKeyValuePairs(config.headers)
82-
: undefined;
83-
8481
const anthropic = createAnthropic({
8582
baseURL: config.baseUrl,
8683
apiKey,
@@ -111,9 +108,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
111108
apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.AZURE_API_KEY,
112109
apiVersion: config.apiVersion,
113110
resourceName: config.resourceName ?? env.AZURE_RESOURCE_NAME,
114-
headers: config.headers
115-
? await extractLanguageModelKeyValuePairs(config.headers)
116-
: undefined,
111+
headers,
117112
});
118113

119114
const reasoningSummary = config.reasoningSummary ?? 'auto';
@@ -131,9 +126,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
131126
const deepseek = createDeepSeek({
132127
baseURL: config.baseUrl,
133128
apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.DEEPSEEK_API_KEY,
134-
headers: config.headers
135-
? await extractLanguageModelKeyValuePairs(config.headers)
136-
: undefined,
129+
headers,
137130
});
138131

139132
return {
@@ -146,9 +139,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
146139
apiKey: config.token
147140
? await getTokenFromConfig(config.token)
148141
: env.GOOGLE_GENERATIVE_AI_API_KEY,
149-
headers: config.headers
150-
? await extractLanguageModelKeyValuePairs(config.headers)
151-
: undefined,
142+
headers,
152143
});
153144

154145
return {
@@ -173,9 +164,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
173164
keyFilename: await getTokenFromConfig(config.credentials),
174165
}
175166
} : {}),
176-
headers: config.headers
177-
? await extractLanguageModelKeyValuePairs(config.headers)
178-
: undefined,
167+
headers,
179168
});
180169

181170
return {
@@ -202,9 +191,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
202191
keyFilename: await getTokenFromConfig(config.credentials),
203192
}
204193
} : {}),
205-
headers: config.headers
206-
? await extractLanguageModelKeyValuePairs(config.headers)
207-
: undefined,
194+
headers,
208195
});
209196

210197
return {
@@ -217,9 +204,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
217204
apiKey: config.token
218205
? await getTokenFromConfig(config.token)
219206
: env.MISTRAL_API_KEY,
220-
headers: config.headers
221-
? await extractLanguageModelKeyValuePairs(config.headers)
222-
: undefined,
207+
headers,
223208
});
224209

225210
return {
@@ -232,9 +217,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
232217
apiKey: config.token
233218
? await getTokenFromConfig(config.token)
234219
: env.OPENAI_API_KEY,
235-
headers: config.headers
236-
? await extractLanguageModelKeyValuePairs(config.headers)
237-
: undefined,
220+
headers,
238221
});
239222

240223
const reasoningSummary = config.reasoningSummary ?? 'auto';
@@ -255,9 +238,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
255238
apiKey: config.token
256239
? await getTokenFromConfig(config.token)
257240
: undefined,
258-
headers: config.headers
259-
? await extractLanguageModelKeyValuePairs(config.headers)
260-
: undefined,
241+
headers,
261242
queryParams: config.queryParams
262243
? await extractLanguageModelKeyValuePairs(config.queryParams)
263244
: undefined,
@@ -282,9 +263,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
282263
apiKey: config.token
283264
? await getTokenFromConfig(config.token)
284265
: env.OPENROUTER_API_KEY,
285-
headers: config.headers
286-
? await extractLanguageModelKeyValuePairs(config.headers)
287-
: undefined,
266+
headers,
288267
});
289268

290269
return {
@@ -297,9 +276,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
297276
apiKey: config.token
298277
? await getTokenFromConfig(config.token)
299278
: env.XAI_API_KEY,
300-
headers: config.headers
301-
? await extractLanguageModelKeyValuePairs(config.headers)
302-
: undefined,
279+
headers,
303280
});
304281

305282
return {
@@ -332,7 +309,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
332309
const extractLanguageModelKeyValuePairs = async (
333310
pairs: {
334311
[k: string]: string | Token;
335-
}
312+
} | undefined
336313
): Promise<Record<string, string>> => {
337314
const resolvedPairs: Record<string, string> = {};
338315

@@ -353,6 +330,28 @@ const extractLanguageModelKeyValuePairs = async (
353330
return resolvedPairs;
354331
};
355332

333+
export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email';
334+
335+
export const resolveLanguageModelHeaders = async (
336+
configuredHeaders: Record<string, string | Token> | undefined,
337+
): Promise<Record<string, string> | undefined> => {
338+
const headers = await extractLanguageModelKeyValuePairs(configuredHeaders);
339+
340+
const userEmail = await (async () => {
341+
if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED !== 'true') {
342+
return undefined;
343+
}
344+
345+
const authContext = await getAuthContext();
346+
return isServiceError(authContext) ? undefined : authContext.user?.email;
347+
})();
348+
if (userEmail) {
349+
headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase();
350+
}
351+
352+
return Object.keys(headers).length > 0 ? headers : undefined;
353+
};
354+
356355
type AnthropicThinkingConfig = NonNullable<AnthropicProviderOptions['thinking']>;
357356
const anthropicThinkingConfigCache = new Map<string, AnthropicThinkingConfig | undefined>();
358357

0 commit comments

Comments
 (0)