Skip to content

Commit fa7a2b0

Browse files
authored
fix(v9/core): Fix OpenAI SDK private field access by binding non-instrumented fns (#17167)
Backport of #17163
1 parent bf79609 commit fa7a2b0

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)