Skip to content

Commit ab9933b

Browse files
committed
Refactor AI request headers for greater flexibility
Replaced `AI_CLIENT_ID` and `AI_USERNAME` environment variables with a single `AI_REQUEST_HEADERS` variable. This new variable accepts a JSON string, allowing users to define arbitrary custom headers for AI provider requests. This change enhances the extensibility of AI provider integrations. Additionally, improved error handling for AI API call failures by providing more concise error messages and removing excessive logging of truncated response bodies.
1 parent 71e97b0 commit ab9933b

File tree

5 files changed

+37
-67
lines changed

5 files changed

+37
-67
lines changed

packages/api/src/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,7 @@ export const AI_PROVIDER = env.AI_PROVIDER as string; // 'anthropic' | 'openai'
5353
export const AI_API_KEY = env.AI_API_KEY as string;
5454
export const AI_BASE_URL = env.AI_BASE_URL as string;
5555
export const AI_MODEL_NAME = env.AI_MODEL_NAME as string;
56-
export const AI_CLIENT_ID = env.AI_CLIENT_ID as string;
57-
export const AI_USERNAME = env.AI_USERNAME as string;
56+
export const AI_REQUEST_HEADERS = env.AI_REQUEST_HEADERS as string;
5857

5958
// Legacy Anthropic-specific configuration (backward compatibility)
6059
export const ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY as string;

packages/api/src/controllers/__tests__/ai.test.ts

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,22 @@ const mockOpenAIModel = {
88
modelId: 'gpt-4o',
99
} as unknown as LanguageModel;
1010

11-
const mockAnthropicFactory = jest.fn(() => mockAnthropicModel);
12-
const mockCreateAnthropic = jest.fn(() => mockAnthropicFactory);
11+
const mockAnthropicFactory = jest.fn((_model?: string) => mockAnthropicModel);
12+
const mockCreateAnthropic = jest.fn(
13+
(_opts?: Record<string, unknown>) => mockAnthropicFactory,
14+
);
1315

14-
const mockOpenAIChatFactory = jest.fn(() => mockOpenAIModel);
15-
const mockCreateOpenAI = jest.fn(() => ({
16+
const mockOpenAIChatFactory = jest.fn((_model?: string) => mockOpenAIModel);
17+
const mockCreateOpenAI = jest.fn((_opts?: Record<string, unknown>) => ({
1618
chat: mockOpenAIChatFactory,
1719
}));
1820

1921
jest.mock('@ai-sdk/anthropic', () => ({
20-
createAnthropic: (...args: unknown[]) => mockCreateAnthropic(...args),
22+
createAnthropic: (opts: Record<string, unknown>) => mockCreateAnthropic(opts),
2123
}));
2224

2325
jest.mock('@ai-sdk/openai', () => ({
24-
createOpenAI: (...args: unknown[]) => mockCreateOpenAI(...args),
26+
createOpenAI: (opts: Record<string, unknown>) => mockCreateOpenAI(opts),
2527
}));
2628

2729
jest.mock('@/utils/logger', () => ({
@@ -208,69 +210,43 @@ describe('openai provider', () => {
208210
);
209211
});
210212

211-
describe('custom headers', () => {
212-
it('adds X-Client-Id header when AI_CLIENT_ID is set', () => {
213+
describe('AI_REQUEST_HEADERS', () => {
214+
it('passes parsed headers to createOpenAI', () => {
213215
setConfig({
214216
AI_PROVIDER: 'openai',
215217
AI_API_KEY: 'sk-test',
216218
AI_MODEL_NAME: 'gpt-4o',
217-
AI_CLIENT_ID: 'MyApp',
219+
AI_REQUEST_HEADERS: '{"X-Custom":"val1","X-Other":"val2"}',
218220
});
219221
getAIModel();
220222
expect(mockCreateOpenAI).toHaveBeenCalledWith(
221223
expect.objectContaining({
222-
headers: expect.objectContaining({
223-
'X-Client-Id': 'MyApp',
224-
}),
224+
headers: { 'X-Custom': 'val1', 'X-Other': 'val2' },
225225
}),
226226
);
227227
});
228228

229-
it('adds X-Username header when AI_USERNAME is set', () => {
229+
it('throws when AI_REQUEST_HEADERS is invalid JSON', () => {
230230
setConfig({
231231
AI_PROVIDER: 'openai',
232232
AI_API_KEY: 'sk-test',
233233
AI_MODEL_NAME: 'gpt-4o',
234-
AI_USERNAME: 'testuser',
234+
AI_REQUEST_HEADERS: '{bad',
235235
});
236-
getAIModel();
237-
expect(mockCreateOpenAI).toHaveBeenCalledWith(
238-
expect.objectContaining({
239-
headers: expect.objectContaining({
240-
'X-Username': 'testuser',
241-
}),
242-
}),
243-
);
244-
});
245-
246-
it('adds both headers when both are set', () => {
247-
setConfig({
248-
AI_PROVIDER: 'openai',
249-
AI_API_KEY: 'sk-test',
250-
AI_MODEL_NAME: 'gpt-4o',
251-
AI_CLIENT_ID: 'MyApp',
252-
AI_USERNAME: 'testuser',
253-
});
254-
getAIModel();
255-
expect(mockCreateOpenAI).toHaveBeenCalledWith(
256-
expect.objectContaining({
257-
headers: {
258-
'X-Client-Id': 'MyApp',
259-
'X-Username': 'testuser',
260-
},
261-
}),
236+
expect(() => getAIModel()).toThrow(
237+
'AI_REQUEST_HEADERS is not valid JSON',
262238
);
263239
});
264240

265-
it('omits headers object when neither is set', () => {
241+
it('omits headers when AI_REQUEST_HEADERS is not set', () => {
266242
setConfig({
267243
AI_PROVIDER: 'openai',
268244
AI_API_KEY: 'sk-test',
269245
AI_MODEL_NAME: 'gpt-4o',
270246
});
271247
getAIModel();
272-
const call = mockCreateOpenAI.mock.calls[0][0];
273-
expect(call.headers).toBeUndefined();
248+
const call = mockCreateOpenAI.mock.calls[0]?.[0];
249+
expect(call?.headers).toBeUndefined();
274250
});
275251
});
276252
});

packages/api/src/controllers/ai.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import z from 'zod';
1717

1818
import * as config from '@/config';
1919
import { ISource } from '@/models/source';
20+
import { parseJSON } from '@/utils/common';
2021
import { Api500Error } from '@/utils/errors';
2122
import logger from '@/utils/logger';
2223

@@ -385,13 +386,12 @@ function getOpenAIModel(): LanguageModel {
385386
);
386387
}
387388

388-
const headers: Record<string, string> = {};
389-
if (config.AI_CLIENT_ID) {
390-
headers['X-Client-Id'] = config.AI_CLIENT_ID;
391-
}
392-
if (config.AI_USERNAME) {
393-
headers['X-Username'] = config.AI_USERNAME;
394-
}
389+
const headers: Record<string, string> = config.AI_REQUEST_HEADERS
390+
? parseJSON<Record<string, string>>(
391+
config.AI_REQUEST_HEADERS,
392+
'AI_REQUEST_HEADERS',
393+
)
394+
: {};
395395

396396
const openai = createOpenAI({
397397
apiKey,

packages/api/src/routers/api/ai.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,9 @@ ${JSON.stringify(allFieldsWithKeys.slice(0, 200).map(f => ({ field: f.key, type:
110110
return res.json(chartConfig);
111111
} catch (err) {
112112
if (err instanceof APICallError) {
113-
const truncatedBody =
114-
typeof err.responseBody === 'string' &&
115-
err.responseBody.length > 500
116-
? err.responseBody.slice(0, 500) + '...[truncated]'
117-
: err.responseBody;
118-
logger.error(
119-
{
120-
statusCode: err.statusCode,
121-
responseBody: truncatedBody,
122-
url: err.url,
123-
isRetryable: err.isRetryable,
124-
cause: err.cause instanceof Error ? err.cause.message : undefined,
125-
},
126-
'AI API call failed',
113+
throw new Api500Error(
114+
`AI Provider Error. Status: ${err.statusCode}. Message: ${err.message}`,
127115
);
128-
throw new Api500Error(`AI Provider Error: ${err.message}`);
129116
}
130117
throw err;
131118
}

packages/api/src/utils/common.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export const tryJSONStringify = (json: Json) => {
2727
return result;
2828
};
2929

30+
export function parseJSON<T = unknown>(raw: string, label: string): T {
31+
try {
32+
return JSON.parse(raw) as T;
33+
} catch (e) {
34+
throw new Error(`${label} is not valid JSON: ${(e as Error).message}`);
35+
}
36+
}
37+
3038
export const truncateString = (str: string, length: number) => {
3139
if (str.length > length) {
3240
return str.substring(0, length) + '...';

0 commit comments

Comments
 (0)