Skip to content

Commit 47e1f56

Browse files
authored
feat: Add OpenAI provider support for AI assistance (#1960)
1 parent 2207edb commit 47e1f56

File tree

8 files changed

+335
-6
lines changed

8 files changed

+335
-6
lines changed

.changeset/nine-zoos-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
feat: Add OpenAI provider support for AI assistance

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
},
99
"dependencies": {
1010
"@ai-sdk/anthropic": "^3.0.58",
11+
"@ai-sdk/openai": "^3.0.47",
1112
"@esm2cjs/p-queue": "^7.3.0",
1213
"@hyperdx/common-utils": "^0.16.1",
1314
"@hyperdx/node-opentelemetry": "^0.9.0",

packages/api/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +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_REQUEST_HEADERS = env.AI_REQUEST_HEADERS as string;
5657

5758
// Legacy Anthropic-specific configuration (backward compatibility)
5859
export const ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY as string;
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import type { LanguageModel } from 'ai';
2+
3+
const mockAnthropicModel = {
4+
modelId: 'claude-sonnet-4-5-20250929',
5+
} as unknown as LanguageModel;
6+
7+
const mockOpenAIModel = {
8+
modelId: 'gpt-4o',
9+
} as unknown as LanguageModel;
10+
11+
const mockAnthropicFactory = jest.fn((_model?: string) => mockAnthropicModel);
12+
const mockCreateAnthropic = jest.fn(
13+
(_opts?: Record<string, unknown>) => mockAnthropicFactory,
14+
);
15+
16+
const mockOpenAIChatFactory = jest.fn((_model?: string) => mockOpenAIModel);
17+
const mockCreateOpenAI = jest.fn((_opts?: Record<string, unknown>) => ({
18+
chat: mockOpenAIChatFactory,
19+
}));
20+
21+
jest.mock('@ai-sdk/anthropic', () => ({
22+
createAnthropic: (opts: Record<string, unknown>) => mockCreateAnthropic(opts),
23+
}));
24+
25+
jest.mock('@ai-sdk/openai', () => ({
26+
createOpenAI: (opts: Record<string, unknown>) => mockCreateOpenAI(opts),
27+
}));
28+
29+
jest.mock('@/utils/logger', () => ({
30+
__esModule: true,
31+
default: {
32+
info: jest.fn(),
33+
warn: jest.fn(),
34+
error: jest.fn(),
35+
},
36+
}));
37+
38+
const mockConfig: Record<string, unknown> = { __esModule: true };
39+
40+
jest.mock('@/config', () => mockConfig);
41+
42+
function setConfig(overrides: Record<string, string | undefined>) {
43+
Object.keys(mockConfig).forEach(k => {
44+
if (k !== '__esModule') delete mockConfig[k];
45+
});
46+
Object.assign(mockConfig, overrides);
47+
}
48+
49+
import { getAIModel } from '@/controllers/ai';
50+
51+
beforeEach(() => {
52+
setConfig({});
53+
jest.clearAllMocks();
54+
});
55+
56+
describe('getAIModel', () => {
57+
describe('provider routing', () => {
58+
it('throws when no provider is configured', () => {
59+
expect(() => getAIModel()).toThrow(
60+
'No AI provider configured. Set AI_PROVIDER and AI_API_KEY environment variables.',
61+
);
62+
});
63+
64+
it('throws on unknown provider', () => {
65+
setConfig({ AI_PROVIDER: 'gemini' });
66+
expect(() => getAIModel()).toThrow(
67+
'Unknown AI provider: gemini. Currently supported: anthropic, openai',
68+
);
69+
});
70+
71+
it('routes to anthropic when AI_PROVIDER=anthropic', () => {
72+
setConfig({
73+
AI_PROVIDER: 'anthropic',
74+
AI_API_KEY: 'sk-test',
75+
});
76+
const model = getAIModel();
77+
expect(model).toBe(mockAnthropicModel);
78+
expect(mockCreateAnthropic).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it('routes to openai when AI_PROVIDER=openai', () => {
82+
setConfig({
83+
AI_PROVIDER: 'openai',
84+
AI_API_KEY: 'sk-test',
85+
AI_MODEL_NAME: 'gpt-4o',
86+
});
87+
const model = getAIModel();
88+
expect(model).toBe(mockOpenAIModel);
89+
expect(mockCreateOpenAI).toHaveBeenCalledTimes(1);
90+
});
91+
});
92+
93+
describe('legacy anthropic support', () => {
94+
it('falls back to anthropic when ANTHROPIC_API_KEY is set without AI_PROVIDER', () => {
95+
setConfig({
96+
ANTHROPIC_API_KEY: 'sk-ant-legacy',
97+
});
98+
const model = getAIModel();
99+
expect(model).toBe(mockAnthropicModel);
100+
expect(mockCreateAnthropic).toHaveBeenCalledWith(
101+
expect.objectContaining({ apiKey: 'sk-ant-legacy' }),
102+
);
103+
});
104+
});
105+
});
106+
107+
describe('anthropic provider', () => {
108+
it('throws when no API key is set', () => {
109+
setConfig({ AI_PROVIDER: 'anthropic' });
110+
expect(() => getAIModel()).toThrow(
111+
'No API key defined for Anthropic. Set AI_API_KEY or ANTHROPIC_API_KEY.',
112+
);
113+
});
114+
115+
it('uses AI_API_KEY over ANTHROPIC_API_KEY', () => {
116+
setConfig({
117+
AI_PROVIDER: 'anthropic',
118+
AI_API_KEY: 'sk-new',
119+
ANTHROPIC_API_KEY: 'sk-old',
120+
});
121+
getAIModel();
122+
expect(mockCreateAnthropic).toHaveBeenCalledWith(
123+
expect.objectContaining({ apiKey: 'sk-new' }),
124+
);
125+
});
126+
127+
it('passes baseURL when AI_BASE_URL is set', () => {
128+
setConfig({
129+
AI_PROVIDER: 'anthropic',
130+
AI_API_KEY: 'sk-test',
131+
AI_BASE_URL: 'https://custom.endpoint.com',
132+
});
133+
getAIModel();
134+
expect(mockCreateAnthropic).toHaveBeenCalledWith(
135+
expect.objectContaining({
136+
apiKey: 'sk-test',
137+
baseURL: 'https://custom.endpoint.com',
138+
}),
139+
);
140+
});
141+
142+
it('uses default model when AI_MODEL_NAME is not set', () => {
143+
setConfig({
144+
AI_PROVIDER: 'anthropic',
145+
AI_API_KEY: 'sk-test',
146+
});
147+
getAIModel();
148+
expect(mockAnthropicFactory).toHaveBeenCalledWith(
149+
'claude-sonnet-4-5-20250929',
150+
);
151+
});
152+
153+
it('uses custom model name when AI_MODEL_NAME is set', () => {
154+
setConfig({
155+
AI_PROVIDER: 'anthropic',
156+
AI_API_KEY: 'sk-test',
157+
AI_MODEL_NAME: 'claude-3-haiku-20240307',
158+
});
159+
getAIModel();
160+
expect(mockAnthropicFactory).toHaveBeenCalledWith(
161+
'claude-3-haiku-20240307',
162+
);
163+
});
164+
});
165+
166+
describe('openai provider', () => {
167+
it('throws when no API key is set', () => {
168+
setConfig({ AI_PROVIDER: 'openai' });
169+
expect(() => getAIModel()).toThrow(
170+
'No API key defined for OpenAI provider. Set AI_API_KEY.',
171+
);
172+
});
173+
174+
it('throws when no model name is set', () => {
175+
setConfig({
176+
AI_PROVIDER: 'openai',
177+
AI_API_KEY: 'sk-test',
178+
});
179+
expect(() => getAIModel()).toThrow(
180+
'No model name configured for OpenAI provider. Set AI_MODEL_NAME',
181+
);
182+
});
183+
184+
it('creates provider with minimal config', () => {
185+
setConfig({
186+
AI_PROVIDER: 'openai',
187+
AI_API_KEY: 'sk-test',
188+
AI_MODEL_NAME: 'gpt-4o',
189+
});
190+
getAIModel();
191+
expect(mockCreateOpenAI).toHaveBeenCalledWith(
192+
expect.objectContaining({ apiKey: 'sk-test' }),
193+
);
194+
expect(mockOpenAIChatFactory).toHaveBeenCalledWith('gpt-4o');
195+
});
196+
197+
it('passes baseURL when AI_BASE_URL is set', () => {
198+
setConfig({
199+
AI_PROVIDER: 'openai',
200+
AI_API_KEY: 'sk-test',
201+
AI_MODEL_NAME: 'gpt-4o',
202+
AI_BASE_URL: 'https://proxy.example.com/v1',
203+
});
204+
getAIModel();
205+
expect(mockCreateOpenAI).toHaveBeenCalledWith(
206+
expect.objectContaining({
207+
apiKey: 'sk-test',
208+
baseURL: 'https://proxy.example.com/v1',
209+
}),
210+
);
211+
});
212+
213+
describe('AI_REQUEST_HEADERS', () => {
214+
it('passes parsed headers to createOpenAI', () => {
215+
setConfig({
216+
AI_PROVIDER: 'openai',
217+
AI_API_KEY: 'sk-test',
218+
AI_MODEL_NAME: 'gpt-4o',
219+
AI_REQUEST_HEADERS: '{"X-Custom":"val1","X-Other":"val2"}',
220+
});
221+
getAIModel();
222+
expect(mockCreateOpenAI).toHaveBeenCalledWith(
223+
expect.objectContaining({
224+
headers: { 'X-Custom': 'val1', 'X-Other': 'val2' },
225+
}),
226+
);
227+
});
228+
229+
it('throws when AI_REQUEST_HEADERS is invalid JSON', () => {
230+
setConfig({
231+
AI_PROVIDER: 'openai',
232+
AI_API_KEY: 'sk-test',
233+
AI_MODEL_NAME: 'gpt-4o',
234+
AI_REQUEST_HEADERS: '{bad',
235+
});
236+
expect(() => getAIModel()).toThrow(
237+
'AI_REQUEST_HEADERS is not valid JSON',
238+
);
239+
});
240+
241+
it('omits headers when AI_REQUEST_HEADERS is not set', () => {
242+
setConfig({
243+
AI_PROVIDER: 'openai',
244+
AI_API_KEY: 'sk-test',
245+
AI_MODEL_NAME: 'gpt-4o',
246+
});
247+
getAIModel();
248+
const call = mockCreateOpenAI.mock.calls[0]?.[0];
249+
expect(call?.headers).toBeUndefined();
250+
});
251+
});
252+
});

packages/api/src/controllers/ai.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createAnthropic } from '@ai-sdk/anthropic';
2+
import { createOpenAI } from '@ai-sdk/openai';
23
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
34
import {
45
getMetadata,
@@ -16,6 +17,7 @@ import z from 'zod';
1617

1718
import * as config from '@/config';
1819
import { ISource } from '@/models/source';
20+
import { parseJSON } from '@/utils/common';
1921
import { Api500Error } from '@/utils/errors';
2022
import logger from '@/utils/logger';
2123

@@ -60,14 +62,11 @@ export function getAIModel(): LanguageModel {
6062
return getAnthropicModel();
6163

6264
case 'openai':
63-
throw new Error(
64-
`Provider '${provider}' is not yet supported. Currently only 'anthropic' is available. ` +
65-
'Support for additional providers can be added in the future.',
66-
);
65+
return getOpenAIModel();
6766

6867
default:
6968
throw new Error(
70-
`Unknown AI provider: ${provider}. Currently supported: anthropic`,
69+
`Unknown AI provider: ${provider}. Currently supported: anthropic, openai`,
7170
);
7271
}
7372
}
@@ -367,3 +366,38 @@ function getAnthropicModel(): LanguageModel {
367366

368367
return anthropic(modelName);
369368
}
369+
370+
/**
371+
* Configure OpenAI-compatible model.
372+
* Works with any OpenAI Chat Completions-compatible endpoint
373+
* (e.g. Azure OpenAI, OpenRouter, LiteLLM proxies).
374+
*/
375+
function getOpenAIModel(): LanguageModel {
376+
const apiKey = config.AI_API_KEY;
377+
378+
if (!apiKey) {
379+
throw new Error('No API key defined for OpenAI provider. Set AI_API_KEY.');
380+
}
381+
382+
if (!config.AI_MODEL_NAME) {
383+
throw new Error(
384+
'No model name configured for OpenAI provider. Set AI_MODEL_NAME ' +
385+
'(e.g. "gpt-4o", "claude-sonnet-4-5-20250929" for LiteLLM proxies).',
386+
);
387+
}
388+
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+
: {};
395+
396+
const openai = createOpenAI({
397+
apiKey,
398+
...(config.AI_BASE_URL && { baseURL: config.AI_BASE_URL }),
399+
...(Object.keys(headers).length > 0 && { headers }),
400+
});
401+
402+
return openai.chat(config.AI_MODEL_NAME);
403+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +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-
throw new Api500Error(`AI Provider Error: ${err.message}`);
113+
throw new Api500Error(
114+
`AI Provider Error. Status: ${err.statusCode}. Message: ${err.message}`,
115+
);
114116
}
115117
throw err;
116118
}

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)