Skip to content

Commit c035a2d

Browse files
authored
feat(openai/responses): support streaming (#127)
1 parent afe2fc0 commit c035a2d

File tree

5 files changed

+983
-10
lines changed

5 files changed

+983
-10
lines changed

gateway/src/api/responses.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import type {
88
ResponseCreateParams,
99
ResponseInputItem,
1010
ResponseOutputItem,
11+
ResponseStreamEvent,
1112
} from 'openai/resources/responses/responses'
1213
import { match, P } from 'ts-pattern'
1314
import type { ChatMessage, InputMessages, MessagePart, OutputMessage, OutputMessages } from '../otel/genai'
14-
import { BaseAPI } from './base'
15+
import { BaseAPI, type ExtractedRequest, type ExtractedResponse, type ExtractorConfig } from './base'
1516

16-
export class ResponsesAPI extends BaseAPI<ResponseCreateParams, Response> {
17+
export class ResponsesAPI extends BaseAPI<ResponseCreateParams, Response, ResponseStreamEvent> {
1718
apiFlavor = 'responses'
1819

1920
// The Responses API does not support stop sequences.
@@ -47,6 +48,79 @@ export class ResponsesAPI extends BaseAPI<ResponseCreateParams, Response> {
4748
outputMessages = (responseBody: Response): OutputMessages | undefined => {
4849
return responseBody.output.map(mapOutputMessage)
4950
}
51+
52+
// SafeExtractor implementation
53+
54+
requestExtractors: ExtractorConfig<ResponseCreateParams, ExtractedRequest> = {
55+
requestModel: (requestBody: ResponseCreateParams) => {
56+
this.extractedRequest.requestModel = requestBody.model ?? undefined
57+
},
58+
maxTokens: (requestBody: ResponseCreateParams) => {
59+
this.extractedRequest.maxTokens = requestBody.max_output_tokens ?? undefined
60+
},
61+
temperature: (requestBody: ResponseCreateParams) => {
62+
this.extractedRequest.temperature = requestBody.temperature ?? undefined
63+
},
64+
topP: (requestBody: ResponseCreateParams) => {
65+
this.extractedRequest.topP = requestBody.top_p ?? undefined
66+
},
67+
inputMessages: (requestBody: ResponseCreateParams) => {
68+
this.extractedRequest.inputMessages = this.inputMessages(requestBody)
69+
},
70+
}
71+
72+
responseExtractors: ExtractorConfig<Response, ExtractedResponse> = {
73+
usage: (responseBody: Response) => {
74+
if (responseBody.usage) {
75+
this.extractedResponse.usage = this.extractUsage(responseBody)
76+
}
77+
},
78+
responseModel: (responseBody: Response) => {
79+
this.extractedResponse.responseModel = responseBody.model ?? undefined
80+
},
81+
responseId: (responseBody: Response) => {
82+
this.extractedResponse.responseId = responseBody.id ?? undefined
83+
},
84+
finishReasons: (responseBody: Response) => {
85+
this.extractedResponse.finishReasons = responseBody.incomplete_details?.reason
86+
? [responseBody.incomplete_details.reason]
87+
: undefined
88+
},
89+
outputMessages: (responseBody: Response) => {
90+
this.extractedResponse.outputMessages = responseBody.output.map(mapOutputMessage)
91+
},
92+
}
93+
94+
chunkExtractors: ExtractorConfig<ResponseStreamEvent, ExtractedResponse> = {
95+
usage: (chunk: ResponseStreamEvent) => {
96+
if ('response' in chunk) {
97+
if (chunk.response?.usage) {
98+
this.extractedResponse.usage = this.extractUsage(chunk.response)
99+
}
100+
}
101+
},
102+
responseModel: (chunk: ResponseStreamEvent) => {
103+
if ('response' in chunk) {
104+
if (chunk.response?.model) {
105+
this.extractedResponse.responseModel = chunk.response.model
106+
}
107+
}
108+
},
109+
responseId: (chunk: ResponseStreamEvent) => {
110+
if ('response' in chunk) {
111+
if (chunk.response?.id) {
112+
this.extractedResponse.responseId = chunk.response.id
113+
}
114+
}
115+
},
116+
finishReasons: (chunk: ResponseStreamEvent) => {
117+
if ('response' in chunk) {
118+
if (chunk.response?.incomplete_details?.reason) {
119+
this.extractedResponse.finishReasons = [chunk.response.incomplete_details.reason]
120+
}
121+
}
122+
},
123+
}
50124
}
51125

52126
function mapInputMessage(input: ResponseInputItem): ChatMessage {

gateway/test/providers/openai.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,26 @@ describe('openai', () => {
172172
expect(deserializeRequest(otelBatch[0]!)).toMatchSnapshot('span')
173173
})
174174

175+
test('openai responses stream', async ({ gateway }) => {
176+
const { fetch, otelBatch } = gateway
177+
178+
const client = new OpenAI({ apiKey: 'healthy', baseURL: 'https://example.com/responses', fetch })
179+
180+
const stream = await client.responses.create({
181+
model: 'gpt-5',
182+
instructions: 'reply concisely',
183+
input: 'what color is the sky?',
184+
stream: true,
185+
})
186+
const chunks: object[] = []
187+
for await (const chunk of stream) {
188+
chunks.push(chunk)
189+
}
190+
expect(chunks).toMatchSnapshot('chunks')
191+
expect(otelBatch, 'otelBatch length not 1').toHaveLength(1)
192+
expect(deserializeRequest(otelBatch[0]!)).toMatchSnapshot('span')
193+
})
194+
175195
test('openai chat legacy name', async ({ gateway }) => {
176196
const { fetch, otelBatch } = gateway
177197

0 commit comments

Comments
 (0)