@robota-sdk/agent-provider-openai 3.0.0-beta.63 → 3.0.0-beta.81

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 (70) hide show
  1. package/LICENSE +661 -21
  2. package/dist/node/index.cjs +1 -2
  3. package/dist/node/index.d.cts +271 -267
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +271 -267
  6. package/dist/node/index.d.ts.map +1 -0
  7. package/dist/node/index.js +2 -2
  8. package/dist/node/index.js.map +1 -0
  9. package/dist/node/loggers/index.cjs +1 -0
  10. package/dist/node/loggers/index.d.cts +94 -0
  11. package/dist/node/loggers/index.d.cts.map +1 -0
  12. package/dist/node/loggers/index.d.ts +94 -0
  13. package/dist/node/loggers/index.d.ts.map +1 -0
  14. package/dist/node/loggers/index.js +2 -0
  15. package/dist/node/loggers/index.js.map +1 -0
  16. package/dist/node/payload-logger-BaW0K8yI.d.cts +61 -0
  17. package/dist/node/payload-logger-BaW0K8yI.d.cts.map +1 -0
  18. package/dist/node/payload-logger-BaW0K8yI.d.ts +61 -0
  19. package/dist/node/payload-logger-BaW0K8yI.d.ts.map +1 -0
  20. package/dist/node/rolldown-runtime-CMqjfN_6.cjs +1 -0
  21. package/package.json +66 -88
  22. package/src/index.ts +1 -0
  23. package/src/openai/__tests__/endpoint-provenance.test.ts +44 -0
  24. package/src/openai/__tests__/provider-errors.test.ts +114 -0
  25. package/src/openai/__tests__/request-format.test.ts +59 -0
  26. package/src/openai/__tests__/strict-tools-closure.test.ts +136 -0
  27. package/src/openai/__tests__/tool-schema-projection.test.ts +302 -0
  28. package/src/openai/__tests__/trace-context-wire.test.ts +114 -0
  29. package/src/openai/adapter.test.ts +494 -0
  30. package/src/openai/adapter.ts +145 -0
  31. package/src/openai/chat-completions-chat.ts +225 -0
  32. package/src/openai/executor-integration.test.ts +213 -0
  33. package/src/openai/index.ts +19 -0
  34. package/src/openai/interfaces/payload-logger.ts +48 -0
  35. package/src/openai/loggers/console-payload-logger.test.ts +173 -0
  36. package/src/openai/loggers/console-payload-logger.ts +96 -0
  37. package/src/openai/loggers/console.ts +9 -0
  38. package/src/openai/loggers/file-payload-logger.test.ts +243 -0
  39. package/src/openai/loggers/file-payload-logger.ts +124 -0
  40. package/src/openai/loggers/file.ts +9 -0
  41. package/src/openai/loggers/index.ts +12 -0
  42. package/src/openai/loggers/sanitize-openai-log-data.test.ts +89 -0
  43. package/src/openai/loggers/sanitize-openai-log-data.ts +14 -0
  44. package/src/openai/message-converter.ts +23 -0
  45. package/src/openai/model-effort-table.ts +19 -0
  46. package/src/openai/model-effort-verification-config.test.ts +45 -0
  47. package/src/openai/model-effort-verification-config.ts +35 -0
  48. package/src/openai/openai-request-format.ts +116 -0
  49. package/src/openai/parsers/response-parser.test.ts +407 -0
  50. package/src/openai/parsers/response-parser.ts +48 -0
  51. package/src/openai/provider-definition.test.ts +74 -0
  52. package/src/openai/provider-definition.ts +139 -0
  53. package/src/openai/provider.test.ts +1686 -0
  54. package/src/openai/provider.ts +392 -0
  55. package/src/openai/reasoning-effort.test.ts +272 -0
  56. package/src/openai/reasoning-effort.ts +49 -0
  57. package/src/openai/request-id.ts +39 -0
  58. package/src/openai/request-options.ts +14 -0
  59. package/src/openai/responses-chat.ts +285 -0
  60. package/src/openai/responses-converter.ts +122 -0
  61. package/src/openai/responses-parser.ts +297 -0
  62. package/src/openai/responses-stream-utils.ts +45 -0
  63. package/src/openai/responses-types.ts +193 -0
  64. package/src/openai/streaming/stream-assembler.ts +3 -0
  65. package/src/openai/types/api-types.ts +113 -0
  66. package/src/openai/types.ts +235 -0
  67. package/CHANGELOG.md +0 -614
  68. package/README.md +0 -467
  69. package/dist/browser/index.d.ts +0 -307
  70. package/dist/browser/index.js +0 -2
@@ -0,0 +1,225 @@
1
+ import { toProviderError } from '@robota-sdk/agent-core';
2
+ import {
3
+ observeProviderNativeRawPayloadStream,
4
+ toOpenAICompatibleToolChoice,
5
+ } from '@robota-sdk/agent-provider-openai-compatible/shared';
6
+
7
+ import { convertToOpenAIMessages, convertToOpenAITools } from './message-converter';
8
+ import { buildOpenAIChatResponseFormat, mergeChatResponseFormat } from './openai-request-format';
9
+ import {
10
+ awaitWithProviderRequestId,
11
+ readOpenAIRequestId,
12
+ withProviderRequestId,
13
+ } from './request-id';
14
+ import { openAIRequestOptions } from './request-options';
15
+ import { assembleOpenAIStream } from './streaming/stream-assembler';
16
+
17
+ import type { IPayloadLogger } from './interfaces/payload-logger';
18
+ import type { OpenAIResponseParser } from './parsers/response-parser';
19
+ import type { IOpenAIProviderOptions } from './types';
20
+ import type { IOpenAILogData } from './types/api-types';
21
+ import type { IChatOptions, TTextDeltaCallback, TUniversalMessage } from '@robota-sdk/agent-core';
22
+ import type OpenAI from 'openai';
23
+
24
+ export interface IOpenAIChatCompletionsOptions {
25
+ client?: OpenAI;
26
+ messages: TUniversalMessage[];
27
+ chatOptions?: IChatOptions;
28
+ providerOptions: IOpenAIProviderOptions;
29
+ payloadLogger?: IPayloadLogger;
30
+ responseParser: OpenAIResponseParser;
31
+ onTextDelta?: TTextDeltaCallback;
32
+ /** Per-request headers (trusted `traceparent`), sent after the raw request payload is captured. */
33
+ requestHeaders?: Readonly<Record<string, string>>;
34
+ }
35
+
36
+ export async function chatWithOpenAIChatCompletions(
37
+ input: IOpenAIChatCompletionsOptions,
38
+ ): Promise<TUniversalMessage> {
39
+ const client = requireClient(input.client);
40
+
41
+ try {
42
+ const requestParams = buildChatRequestParams(input);
43
+ const textDeltaCb = input.chatOptions?.onTextDelta ?? input.onTextDelta;
44
+ if (textDeltaCb) {
45
+ return await chatWithStreamingAssembly(client, input, {
46
+ ...requestParams,
47
+ stream: true,
48
+ ...buildStreamOptions(input),
49
+ });
50
+ }
51
+
52
+ await logPayload(input, requestParams, 'chat');
53
+ input.chatOptions?.onProviderNativeRawPayload?.({
54
+ provider: 'openai',
55
+ apiSurface: 'chat-completions',
56
+ payloadKind: 'request',
57
+ payload: requestParams,
58
+ });
59
+ const requestOptions = openAIRequestOptions(undefined, input.requestHeaders);
60
+ const response = requestOptions
61
+ ? await client.chat.completions.create(requestParams, requestOptions)
62
+ : await client.chat.completions.create(requestParams);
63
+ input.chatOptions?.onProviderNativeRawPayload?.({
64
+ provider: 'openai',
65
+ apiSurface: 'chat-completions',
66
+ payloadKind: 'response',
67
+ payload: response,
68
+ });
69
+ return withProviderRequestId(
70
+ input.responseParser.parseResponse(response),
71
+ readOpenAIRequestId(response),
72
+ );
73
+ } catch (error) {
74
+ throw toProviderError(error, 'openai', 'OpenAI chat failed');
75
+ }
76
+ }
77
+
78
+ export async function* chatStreamWithOpenAIChatCompletions(
79
+ input: IOpenAIChatCompletionsOptions,
80
+ ): AsyncIterable<TUniversalMessage> {
81
+ const client = requireClient(input.client);
82
+
83
+ try {
84
+ const requestParams: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {
85
+ ...buildChatRequestParams(input),
86
+ stream: true,
87
+ ...buildStreamOptions(input),
88
+ };
89
+
90
+ await logPayload(input, requestParams, 'stream');
91
+ input.chatOptions?.onProviderNativeRawPayload?.({
92
+ provider: 'openai',
93
+ apiSurface: 'chat-completions',
94
+ payloadKind: 'request',
95
+ payload: requestParams,
96
+ });
97
+ const { data: stream, providerRequestId } = await awaitWithProviderRequestId(
98
+ client.chat.completions.create(
99
+ requestParams,
100
+ openAIRequestOptions(input.chatOptions?.signal, input.requestHeaders),
101
+ ),
102
+ );
103
+
104
+ for await (const chunk of observeProviderNativeRawPayloadStream(stream, {
105
+ provider: 'openai',
106
+ apiSurface: 'chat-completions',
107
+ onProviderNativeRawPayload: input.chatOptions?.onProviderNativeRawPayload,
108
+ })) {
109
+ const universalMessage = input.responseParser.parseStreamingChunk(chunk);
110
+ if (universalMessage) {
111
+ yield withProviderRequestId(universalMessage, providerRequestId);
112
+ }
113
+ }
114
+ } catch (error) {
115
+ throw toProviderError(error, 'openai', 'OpenAI stream failed');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Streaming requests opt into token usage via `stream_options: { include_usage: true }`
121
+ * unless the provider sets `includeStreamUsage: false`. Only ever sent on streaming requests.
122
+ */
123
+ function buildStreamOptions(
124
+ input: IOpenAIChatCompletionsOptions,
125
+ ): { stream_options: { include_usage: true } } | Record<string, never> {
126
+ if (input.providerOptions.includeStreamUsage === false) {
127
+ return {};
128
+ }
129
+ return { stream_options: { include_usage: true } };
130
+ }
131
+
132
+ function buildChatRequestParams(
133
+ input: IOpenAIChatCompletionsOptions,
134
+ ): OpenAI.Chat.ChatCompletionCreateParamsNonStreaming {
135
+ const openaiMessages = convertToOpenAIMessages(input.messages);
136
+ const model = input.chatOptions?.model ?? input.providerOptions.defaultModel;
137
+ if (!model) {
138
+ throw new Error(
139
+ 'Model is required in chat options. Please specify a model in defaultModel configuration.',
140
+ );
141
+ }
142
+
143
+ const responseFormat = buildOpenAIChatResponseFormat(
144
+ mergeChatResponseFormat(input.providerOptions, input.chatOptions?.responseFormat),
145
+ );
146
+ return {
147
+ model,
148
+ messages: openaiMessages,
149
+ ...(input.chatOptions?.temperature !== undefined && {
150
+ temperature: input.chatOptions.temperature,
151
+ }),
152
+ ...(input.chatOptions?.maxTokens !== undefined && { max_tokens: input.chatOptions.maxTokens }),
153
+ ...(input.chatOptions?.tools && {
154
+ tools: convertToOpenAITools(input.chatOptions.tools),
155
+ tool_choice: toOpenAICompatibleToolChoice(input.chatOptions.toolChoice),
156
+ }),
157
+ ...(responseFormat !== undefined && { response_format: responseFormat }),
158
+ } as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming;
159
+ }
160
+
161
+ async function chatWithStreamingAssembly(
162
+ client: OpenAI,
163
+ input: IOpenAIChatCompletionsOptions,
164
+ requestParams: OpenAI.Chat.ChatCompletionCreateParamsStreaming,
165
+ ): Promise<TUniversalMessage> {
166
+ try {
167
+ await logPayload(input, requestParams, 'stream');
168
+ input.chatOptions?.onProviderNativeRawPayload?.({
169
+ provider: 'openai',
170
+ apiSurface: 'chat-completions',
171
+ payloadKind: 'request',
172
+ payload: requestParams,
173
+ });
174
+ const { data: stream, providerRequestId } = await awaitWithProviderRequestId(
175
+ client.chat.completions.create(
176
+ requestParams,
177
+ openAIRequestOptions(input.chatOptions?.signal, input.requestHeaders),
178
+ ),
179
+ );
180
+
181
+ const assembled = await assembleOpenAIStream({
182
+ stream: observeProviderNativeRawPayloadStream(stream, {
183
+ provider: 'openai',
184
+ apiSurface: 'chat-completions',
185
+ onProviderNativeRawPayload: input.chatOptions?.onProviderNativeRawPayload,
186
+ }),
187
+ onTextDelta: input.chatOptions?.onTextDelta ?? input.onTextDelta,
188
+ signal: input.chatOptions?.signal,
189
+ });
190
+ return withProviderRequestId(assembled, providerRequestId);
191
+ } catch (error) {
192
+ throw toProviderError(error, 'openai', 'OpenAI stream failed');
193
+ }
194
+ }
195
+
196
+ async function logPayload(
197
+ input: IOpenAIChatCompletionsOptions,
198
+ requestParams:
199
+ | OpenAI.Chat.ChatCompletionCreateParamsNonStreaming
200
+ | OpenAI.Chat.ChatCompletionCreateParamsStreaming,
201
+ type: 'chat' | 'stream',
202
+ ): Promise<void> {
203
+ if (!input.payloadLogger?.isEnabled()) {
204
+ return;
205
+ }
206
+
207
+ const logData: IOpenAILogData = {
208
+ model: requestParams.model,
209
+ messagesCount: requestParams.messages.length,
210
+ hasTools: !!requestParams.tools,
211
+ temperature: requestParams.temperature ?? undefined,
212
+ maxTokens: requestParams.max_tokens ?? undefined,
213
+ timestamp: new Date().toISOString(),
214
+ };
215
+ await input.payloadLogger.logPayload(logData, type);
216
+ }
217
+
218
+ function requireClient(client: OpenAI | undefined): OpenAI {
219
+ if (!client) {
220
+ throw new Error(
221
+ 'OpenAI client not available. Either provide a client/apiKey or use an executor.',
222
+ );
223
+ }
224
+ return client;
225
+ }
@@ -0,0 +1,213 @@
1
+ import { OpenAIProvider } from './provider';
2
+ import { LocalExecutor } from '@robota-sdk/agent-core';
3
+ import type { TUniversalMessage } from '@robota-sdk/agent-core';
4
+
5
+ describe('OpenAI Provider Executor Integration', () => {
6
+ let localExecutor: LocalExecutor;
7
+ let provider: OpenAIProvider;
8
+
9
+ beforeEach(() => {
10
+ localExecutor = new LocalExecutor();
11
+
12
+ // Create a mock provider to register with the executor
13
+ const mockProvider = {
14
+ name: 'openai',
15
+ async chat(messages: TUniversalMessage[]): Promise<TUniversalMessage> {
16
+ return {
17
+ id: 'msg-1',
18
+ state: 'complete' as const,
19
+ role: 'assistant' as const,
20
+ content: `Mock response: ${messages[messages.length - 1]?.content}`,
21
+ timestamp: new Date(),
22
+ };
23
+ },
24
+ async *chatStream(messages: TUniversalMessage[]): AsyncIterable<TUniversalMessage> {
25
+ const chunks = ['Mock', ' streaming', ' response'];
26
+ for (const chunk of chunks) {
27
+ yield {
28
+ id: 'msg-1',
29
+ state: 'complete' as const,
30
+ role: 'assistant' as const,
31
+ content: chunk,
32
+ timestamp: new Date(),
33
+ };
34
+ }
35
+ },
36
+ supportsTools: () => true,
37
+ validateConfig: () => true,
38
+ dispose: async () => {},
39
+ };
40
+
41
+ localExecutor.registerProvider('openai', mockProvider);
42
+
43
+ // Create provider with executor (no API key needed)
44
+ provider = new OpenAIProvider({
45
+ executor: localExecutor,
46
+ });
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await provider.dispose();
51
+ });
52
+
53
+ describe('Provider with Executor', () => {
54
+ it('should use executor for chat requests', async () => {
55
+ const messages: TUniversalMessage[] = [
56
+ {
57
+ id: 'msg-1',
58
+ state: 'complete' as const,
59
+ role: 'user',
60
+ content: 'Hello!',
61
+ timestamp: new Date(),
62
+ },
63
+ ];
64
+
65
+ const options = {
66
+ model: 'gpt-4',
67
+ temperature: 0.7,
68
+ };
69
+
70
+ const response = await provider.chat(messages, options);
71
+
72
+ expect(response.role).toBe('assistant');
73
+ expect(response.content).toContain('Mock response: Hello!');
74
+ });
75
+
76
+ it('should use executor for streaming chat requests', async () => {
77
+ const messages: TUniversalMessage[] = [
78
+ {
79
+ id: 'msg-1',
80
+ state: 'complete' as const,
81
+ role: 'user',
82
+ content: 'Tell me a story',
83
+ timestamp: new Date(),
84
+ },
85
+ ];
86
+
87
+ const options = {
88
+ model: 'gpt-4',
89
+ temperature: 0.7,
90
+ };
91
+
92
+ const chunks: string[] = [];
93
+ for await (const chunk of provider.chatStream(messages, options)) {
94
+ if (chunk.content) {
95
+ chunks.push(chunk.content);
96
+ }
97
+ }
98
+
99
+ expect(chunks).toEqual(['Mock', ' streaming', ' response']);
100
+ });
101
+
102
+ it('should handle executor errors gracefully', async () => {
103
+ // Create provider with executor that doesn't have registered provider
104
+ const errorExecutor = new LocalExecutor();
105
+ const errorProvider = new OpenAIProvider({
106
+ executor: errorExecutor,
107
+ });
108
+
109
+ const messages: TUniversalMessage[] = [
110
+ {
111
+ id: 'msg-1',
112
+ state: 'complete' as const,
113
+ role: 'user',
114
+ content: 'Hello!',
115
+ timestamp: new Date(),
116
+ },
117
+ ];
118
+
119
+ await expect(errorProvider.chat(messages, { model: 'gpt-4' })).rejects.toThrow(
120
+ 'Provider "openai" not registered with LocalExecutor',
121
+ );
122
+
123
+ await errorProvider.dispose();
124
+ });
125
+
126
+ it('should support tools when executor supports tools', () => {
127
+ expect(provider.supportsTools()).toBe(true);
128
+ });
129
+
130
+ it('should validate configuration with executor', () => {
131
+ // Provider with executor should validate successfully
132
+ expect(provider.supportsTools()).toBe(true);
133
+ });
134
+
135
+ it('does not dispose an INJECTED executor, whose lifecycle its creator owns', async () => {
136
+ // HARNESS-052. This test previously read `should clean up executor when provider is
137
+ // disposed` and asserted `expect(true).toBe(true)` — a claim the assertion could not check
138
+ // and which `OpenAIProvider.dispose()` does not in fact make: it is empty. The executor is
139
+ // constructor-injected, so disposing it here would tear down a dependency the caller still
140
+ // owns and may share with other providers. This asserts the real contract, and goes RED if
141
+ // provider disposal ever starts reaching into the injected executor.
142
+ const executorDispose = vi.spyOn(localExecutor, 'dispose');
143
+
144
+ await provider.dispose();
145
+
146
+ expect(executorDispose).not.toHaveBeenCalled();
147
+ // The executor stays usable after the provider that borrowed it is gone.
148
+ expect(localExecutor.getProvider('openai')).toBeDefined();
149
+ });
150
+ });
151
+
152
+ describe('Provider without Executor', () => {
153
+ it('should require either client, apiKey, or executor', () => {
154
+ expect(() => {
155
+ new OpenAIProvider({});
156
+ }).toThrow('Either OpenAI client, apiKey, or executor is required');
157
+ });
158
+
159
+ it('should work with API key (traditional mode)', () => {
160
+ const apiKeyProvider = new OpenAIProvider({
161
+ apiKey: 'sk-test-key',
162
+ });
163
+
164
+ expect(apiKeyProvider.name).toBe('openai');
165
+ expect(apiKeyProvider.version).toBe('1.0.0');
166
+ });
167
+
168
+ it('should work with client (traditional mode)', () => {
169
+ const mockClient = {} as any; // Mock OpenAI client
170
+ const clientProvider = new OpenAIProvider({
171
+ client: mockClient,
172
+ });
173
+
174
+ expect(clientProvider.name).toBe('openai');
175
+ expect(clientProvider.version).toBe('1.0.0');
176
+ });
177
+ });
178
+
179
+ describe('Mixed Mode Validation', () => {
180
+ it('should prioritize executor over direct API when both are provided', async () => {
181
+ const mixedProvider = new OpenAIProvider({
182
+ apiKey: 'sk-test-key',
183
+ executor: localExecutor,
184
+ });
185
+
186
+ // Should use executor, not direct API
187
+ const messages: TUniversalMessage[] = [
188
+ {
189
+ id: 'msg-1',
190
+ state: 'complete' as const,
191
+ role: 'user',
192
+ content: 'Hello!',
193
+ timestamp: new Date(),
194
+ },
195
+ ];
196
+
197
+ const response = await mixedProvider.chat(messages, { model: 'gpt-4' });
198
+ expect(response.content).toContain('Mock response:');
199
+
200
+ await mixedProvider.dispose();
201
+ });
202
+
203
+ it('should handle executor initialization properly', () => {
204
+ const provider = new OpenAIProvider({
205
+ executor: localExecutor,
206
+ });
207
+
208
+ // Check that executor was set correctly
209
+ expect((provider as any).executor).toBe(localExecutor);
210
+ expect((provider as any).client).toBeUndefined();
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @robota-sdk/agent-provider (openai)
3
+ *
4
+ * Provides Provider implementation for using OpenAI API.
5
+ */
6
+
7
+ // Export main provider (new architecture)
8
+ export { OpenAIProvider } from './provider';
9
+
10
+ // Export types and utilities
11
+ export * from './types';
12
+ export * from './adapter';
13
+ export * from './provider-definition';
14
+
15
+ // Export payload logging interfaces only (implementations are in separate subpaths)
16
+ export type { IPayloadLogger, IPayloadLoggerOptions } from './interfaces/payload-logger';
17
+
18
+ // Export modular components (optional - for advanced users)
19
+ // export { OpenAIResponseParser } from './parsers/response-parser';
@@ -0,0 +1,48 @@
1
+ import type { IOpenAILogData } from '../types/api-types';
2
+ import type { ILogger } from '@robota-sdk/agent-core';
3
+
4
+ /**
5
+ * IPayloadLogger interface for logging OpenAI API payloads
6
+ *
7
+ * This interface provides a contract for different logging implementations:
8
+ * - FilePayloadLogger: Node.js file-based logging
9
+ * - ConsolePayloadLogger: Browser console-based logging
10
+ * - Custom implementations: User-defined loggers
11
+ */
12
+ export interface IPayloadLogger {
13
+ /**
14
+ * Check if logging is enabled
15
+ * @returns true if logging is active, false otherwise
16
+ */
17
+ isEnabled(): boolean;
18
+
19
+ /**
20
+ * Log API payload data
21
+ * @param payload - The API request/response payload data
22
+ * @param type - Type of operation ('chat' or 'stream')
23
+ */
24
+ logPayload(payload: IOpenAILogData, type: 'chat' | 'stream'): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Configuration options for payload loggers
29
+ */
30
+ export interface IPayloadLoggerOptions {
31
+ /**
32
+ * Whether logging is enabled
33
+ * @defaultValue true
34
+ */
35
+ enabled?: boolean;
36
+
37
+ /**
38
+ * Include timestamp in log entries
39
+ * @defaultValue true
40
+ */
41
+ includeTimestamp?: boolean;
42
+
43
+ /**
44
+ * Logger instance for console output
45
+ * @defaultValue SilentLogger
46
+ */
47
+ logger?: ILogger;
48
+ }
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ConsolePayloadLogger } from './console-payload-logger';
3
+ import type { ILogger } from '@robota-sdk/agent-core';
4
+ import type { IOpenAILogData } from '../types/api-types';
5
+
6
+ function createMockLogger(): ILogger & {
7
+ group: ReturnType<typeof vi.fn>;
8
+ groupEnd: ReturnType<typeof vi.fn>;
9
+ } {
10
+ return {
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ error: vi.fn(),
14
+ debug: vi.fn(),
15
+ log: vi.fn(),
16
+ group: vi.fn(),
17
+ groupEnd: vi.fn(),
18
+ };
19
+ }
20
+
21
+ function createSamplePayload(): IOpenAILogData {
22
+ return {
23
+ model: 'gpt-4',
24
+ messagesCount: 3,
25
+ hasTools: true,
26
+ temperature: 0.7,
27
+ maxTokens: 1000,
28
+ timestamp: '2026-01-01T00:00:00.000Z',
29
+ };
30
+ }
31
+
32
+ describe('ConsolePayloadLogger', () => {
33
+ describe('constructor', () => {
34
+ it('should default to enabled', () => {
35
+ const logger = new ConsolePayloadLogger();
36
+ expect(logger.isEnabled()).toBe(true);
37
+ });
38
+
39
+ it('should respect enabled option set to false', () => {
40
+ const logger = new ConsolePayloadLogger({ enabled: false });
41
+ expect(logger.isEnabled()).toBe(false);
42
+ });
43
+
44
+ it('should accept custom logger', () => {
45
+ const mockLogger = createMockLogger();
46
+ const logger = new ConsolePayloadLogger({ logger: mockLogger });
47
+ expect(logger.isEnabled()).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe('isEnabled', () => {
52
+ it('should return true when enabled', () => {
53
+ const logger = new ConsolePayloadLogger({ enabled: true });
54
+ expect(logger.isEnabled()).toBe(true);
55
+ });
56
+
57
+ it('should return false when disabled', () => {
58
+ const logger = new ConsolePayloadLogger({ enabled: false });
59
+ expect(logger.isEnabled()).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe('logPayload', () => {
64
+ it('should log payload to console with chat type', async () => {
65
+ const mockLogger = createMockLogger();
66
+ const logger = new ConsolePayloadLogger({ logger: mockLogger });
67
+ const payload = createSamplePayload();
68
+
69
+ await logger.logPayload(payload, 'chat');
70
+
71
+ expect(mockLogger.group).toHaveBeenCalledWith(expect.stringContaining('[OpenAI CHAT]'));
72
+ expect(mockLogger.info).toHaveBeenCalled();
73
+ expect(mockLogger.debug).toHaveBeenCalled();
74
+ expect(mockLogger.groupEnd).toHaveBeenCalled();
75
+ });
76
+
77
+ it('should log payload with stream type', async () => {
78
+ const mockLogger = createMockLogger();
79
+ const logger = new ConsolePayloadLogger({ logger: mockLogger });
80
+ const payload = createSamplePayload();
81
+
82
+ await logger.logPayload(payload, 'stream');
83
+
84
+ expect(mockLogger.group).toHaveBeenCalledWith(expect.stringContaining('[OpenAI STREAM]'));
85
+ });
86
+
87
+ it('should include timestamp in group title when includeTimestamp is true', async () => {
88
+ const mockLogger = createMockLogger();
89
+ const logger = new ConsolePayloadLogger({
90
+ logger: mockLogger,
91
+ includeTimestamp: true,
92
+ });
93
+ const payload = createSamplePayload();
94
+
95
+ await logger.logPayload(payload, 'chat');
96
+
97
+ expect(mockLogger.group).toHaveBeenCalledWith(expect.stringContaining(payload.timestamp));
98
+ });
99
+
100
+ it('should not include timestamp when includeTimestamp is false', async () => {
101
+ const mockLogger = createMockLogger();
102
+ const logger = new ConsolePayloadLogger({
103
+ logger: mockLogger,
104
+ includeTimestamp: false,
105
+ });
106
+ const payload = createSamplePayload();
107
+
108
+ await logger.logPayload(payload, 'chat');
109
+
110
+ const groupCall = mockLogger.group.mock.calls[0][0] as string;
111
+ expect(groupCall).not.toContain(payload.timestamp);
112
+ });
113
+
114
+ it('should skip logging when disabled', async () => {
115
+ const mockLogger = createMockLogger();
116
+ const logger = new ConsolePayloadLogger({
117
+ logger: mockLogger,
118
+ enabled: false,
119
+ });
120
+ const payload = createSamplePayload();
121
+
122
+ await logger.logPayload(payload, 'chat');
123
+
124
+ expect(mockLogger.group).not.toHaveBeenCalled();
125
+ expect(mockLogger.info).not.toHaveBeenCalled();
126
+ expect(mockLogger.debug).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('should not throw on internal error and log the error', async () => {
130
+ const mockLogger = createMockLogger();
131
+ // Make group throw an error
132
+ mockLogger.group.mockImplementation(() => {
133
+ throw new Error('Console error');
134
+ });
135
+ const logger = new ConsolePayloadLogger({ logger: mockLogger });
136
+ const payload = createSamplePayload();
137
+
138
+ // Should not throw
139
+ await expect(logger.logPayload(payload, 'chat')).resolves.toBeUndefined();
140
+ expect(mockLogger.error).toHaveBeenCalledWith(
141
+ expect.stringContaining('[ConsolePayloadLogger]'),
142
+ expect.any(String),
143
+ );
144
+ });
145
+
146
+ it('should log request details with payload fields', async () => {
147
+ const mockLogger = createMockLogger();
148
+ const logger = new ConsolePayloadLogger({ logger: mockLogger });
149
+ const payload = createSamplePayload();
150
+
151
+ await logger.logPayload(payload, 'chat');
152
+
153
+ expect(mockLogger.info).toHaveBeenCalledWith(
154
+ expect.any(String),
155
+ expect.objectContaining({
156
+ model: 'gpt-4',
157
+ messagesCount: 3,
158
+ hasTools: true,
159
+ }),
160
+ );
161
+ });
162
+
163
+ it('should default type to chat', async () => {
164
+ const mockLogger = createMockLogger();
165
+ const logger = new ConsolePayloadLogger({ logger: mockLogger });
166
+ const payload = createSamplePayload();
167
+
168
+ await logger.logPayload(payload, 'chat');
169
+
170
+ expect(mockLogger.group).toHaveBeenCalledWith(expect.stringContaining('CHAT'));
171
+ });
172
+ });
173
+ });