@robota-sdk/agent-provider-openai 3.0.0-beta.82 → 3.0.0-beta.84

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +135 -0
  3. package/dist/node/index.cjs +1 -1
  4. package/dist/node/index.d.cts +8 -3
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +8 -3
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +1 -1
  9. package/dist/node/index.js.map +1 -1
  10. package/dist/node/loggers/index.d.cts +6 -6
  11. package/dist/node/loggers/index.d.cts.map +1 -1
  12. package/dist/node/loggers/index.d.ts +6 -6
  13. package/dist/node/loggers/index.d.ts.map +1 -1
  14. package/dist/node/loggers/index.js.map +1 -1
  15. package/dist/node/{payload-logger-BaW0K8yI.d.ts → payload-logger-n89AQdUn.d.cts} +6 -4
  16. package/dist/node/payload-logger-n89AQdUn.d.cts.map +1 -0
  17. package/dist/node/{payload-logger-BaW0K8yI.d.cts → payload-logger-n89AQdUn.d.ts} +6 -4
  18. package/dist/node/payload-logger-n89AQdUn.d.ts.map +1 -0
  19. package/package.json +11 -5
  20. package/src/openai/__tests__/abort-signal-wire.test.ts +210 -0
  21. package/src/openai/__tests__/provider-errors.test.ts +34 -5
  22. package/src/openai/__tests__/strict-tools-closure.test.ts +49 -2
  23. package/src/openai/__tests__/tool-schema-projection.test.ts +16 -2
  24. package/src/openai/chat-completions-chat.ts +2 -2
  25. package/src/openai/interfaces/payload-logger.ts +5 -3
  26. package/src/openai/loggers/console-payload-logger.ts +2 -2
  27. package/src/openai/loggers/file-payload-logger.ts +6 -5
  28. package/src/openai/message-converter.ts +7 -3
  29. package/src/openai/responses-parser.test.ts +87 -0
  30. package/src/openai/responses-parser.ts +15 -9
  31. package/src/openai/responses-types.ts +2 -0
  32. package/src/openai/types.ts +7 -2
  33. package/dist/node/payload-logger-BaW0K8yI.d.cts.map +0 -1
  34. package/dist/node/payload-logger-BaW0K8yI.d.ts.map +0 -1
@@ -113,16 +113,23 @@ interface ISentTool {
113
113
  name: string;
114
114
  description: string;
115
115
  parameters: unknown;
116
+ /** Whether the tool was declared strict on the wire (absent counts as not strict). */
117
+ strict: boolean;
116
118
  }
117
119
 
118
120
  function sentToolsFromResponses(client: IFakeClient): ISentTool[] {
119
121
  const [requestParams] = client.responses.create.mock.calls[
120
122
  client.responses.create.mock.calls.length - 1
121
- ] as [{ tools?: Array<{ name: string; description: string; parameters: unknown }> }];
123
+ ] as [
124
+ {
125
+ tools?: Array<{ name: string; description: string; parameters: unknown; strict?: boolean }>;
126
+ },
127
+ ];
122
128
  return (requestParams.tools ?? []).map((tool) => ({
123
129
  name: tool.name,
124
130
  description: tool.description,
125
131
  parameters: tool.parameters,
132
+ strict: tool.strict === true,
126
133
  }));
127
134
  }
128
135
 
@@ -131,13 +138,16 @@ function sentToolsFromChatCompletions(client: IFakeClient): ISentTool[] {
131
138
  client.chat.completions.create.mock.calls.length - 1
132
139
  ] as [
133
140
  {
134
- tools?: Array<{ function: { name: string; description: string; parameters: unknown } }>;
141
+ tools?: Array<{
142
+ function: { name: string; description: string; parameters: unknown; strict?: boolean };
143
+ }>;
135
144
  },
136
145
  ];
137
146
  return (requestParams.tools ?? []).map((tool) => ({
138
147
  name: tool.function.name,
139
148
  description: tool.function.description,
140
149
  parameters: tool.function.parameters,
150
+ strict: tool.function.strict === true,
141
151
  }));
142
152
  }
143
153
 
@@ -173,6 +183,7 @@ describe.each([
173
183
 
174
184
  const sent = sentTools(client);
175
185
  expect(sent).toHaveLength(3);
186
+ expect(sent.every((tool) => !tool.strict)).toBe(true);
176
187
  for (const [index, tool] of tools.entries()) {
177
188
  const projection = projectToolSchema(tool, PERMISSIVE_PROFILE);
178
189
  expect(sent[index]?.parameters).toEqual(projection.tool.parameters);
@@ -242,6 +253,7 @@ describe.each([
242
253
 
243
254
  const sent = sentTools(client);
244
255
  expect(sent.map((tool) => tool.name)).toEqual(['good_tool_one', 'good_tool_two']);
256
+ expect(sent.every((tool) => !tool.strict)).toBe(true);
245
257
  });
246
258
  },
247
259
  );
@@ -274,6 +286,7 @@ describe.each([
274
286
  const projection = projectToolSchema(tool, STRICT_PROFILE);
275
287
  expect(sent[index]?.parameters).toEqual(projection.tool.parameters);
276
288
  expect(sent[index]?.description).toEqual(projection.tool.description);
289
+ expect(sent[index]?.strict).toBe(true);
277
290
  }
278
291
  });
279
292
 
@@ -297,6 +310,7 @@ describe.each([
297
310
  const sent = sentTools(client);
298
311
  const projection = projectToolSchema(fixtures.subsetOnly, STRICT_PROFILE);
299
312
  expect(sent[0]?.parameters).toEqual(projection.tool.parameters);
313
+ expect(sent[0]?.strict).toBe(true);
300
314
  });
301
315
  },
302
316
  );
@@ -56,7 +56,7 @@ export async function chatWithOpenAIChatCompletions(
56
56
  payloadKind: 'request',
57
57
  payload: requestParams,
58
58
  });
59
- const requestOptions = openAIRequestOptions(undefined, input.requestHeaders);
59
+ const requestOptions = openAIRequestOptions(input.chatOptions?.signal, input.requestHeaders);
60
60
  const response = requestOptions
61
61
  ? await client.chat.completions.create(requestParams, requestOptions)
62
62
  : await client.chat.completions.create(requestParams);
@@ -151,7 +151,7 @@ function buildChatRequestParams(
151
151
  }),
152
152
  ...(input.chatOptions?.maxTokens !== undefined && { max_tokens: input.chatOptions.maxTokens }),
153
153
  ...(input.chatOptions?.tools && {
154
- tools: convertToOpenAITools(input.chatOptions.tools),
154
+ tools: convertToOpenAITools(input.chatOptions.tools, input.providerOptions.strictTools),
155
155
  tool_choice: toOpenAICompatibleToolChoice(input.chatOptions.toolChoice),
156
156
  }),
157
157
  ...(responseFormat !== undefined && { response_format: responseFormat }),
@@ -2,7 +2,9 @@ import type { IOpenAILogData } from '../types/api-types';
2
2
  import type { ILogger } from '@robota-sdk/agent-core';
3
3
 
4
4
  /**
5
- * IPayloadLogger interface for logging OpenAI API payloads
5
+ * IPayloadLogger interface for logging a summary of each OpenAI Chat Completions request
6
+ * (request metadata such as model, message count and whether tools were sent — not prompt or
7
+ * response content).
6
8
  *
7
9
  * This interface provides a contract for different logging implementations:
8
10
  * - FilePayloadLogger: Node.js file-based logging
@@ -17,8 +19,8 @@ export interface IPayloadLogger {
17
19
  isEnabled(): boolean;
18
20
 
19
21
  /**
20
- * Log API payload data
21
- * @param payload - The API request/response payload data
22
+ * Log a request summary
23
+ * @param payload - Summary of the outgoing Chat Completions request
22
24
  * @param type - Type of operation ('chat' or 'stream')
23
25
  */
24
26
  logPayload(payload: IOpenAILogData, type: 'chat' | 'stream'): Promise<void>;
@@ -8,7 +8,7 @@ import type { IOpenAILogData } from '../types/api-types';
8
8
  /**
9
9
  * Console-based payload logger for browser environments
10
10
  *
11
- * This logger outputs API request/response payloads to the browser console
11
+ * This logger outputs a summary of each Chat Completions request to the browser console
12
12
  * using structured logging. It's designed specifically for browser environments
13
13
  * and development/debugging scenarios.
14
14
  *
@@ -47,7 +47,7 @@ export class ConsolePayloadLogger implements IPayloadLogger {
47
47
 
48
48
  /**
49
49
  * Log API payload to browser console
50
- * @param payload - The API request payload
50
+ * @param payload - Summary of the outgoing Chat Completions request
51
51
  * @param type - Type of request ('chat' or 'stream')
52
52
  */
53
53
  async logPayload(payload: IOpenAILogData, type: 'chat' | 'stream' = 'chat'): Promise<void> {
@@ -16,7 +16,7 @@ const OWNER_ONLY_DIR_MODE = 0o700;
16
16
  /**
17
17
  * File-based payload logger for Node.js environments
18
18
  *
19
- * This logger saves API request/response payloads to JSON files on disk.
19
+ * This logger saves a summary of each Chat Completions request to a JSON file on disk.
20
20
  * It's designed specifically for Node.js environments with filesystem access.
21
21
  *
22
22
  * @example
@@ -65,8 +65,8 @@ export class FilePayloadLogger implements IPayloadLogger {
65
65
  }
66
66
 
67
67
  /**
68
- * Log API payload to file
69
- * @param payload - The API request payload
68
+ * Log a request summary to file
69
+ * @param payload - Summary of the outgoing Chat Completions request
70
70
  * @param type - Type of request ('chat' or 'stream')
71
71
  */
72
72
  async logPayload(payload: IOpenAILogData, type: 'chat' | 'stream' = 'chat'): Promise<void> {
@@ -89,8 +89,9 @@ export class FilePayloadLogger implements IPayloadLogger {
89
89
  payload: sanitizeOpenAILogData(payload),
90
90
  };
91
91
 
92
- // SEC-003: payload logs contain prompt/response content and `logDir` is
93
- // caller-supplied, so create them owner-only rather than under the process umask.
92
+ // SEC-003: payload logs record the caller's request history (a request summary, not
93
+ // prompt/response content) and `logDir` is caller-supplied, so create them owner-only
94
+ // rather than under the process umask.
94
95
  await fs.promises.writeFile(filepath, JSON.stringify(logData, null, 2), {
95
96
  encoding: 'utf8',
96
97
  mode: OWNER_ONLY_FILE_MODE,
@@ -16,8 +16,12 @@ export function convertToOpenAIMessages(
16
16
  }
17
17
 
18
18
  /**
19
- * Convert tool schemas to OpenAI function tool format.
19
+ * Convert tool schemas to OpenAI function tool format. With `strictTools` each function is declared
20
+ * `strict: true`, as the Responses surface does, so strict mode is requested on both surfaces.
20
21
  */
21
- export function convertToOpenAITools(tools: IToolSchema[]): OpenAI.Chat.ChatCompletionTool[] {
22
- return convertToOpenAICompatibleTools(tools);
22
+ export function convertToOpenAITools(
23
+ tools: IToolSchema[],
24
+ strictTools?: boolean,
25
+ ): OpenAI.Chat.ChatCompletionTool[] {
26
+ return convertToOpenAICompatibleTools(tools, { strict: strictTools === true });
23
27
  }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { assembleOpenAIResponsesStream, parseOpenAIResponsesResponse } from './responses-parser';
4
+
5
+ import type {
6
+ IOpenAIResponsesResponse,
7
+ IOpenAIResponsesUsage,
8
+ TOpenAIResponsesStreamEvent,
9
+ } from './responses-types';
10
+
11
+ function responseWithUsage(usage: IOpenAIResponsesUsage): IOpenAIResponsesResponse {
12
+ return {
13
+ id: 'resp-cached',
14
+ model: 'gpt-4o',
15
+ output_text: 'ok',
16
+ output: [],
17
+ status: 'completed',
18
+ usage,
19
+ };
20
+ }
21
+
22
+ function usageOf(message: unknown): Record<string, number> | undefined {
23
+ return (message as { usage?: Record<string, number> }).usage;
24
+ }
25
+
26
+ async function* eventsFrom(
27
+ events: TOpenAIResponsesStreamEvent[],
28
+ ): AsyncIterable<TOpenAIResponsesStreamEvent> {
29
+ for (const event of events) yield event;
30
+ }
31
+
32
+ describe('OpenAI Responses usage', () => {
33
+ it('surfaces input_tokens_details.cached_tokens as cacheReadTokens on the message usage', () => {
34
+ const message = parseOpenAIResponsesResponse(
35
+ responseWithUsage({
36
+ input_tokens: 1000,
37
+ output_tokens: 20,
38
+ total_tokens: 1020,
39
+ input_tokens_details: { cached_tokens: 800 },
40
+ }),
41
+ );
42
+
43
+ expect(usageOf(message)).toEqual({
44
+ promptTokens: 1000,
45
+ completionTokens: 20,
46
+ totalTokens: 1020,
47
+ cacheReadTokens: 800,
48
+ });
49
+ });
50
+
51
+ it('adds no cacheReadTokens when the usage carries no cached-token details', () => {
52
+ const message = parseOpenAIResponsesResponse(
53
+ responseWithUsage({ input_tokens: 1000, output_tokens: 20, total_tokens: 1020 }),
54
+ );
55
+
56
+ expect(usageOf(message)).toBeDefined();
57
+ expect(usageOf(message)).not.toHaveProperty('cacheReadTokens');
58
+ });
59
+
60
+ it('leaves totalTokens absent when the response omits total_tokens', () => {
61
+ const message = parseOpenAIResponsesResponse(
62
+ responseWithUsage({ input_tokens: 5000, output_tokens: 100 }),
63
+ );
64
+
65
+ expect(usageOf(message)).toEqual({ promptTokens: 5000, completionTokens: 100 });
66
+ expect(message.metadata?.['usageProvenance']).toBe('partial');
67
+ });
68
+
69
+ it('carries the cached tokens of a streamed response.completed usage', async () => {
70
+ const message = await assembleOpenAIResponsesStream({
71
+ stream: eventsFrom([
72
+ { type: 'response.output_text.delta', delta: 'ok' },
73
+ {
74
+ type: 'response.completed',
75
+ response: responseWithUsage({
76
+ input_tokens: 1000,
77
+ output_tokens: 20,
78
+ total_tokens: 1020,
79
+ input_tokens_details: { cached_tokens: 800 },
80
+ }),
81
+ },
82
+ ]),
83
+ });
84
+
85
+ expect(usageOf(message)).toMatchObject({ promptTokens: 1000, cacheReadTokens: 800 });
86
+ });
87
+ });
@@ -13,7 +13,16 @@ import type {
13
13
  TOpenAIResponsesOutputItem,
14
14
  TOpenAIResponsesStreamEvent,
15
15
  } from './responses-types';
16
- import type { IToolCall, TTextDeltaCallback, TUniversalMessage } from '@robota-sdk/agent-core';
16
+ import type {
17
+ IToolCall,
18
+ ITokenUsageWithCacheRead,
19
+ TTextDeltaCallback,
20
+ TUniversalMessage,
21
+ } from '@robota-sdk/agent-core';
22
+
23
+ /** Message usage; `totalTokens` is absent when the response omitted `total_tokens`. */
24
+ type TResponsesMessageUsage = Omit<ITokenUsageWithCacheRead, 'totalTokens'> &
25
+ Partial<Pick<ITokenUsageWithCacheRead, 'totalTokens'>>;
17
26
 
18
27
  interface IOpenAIResponsesStreamAssemblyOptions {
19
28
  stream: AsyncIterable<TOpenAIResponsesStreamEvent>;
@@ -27,12 +36,6 @@ interface IOpenAIResponsesReasoningMetadata {
27
36
  hasEncryptedReasoning: boolean;
28
37
  }
29
38
 
30
- interface IOpenAIResponseUsage {
31
- promptTokens: number;
32
- completionTokens: number;
33
- totalTokens: number;
34
- }
35
-
36
39
  interface IOpenAIResponsesStreamState {
37
40
  textParts: string[];
38
41
  toolCalls: IToolCall[];
@@ -191,11 +194,14 @@ function buildMetadata(
191
194
  };
192
195
  }
193
196
 
194
- function mapUsage(usage: IOpenAIResponsesUsage): IOpenAIResponseUsage {
197
+ function mapUsage(usage: IOpenAIResponsesUsage): TResponsesMessageUsage {
198
+ const cachedTokens = usage.input_tokens_details?.cached_tokens;
195
199
  return {
196
200
  promptTokens: usage.input_tokens ?? 0,
197
201
  completionTokens: usage.output_tokens ?? 0,
198
- totalTokens: usage.total_tokens ?? 0,
202
+ // An omitted total stays omitted: a 0 reads downstream as a real, empty call.
203
+ ...(usage.total_tokens !== undefined && { totalTokens: usage.total_tokens }),
204
+ ...(typeof cachedTokens === 'number' && { cacheReadTokens: cachedTokens }),
199
205
  };
200
206
  }
201
207
 
@@ -148,6 +148,8 @@ export interface IOpenAIResponsesUsage {
148
148
  input_tokens?: number;
149
149
  output_tokens?: number;
150
150
  total_tokens?: number;
151
+ /** `cached_tokens` is the part of `input_tokens` served from the prompt cache. */
152
+ input_tokens_details?: { cached_tokens?: number } | null;
151
153
  }
152
154
 
153
155
  export interface IOpenAIResponsesErrorBody {
@@ -93,7 +93,7 @@ export interface IOpenAIProviderOptions {
93
93
  * @example
94
94
  * ```ts
95
95
  * // Vercel AI Gateway with a non-OpenAI model slug
96
- * createOpenAIProvider({
96
+ * new OpenAIProvider({
97
97
  * apiKey: process.env.AI_GATEWAY_API_KEY,
98
98
  * baseURL: 'https://ai-gateway.vercel.sh/v1',
99
99
  * defaultModel: 'anthropic/claude-sonnet-4-5',
@@ -156,6 +156,9 @@ export interface IOpenAIProviderOptions {
156
156
  * schema marked optional becomes required and gains a `null` branch. The model must then supply
157
157
  * the key explicitly, with `null` standing for "not provided". A tool whose handler distinguishes
158
158
  * an absent key from a null value will see the difference.
159
+ *
160
+ * Both surfaces declare the functions strict (`strict: true` on each tool), so an OpenAI-compatible
161
+ * endpoint that rejects that field needs `strictTools` left off.
159
162
  */
160
163
  strictTools?: boolean;
161
164
 
@@ -174,7 +177,9 @@ export interface IOpenAIProviderOptions {
174
177
  client?: OpenAI;
175
178
 
176
179
  /**
177
- * Payload logger instance for debugging API requests/responses
180
+ * Payload logger that receives a summary of each Chat Completions request (model, message
181
+ * count, whether tools were sent, temperature, max tokens) for debugging — not prompt or
182
+ * response content. Not called on the Responses API surface.
178
183
  *
179
184
  * Use different implementations based on your environment:
180
185
  * - FilePayloadLogger: Node.js file-based logging
@@ -1 +0,0 @@
1
- {"version":3,"file":"payload-logger-BaW0K8yI.d.cts","names":[],"sources":["../../src/openai/types/api-types.ts","../../src/openai/interfaces/payload-logger.ts"],"mappings":";;;;;;UAwGiB;EACf;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;UCpGe;;;;;EAKf;;;;;;EAOA,WAAW,SAAS,gBAAgB,0BAA0B;;;;;UAM/C;;;;;EAKf;;;;;EAMA;;;;;EAMA,SAAS"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"payload-logger-BaW0K8yI.d.ts","names":[],"sources":["../../src/openai/types/api-types.ts","../../src/openai/interfaces/payload-logger.ts"],"mappings":";;;;;;UAwGiB;EACf;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;UCpGe;;;;;EAKf;;;;;;EAOA,WAAW,SAAS,gBAAgB,0BAA0B;;;;;UAM/C;;;;;EAKf;;;;;EAMA;;;;;EAMA,SAAS"}