@adminforth/completion-adapter-openai-responses 1.0.0 → 1.0.1

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/langchain.ts ADDED
@@ -0,0 +1,169 @@
1
+ import { AIMessage } from "@langchain/core/messages";
2
+ import { ChatOpenAI } from "@langchain/openai";
3
+ import { createMiddleware } from "langchain";
4
+ import type { AdapterOptions } from "./types.js";
5
+
6
+ type ReasoningEffort =
7
+ | "none"
8
+ | "minimal"
9
+ | "low"
10
+ | "medium"
11
+ | "high"
12
+ | "xhigh";
13
+
14
+ export type AgentModelPurpose = "primary" | "summary";
15
+
16
+ type OpenAiResponsesMetadata = {
17
+ id?: string;
18
+ };
19
+
20
+ type OpenAiResponsesContext = {
21
+ sessionId: string;
22
+ turnId: string;
23
+ abortSignal?: AbortSignal;
24
+ };
25
+ type ExtraReasoning = NonNullable<
26
+ AdapterOptions["extraRequestBodyParameters"]
27
+ >["reasoning"];
28
+
29
+ function getAgentReasoningEffort(
30
+ purpose: AgentModelPurpose,
31
+ ): Exclude<ReasoningEffort, "none"> {
32
+ return purpose === "summary" ? "minimal" : "low";
33
+ }
34
+
35
+ function buildReasoningConfig(params: {
36
+ reasoning?: ExtraReasoning;
37
+ effort: Exclude<ReasoningEffort, "none"> | ReasoningEffort;
38
+ }) {
39
+ return {
40
+ summary: "auto",
41
+ effort: params.effort,
42
+ ...(params.reasoning ?? {}),
43
+ };
44
+ }
45
+
46
+ function getTurnKey(context: OpenAiResponsesContext) {
47
+ return `${context.sessionId}:${context.turnId}`;
48
+ }
49
+
50
+ function getResponseId(message: AIMessage) {
51
+ const metadata = message.response_metadata as OpenAiResponsesMetadata | undefined;
52
+ return metadata?.id ?? null;
53
+ }
54
+
55
+ function getPreviousResponseId(modelSettings?: Record<string, unknown>) {
56
+ return (modelSettings as { previous_response_id?: string } | undefined)
57
+ ?.previous_response_id;
58
+ }
59
+
60
+ function getContinuationMessages<T extends { response_metadata?: unknown }>(
61
+ messages: T[],
62
+ previousResponseId: string,
63
+ ) {
64
+ let continuationStartIndex: number | null = null;
65
+
66
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
67
+ const message = messages[index];
68
+
69
+ if (
70
+ AIMessage.isInstance(message) &&
71
+ (message.response_metadata as OpenAiResponsesMetadata | undefined)?.id ===
72
+ previousResponseId
73
+ ) {
74
+ continuationStartIndex = index + 1;
75
+ break;
76
+ }
77
+ }
78
+
79
+ if (continuationStartIndex === null) {
80
+ return null;
81
+ }
82
+
83
+ return messages.slice(continuationStartIndex);
84
+ }
85
+
86
+ function createOpenAiResponsesContinuationMiddleware() {
87
+ const responseIdsByTurn = new Map<string, string>();
88
+
89
+ return createMiddleware({
90
+ name: "OpenAiResponsesContinuationMiddleware",
91
+ async wrapModelCall(request, handler) {
92
+ const context = request.runtime.context as OpenAiResponsesContext;
93
+ const turnKey = getTurnKey(context);
94
+ const previousResponseId =
95
+ getPreviousResponseId(request.modelSettings) ??
96
+ responseIdsByTurn.get(turnKey);
97
+ const continuationMessages = previousResponseId
98
+ ? getContinuationMessages(request.messages, previousResponseId)
99
+ : null;
100
+
101
+ const response = (await handler(
102
+ previousResponseId && continuationMessages
103
+ ? {
104
+ ...request,
105
+ messages: continuationMessages,
106
+ modelSettings: {
107
+ ...request.modelSettings,
108
+ previous_response_id: previousResponseId,
109
+ },
110
+ }
111
+ : request,
112
+ )) as AIMessage;
113
+
114
+ const responseId = getResponseId(response);
115
+
116
+ if (responseId) {
117
+ responseIdsByTurn.set(turnKey, responseId);
118
+ } else {
119
+ responseIdsByTurn.delete(turnKey);
120
+ }
121
+
122
+ return response;
123
+ },
124
+ });
125
+ }
126
+
127
+ export function createLangChainAgentSpec(params: {
128
+ options: AdapterOptions;
129
+ maxTokens: number;
130
+ purpose: AgentModelPurpose;
131
+ configuredBaseUrl?: string;
132
+ clientConfiguration?: Record<string, unknown>;
133
+ useComplitionApi: boolean;
134
+ }) {
135
+ const extraRequestBodyParameters =
136
+ params.options.extraRequestBodyParameters || {};
137
+ const { reasoning, ...modelKwargs } = extraRequestBodyParameters;
138
+ const normalizedModelKwargs = { ...modelKwargs };
139
+
140
+ const chatOpenAiOptions: Record<string, unknown> = {
141
+ model: params.options.model || "gpt-5-nano",
142
+ apiKey: params.options.openAiApiKey,
143
+ maxTokens: params.maxTokens,
144
+ reasoning: buildReasoningConfig({
145
+ reasoning,
146
+ effort: getAgentReasoningEffort(params.purpose),
147
+ }),
148
+ modelKwargs: normalizedModelKwargs,
149
+ };
150
+
151
+ chatOpenAiOptions.useResponsesApi = !params.useComplitionApi;
152
+
153
+ let supportsResponseContinuation = true;
154
+ if (params.configuredBaseUrl || params.useComplitionApi) {
155
+ supportsResponseContinuation = false;
156
+ }
157
+
158
+ if (params.clientConfiguration) {
159
+ chatOpenAiOptions.configuration = params.clientConfiguration;
160
+ }
161
+
162
+ return {
163
+ model: new ChatOpenAI(chatOpenAiOptions as any),
164
+ middleware:
165
+ params.purpose === "primary" && supportsResponseContinuation
166
+ ? [createOpenAiResponsesContinuationMiddleware()]
167
+ : [],
168
+ };
169
+ }
package/openai.ts ADDED
@@ -0,0 +1,467 @@
1
+ import type {
2
+ CompletionStreamEvent,
3
+ CompletionTool,
4
+ } from "adminforth";
5
+ import OpenAI from "openai";
6
+ import type { AdapterOptions } from "./types.js";
7
+
8
+ export type StreamChunkCallback = (
9
+ chunk: string,
10
+ event?: CompletionStreamEvent,
11
+ ) => void | Promise<void>;
12
+
13
+ export type ReasoningEffort =
14
+ | "none"
15
+ | "minimal"
16
+ | "low"
17
+ | "medium"
18
+ | "high"
19
+ | "xhigh";
20
+
21
+ export type CompletionRequestInput = {
22
+ content: string;
23
+ maxTokens?: number;
24
+ outputSchema?: any;
25
+ reasoningEffort?: ReasoningEffort;
26
+ tools?: CompletionTool[];
27
+ onChunk?: StreamChunkCallback;
28
+ signal?: AbortSignal;
29
+ previousResponseId?: string;
30
+ };
31
+
32
+ type ResponseCreateBody = Omit<
33
+ OpenAI.Responses.ResponseCreateParamsNonStreaming,
34
+ "stream"
35
+ >;
36
+ type OpenAIResponsesSuccess = OpenAI.Responses.Response;
37
+ type OpenAITool = NonNullable<ResponseCreateBody["tools"]>[number];
38
+ type OpenAIFunctionCall = Extract<
39
+ OpenAI.Responses.ResponseOutputItem,
40
+ { type: "function_call" }
41
+ >;
42
+ type ReasoningConfig = ResponseCreateBody["reasoning"];
43
+
44
+ export type UsedTokens = {
45
+ input_uncached: number;
46
+ input_cached: number;
47
+ output: number;
48
+ };
49
+
50
+ export type CompletionResult =
51
+ | {
52
+ content?: string;
53
+ finishReason?: string;
54
+ responseId?: string;
55
+ used_tokens?: UsedTokens;
56
+ error?: undefined;
57
+ }
58
+ | {
59
+ error: string;
60
+ content?: string;
61
+ finishReason?: string;
62
+ responseId?: string;
63
+ used_tokens?: UsedTokens;
64
+ };
65
+
66
+ const RAW_REQUEST_LOG_PREFIX = "[CompletionAdapterOpenAIResponses] Raw /responses request";
67
+
68
+ type FetchInput = Parameters<typeof fetch>[0];
69
+ type FetchInit = Parameters<typeof fetch>[1];
70
+
71
+ function extractOutputText(data: OpenAIResponsesSuccess): string {
72
+ return data.output_text || "";
73
+ }
74
+
75
+ function extractFunctionCall(
76
+ data: OpenAIResponsesSuccess,
77
+ ): OpenAIFunctionCall | undefined {
78
+ for (const item of data.output ?? []) {
79
+ if (item.type === "function_call") {
80
+ return item;
81
+ }
82
+ }
83
+
84
+ return undefined;
85
+ }
86
+
87
+ function extractUsedTokens(data: OpenAIResponsesSuccess): UsedTokens | undefined {
88
+ const usage = data.usage;
89
+ if (!usage) {
90
+ return undefined;
91
+ }
92
+
93
+ const inputCached = usage.input_tokens_details?.cached_tokens ?? 0;
94
+
95
+ return {
96
+ input_uncached: Math.max(usage.input_tokens - inputCached, 0),
97
+ input_cached: inputCached,
98
+ output: usage.output_tokens,
99
+ };
100
+ }
101
+
102
+ async function executeToolCall(
103
+ toolCall: OpenAIFunctionCall,
104
+ tools?: CompletionTool[],
105
+ ): Promise<string> {
106
+ const tool = tools?.find((candidate) => candidate.name === toolCall.name);
107
+ if (!tool) {
108
+ throw new Error(`Tool "${toolCall.name}" not found`);
109
+ }
110
+
111
+ const toolResult = await tool.handler(JSON.parse(toolCall.arguments));
112
+ if (typeof toolResult === "string") return toolResult;
113
+ if (typeof toolResult === "undefined") return "";
114
+ return JSON.stringify(toolResult);
115
+ }
116
+
117
+ async function resolveToolCallResult(params: {
118
+ response: OpenAIResponsesSuccess;
119
+ tools?: CompletionTool[];
120
+ currentContent?: string;
121
+ onChunk?: StreamChunkCallback;
122
+ usedTokens?: UsedTokens;
123
+ }): Promise<CompletionResult | null> {
124
+ const toolCall = extractFunctionCall(params.response);
125
+ if (!toolCall) {
126
+ return null;
127
+ }
128
+
129
+ try {
130
+ const toolResult = await executeToolCall(toolCall, params.tools);
131
+ if (typeof params.currentContent === "string" && toolResult) {
132
+ const delta = toolResult.startsWith(params.currentContent)
133
+ ? toolResult.slice(params.currentContent.length)
134
+ : toolResult;
135
+ if (delta) {
136
+ await params.onChunk?.(delta, {
137
+ type: "output",
138
+ delta,
139
+ text: toolResult,
140
+ });
141
+ }
142
+ }
143
+
144
+ return {
145
+ content: toolResult,
146
+ finishReason: "tool_call",
147
+ responseId: params.response.id,
148
+ used_tokens: params.usedTokens,
149
+ };
150
+ } catch (error: any) {
151
+ return {
152
+ error: error?.message || "Tool execution failed",
153
+ content: params.currentContent || undefined,
154
+ finishReason: "tool_call",
155
+ responseId: params.response.id,
156
+ used_tokens: params.usedTokens,
157
+ };
158
+ }
159
+ }
160
+
161
+ async function handleCompletedResponse(params: {
162
+ response: OpenAIResponsesSuccess;
163
+ tools?: CompletionTool[];
164
+ }): Promise<CompletionResult> {
165
+ const usedTokens = extractUsedTokens(params.response);
166
+
167
+ const toolCallResult = await resolveToolCallResult({
168
+ response: params.response,
169
+ tools: params.tools,
170
+ usedTokens,
171
+ });
172
+ if (toolCallResult) {
173
+ return toolCallResult;
174
+ }
175
+
176
+ return {
177
+ content: extractOutputText(params.response),
178
+ finishReason: params.response.incomplete_details?.reason
179
+ ? params.response.incomplete_details.reason
180
+ : undefined,
181
+ responseId: params.response.id,
182
+ used_tokens: usedTokens,
183
+ };
184
+ }
185
+
186
+ function buildReasoningConfig(params: {
187
+ reasoning?: ReasoningConfig;
188
+ effort: Exclude<ReasoningEffort, "none"> | ReasoningEffort;
189
+ }): ReasoningConfig {
190
+ return {
191
+ summary: "auto",
192
+ effort: params.effort,
193
+ ...params.reasoning,
194
+ };
195
+ }
196
+
197
+ function splitExtraRequestBodyParameters(extra: Partial<ResponseCreateBody>) {
198
+ const { reasoning, ...bodyParameters } = extra;
199
+
200
+ return {
201
+ reasoning,
202
+ bodyParameters,
203
+ };
204
+ }
205
+
206
+ function mapTools(tools?: CompletionTool[]): ResponseCreateBody["tools"] {
207
+ if (!tools?.length) {
208
+ return undefined;
209
+ }
210
+
211
+ return tools.map((tool): OpenAITool => ({
212
+ type: "function",
213
+ name: tool.name,
214
+ description: tool.description,
215
+ parameters: tool.input_schema,
216
+ strict: false,
217
+ }));
218
+ }
219
+
220
+ function buildResponseBody(params: {
221
+ options: AdapterOptions;
222
+ request: CompletionRequestInput;
223
+ }): ResponseCreateBody {
224
+ const {
225
+ content,
226
+ maxTokens: requestMaxTokens = 50,
227
+ outputSchema: requestOutputSchema,
228
+ reasoningEffort: requestReasoningEffort = "low",
229
+ tools,
230
+ } = params.request;
231
+ const {
232
+ reasoning: extraReasoning,
233
+ bodyParameters: extraBodyParameters,
234
+ } = splitExtraRequestBodyParameters(
235
+ params.options.extraRequestBodyParameters ?? {},
236
+ );
237
+
238
+ return {
239
+ ...extraBodyParameters,
240
+ model: params.options.model || "gpt-5-nano",
241
+ input: content,
242
+ max_output_tokens: requestMaxTokens,
243
+ text: requestOutputSchema
244
+ ? {
245
+ format: {
246
+ type: "json_schema",
247
+ ...requestOutputSchema,
248
+ },
249
+ }
250
+ : {
251
+ format: {
252
+ type: "text",
253
+ },
254
+ },
255
+ reasoning: buildReasoningConfig({
256
+ reasoning: extraReasoning,
257
+ effort: requestReasoningEffort,
258
+ }),
259
+ tools: mapTools(tools),
260
+ ...(params.request.previousResponseId
261
+ ? { previous_response_id: params.request.previousResponseId }
262
+ : {}),
263
+ };
264
+ }
265
+
266
+ export class OpenAIResponsesService {
267
+ private client: OpenAI | null = null;
268
+
269
+ constructor(private options: AdapterOptions) {}
270
+
271
+ getClientConfiguration() {
272
+ const configuredBaseUrl = this.options.baseUrl;
273
+ const debugFetch = this.options.dumpRawRequest === true
274
+ ? this.createResponsesDebugFetch()
275
+ : undefined;
276
+
277
+ if (!configuredBaseUrl && !debugFetch) {
278
+ return undefined;
279
+ }
280
+
281
+ return {
282
+ ...(configuredBaseUrl ? { baseURL: configuredBaseUrl } : {}),
283
+ ...(debugFetch ? { fetch: debugFetch } : {}),
284
+ };
285
+ }
286
+
287
+ async complete(
288
+ request: CompletionRequestInput,
289
+ signal: AbortSignal,
290
+ ): Promise<CompletionResult> {
291
+ const { tools, onChunk: streamChunkCallback } = request;
292
+ const isStreaming = typeof streamChunkCallback === "function";
293
+ const body = buildResponseBody({
294
+ options: this.options,
295
+ request,
296
+ });
297
+
298
+ let fullContent = "";
299
+ let fullReasoning = "";
300
+ let finishReason: string | undefined;
301
+ let completedResponse: OpenAIResponsesSuccess | undefined;
302
+ let usedTokens: UsedTokens | undefined;
303
+
304
+ const handleStreamEvent = async (
305
+ event: OpenAI.Responses.ResponseStreamEvent,
306
+ ) => {
307
+ switch (event.type) {
308
+ case "response.output_text.delta": {
309
+ const delta = event.delta || "";
310
+ if (!delta) return;
311
+ fullContent += delta;
312
+ await streamChunkCallback?.(delta, {
313
+ type: "output",
314
+ delta,
315
+ text: fullContent,
316
+ });
317
+ return;
318
+ }
319
+
320
+ case "response.reasoning_summary_text.delta":
321
+ case "response.reasoning_text.delta": {
322
+ const delta = event.delta || "";
323
+ if (!delta) return;
324
+ fullReasoning += delta;
325
+ await streamChunkCallback?.(delta, {
326
+ type: "reasoning",
327
+ delta,
328
+ text: fullReasoning,
329
+ });
330
+ return;
331
+ }
332
+
333
+ case "response.completed":
334
+ case "response.incomplete": {
335
+ const response = event.response;
336
+ finishReason =
337
+ response.incomplete_details?.reason || response.status || finishReason;
338
+ completedResponse = response;
339
+ usedTokens = extractUsedTokens(response);
340
+ return;
341
+ }
342
+
343
+ case "response.failed":
344
+ throw new Error(event.response.error?.message || "Response failed");
345
+
346
+ case "error":
347
+ throw new Error(event.message || "Response failed");
348
+ }
349
+ };
350
+
351
+ try {
352
+ if (!isStreaming) {
353
+ const params: OpenAI.Responses.ResponseCreateParamsNonStreaming = {
354
+ ...body,
355
+ stream: false,
356
+ };
357
+ const data = await this.getClient().responses.create(params, { signal });
358
+
359
+ return handleCompletedResponse({ response: data, tools });
360
+ }
361
+
362
+ const params: OpenAI.Responses.ResponseCreateParamsStreaming = {
363
+ ...body,
364
+ stream: true,
365
+ };
366
+ const stream = await this.getClient().responses.create(params, { signal });
367
+
368
+ for await (const event of stream) {
369
+ await handleStreamEvent(event);
370
+ }
371
+
372
+ if (completedResponse) {
373
+ const toolCallResult = await resolveToolCallResult({
374
+ response: completedResponse,
375
+ tools,
376
+ currentContent: fullContent,
377
+ onChunk: streamChunkCallback,
378
+ usedTokens,
379
+ });
380
+ if (toolCallResult) {
381
+ return toolCallResult;
382
+ }
383
+ }
384
+
385
+ return {
386
+ content: fullContent || undefined,
387
+ finishReason,
388
+ responseId: completedResponse?.id,
389
+ used_tokens: usedTokens,
390
+ };
391
+ } catch (error: any) {
392
+ if (signal.aborted) {
393
+ return {
394
+ error: error?.message || "Generation aborted",
395
+ content: fullContent || undefined,
396
+ finishReason: "aborted",
397
+ used_tokens: usedTokens,
398
+ };
399
+ }
400
+
401
+ if (isStreaming) {
402
+ return {
403
+ error: error?.message || "Streaming failed",
404
+ content: fullContent || undefined,
405
+ finishReason,
406
+ used_tokens: usedTokens,
407
+ };
408
+ }
409
+
410
+ return {
411
+ error: error?.message || "OpenAI request failed",
412
+ };
413
+ }
414
+ }
415
+
416
+ private getClient() {
417
+ if (!this.client) {
418
+ this.client = new OpenAI({
419
+ apiKey: this.options.openAiApiKey,
420
+ ...this.getClientConfiguration(),
421
+ });
422
+ }
423
+
424
+ return this.client;
425
+ }
426
+
427
+ private createResponsesDebugFetch() {
428
+ return async (input: FetchInput, init?: FetchInit) => {
429
+ const url = this.getFetchUrl(input);
430
+
431
+ if (this.isResponsesUrl(url) && typeof init?.body === "string") {
432
+ this.dumpRawRequest(url, init.body);
433
+ }
434
+
435
+ return fetch(input, init);
436
+ };
437
+ }
438
+
439
+ private getFetchUrl(input: FetchInput) {
440
+ if (typeof input === "string") {
441
+ return input;
442
+ }
443
+
444
+ if (input instanceof URL) {
445
+ return input.toString();
446
+ }
447
+
448
+ return input.url;
449
+ }
450
+
451
+ private isResponsesUrl(url: string) {
452
+ try {
453
+ return new URL(url).pathname.endsWith("/responses");
454
+ } catch {
455
+ return url.endsWith("/responses") || url.includes("/responses?");
456
+ }
457
+ }
458
+
459
+ private dumpRawRequest(url: string, body: string) {
460
+ console.info(`${RAW_REQUEST_LOG_PREFIX} ${url}`);
461
+ try {
462
+ console.info(JSON.stringify(JSON.parse(body), null, 2));
463
+ } catch {
464
+ console.info(body);
465
+ }
466
+ }
467
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/completion-adapter-openai-responses",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "@langchain/core": "^1.1.41",
29
29
  "@langchain/openai": "1.4.4",
30
30
  "langchain": "^1.3.4",
31
- "openai": "^6.34.0",
31
+ "openai": "^6.42.0",
32
32
  "tiktoken": "^1.0.22"
33
33
  },
34
34
  "peerDependencies": {
package/types.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import type OpenAI from "openai";
2
+
3
+ export type AdapterExtraRequestBodyParameters = Partial<
4
+ Omit<OpenAI.Responses.ResponseCreateParamsNonStreaming, "stream">
5
+ >;
6
+
1
7
  export interface AdapterOptions {
2
8
  /**
3
9
  * OpenAI API key. Go to https://platform.openai.com/, go to Dashboard -> API keys -> Create new secret key
@@ -32,7 +38,7 @@ export interface AdapterOptions {
32
38
  /**
33
39
  * Additional request body parameters to include in the API request.
34
40
  */
35
- extraRequestBodyParameters?: Record<string, unknown>;
41
+ extraRequestBodyParameters?: AdapterExtraRequestBodyParameters;
36
42
 
37
43
  /**
38
44
  * Logs the exact JSON body sent to the OpenAI Responses endpoint.