Skip to content

Commit 665a2da

Browse files
refactor(web): resolve LLM user from auth context
1 parent 712b0d6 commit 665a2da

11 files changed

Lines changed: 60 additions & 29 deletions

File tree

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

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

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

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

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageMod
4646
const name = await generateChatNameFromMessage({
4747
message,
4848
languageModelConfig,
49-
userEmail: user?.email,
5049
});
5150

5251
await prisma.chat.update({

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@ import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server";
77
export const generateChatNameFromMessage = async ({
88
message,
99
languageModelConfig,
10-
userEmail,
1110
}: {
1211
message: string,
1312
languageModelConfig: LanguageModel,
14-
userEmail?: string,
1513
}) => {
16-
const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig, userEmail);
14+
const { model } = await getAISDKLanguageModelAndOptions(languageModelConfig);
1715

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

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

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

87-
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(
88-
languageModelConfig,
89-
user?.email,
90-
);
87+
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
9188
const modelName = languageModelConfig.displayName ?? languageModelConfig.model;
9289
const contextWindow = await resolveContextWindow(languageModelConfig);
9390
const { inputModalities, supportedDocumentTypes } = await resolveModelCapabilities(languageModelConfig);
@@ -214,7 +211,6 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul
214211
generateChatNameFromMessage({
215212
message: query,
216213
languageModelConfig,
217-
userEmail: user?.email,
218214
})
219215
]);
220216

packages/web/src/features/chat/languageModelHeaders.server.test.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
import type { UserWithAccounts } from '@sourcebot/db';
3+
import { runWithCurrentUser } from '@/lib/currentUserContext';
24

35
const mocks = vi.hoisted(() => ({
46
env: {
@@ -15,37 +17,54 @@ import {
1517
SOURCEBOT_USER_EMAIL_HEADER,
1618
} from './languageModelHeaders.server';
1719

20+
const resolveHeadersForUser = (
21+
email: string,
22+
configuredHeaders?: Parameters<typeof resolveLanguageModelHeaders>[0],
23+
) =>
24+
runWithCurrentUser(
25+
{ email } as unknown as UserWithAccounts,
26+
() => resolveLanguageModelHeaders(configuredHeaders),
27+
);
28+
1829
describe('resolveLanguageModelHeaders', () => {
1930
beforeEach(() => {
2031
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false';
2132
mocks.getTokenFromConfig.mockReset();
2233
});
2334

2435
test('does not add the user email header by default', async () => {
25-
await expect(resolveLanguageModelHeaders(undefined, 'User@Example.com')).resolves.toBeUndefined();
36+
await expect(resolveHeadersForUser('User@Example.com')).resolves.toBeUndefined();
2637
});
2738

28-
test('adds a lower-cased user email when enabled', async () => {
39+
test('adds the current user email in lower case when enabled', async () => {
2940
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
3041

31-
await expect(resolveLanguageModelHeaders(undefined, 'User@Example.COM')).resolves.toEqual({
42+
await expect(resolveHeadersForUser('User@Example.COM')).resolves.toEqual({
3243
[SOURCEBOT_USER_EMAIL_HEADER]: 'user@example.com',
3344
});
3445
});
3546

3647
test('omits the user email header for anonymous requests', async () => {
3748
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
3849

39-
await expect(resolveLanguageModelHeaders(undefined, undefined)).resolves.toBeUndefined();
50+
await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined();
51+
});
52+
53+
test('omits synthetic placeholder emails', async () => {
54+
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
55+
56+
await expect(resolveHeadersForUser(
57+
'placeholder-internal-user-id@no-email.invalid',
58+
)).resolves.toBeUndefined();
4059
});
4160

4261
test('preserves configured headers and overrides a case-insensitive email header', async () => {
4362
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
4463

45-
await expect(resolveLanguageModelHeaders({
64+
await expect(resolveHeadersForUser('Authenticated@Example.com', {
4665
'x-sourcebot-user-email': 'configured@example.com',
4766
'X-Custom-Header': 'custom-value',
48-
}, 'Authenticated@Example.com')).resolves.toEqual({
67+
})).resolves.toEqual({
4968
'X-Custom-Header': 'custom-value',
5069
[SOURCEBOT_USER_EMAIL_HEADER]: 'authenticated@example.com',
5170
});
@@ -55,7 +74,7 @@ describe('resolveLanguageModelHeaders', () => {
5574
const token = { env: 'CUSTOM_HEADER' };
5675
mocks.getTokenFromConfig.mockResolvedValue('resolved-value');
5776

58-
await expect(resolveLanguageModelHeaders({ Authorization: token }, undefined)).resolves.toEqual({
77+
await expect(resolveLanguageModelHeaders({ Authorization: token })).resolves.toEqual({
5978
Authorization: 'resolved-value',
6079
});
6180
expect(mocks.getTokenFromConfig).toHaveBeenCalledWith(token);

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import 'server-only';
22

33
import { Token } from '@sourcebot/schemas/v3/shared.type';
44
import { env, getTokenFromConfig } from '@sourcebot/shared';
5+
import { getCurrentUser } from '@/lib/currentUserContext';
56

67
export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email';
8+
const PLACEHOLDER_EMAIL_PATTERN = /^placeholder-.+@no-email\.invalid$/i;
79

810
export const resolveLanguageModelHeaders = async (
911
configuredHeaders: Record<string, string | Token> | undefined,
10-
userEmail: string | undefined,
1112
): Promise<Record<string, string> | undefined> => {
1213
const headers: Record<string, string> = {};
1314

@@ -17,7 +18,12 @@ export const resolveLanguageModelHeaders = async (
1718
: await getTokenFromConfig(value);
1819
}
1920

20-
if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' && userEmail) {
21+
const userEmail = getCurrentUser()?.email;
22+
if (
23+
env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED === 'true' &&
24+
userEmail &&
25+
!PLACEHOLDER_EMAIL_PATTERN.test(userEmail)
26+
) {
2127
// Header names are case-insensitive. Remove any configured variant so
2228
// the authenticated user's email is always the authoritative value.
2329
for (const key of Object.keys(headers)) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ import { resolveLanguageModelHeaders } from './languageModelHeaders.server';
3232
// review agent). The re-licensed Ask logic (prompts, tools, threads, chat
3333
// name generation) lives in `@/ee/features/chat`.
3434

35-
export const getAISDKLanguageModelAndOptions = async (config: LanguageModel, userEmail?: string): Promise<{
35+
export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Promise<{
3636
model: AISDKLanguageModelV3,
3737
providerOptions?: Record<string, Record<string, JSONValue>>,
3838
temperature?: number,
3939
}> => {
4040
const { provider, model: modelId } = config;
41-
const headers = await resolveLanguageModelHeaders(config.headers, userEmail);
41+
const headers = await resolveLanguageModelHeaders(config.headers);
4242

4343
const { model: _model, providerOptions } = await (async (): Promise<{
4444
model: AISDKLanguageModelV3,

packages/web/src/features/searchAssist/actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ ${SEARCH_SYNTAX_DESCRIPTION}
2626
`;
2727

2828
export const translateSearchQuery = async ({ prompt }: { prompt: string }) => sew(() =>
29-
withOptionalAuth(async ({ user }) => {
29+
withOptionalAuth(async () => {
3030
const models = await getConfiguredLanguageModels();
3131

3232
if (models.length === 0) {
@@ -37,7 +37,7 @@ export const translateSearchQuery = async ({ prompt }: { prompt: string }) => se
3737
} satisfies ServiceError;
3838
}
3939

40-
const { model } = await getAISDKLanguageModelAndOptions(models[0], user?.email);
40+
const { model } = await getAISDKLanguageModelAndOptions(models[0]);
4141

4242
const { object } = await generateObject({
4343
model,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
2+
import type { UserWithAccounts } from '@sourcebot/db';
3+
4+
const currentUserStorage = new AsyncLocalStorage<UserWithAccounts | undefined>();
5+
6+
export function runWithCurrentUser<T>(user: UserWithAccounts | undefined, fn: () => T): T {
7+
return currentUserStorage.run(user, fn);
8+
}
9+
10+
export function getCurrentUser(): UserWithAccounts | undefined {
11+
return currentUserStorage.getStore();
12+
}

packages/web/src/middleware/withAuth.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ErrorCode } from '../lib/errorCodes';
99
import { StatusCodes } from 'http-status-codes';
1010
import { userScopedPrismaClientExtension } from '@/prisma';
1111
import { runWithRequestContext } from '@/lib/requestContext';
12+
import { getCurrentUser } from '@/lib/currentUserContext';
1213

1314
const TEST_OAUTH_SCOPE = 'read';
1415

@@ -481,7 +482,9 @@ describe('getAuthContext', () => {
481482
prisma.userToOrg.updateMany.mockResolvedValue({ count: 1 });
482483

483484
setMockSession(createMockSession({ user: { id: userId } }));
484-
const cb = vi.fn();
485+
const cb = vi.fn(async () => {
486+
expect(getCurrentUser()).toMatchObject({ id: userId });
487+
});
485488
const result = await withAuth(cb);
486489

487490
expect(result).toBeUndefined();

0 commit comments

Comments
 (0)