diff --git a/src/client.ts b/src/client.ts index d77a85ba4..38f4a501d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -330,7 +330,9 @@ export class OpenAI { fetchOptions: MergedRequestInit | undefined; private fetch: Fetch; - #encoder: Opts.RequestEncoder; + // Use a normal private field instead of JS #private to avoid + // brand-check crashes when methods are invoked across copies. + private encoder: Opts.RequestEncoder; protected idempotencyHeader?: string; protected _options: ClientOptions; @@ -392,7 +394,7 @@ export class OpenAI { this.fetchOptions = options.fetchOptions; this.maxRetries = options.maxRetries ?? 2; this.fetch = options.fetch ?? Shims.getDefaultFetch(); - this.#encoder = Opts.FallbackEncoder; + this.encoder = Opts.FallbackEncoder; this._options = options; @@ -427,7 +429,7 @@ export class OpenAI { /** * Check whether the base URL is set to its default. */ - #baseURLOverridden(): boolean { + private baseURLOverridden(): boolean { return this.baseURL !== 'https://api.openai.com/v1'; } @@ -494,7 +496,7 @@ export class OpenAI { query: Record | null | undefined, defaultBaseURL?: string | undefined, ): string { - const baseURL = (!this.#baseURLOverridden() && defaultBaseURL) || this.baseURL; + const baseURL = (!this.baseURLOverridden() && defaultBaseURL) || this.baseURL; const url = isAbsoluteURL(path) ? new URL(path) @@ -960,7 +962,7 @@ export class OpenAI { ) { return { bodyHeaders: undefined, body: Shims.ReadableStreamFrom(body as AsyncIterable) }; } else { - return this.#encoder({ body, headers }); + return this.encoder({ body, headers }); } } diff --git a/src/core/api-promise.ts b/src/core/api-promise.ts index 9e6c756c8..769e539f4 100644 --- a/src/core/api-promise.ts +++ b/src/core/api-promise.ts @@ -1,6 +1,7 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import { type OpenAI } from '../client'; +import { OpenAIError } from './error'; import { type PromiseOrValue } from '../internal/types'; import { @@ -14,9 +15,12 @@ import { * A subclass of `Promise` providing additional helper methods * for interacting with the SDK. */ +// Associate instance with client via a module WeakMap to avoid +// JS private-field brand checks across bundles/copies. +const apiPromiseClient = /* @__PURE__ */ new WeakMap(); + export class APIPromise extends Promise> { private parsedPromise: Promise> | undefined; - #client: OpenAI; constructor( client: OpenAI, @@ -32,11 +36,13 @@ export class APIPromise extends Promise> { // to parse the response resolve(null as any); }); - this.#client = client; + apiPromiseClient.set(this, client); } _thenUnwrap(transform: (data: T, props: APIResponseProps) => U): APIPromise { - return new APIPromise(this.#client, this.responsePromise, async (client, props) => + const client = apiPromiseClient.get(this); + if (!client) throw new OpenAIError('Illegal invocation of APIPromise method'); + return new APIPromise(client, this.responsePromise, async (client, props) => addRequestID(transform(await this.parseResponse(client, props), props), props.response), ); } @@ -75,8 +81,10 @@ export class APIPromise extends Promise> { private parse(): Promise> { if (!this.parsedPromise) { + const client = apiPromiseClient.get(this); + if (!client) throw new OpenAIError('Illegal invocation of APIPromise method'); this.parsedPromise = this.responsePromise.then((data) => - this.parseResponse(this.#client, data), + this.parseResponse(client, data), ) as any as Promise>; } return this.parsedPromise; diff --git a/src/core/pagination.ts b/src/core/pagination.ts index 9bef26447..eeedabdd8 100644 --- a/src/core/pagination.ts +++ b/src/core/pagination.ts @@ -10,15 +10,18 @@ import { maybeObj } from '../internal/utils/values'; export type PageRequestOptions = Pick; +// Associate pages with their client via a module WeakMap to avoid +// JS private-field brand checks across bundles/copies. +const pageClient = /* @__PURE__ */ new WeakMap(); + export abstract class AbstractPage implements AsyncIterable { - #client: OpenAI; protected options: FinalRequestOptions; protected response: Response; protected body: unknown; constructor(client: OpenAI, response: Response, body: unknown, options: FinalRequestOptions) { - this.#client = client; + pageClient.set(this, client); this.options = options; this.response = response; this.body = body; @@ -42,7 +45,9 @@ export abstract class AbstractPage implements AsyncIterable { ); } - return await this.#client.requestAPIList(this.constructor as any, nextOptions); + const client = pageClient.get(this); + if (!client) throw new OpenAIError('Illegal invocation of Page method'); + return await client.requestAPIList(this.constructor as any, nextOptions); } async *iterPages(): AsyncGenerator { diff --git a/src/core/streaming.ts b/src/core/streaming.ts index efd854046..01cd3dd38 100644 --- a/src/core/streaming.ts +++ b/src/core/streaming.ts @@ -18,9 +18,12 @@ export type ServerSentEvent = { raw: string[]; }; +// Associate Stream instances with their client via a module WeakMap to avoid +// JS private-field brand checks across bundles/copies. +const streamClient = /* @__PURE__ */ new WeakMap(); + export class Stream implements AsyncIterable { controller: AbortController; - #client: OpenAI | undefined; constructor( private iterator: () => AsyncIterator, @@ -28,7 +31,7 @@ export class Stream implements AsyncIterable { client?: OpenAI, ) { this.controller = controller; - this.#client = client; + streamClient.set(this, client); } static fromSSEResponse( @@ -75,8 +78,8 @@ export class Stream implements AsyncIterable { try { data = JSON.parse(sse.data); } catch (e) { - console.error(`Could not parse message into JSON:`, sse.data); - console.error(`From chunk:`, sse.raw); + logger.error(`Could not parse message into JSON:`, sse.data); + logger.error(`From chunk:`, sse.raw); throw e; } // TODO: Is this where the error should be thrown? @@ -177,9 +180,10 @@ export class Stream implements AsyncIterable { }; }; + const client = streamClient.get(this); return [ - new Stream(() => teeIterator(left), this.controller, this.#client), - new Stream(() => teeIterator(right), this.controller, this.#client), + new Stream(() => teeIterator(left), this.controller, client), + new Stream(() => teeIterator(right), this.controller, client), ]; } diff --git a/tests/core/brand-guards.test.ts b/tests/core/brand-guards.test.ts new file mode 100644 index 000000000..8a10012d8 --- /dev/null +++ b/tests/core/brand-guards.test.ts @@ -0,0 +1,65 @@ +import { APIPromise } from 'openai/core/api-promise'; +import { AbstractPage, Page } from 'openai/core/pagination'; +import { Stream } from 'openai/core/streaming'; + +const dummyResponseProps: any = { response: new Response(), options: {} }; +const dummyParse = (_client: any, _props: any) => ({ data: null, response: new Response() }); + +describe('core brand-guard stability', () => { + test('APIPromise.then with mismatched this does not throw private-field TypeError', async () => { + const fake: any = Object.create(APIPromise.prototype); + fake.responsePromise = Promise.resolve(dummyResponseProps); + fake.parseResponse = dummyParse; + + const call = () => (APIPromise.prototype.then as any).call(fake, () => {}); + expect(call).toThrow(Error); + expect(call).not.toThrow(/private member/i); + }); + + test('APIPromise.catch with mismatched this does not throw private-field TypeError', async () => { + const fake: any = Object.create(APIPromise.prototype); + fake.responsePromise = Promise.resolve(dummyResponseProps); + fake.parseResponse = dummyParse; + + const call = () => (APIPromise.prototype.catch as any).call(fake, () => {}); + expect(call).toThrow(Error); + expect(call).not.toThrow(/private member/i); + }); + + test('APIPromise.finally with mismatched this does not throw private-field TypeError', async () => { + const fake: any = Object.create(APIPromise.prototype); + fake.responsePromise = Promise.resolve(dummyResponseProps); + fake.parseResponse = dummyParse; + + const call = () => (APIPromise.prototype.finally as any).call(fake, () => {}); + expect(call).toThrow(Error); + expect(call).not.toThrow(/private member/i); + }); + + test('AbstractPage.getNextPage with mismatched this does not throw private-field TypeError', async () => { + class TestPage extends Page { + override nextPageRequestOptions() { + return { path: '/v1/anything', method: 'get' } as any; + } + } + + const fake: any = Object.create(TestPage.prototype); + fake.options = { path: '/v1/anything', method: 'get' }; + fake.getPaginatedItems = () => [1]; + fake.response = new Response(); + fake.body = {}; + + const call = () => (AbstractPage.prototype.getNextPage as any).call(fake); + await expect(call()).rejects.toBeInstanceOf(Error); + await expect(call()).rejects.not.toThrow(/private member/i); + }); + + test('Stream.tee with mismatched this does not throw private-field TypeError', () => { + const fake: any = Object.create(Stream.prototype); + fake.controller = new AbortController(); + fake.iterator = async function* () {}; + + const call = () => (Stream.prototype.tee as any).call(fake); + expect(call).not.toThrow(/private member/i); + }); +});