Skip to content

Commit 0bef40a

Browse files
committed
feat(client): add ._request_id property to object responses
1 parent e26f31c commit 0bef40a

File tree

3 files changed

+153
-17
lines changed

3 files changed

+153
-17
lines changed

src/core.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ type APIResponseProps = {
3737
controller: AbortController;
3838
};
3939

40-
async function defaultParseResponse<T>(props: APIResponseProps): Promise<T> {
40+
async function defaultParseResponse<T>(props: APIResponseProps): Promise<WithRequestID<T>> {
4141
const { response } = props;
4242
if (props.options.stream) {
4343
debug('response', response.status, response.url, response.headers, response.body);
@@ -54,11 +54,11 @@ async function defaultParseResponse<T>(props: APIResponseProps): Promise<T> {
5454

5555
// fetch refuses to read the body when the status code is 204.
5656
if (response.status === 204) {
57-
return null as T;
57+
return null as WithRequestID<T>;
5858
}
5959

6060
if (props.options.__binaryResponse) {
61-
return response as unknown as T;
61+
return response as unknown as WithRequestID<T>;
6262
}
6363

6464
const contentType = response.headers.get('content-type');
@@ -69,26 +69,44 @@ async function defaultParseResponse<T>(props: APIResponseProps): Promise<T> {
6969

7070
debug('response', response.status, response.url, response.headers, json);
7171

72-
return json as T;
72+
return _addRequestID(json, response);
7373
}
7474

7575
const text = await response.text();
7676
debug('response', response.status, response.url, response.headers, text);
7777

7878
// TODO handle blob, arraybuffer, other content types, etc.
79-
return text as unknown as T;
79+
return text as unknown as WithRequestID<T>;
80+
}
81+
82+
type WithRequestID<T> =
83+
T extends Array<any> | Response | AbstractPage<any> ? T
84+
: T extends Record<string, any> ? T & { _request_id?: string | null }
85+
: T;
86+
87+
function _addRequestID<T>(value: T, response: Response): WithRequestID<T> {
88+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
89+
return value as WithRequestID<T>;
90+
}
91+
92+
return Object.defineProperty(value, '_request_id', {
93+
value: response.headers.get('x-request-id'),
94+
enumerable: false,
95+
}) as WithRequestID<T>;
8096
}
8197

8298
/**
8399
* A subclass of `Promise` providing additional helper methods
84100
* for interacting with the SDK.
85101
*/
86-
export class APIPromise<T> extends Promise<T> {
87-
private parsedPromise: Promise<T> | undefined;
102+
export class APIPromise<T> extends Promise<WithRequestID<T>> {
103+
private parsedPromise: Promise<WithRequestID<T>> | undefined;
88104

89105
constructor(
90106
private responsePromise: Promise<APIResponseProps>,
91-
private parseResponse: (props: APIResponseProps) => PromiseOrValue<T> = defaultParseResponse,
107+
private parseResponse: (
108+
props: APIResponseProps,
109+
) => PromiseOrValue<WithRequestID<T>> = defaultParseResponse,
92110
) {
93111
super((resolve) => {
94112
// this is maybe a bit weird but this has to be a no-op to not implicitly
@@ -99,7 +117,9 @@ export class APIPromise<T> extends Promise<T> {
99117
}
100118

101119
_thenUnwrap<U>(transform: (data: T) => U): APIPromise<U> {
102-
return new APIPromise(this.responsePromise, async (props) => transform(await this.parseResponse(props)));
120+
return new APIPromise(this.responsePromise, async (props) =>
121+
_addRequestID(transform(await this.parseResponse(props)), props.response),
122+
);
103123
}
104124

105125
/**
@@ -136,27 +156,27 @@ export class APIPromise<T> extends Promise<T> {
136156
return { data, response };
137157
}
138158

139-
private parse(): Promise<T> {
159+
private parse(): Promise<WithRequestID<T>> {
140160
if (!this.parsedPromise) {
141-
this.parsedPromise = this.responsePromise.then(this.parseResponse);
161+
this.parsedPromise = this.responsePromise.then(this.parseResponse) as any as Promise<WithRequestID<T>>;
142162
}
143163
return this.parsedPromise;
144164
}
145165

146-
override then<TResult1 = T, TResult2 = never>(
147-
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
166+
override then<TResult1 = WithRequestID<T>, TResult2 = never>(
167+
onfulfilled?: ((value: WithRequestID<T>) => TResult1 | PromiseLike<TResult1>) | undefined | null,
148168
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
149169
): Promise<TResult1 | TResult2> {
150170
return this.parse().then(onfulfilled, onrejected);
151171
}
152172

153173
override catch<TResult = never>(
154174
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null,
155-
): Promise<T | TResult> {
175+
): Promise<WithRequestID<T> | TResult> {
156176
return this.parse().catch(onrejected);
157177
}
158178

159-
override finally(onfinally?: (() => void) | undefined | null): Promise<T> {
179+
override finally(onfinally?: (() => void) | undefined | null): Promise<WithRequestID<T>> {
160180
return this.parse().finally(onfinally);
161181
}
162182
}
@@ -706,7 +726,13 @@ export class PagePromise<
706726
) {
707727
super(
708728
request,
709-
async (props) => new Page(client, props.response, await defaultParseResponse(props), props.options),
729+
async (props) =>
730+
new Page(
731+
client,
732+
props.response,
733+
await defaultParseResponse(props),
734+
props.options,
735+
) as WithRequestID<PageClass>,
710736
);
711737
}
712738

tests/responses.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { createResponseHeaders } from 'openai/core';
1+
import { APIPromise, createResponseHeaders } from 'openai/core';
2+
import OpenAI from 'openai/index';
23
import { Headers } from 'openai/_shims/index';
4+
import { Response } from 'node-fetch';
5+
import { compareType } from './utils/typing';
36

47
describe('response parsing', () => {
58
// TODO: test unicode characters
@@ -23,3 +26,101 @@ describe('response parsing', () => {
2326
expect(headers['content-type']).toBe('text/xml, application/json');
2427
});
2528
});
29+
30+
describe('request id', () => {
31+
test('types', () => {
32+
compareType<Awaited<APIPromise<string>>, string>(true);
33+
compareType<Awaited<APIPromise<number>>, number>(true);
34+
compareType<Awaited<APIPromise<null>>, null>(true);
35+
compareType<Awaited<APIPromise<void>>, void>(true);
36+
compareType<Awaited<APIPromise<Response>>, Response>(true);
37+
compareType<Awaited<APIPromise<Response>>, Response>(true);
38+
compareType<Awaited<APIPromise<{ foo: string }>>, { foo: string } & { _request_id?: string | null }>(
39+
true,
40+
);
41+
compareType<Awaited<APIPromise<Array<{ foo: string }>>>, Array<{ foo: string }>>(true);
42+
});
43+
44+
test('object response', async () => {
45+
const client = new OpenAI({
46+
apiKey: 'dummy',
47+
fetch: async () =>
48+
new Response(JSON.stringify({ id: 'bar' }), {
49+
headers: { 'x-request-id': 'req_id_xxx', 'content-type': 'application/json' },
50+
}),
51+
});
52+
53+
const rsp = await client.chat.completions.create({ messages: [], model: 'gpt-4' });
54+
expect(rsp.id).toBe('bar');
55+
expect(rsp._request_id).toBe('req_id_xxx');
56+
});
57+
58+
test('envelope response', async () => {
59+
const promise = new APIPromise<{ data: { foo: string } }>(
60+
(async () => {
61+
return {
62+
response: new Response(JSON.stringify({ data: { foo: 'bar' } }), {
63+
headers: { 'x-request-id': 'req_id_xxx', 'content-type': 'application/json' },
64+
}),
65+
controller: {} as any,
66+
options: {} as any,
67+
};
68+
})(),
69+
)._thenUnwrap((d) => d.data);
70+
71+
const rsp = await promise;
72+
expect(rsp.foo).toBe('bar');
73+
expect(rsp._request_id).toBe('req_id_xxx');
74+
});
75+
76+
test('page response', async () => {
77+
const client = new OpenAI({
78+
apiKey: 'dummy',
79+
fetch: async () =>
80+
new Response(JSON.stringify({ data: [{ foo: 'bar' }] }), {
81+
headers: { 'x-request-id': 'req_id_xxx', 'content-type': 'application/json' },
82+
}),
83+
});
84+
85+
const page = await client.fineTuning.jobs.list();
86+
expect(page.data).toMatchObject([{ foo: 'bar' }]);
87+
expect((page as any)._request_id).toBeUndefined();
88+
});
89+
90+
test('array response', async () => {
91+
const promise = new APIPromise<Array<{ foo: string }>>(
92+
(async () => {
93+
return {
94+
response: new Response(JSON.stringify([{ foo: 'bar' }]), {
95+
headers: { 'x-request-id': 'req_id_xxx', 'content-type': 'application/json' },
96+
}),
97+
controller: {} as any,
98+
options: {} as any,
99+
};
100+
})(),
101+
);
102+
103+
const rsp = await promise;
104+
expect(rsp.length).toBe(1);
105+
expect(rsp[0]).toMatchObject({ foo: 'bar' });
106+
expect((rsp as any)._request_id).toBeUndefined();
107+
});
108+
109+
test('string response', async () => {
110+
const promise = new APIPromise<string>(
111+
(async () => {
112+
return {
113+
response: new Response('hello world', {
114+
headers: { 'x-request-id': 'req_id_xxx', 'content-type': 'application/text' },
115+
}),
116+
controller: {} as any,
117+
options: {} as any,
118+
};
119+
})(),
120+
);
121+
122+
const result = await promise;
123+
expect(result).toBe('hello world');
124+
expect((result as any)._request_id).toBeUndefined();
125+
});
126+
});

tests/utils/typing.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
2+
3+
export const expectType = <T>(_expression: T): void => {
4+
return;
5+
};
6+
7+
export const compareType = <T1, T2>(_expression: Equal<T1, T2>): void => {
8+
return;
9+
};

0 commit comments

Comments
 (0)