@adminforth/completion-adapter-openai-responses 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.woodpecker/buildRelease.sh +9 -0
- package/.woodpecker/buildSlackNotify.sh +44 -0
- package/.woodpecker/release.yml +56 -0
- package/Changelog.md +6 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/build.log +4 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +613 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +1 -0
- package/index.ts +818 -0
- package/package.json +62 -0
- package/tsconfig.json +14 -0
- package/types.ts +42 -0
package/index.ts
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import type { AdapterOptions } from "./types.js";
|
|
2
|
+
import type {
|
|
3
|
+
CompletionAdapter,
|
|
4
|
+
CompletionStreamEvent,
|
|
5
|
+
CompletionTool,
|
|
6
|
+
} from "adminforth";
|
|
7
|
+
import { AIMessage } from "@langchain/core/messages";
|
|
8
|
+
import { ChatOpenAI } from "@langchain/openai";
|
|
9
|
+
import { createMiddleware } from "langchain";
|
|
10
|
+
import { encoding_for_model, type TiktokenModel } from "tiktoken";
|
|
11
|
+
import type OpenAI from "openai";
|
|
12
|
+
|
|
13
|
+
export type { AdapterOptions } from "./types.js";
|
|
14
|
+
|
|
15
|
+
type StreamChunkCallback = (
|
|
16
|
+
chunk: string,
|
|
17
|
+
event?: CompletionStreamEvent,
|
|
18
|
+
) => void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
type ReasoningEffort =
|
|
21
|
+
| "none"
|
|
22
|
+
| "minimal"
|
|
23
|
+
| "low"
|
|
24
|
+
| "medium"
|
|
25
|
+
| "high"
|
|
26
|
+
| "xhigh";
|
|
27
|
+
|
|
28
|
+
type AgentModelPurpose = "primary" | "summary";
|
|
29
|
+
|
|
30
|
+
type CompletionRequestInput = {
|
|
31
|
+
content: string;
|
|
32
|
+
maxTokens?: number;
|
|
33
|
+
outputSchema?: any;
|
|
34
|
+
reasoningEffort?: ReasoningEffort;
|
|
35
|
+
tools?: CompletionTool[];
|
|
36
|
+
onChunk?: StreamChunkCallback;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ResponseCreateBody = OpenAI.Responses.ResponseCreateParams;
|
|
41
|
+
type OpenAIResponsesSuccess = OpenAI.Responses.Response;
|
|
42
|
+
type OpenAIErrorResponse = {
|
|
43
|
+
error?: {
|
|
44
|
+
message?: string;
|
|
45
|
+
type?: string;
|
|
46
|
+
param?: string | null;
|
|
47
|
+
code?: string | null;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
type OpenAITool = OpenAI.Responses.Tool;
|
|
51
|
+
type OpenAIFunctionCall = Extract<
|
|
52
|
+
OpenAI.Responses.ResponseOutputItem,
|
|
53
|
+
{ type: "function_call" }
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
type OpenAiResponsesMetadata = {
|
|
57
|
+
id?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type OpenAiResponsesContext = {
|
|
61
|
+
sessionId: string;
|
|
62
|
+
turnId: string;
|
|
63
|
+
abortSignal?: AbortSignal;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type UsedTokens = {
|
|
67
|
+
input_uncached: number;
|
|
68
|
+
input_cached: number;
|
|
69
|
+
output: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type CompletionResult = {
|
|
73
|
+
content?: string;
|
|
74
|
+
finishReason?: string;
|
|
75
|
+
error?: string;
|
|
76
|
+
used_tokens?: UsedTokens;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
80
|
+
const RAW_REQUEST_LOG_PREFIX = "[CompletionAdapterOpenAIResponses] Raw /responses request";
|
|
81
|
+
|
|
82
|
+
type FetchInput = Parameters<typeof fetch>[0];
|
|
83
|
+
type FetchInit = Parameters<typeof fetch>[1];
|
|
84
|
+
|
|
85
|
+
function extractOutputText(data: OpenAIResponsesSuccess): string {
|
|
86
|
+
let text = "";
|
|
87
|
+
|
|
88
|
+
for (const item of data.output ?? []) {
|
|
89
|
+
if (item.type !== "message" || !Array.isArray(item.content)) continue;
|
|
90
|
+
for (const part of item.content) {
|
|
91
|
+
if (part.type === "output_text" && typeof part.text === "string") {
|
|
92
|
+
text += part.text;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractReasoning(data: OpenAIResponsesSuccess): string | undefined {
|
|
101
|
+
let reasoning = "";
|
|
102
|
+
|
|
103
|
+
for (const item of data.output ?? []) {
|
|
104
|
+
if (item.type !== "reasoning") continue;
|
|
105
|
+
|
|
106
|
+
for (const part of item.summary ?? []) {
|
|
107
|
+
if (part?.type === "summary_text" && typeof part.text === "string") {
|
|
108
|
+
reasoning += part.text;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!reasoning) {
|
|
113
|
+
for (const part of item.content ?? []) {
|
|
114
|
+
if (part?.type === "reasoning_text" && typeof part.text === "string") {
|
|
115
|
+
reasoning += part.text;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return reasoning || undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractFunctionCall(
|
|
125
|
+
data: OpenAIResponsesSuccess,
|
|
126
|
+
): OpenAIFunctionCall | undefined {
|
|
127
|
+
for (const item of data.output ?? []) {
|
|
128
|
+
if (item.type === "function_call") {
|
|
129
|
+
return item;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractUsedTokens(data: OpenAIResponsesSuccess): UsedTokens | undefined {
|
|
137
|
+
const usage = data.usage;
|
|
138
|
+
if (!usage) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const inputCached = usage.input_tokens_details?.cached_tokens ?? 0;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
input_uncached: Math.max(usage.input_tokens - inputCached, 0),
|
|
146
|
+
input_cached: inputCached,
|
|
147
|
+
output: usage.output_tokens,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function executeToolCall(
|
|
152
|
+
toolCall: OpenAIFunctionCall,
|
|
153
|
+
tools?: CompletionTool[],
|
|
154
|
+
): Promise<string> {
|
|
155
|
+
const tool = tools?.find((candidate) => candidate.name === toolCall.name);
|
|
156
|
+
if (!tool) {
|
|
157
|
+
throw new Error(`Tool "${toolCall.name}" not found`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const toolResult = await tool.handler(JSON.parse(toolCall.arguments));
|
|
161
|
+
if (typeof toolResult === "string") return toolResult;
|
|
162
|
+
if (typeof toolResult === "undefined") return "";
|
|
163
|
+
return JSON.stringify(toolResult);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseSseBlock(block: string) {
|
|
167
|
+
let event: string | undefined;
|
|
168
|
+
let data = "";
|
|
169
|
+
|
|
170
|
+
for (const rawLine of block.split("\n")) {
|
|
171
|
+
const line = rawLine.trimEnd();
|
|
172
|
+
if (!line) continue;
|
|
173
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
174
|
+
if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return data ? { event, data } : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getAgentReasoningEffort(
|
|
181
|
+
purpose: AgentModelPurpose,
|
|
182
|
+
): Exclude<ReasoningEffort, "none"> {
|
|
183
|
+
return purpose === "summary" ? "minimal" : "low";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildReasoningConfig(params: {
|
|
187
|
+
reasoning?: Record<string, unknown>;
|
|
188
|
+
effort: Exclude<ReasoningEffort, "none"> | ReasoningEffort;
|
|
189
|
+
}) {
|
|
190
|
+
return {
|
|
191
|
+
summary: "auto",
|
|
192
|
+
effort: params.effort,
|
|
193
|
+
...(params.reasoning ?? {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getTurnKey(context: OpenAiResponsesContext) {
|
|
198
|
+
return `${context.sessionId}:${context.turnId}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getResponseId(message: AIMessage) {
|
|
202
|
+
const metadata = message.response_metadata as OpenAiResponsesMetadata | undefined;
|
|
203
|
+
return metadata?.id ?? null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getPreviousResponseId(modelSettings?: Record<string, unknown>) {
|
|
207
|
+
return (modelSettings as { previous_response_id?: string } | undefined)
|
|
208
|
+
?.previous_response_id;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getContinuationMessages<T extends { response_metadata?: unknown }>(
|
|
212
|
+
messages: T[],
|
|
213
|
+
previousResponseId: string,
|
|
214
|
+
) {
|
|
215
|
+
let continuationStartIndex: number | null = null;
|
|
216
|
+
|
|
217
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
218
|
+
const message = messages[index];
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
AIMessage.isInstance(message) &&
|
|
222
|
+
(message.response_metadata as OpenAiResponsesMetadata | undefined)?.id ===
|
|
223
|
+
previousResponseId
|
|
224
|
+
) {
|
|
225
|
+
continuationStartIndex = index + 1;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (continuationStartIndex === null) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return messages.slice(continuationStartIndex);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createOpenAiResponsesContinuationMiddleware() {
|
|
238
|
+
const responseIdsByTurn = new Map<string, string>();
|
|
239
|
+
|
|
240
|
+
return createMiddleware({
|
|
241
|
+
name: "OpenAiResponsesContinuationMiddleware",
|
|
242
|
+
async wrapModelCall(request, handler) {
|
|
243
|
+
const context = request.runtime.context as OpenAiResponsesContext;
|
|
244
|
+
const turnKey = getTurnKey(context);
|
|
245
|
+
const previousResponseId =
|
|
246
|
+
getPreviousResponseId(request.modelSettings) ??
|
|
247
|
+
responseIdsByTurn.get(turnKey);
|
|
248
|
+
const continuationMessages = previousResponseId
|
|
249
|
+
? getContinuationMessages(request.messages, previousResponseId)
|
|
250
|
+
: null;
|
|
251
|
+
|
|
252
|
+
const response = (await handler(
|
|
253
|
+
previousResponseId && continuationMessages
|
|
254
|
+
? {
|
|
255
|
+
...request,
|
|
256
|
+
messages: continuationMessages,
|
|
257
|
+
modelSettings: {
|
|
258
|
+
...request.modelSettings,
|
|
259
|
+
previous_response_id: previousResponseId,
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
: request,
|
|
263
|
+
)) as AIMessage;
|
|
264
|
+
|
|
265
|
+
const responseId = getResponseId(response);
|
|
266
|
+
|
|
267
|
+
if (responseId) {
|
|
268
|
+
responseIdsByTurn.set(turnKey, responseId);
|
|
269
|
+
} else {
|
|
270
|
+
responseIdsByTurn.delete(turnKey);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return response;
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default class CompletionAdapterOpenAIResponses
|
|
279
|
+
implements CompletionAdapter
|
|
280
|
+
{
|
|
281
|
+
options: AdapterOptions;
|
|
282
|
+
private encoding: ReturnType<typeof encoding_for_model>;
|
|
283
|
+
private activeAbortController: AbortController | null = null;
|
|
284
|
+
|
|
285
|
+
constructor(options: AdapterOptions) {
|
|
286
|
+
this.options = options;
|
|
287
|
+
try {
|
|
288
|
+
this.encoding = encoding_for_model(
|
|
289
|
+
(this.options.model || "gpt-5-nano") as TiktokenModel,
|
|
290
|
+
);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
// console.warn(
|
|
293
|
+
// `Failed to initialize tiktoken tokenizer for model "${this.options.model}", falling back to "gpt-5-nano". Error:`,
|
|
294
|
+
// );
|
|
295
|
+
this.encoding = encoding_for_model("gpt-5-nano" as TiktokenModel);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
validate() {
|
|
300
|
+
if (!this.options.openAiApiKey) {
|
|
301
|
+
throw new Error("openAiApiKey is required");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
measureTokensCount(content: string): number {
|
|
306
|
+
return this.encoding.encode(content).length;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
abort() {
|
|
310
|
+
this.activeAbortController?.abort();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
isGenerationInProgress() {
|
|
314
|
+
return Boolean(this.activeAbortController);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private getConfiguredBaseUrl() {
|
|
318
|
+
return this.options.baseUrl;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private shouldUseComplitionApi() {
|
|
322
|
+
if (typeof this.options.useComplitionApi === "boolean") {
|
|
323
|
+
return this.options.useComplitionApi;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return Boolean(this.getConfiguredBaseUrl());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private shouldDumpRawRequest() {
|
|
330
|
+
return this.options.dumpRawRequest === true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private getClientConfiguration() {
|
|
334
|
+
const configuredBaseUrl = this.getConfiguredBaseUrl();
|
|
335
|
+
const debugFetch = this.shouldDumpRawRequest()
|
|
336
|
+
? this.createResponsesDebugFetch()
|
|
337
|
+
: undefined;
|
|
338
|
+
|
|
339
|
+
if (!configuredBaseUrl && !debugFetch) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
...(configuredBaseUrl ? { baseURL: configuredBaseUrl } : {}),
|
|
345
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private createResponsesDebugFetch() {
|
|
350
|
+
return async (input: FetchInput, init?: FetchInit) => {
|
|
351
|
+
const url = this.getFetchUrl(input);
|
|
352
|
+
|
|
353
|
+
if (this.isResponsesUrl(url) && typeof init?.body === "string") {
|
|
354
|
+
this.dumpRawRequest(url, init.body);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return fetch(input, init);
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private getFetchUrl(input: FetchInput) {
|
|
362
|
+
if (typeof input === "string") {
|
|
363
|
+
return input;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (input instanceof URL) {
|
|
367
|
+
return input.toString();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return input.url;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private isResponsesUrl(url: string) {
|
|
374
|
+
try {
|
|
375
|
+
return new URL(url).pathname.endsWith("/responses");
|
|
376
|
+
} catch {
|
|
377
|
+
return url.endsWith("/responses") || url.includes("/responses?");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private dumpRawRequest(url: string, body: string) {
|
|
382
|
+
console.info(`${RAW_REQUEST_LOG_PREFIX} ${url}`);
|
|
383
|
+
try {
|
|
384
|
+
console.info(JSON.stringify(JSON.parse(body), null, 2));
|
|
385
|
+
} catch {
|
|
386
|
+
console.info(body);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private getResponsesUrl() {
|
|
391
|
+
const baseUrl = this.getConfiguredBaseUrl() || DEFAULT_OPENAI_BASE_URL;
|
|
392
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
393
|
+
|
|
394
|
+
return new URL("responses", normalizedBaseUrl).toString();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
getLangChainAgentSpec(params: {
|
|
398
|
+
maxTokens: number;
|
|
399
|
+
purpose: AgentModelPurpose;
|
|
400
|
+
}) {
|
|
401
|
+
const extraRequestBodyParameters =
|
|
402
|
+
(this.options.extraRequestBodyParameters || {}) as Record<string, unknown> & {
|
|
403
|
+
reasoning?: Record<string, unknown>;
|
|
404
|
+
text?: Record<string, unknown>;
|
|
405
|
+
};
|
|
406
|
+
const { reasoning, ...modelKwargs } = extraRequestBodyParameters;
|
|
407
|
+
const configuredBaseUrl = this.getConfiguredBaseUrl();
|
|
408
|
+
const normalizedModelKwargs = { ...modelKwargs };
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
const clientConfiguration = this.getClientConfiguration();
|
|
412
|
+
const useComplitionApi = this.shouldUseComplitionApi();
|
|
413
|
+
const chatOpenAiOptions: Record<string, unknown> = {
|
|
414
|
+
model: this.options.model || "gpt-5-nano",
|
|
415
|
+
apiKey: this.options.openAiApiKey,
|
|
416
|
+
maxTokens: params.maxTokens,
|
|
417
|
+
reasoning: buildReasoningConfig({
|
|
418
|
+
reasoning,
|
|
419
|
+
effort: getAgentReasoningEffort(params.purpose),
|
|
420
|
+
}),
|
|
421
|
+
modelKwargs: normalizedModelKwargs,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
chatOpenAiOptions.useResponsesApi = !useComplitionApi;
|
|
425
|
+
|
|
426
|
+
let supportsResponseContinuation = true;
|
|
427
|
+
if (configuredBaseUrl || useComplitionApi) {
|
|
428
|
+
supportsResponseContinuation = false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (clientConfiguration) {
|
|
432
|
+
chatOpenAiOptions.configuration = clientConfiguration;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
model: new ChatOpenAI(chatOpenAiOptions as any),
|
|
437
|
+
middleware:
|
|
438
|
+
params.purpose === "primary" && supportsResponseContinuation
|
|
439
|
+
? [createOpenAiResponsesContinuationMiddleware()]
|
|
440
|
+
: [],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
complete = async (
|
|
445
|
+
requestOrContent: CompletionRequestInput | string,
|
|
446
|
+
maxTokens = 50,
|
|
447
|
+
outputSchema?: any,
|
|
448
|
+
reasoningEffort: ReasoningEffort = "low",
|
|
449
|
+
toolsOrOnChunk?: CompletionTool[] | StreamChunkCallback,
|
|
450
|
+
onChunk?: StreamChunkCallback,
|
|
451
|
+
): Promise<CompletionResult> => {
|
|
452
|
+
const request =
|
|
453
|
+
typeof requestOrContent === "string"
|
|
454
|
+
? {
|
|
455
|
+
content: requestOrContent,
|
|
456
|
+
maxTokens,
|
|
457
|
+
outputSchema,
|
|
458
|
+
reasoningEffort,
|
|
459
|
+
tools: Array.isArray(toolsOrOnChunk) ? toolsOrOnChunk : undefined,
|
|
460
|
+
onChunk:
|
|
461
|
+
typeof toolsOrOnChunk === "function"
|
|
462
|
+
? toolsOrOnChunk
|
|
463
|
+
: onChunk,
|
|
464
|
+
}
|
|
465
|
+
: requestOrContent;
|
|
466
|
+
const {
|
|
467
|
+
content,
|
|
468
|
+
maxTokens: requestMaxTokens = 50,
|
|
469
|
+
outputSchema: requestOutputSchema,
|
|
470
|
+
reasoningEffort: requestReasoningEffort = "low",
|
|
471
|
+
tools,
|
|
472
|
+
onChunk: streamChunkCallback,
|
|
473
|
+
signal: requestSignal,
|
|
474
|
+
} = request;
|
|
475
|
+
const model = this.options.model || "gpt-5-nano";
|
|
476
|
+
const isStreaming = typeof streamChunkCallback === "function";
|
|
477
|
+
const extra =
|
|
478
|
+
this.options.extraRequestBodyParameters as
|
|
479
|
+
| (Record<string, unknown> & { reasoning?: Record<string, unknown> })
|
|
480
|
+
| undefined;
|
|
481
|
+
const { reasoning: extraReasoning, ...extraWithoutReasoning } = extra ?? {};
|
|
482
|
+
let openAiTools: OpenAITool[] | undefined = undefined;
|
|
483
|
+
if (tools && tools.length > 0) {
|
|
484
|
+
openAiTools = tools.map((tool) => ({
|
|
485
|
+
type: "function",
|
|
486
|
+
name: tool.name,
|
|
487
|
+
description: tool.description,
|
|
488
|
+
parameters: tool.input_schema,
|
|
489
|
+
strict: false,
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const body = {
|
|
494
|
+
model,
|
|
495
|
+
input: content,
|
|
496
|
+
max_output_tokens: requestMaxTokens,
|
|
497
|
+
stream: isStreaming,
|
|
498
|
+
text: requestOutputSchema
|
|
499
|
+
? {
|
|
500
|
+
format: {
|
|
501
|
+
type: "json_schema",
|
|
502
|
+
...requestOutputSchema,
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
: {
|
|
506
|
+
format: {
|
|
507
|
+
type: "text",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
reasoning: {
|
|
511
|
+
...buildReasoningConfig({
|
|
512
|
+
reasoning: extraReasoning,
|
|
513
|
+
effort: requestReasoningEffort,
|
|
514
|
+
}),
|
|
515
|
+
},
|
|
516
|
+
tools: openAiTools,
|
|
517
|
+
...extraWithoutReasoning,
|
|
518
|
+
} as ResponseCreateBody;
|
|
519
|
+
|
|
520
|
+
const serializedBody = JSON.stringify(body);
|
|
521
|
+
|
|
522
|
+
if (this.shouldDumpRawRequest()) {
|
|
523
|
+
this.dumpRawRequest(this.getResponsesUrl(), serializedBody);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const abortController = new AbortController();
|
|
527
|
+
this.activeAbortController = abortController;
|
|
528
|
+
const abortFromRequestSignal = () => abortController.abort(requestSignal?.reason);
|
|
529
|
+
|
|
530
|
+
if (requestSignal?.aborted) {
|
|
531
|
+
abortController.abort(requestSignal.reason);
|
|
532
|
+
} else {
|
|
533
|
+
requestSignal?.addEventListener("abort", abortFromRequestSignal, { once: true });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let resp: Response | null = null;
|
|
537
|
+
try {
|
|
538
|
+
resp = await fetch(this.getResponsesUrl(), {
|
|
539
|
+
method: "POST",
|
|
540
|
+
headers: {
|
|
541
|
+
"Content-Type": "application/json",
|
|
542
|
+
Authorization: `Bearer ${this.options.openAiApiKey}`,
|
|
543
|
+
},
|
|
544
|
+
body: serializedBody,
|
|
545
|
+
signal: abortController.signal,
|
|
546
|
+
});
|
|
547
|
+
} catch (error: any) {
|
|
548
|
+
if (this.activeAbortController === abortController) {
|
|
549
|
+
this.activeAbortController = null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (abortController.signal.aborted) {
|
|
553
|
+
return {
|
|
554
|
+
error: error?.message || "Generation aborted",
|
|
555
|
+
finishReason: "aborted",
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
error: error?.message || "OpenAI request failed",
|
|
561
|
+
};
|
|
562
|
+
} finally {
|
|
563
|
+
requestSignal?.removeEventListener("abort", abortFromRequestSignal);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (!resp) {
|
|
567
|
+
if (this.activeAbortController === abortController) {
|
|
568
|
+
this.activeAbortController = null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
error: "OpenAI request failed",
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
if (!resp.ok) {
|
|
578
|
+
let errorMessage = `OpenAI request failed with status ${resp.status}`;
|
|
579
|
+
try {
|
|
580
|
+
const errorData = (await resp.json()) as OpenAIErrorResponse;
|
|
581
|
+
if (errorData.error?.message) errorMessage = errorData.error.message;
|
|
582
|
+
} catch {}
|
|
583
|
+
return { error: errorMessage };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (!isStreaming) {
|
|
587
|
+
const json = await resp.json();
|
|
588
|
+
const data = json as OpenAIResponsesSuccess & OpenAIErrorResponse;
|
|
589
|
+
if (data.error) {
|
|
590
|
+
return { error: data.error.message };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const usedTokens = extractUsedTokens(data);
|
|
594
|
+
|
|
595
|
+
const toolCall = extractFunctionCall(data);
|
|
596
|
+
if (toolCall) {
|
|
597
|
+
try {
|
|
598
|
+
const toolResult = await executeToolCall(toolCall, tools);
|
|
599
|
+
return {
|
|
600
|
+
content: toolResult,
|
|
601
|
+
finishReason: "tool_call",
|
|
602
|
+
used_tokens: usedTokens,
|
|
603
|
+
};
|
|
604
|
+
} catch (error: any) {
|
|
605
|
+
return {
|
|
606
|
+
error: error?.message || "Tool execution failed",
|
|
607
|
+
finishReason: "tool_call",
|
|
608
|
+
used_tokens: usedTokens,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const parsedContent = extractOutputText(data);
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
content: parsedContent,
|
|
617
|
+
finishReason: data.incomplete_details?.reason
|
|
618
|
+
? data.incomplete_details.reason
|
|
619
|
+
: undefined,
|
|
620
|
+
used_tokens: usedTokens,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (!resp.body) {
|
|
625
|
+
return { error: "Response body is empty" };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const reader = resp.body.getReader();
|
|
629
|
+
const decoder = new TextDecoder("utf-8");
|
|
630
|
+
|
|
631
|
+
let buffer = "";
|
|
632
|
+
let fullContent = "";
|
|
633
|
+
let fullReasoning = "";
|
|
634
|
+
let finishReason: string | undefined;
|
|
635
|
+
let completedResponse: OpenAIResponsesSuccess | undefined;
|
|
636
|
+
let usedTokens: UsedTokens | undefined;
|
|
637
|
+
|
|
638
|
+
const handleEvent = async (event: any, eventType?: string) => {
|
|
639
|
+
const type = event?.type || eventType;
|
|
640
|
+
|
|
641
|
+
if (type === "response.output_text.delta") {
|
|
642
|
+
const delta = event?.delta || "";
|
|
643
|
+
if (!delta) return;
|
|
644
|
+
fullContent += delta;
|
|
645
|
+
await streamChunkCallback?.(delta, { type: "output", delta, text: fullContent });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (
|
|
650
|
+
type === "response.reasoning_summary_text.delta" ||
|
|
651
|
+
type === "response.reasoning_text.delta"
|
|
652
|
+
) {
|
|
653
|
+
const delta = event?.delta || "";
|
|
654
|
+
if (!delta) return;
|
|
655
|
+
fullReasoning += delta;
|
|
656
|
+
await streamChunkCallback?.(delta, {
|
|
657
|
+
type: "reasoning",
|
|
658
|
+
delta,
|
|
659
|
+
text: fullReasoning,
|
|
660
|
+
});
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (type === "response.completed" || type === "response.incomplete") {
|
|
665
|
+
const response = event?.response as OpenAIResponsesSuccess | undefined;
|
|
666
|
+
if (!response) return;
|
|
667
|
+
|
|
668
|
+
const finalContent = extractOutputText(response);
|
|
669
|
+
if (finalContent.startsWith(fullContent)) {
|
|
670
|
+
const delta = finalContent.slice(fullContent.length);
|
|
671
|
+
if (delta) {
|
|
672
|
+
fullContent = finalContent;
|
|
673
|
+
await streamChunkCallback?.(delta, {
|
|
674
|
+
type: "output",
|
|
675
|
+
delta,
|
|
676
|
+
text: fullContent,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const finalReasoning = extractReasoning(response) || "";
|
|
682
|
+
if (finalReasoning.startsWith(fullReasoning)) {
|
|
683
|
+
const delta = finalReasoning.slice(fullReasoning.length);
|
|
684
|
+
if (delta) {
|
|
685
|
+
fullReasoning = finalReasoning;
|
|
686
|
+
await streamChunkCallback?.(delta, {
|
|
687
|
+
type: "reasoning",
|
|
688
|
+
delta,
|
|
689
|
+
text: fullReasoning,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
finishReason =
|
|
695
|
+
response.incomplete_details?.reason || response.status || finishReason;
|
|
696
|
+
completedResponse = response;
|
|
697
|
+
usedTokens = extractUsedTokens(response);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (type === "response.failed") {
|
|
702
|
+
throw new Error(
|
|
703
|
+
event?.response?.error?.message ||
|
|
704
|
+
event?.error?.message ||
|
|
705
|
+
"Response failed",
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
while (true) {
|
|
712
|
+
const { value, done } = await reader.read();
|
|
713
|
+
if (done) break;
|
|
714
|
+
|
|
715
|
+
buffer += decoder.decode(value, { stream: true });
|
|
716
|
+
|
|
717
|
+
const blocks = buffer.split("\n\n");
|
|
718
|
+
buffer = blocks.pop() || "";
|
|
719
|
+
|
|
720
|
+
for (const block of blocks) {
|
|
721
|
+
const parsedBlock = parseSseBlock(block);
|
|
722
|
+
if (!parsedBlock?.data || parsedBlock.data === "[DONE]") continue;
|
|
723
|
+
|
|
724
|
+
let event: any;
|
|
725
|
+
try {
|
|
726
|
+
event = JSON.parse(parsedBlock.data);
|
|
727
|
+
} catch {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (event?.error?.message) {
|
|
732
|
+
return { error: event.error.message };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
await handleEvent(event, parsedBlock.event);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (buffer.trim()) {
|
|
740
|
+
const parsedBlock = parseSseBlock(buffer.trim());
|
|
741
|
+
if (parsedBlock?.data && parsedBlock.data !== "[DONE]") {
|
|
742
|
+
try {
|
|
743
|
+
await handleEvent(JSON.parse(parsedBlock.data), parsedBlock.event);
|
|
744
|
+
} catch (error: any) {
|
|
745
|
+
return {
|
|
746
|
+
error: error?.message || "Streaming failed",
|
|
747
|
+
content: fullContent || undefined,
|
|
748
|
+
finishReason,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (completedResponse) {
|
|
755
|
+
const toolCall = extractFunctionCall(completedResponse);
|
|
756
|
+
if (toolCall) {
|
|
757
|
+
try {
|
|
758
|
+
const toolResult = await executeToolCall(toolCall, tools);
|
|
759
|
+
if (toolResult) {
|
|
760
|
+
const delta = toolResult.startsWith(fullContent)
|
|
761
|
+
? toolResult.slice(fullContent.length)
|
|
762
|
+
: toolResult;
|
|
763
|
+
if (delta) {
|
|
764
|
+
await streamChunkCallback?.(delta, {
|
|
765
|
+
type: "output",
|
|
766
|
+
delta,
|
|
767
|
+
text: toolResult,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
content: toolResult,
|
|
774
|
+
finishReason: "tool_call",
|
|
775
|
+
used_tokens: usedTokens,
|
|
776
|
+
};
|
|
777
|
+
} catch (error: any) {
|
|
778
|
+
return {
|
|
779
|
+
error: error?.message || "Tool execution failed",
|
|
780
|
+
content: fullContent || undefined,
|
|
781
|
+
finishReason: "tool_call",
|
|
782
|
+
used_tokens: usedTokens,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
content: fullContent || undefined,
|
|
790
|
+
finishReason,
|
|
791
|
+
used_tokens: usedTokens,
|
|
792
|
+
};
|
|
793
|
+
} catch (error: any) {
|
|
794
|
+
if (abortController.signal.aborted) {
|
|
795
|
+
return {
|
|
796
|
+
error: error?.message || "Generation aborted",
|
|
797
|
+
content: fullContent || undefined,
|
|
798
|
+
finishReason: "aborted",
|
|
799
|
+
used_tokens: usedTokens,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
error: error?.message || "Streaming failed",
|
|
805
|
+
content: fullContent || undefined,
|
|
806
|
+
finishReason,
|
|
807
|
+
used_tokens: usedTokens,
|
|
808
|
+
};
|
|
809
|
+
} finally {
|
|
810
|
+
reader.releaseLock();
|
|
811
|
+
}
|
|
812
|
+
} finally {
|
|
813
|
+
if (this.activeAbortController === abortController) {
|
|
814
|
+
this.activeAbortController = null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
}
|