Skip to content

Commit bfebb26

Browse files
authored
fix(openai): Fix wrapOpenAI breaking native private fields of client (#1704)
Aims to address #1693 - Adds regression tests - Adds a more robust method of returning the accessed functions Please see comments in code for further explanation. We didn't chose the original approach the referenced issue takes (`Reflect.get(..., target)`) because it doesn't work since `this` is still the proxied instance.
1 parent e0a7991 commit bfebb26

File tree

5 files changed

+137
-4
lines changed

5 files changed

+137
-4
lines changed

e2e/scenarios/openai-instrumentation/assertions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ function buildPayloadSummary(events: CapturedLogEvent[]): Json {
427427
}
428428

429429
export function defineOpenAIInstrumentationAssertions(options: {
430+
assertPrivateFieldMethodsOperation?: boolean;
430431
name: string;
431432
runScenario: RunOpenAIScenario;
432433
snapshotName: string;
@@ -466,6 +467,26 @@ export function defineOpenAIInstrumentationAssertions(options: {
466467
});
467468
});
468469

470+
if (options.assertPrivateFieldMethodsOperation) {
471+
test(
472+
"keeps wrapped v6 client private-field methods callable",
473+
testConfig,
474+
() => {
475+
const root = findLatestSpan(events, ROOT_NAME);
476+
const operation = findLatestSpan(
477+
events,
478+
"openai-client-private-fields-operation",
479+
);
480+
481+
expect(operation).toBeDefined();
482+
expect(operation?.row.metadata).toMatchObject({
483+
operation: "client-private-fields",
484+
});
485+
expect(operation?.span.parentIds).toEqual([root?.span.id ?? ""]);
486+
},
487+
);
488+
}
489+
469490
for (const spec of OPERATION_SPECS) {
470491
test(spec.testName, testConfig, () => {
471492
const root = findLatestSpan(events, ROOT_NAME);

e2e/scenarios/openai-instrumentation/scenario.impl.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ async function awaitMaybeWithResponse(request) {
4444
};
4545
}
4646

47+
function parseMajorVersion(version) {
48+
if (typeof version !== "string") {
49+
return null;
50+
}
51+
52+
const major = Number.parseInt(version.split(".")[0], 10);
53+
return Number.isNaN(major) ? null : major;
54+
}
55+
4756
export async function runOpenAIInstrumentationScenario(options) {
4857
const baseClient = new options.OpenAI({
4958
apiKey: process.env.OPENAI_API_KEY,
@@ -52,9 +61,51 @@ export async function runOpenAIInstrumentationScenario(options) {
5261
const client = options.decorateClient
5362
? options.decorateClient(baseClient)
5463
: baseClient;
64+
const openAIMajorVersion = parseMajorVersion(options.openaiSdkVersion);
65+
const shouldCheckPrivateFieldMethods =
66+
typeof options.decorateClient === "function" &&
67+
openAIMajorVersion !== null &&
68+
openAIMajorVersion >= 6;
5569

5670
await runTracedScenario({
5771
callback: async () => {
72+
if (shouldCheckPrivateFieldMethods) {
73+
await runOperation(
74+
"openai-client-private-fields-operation",
75+
"client-private-fields",
76+
async () => {
77+
if (
78+
typeof client.buildURL !== "function" ||
79+
typeof client.buildRequest !== "function"
80+
) {
81+
throw new Error(
82+
"Expected wrapped OpenAI v6 client to expose buildURL and buildRequest",
83+
);
84+
}
85+
86+
const builtUrl = client.buildURL("/files", null);
87+
if (typeof builtUrl !== "string" || !builtUrl.includes("/files")) {
88+
throw new Error(
89+
`Unexpected buildURL result: ${String(builtUrl)}`,
90+
);
91+
}
92+
93+
const builtRequest = await client.buildRequest(
94+
{ method: "post", path: "/files" },
95+
{ retryCount: 0 },
96+
);
97+
if (
98+
typeof builtRequest?.url !== "string" ||
99+
!builtRequest.url.includes("/files")
100+
) {
101+
throw new Error(
102+
`Unexpected buildRequest result: ${String(builtRequest?.url)}`,
103+
);
104+
}
105+
},
106+
);
107+
}
108+
58109
await runOperation("openai-chat-operation", "chat", async () => {
59110
await client.chat.completions.create({
60111
model: OPENAI_MODEL,

e2e/scenarios/openai-instrumentation/scenario.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ const openaiScenarios = await Promise.all(
1414
[
1515
{
1616
autoEntry: "scenario.openai-v4.mjs",
17+
disablePrivateFieldMethodsAssertion: true,
1718
dependencyName: "openai-v4",
1819
snapshotName: "openai-v4",
1920
wrapperEntry: "scenario.openai-v4.ts",
2021
},
2122
{
2223
autoEntry: "scenario.openai-v5.mjs",
24+
disablePrivateFieldMethodsAssertion: true,
2325
dependencyName: "openai-v5",
2426
snapshotName: "openai-v5",
2527
wrapperEntry: "scenario.openai-v5.ts",
@@ -40,8 +42,12 @@ const openaiScenarios = await Promise.all(
4042
);
4143

4244
for (const scenario of openaiScenarios) {
45+
const assertPrivateFieldMethodsOperation =
46+
!scenario.disablePrivateFieldMethodsAssertion;
47+
4348
describe(`openai sdk ${scenario.version}`, () => {
4449
defineOpenAIInstrumentationAssertions({
50+
assertPrivateFieldMethodsOperation,
4551
name: "wrapped instrumentation",
4652
runScenario: async ({ runScenarioDir }) => {
4753
await runScenarioDir({

js/src/wrappers/oai.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ test("openai is installed", () => {
3535
assert.ok(OpenAI);
3636
});
3737

38+
test("wrapOpenAI keeps private-field helper methods callable", async () => {
39+
const wrapped = wrapOpenAI(new OpenAI({ apiKey: "test-key" }));
40+
41+
expect(wrapped.buildURL).toBe(wrapped.buildURL);
42+
expect(wrapped.buildRequest).toBe(wrapped.buildRequest);
43+
expect(() => wrapped.buildURL("/files", null)).not.toThrow();
44+
45+
const request = await wrapped.buildRequest(
46+
{ method: "post", path: "/files" },
47+
{ retryCount: 0 },
48+
);
49+
expect(request.url).toContain("/files");
50+
});
51+
3852
describe("openai client unit tests", TEST_SUITE_OPTIONS, () => {
3953
let oai: OpenAI;
4054
let client: OpenAI;

js/src/wrappers/oai.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ type OpenAILike = OpenAIV4Client;
7474
export function wrapOpenAIv4<T extends OpenAILike>(openai: T): T {
7575
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
7676
const typedOpenai = openai as OpenAIV4Client;
77+
// Recover `this` for fallback methods so private fields and internal slots
78+
// keep seeing the original OpenAI instance instead of the proxy.
79+
const privateMethodWorkaroundCache = new WeakMap<
80+
(...args: unknown[]) => unknown,
81+
(...args: unknown[]) => unknown
82+
>();
7783

7884
const completionProxy = new Proxy(typedOpenai.chat.completions, {
7985
get(target, name, receiver) {
@@ -142,8 +148,8 @@ export function wrapOpenAIv4<T extends OpenAILike>(openai: T): T {
142148
}
143149

144150
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
145-
return new Proxy(typedOpenai, {
146-
get(target, name, receiver) {
151+
const topLevelProxy = new Proxy(typedOpenai, {
152+
get(target, name) {
147153
switch (name) {
148154
case "chat":
149155
return chatProxy;
@@ -158,9 +164,44 @@ export function wrapOpenAIv4<T extends OpenAILike>(openai: T): T {
158164
if (name === "beta" && betaProxy) {
159165
return betaProxy;
160166
}
161-
return Reflect.get(target, name, receiver);
167+
168+
// The following rather convoluted code is a workaround for https://github.com/braintrustdata/braintrust-sdk-javascript/issues/1693
169+
// The problem is that Proxies are inherently difficult to work with native private class fields because when a
170+
// class function accesses a private field, JS checks whether `this` is equal to the actual instance with the
171+
// private field and if that's not the case, it throws a `TypeError`.
172+
// We could have also done `if (typeof value === "function") return value.bind(target);`, but it would have
173+
// created a new function on each function access, so we are caching, and also it would have always stomped on
174+
// someone passing another `this` which may clash with different instrumentations.
175+
176+
// Use the real client as receiver when reading fallback members.
177+
const value = Reflect.get(target, name, target);
178+
if (typeof value !== "function") {
179+
return value;
180+
}
181+
182+
const cachedValue = privateMethodWorkaroundCache.get(value);
183+
if (cachedValue) {
184+
return cachedValue;
185+
}
186+
187+
const thisBoundValue = function (
188+
this: unknown,
189+
...args: unknown[]
190+
): unknown {
191+
// Calling through the proxy would set `this` to the proxy and break
192+
// native private-field methods, so recover the original target.
193+
const thisArg = this === topLevelProxy ? target : this;
194+
const output = Reflect.apply(value, thisArg, args);
195+
// Preserve chaining on wrapped clients (method returns `this`).
196+
return output === target ? topLevelProxy : output;
197+
};
198+
199+
privateMethodWorkaroundCache.set(value, thisBoundValue);
200+
return thisBoundValue;
162201
},
163-
}) as T;
202+
});
203+
204+
return topLevelProxy as T;
164205
}
165206

166207
type SpanInfo = {

0 commit comments

Comments
 (0)