Skip to content

Commit 8b2c685

Browse files
authored
fix(core): Fix OpenAI SDK private field access by binding non-instrumented fns (#17163)
The OpenAI SDK v5 uses ES private class fields (e.g. #baseURL), which can only be accessed when methods are called with the correct this context. In our instrumentation, methods that are not explicitly instrumented (i.e. skipped by shouldInstrument) were returned unbound, which broke calls to internal OpenAI methods like .parse() by triggering: TypeError: Cannot read private member from an object whose class did not declare it. This PR fixes that by explicitly binding all non-instrumented functions to their original instance (value.bind(obj)), ensuring correct this context and avoiding runtime errors when accessing private fields.
1 parent 8393b79 commit 8b2c685

File tree

2 files changed

+216
-0
lines changed

2 files changed

+216
-0
lines changed

packages/core/src/utils/openai/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ function createDeepProxy(target: object, currentPath = '', options?: OpenAiOptio
264264
return instrumentMethod(value as (...args: unknown[]) => Promise<unknown>, methodPath, obj, options);
265265
}
266266

267+
if (typeof value === 'function') {
268+
// Bind non-instrumented functions to preserve the original `this` context,
269+
// which is required for accessing private class fields (e.g. #baseURL) in OpenAI SDK v5.
270+
return value.bind(obj);
271+
}
272+
267273
if (value && typeof value === 'object') {
268274
return createDeepProxy(value as object, methodPath, options);
269275
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import type { OpenAiClient } from '../../src';
3+
import { instrumentOpenAiClient } from '../../src/utils/openai';
4+
5+
interface FullOpenAIClient {
6+
chat: {
7+
completions: {
8+
create: (params: ChatCompletionParams) => Promise<ChatCompletionResponse>;
9+
parse: (params: ParseCompletionParams) => Promise<ParseCompletionResponse>;
10+
};
11+
};
12+
}
13+
interface ChatCompletionParams {
14+
model: string;
15+
messages: Array<{ role: string; content: string }>;
16+
}
17+
18+
interface ChatCompletionResponse {
19+
id: string;
20+
model: string;
21+
choices: Array<{ message: { content: string } }>;
22+
}
23+
24+
interface ParseCompletionParams {
25+
model: string;
26+
messages: Array<{ role: string; content: string }>;
27+
response_format: {
28+
type: string;
29+
json_schema: {
30+
name: string;
31+
schema: {
32+
type: string;
33+
properties: Record<string, { type: string }>;
34+
};
35+
};
36+
};
37+
}
38+
39+
interface ParseCompletionResponse {
40+
id: string;
41+
model: string;
42+
choices: Array<{
43+
message: {
44+
content: string;
45+
parsed: { name: string; age: number };
46+
};
47+
}>;
48+
parsed: { name: string; age: number };
49+
}
50+
51+
/**
52+
* Mock OpenAI client that simulates the private field behavior
53+
* that causes the "Cannot read private member" error
54+
*/
55+
class MockOpenAIClient implements FullOpenAIClient {
56+
// Simulate private fields using WeakMap (similar to how TypeScript private fields work)
57+
static #privateData = new WeakMap();
58+
59+
// Simulate instrumented methods
60+
chat = {
61+
completions: {
62+
create: async (params: ChatCompletionParams): Promise<ChatCompletionResponse> => {
63+
this.#buildURL('/chat/completions');
64+
return { id: 'test', model: params.model, choices: [{ message: { content: 'Hello!' } }] };
65+
},
66+
67+
// This is NOT instrumented
68+
parse: async (params: ParseCompletionParams): Promise<ParseCompletionResponse> => {
69+
this.#buildURL('/chat/completions');
70+
return {
71+
id: 'test',
72+
model: params.model,
73+
choices: [
74+
{
75+
message: {
76+
content: 'Hello!',
77+
parsed: { name: 'John', age: 30 },
78+
},
79+
},
80+
],
81+
parsed: { name: 'John', age: 30 },
82+
};
83+
},
84+
},
85+
};
86+
87+
constructor() {
88+
MockOpenAIClient.#privateData.set(this, {
89+
apiKey: 'test-key',
90+
baseURL: 'https://api.openai.com',
91+
});
92+
}
93+
94+
// Simulate the buildURL method that accesses private fields
95+
#buildURL(path: string): string {
96+
const data = MockOpenAIClient.#privateData.get(this);
97+
if (!data) {
98+
throw new TypeError('Cannot read private member from an object whose class did not declare it');
99+
}
100+
return `${data.baseURL}${path}`;
101+
}
102+
}
103+
104+
describe('OpenAI Integration Private Field Fix', () => {
105+
let mockClient: MockOpenAIClient;
106+
let instrumentedClient: FullOpenAIClient & OpenAiClient;
107+
108+
beforeEach(() => {
109+
mockClient = new MockOpenAIClient();
110+
instrumentedClient = instrumentOpenAiClient(mockClient as unknown as OpenAiClient) as FullOpenAIClient &
111+
OpenAiClient;
112+
});
113+
114+
it('should work with instrumented methods (chat.completions.create)', async () => {
115+
// This should work because it's instrumented and we handle it properly
116+
const result = await instrumentedClient.chat.completions.create({
117+
model: 'gpt-4',
118+
messages: [{ role: 'user', content: 'test' }],
119+
});
120+
121+
expect(result.model).toBe('gpt-4');
122+
});
123+
124+
it('should work with non-instrumented methods without breaking private fields', async () => {
125+
// The parse method should work now with our fix - previously it would throw:
126+
// "TypeError: Cannot read private member from an object whose class did not declare it"
127+
128+
await expect(
129+
instrumentedClient.chat.completions.parse({
130+
model: 'gpt-4',
131+
messages: [{ role: 'user', content: 'Extract name and age from: John is 30 years old' }],
132+
response_format: {
133+
type: 'json_schema',
134+
json_schema: {
135+
name: 'person',
136+
schema: {
137+
type: 'object',
138+
properties: {
139+
name: { type: 'string' },
140+
age: { type: 'number' },
141+
},
142+
},
143+
},
144+
},
145+
}),
146+
).resolves.toBeDefined();
147+
});
148+
149+
it('should preserve the original context for all method calls', async () => {
150+
// Verify that 'this' context is preserved for instrumented methods
151+
const createResult = await instrumentedClient.chat.completions.create({
152+
model: 'gpt-4',
153+
messages: [{ role: 'user', content: 'test' }],
154+
});
155+
156+
expect(createResult.model).toBe('gpt-4');
157+
158+
// Verify that 'this' context is preserved for non-instrumented methods
159+
const parseResult = await instrumentedClient.chat.completions.parse({
160+
model: 'gpt-4',
161+
messages: [{ role: 'user', content: 'Extract name and age from: John is 30 years old' }],
162+
response_format: {
163+
type: 'json_schema',
164+
json_schema: {
165+
name: 'person',
166+
schema: {
167+
type: 'object',
168+
properties: {
169+
name: { type: 'string' },
170+
age: { type: 'number' },
171+
},
172+
},
173+
},
174+
},
175+
});
176+
177+
expect(parseResult.parsed).toEqual({ name: 'John', age: 30 });
178+
});
179+
180+
it('should handle nested object access correctly', async () => {
181+
expect(typeof instrumentedClient.chat.completions.create).toBe('function');
182+
expect(typeof instrumentedClient.chat.completions.parse).toBe('function');
183+
});
184+
185+
it('should work with non-instrumented methods', async () => {
186+
const result = await instrumentedClient.chat.completions.parse({
187+
model: 'gpt-4',
188+
messages: [{ role: 'user', content: 'Extract name and age from: John is 30 years old' }],
189+
response_format: {
190+
type: 'json_schema',
191+
json_schema: {
192+
name: 'person',
193+
schema: {
194+
type: 'object',
195+
properties: {
196+
name: { type: 'string' },
197+
age: { type: 'number' },
198+
},
199+
},
200+
},
201+
},
202+
});
203+
204+
expect(result.model).toBe('gpt-4');
205+
expect(result.parsed).toEqual({ name: 'John', age: 30 });
206+
207+
// Verify we can access the parse method without issues
208+
expect(typeof instrumentedClient.chat.completions.parse).toBe('function');
209+
});
210+
});

0 commit comments

Comments
 (0)