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

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 +69 -86
  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,392 @@
1
+ import { AbstractAIProvider } from '@robota-sdk/agent-core';
2
+ import { createModelEffortOutcome } from '@robota-sdk/agent-core';
3
+ import { PERMISSIVE_TOOL_SCHEMA_PROFILE, STRICT_TOOL_SCHEMA_PROFILE } from '@robota-sdk/agent-core';
4
+ import { resolveModelEffort } from '@robota-sdk/agent-core';
5
+ import { SilentLogger } from '@robota-sdk/agent-core';
6
+ import { traceHeadersFor } from '@robota-sdk/agent-core';
7
+ import OpenAI from 'openai';
8
+
9
+ import {
10
+ chatStreamWithOpenAIChatCompletions,
11
+ chatWithOpenAIChatCompletions,
12
+ } from './chat-completions-chat';
13
+ import { OPENAI_MODEL_EFFORT_TABLE } from './model-effort-table';
14
+ import { OpenAIResponseParser } from './parsers/response-parser';
15
+ import { chatStreamWithOpenAIResponsesApi, chatWithOpenAIResponsesApi } from './responses-chat';
16
+
17
+ import type { IPayloadLogger } from './interfaces/payload-logger';
18
+ import type { IOpenAIProviderOptions, TOpenAIApiSurface } from './types';
19
+ import type {
20
+ TUniversalMessage,
21
+ IChatOptions,
22
+ IAssistantMessage,
23
+ IProviderCapabilities,
24
+ IProviderModelEffortTable,
25
+ IToolSchemaProjectionProfile,
26
+ TTextDeltaCallback,
27
+ } from '@robota-sdk/agent-core';
28
+
29
+ /**
30
+ * OpenAI provider implementation for Robota
31
+ *
32
+ * Provides integration with OpenAI models through the Robota provider contract.
33
+ * Uses OpenAI SDK native types internally for optimal performance and feature support.
34
+ *
35
+ * @public
36
+ */
37
+ export class OpenAIProvider extends AbstractAIProvider {
38
+ override readonly name = 'openai';
39
+ override readonly version = '1.0.0';
40
+
41
+ private readonly client?: OpenAI;
42
+ private readonly options: IOpenAIProviderOptions;
43
+ private readonly apiSurface: TOpenAIApiSurface;
44
+ private readonly payloadLogger: IPayloadLogger | undefined;
45
+ private readonly responseParser: OpenAIResponseParser;
46
+
47
+ /**
48
+ * Optional callback for text deltas during streaming.
49
+ * Set by the consumer (e.g., Session) to receive real-time text chunks.
50
+ * When set, chat() uses streaming internally while still returning
51
+ * the complete assembled message.
52
+ */
53
+ onTextDelta?: TTextDeltaCallback;
54
+
55
+ constructor(options: IOpenAIProviderOptions) {
56
+ super(options.logger || SilentLogger);
57
+ this.options = options;
58
+ this.apiSurface = resolveApiSurface(options);
59
+ validateOpenAIProviderNativeWebTools(this.apiSurface, options.nativeWebTools);
60
+
61
+ if (options.executor) {
62
+ this.executor = options.executor;
63
+ }
64
+
65
+ if (!this.executor) {
66
+ if (options.client) {
67
+ this.client = options.client;
68
+ } else if (options.apiKey) {
69
+ this.client = new OpenAI({
70
+ apiKey: options.apiKey,
71
+ ...(options.organization && { organization: options.organization }),
72
+ ...(options.timeout && { timeout: options.timeout }),
73
+ ...(options.baseURL && { baseURL: options.baseURL }),
74
+ });
75
+ } else {
76
+ throw new Error('Either OpenAI client, apiKey, or executor is required');
77
+ }
78
+ }
79
+
80
+ this.responseParser = new OpenAIResponseParser(this.logger);
81
+ this.payloadLogger = options.payloadLogger;
82
+ }
83
+
84
+ override async chat(
85
+ messages: TUniversalMessage[],
86
+ options?: IChatOptions,
87
+ ): Promise<TUniversalMessage> {
88
+ this.validateMessages(messages);
89
+ this.validateNativeWebTools(options?.nativeWebTools);
90
+ const resolvedOptions = this.resolveEffortOptions(options);
91
+
92
+ if (this.executor) {
93
+ try {
94
+ const result = await this.executeViaExecutorOrDirect(messages, resolvedOptions);
95
+ if (result.modelEffortOutcome === undefined) {
96
+ this.publishModelEffortOutcome(resolvedOptions, 'opaque-executor');
97
+ }
98
+ return result.message;
99
+ } catch (error) {
100
+ this.logger.error(
101
+ 'OpenAI Provider executor chat error:',
102
+ error instanceof Error ? error.message : String(error),
103
+ );
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ if (this.apiSurface === 'responses') {
109
+ const response = await chatWithOpenAIResponsesApi({
110
+ client: this.client,
111
+ messages,
112
+ chatOptions: this.projectChatOptions(resolvedOptions),
113
+ providerOptions: this.options,
114
+ onTextDelta: this.onTextDelta,
115
+ requestHeaders: this.traceRequestHeaders(resolvedOptions),
116
+ });
117
+ this.publishModelEffortOutcome(resolvedOptions);
118
+ return response;
119
+ }
120
+
121
+ const response = await chatWithOpenAIChatCompletions({
122
+ client: this.client,
123
+ messages,
124
+ chatOptions: this.projectChatOptions(resolvedOptions),
125
+ providerOptions: this.options,
126
+ payloadLogger: this.payloadLogger,
127
+ responseParser: this.responseParser,
128
+ onTextDelta: this.onTextDelta,
129
+ requestHeaders: this.traceRequestHeaders(resolvedOptions),
130
+ });
131
+ this.publishModelEffortOutcome(resolvedOptions);
132
+ return response;
133
+ }
134
+
135
+ override async *chatStream(
136
+ messages: TUniversalMessage[],
137
+ options?: IChatOptions,
138
+ ): AsyncIterable<TUniversalMessage> {
139
+ this.validateNativeWebTools(options?.nativeWebTools);
140
+ const resolvedOptions = this.resolveEffortOptions(options);
141
+
142
+ if (this.executor) {
143
+ try {
144
+ let executorOutcomeSeen = false;
145
+ for await (const event of this.executeStreamViaExecutorOrDirect(
146
+ messages,
147
+ resolvedOptions,
148
+ )) {
149
+ if (event.kind === 'message') {
150
+ yield event.message;
151
+ } else {
152
+ executorOutcomeSeen = event.modelEffortOutcome !== undefined;
153
+ }
154
+ }
155
+ if (!executorOutcomeSeen) {
156
+ this.publishModelEffortOutcome(resolvedOptions, 'opaque-executor');
157
+ }
158
+ return;
159
+ } catch (error) {
160
+ this.logger.error(
161
+ 'OpenAI Provider executor stream error:',
162
+ error instanceof Error ? error.message : String(error),
163
+ );
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ yield* this.streamDirect(messages, resolvedOptions);
169
+ }
170
+
171
+ private async *streamDirect(
172
+ messages: TUniversalMessage[],
173
+ resolvedOptions: IChatOptions | undefined,
174
+ ): AsyncIterable<TUniversalMessage> {
175
+ if (this.apiSurface === 'responses') {
176
+ yield* chatStreamWithOpenAIResponsesApi({
177
+ client: this.client,
178
+ messages,
179
+ chatOptions: this.projectChatOptions(resolvedOptions),
180
+ providerOptions: this.options,
181
+ onTextDelta: this.onTextDelta,
182
+ requestHeaders: this.traceRequestHeaders(resolvedOptions),
183
+ });
184
+ this.publishModelEffortOutcome(resolvedOptions);
185
+ return;
186
+ }
187
+
188
+ yield* chatStreamWithOpenAIChatCompletions({
189
+ client: this.client,
190
+ messages,
191
+ chatOptions: this.projectChatOptions(resolvedOptions),
192
+ providerOptions: this.options,
193
+ payloadLogger: this.payloadLogger,
194
+ responseParser: this.responseParser,
195
+ onTextDelta: this.onTextDelta,
196
+ requestHeaders: this.traceRequestHeaders(resolvedOptions),
197
+ });
198
+ this.publishModelEffortOutcome(resolvedOptions);
199
+ }
200
+
201
+ /**
202
+ * CORE-043: this provider declares no capability table — nobody has verified one for OpenAI, and
203
+ * inventing one would be a fabricated claim. It can still answer THIS question honestly, which is
204
+ * why the endpoint signal is not a field on the table.
205
+ *
206
+ * It matters most here of all the providers: setting `baseURL` also switches the API surface to
207
+ * `chat-completions` (see `resolveApiSurface`), so the advertised gateway configuration is exactly
208
+ * the one where a structured request is least likely to be enforced by whatever is on the far end.
209
+ */
210
+ endpointIsVendorDefault(): boolean {
211
+ return this.options.baseURL === undefined;
212
+ }
213
+
214
+ /**
215
+ * The client's own base URL is the origin every request goes to (the SDK has already applied a
216
+ * constructor option or `OPENAI_BASE_URL`). An executor sends elsewhere, and an injected client
217
+ * whose base URL cannot be read gives no origin to compare, so neither can propagate.
218
+ */
219
+ canPropagateTraceContext(): boolean {
220
+ return !this.executor && this.effectiveBaseUrl() !== undefined;
221
+ }
222
+
223
+ private effectiveBaseUrl(): string | undefined {
224
+ const baseURL: unknown = (this.client as { baseURL?: unknown } | undefined)?.baseURL;
225
+ return typeof baseURL === 'string' && baseURL.length > 0 ? baseURL : undefined;
226
+ }
227
+
228
+ private traceRequestHeaders(options: IChatOptions | undefined): Readonly<Record<string, string>> {
229
+ if (!this.canPropagateTraceContext()) return {};
230
+ return traceHeadersFor(this.effectiveBaseUrl(), options?.outboundTraceContext);
231
+ }
232
+
233
+ override effortTable(): IProviderModelEffortTable | undefined {
234
+ return this.endpointIsVendorDefault() && this.apiSurface === 'responses'
235
+ ? OPENAI_MODEL_EFFORT_TABLE
236
+ : undefined;
237
+ }
238
+
239
+ private resolveEffortOptions(options: IChatOptions | undefined): IChatOptions | undefined {
240
+ if (options?.effort === undefined || options.effortResolution !== undefined) return options;
241
+ const model = options.model ?? this.options.defaultModel;
242
+ if (model === undefined) return options;
243
+ return {
244
+ ...options,
245
+ effortResolution: resolveModelEffort(this.effortTable(), model, options.effort),
246
+ };
247
+ }
248
+
249
+ /**
250
+ * MCP-005: strict mode's supported subset is documented; non-strict accepts standard JSON Schema.
251
+ * Both the Responses and Chat Completions surfaces honor `strictTools` identically (§ Profiles).
252
+ */
253
+ protected override projectionProfile(): IToolSchemaProjectionProfile | undefined {
254
+ return this.options.strictTools
255
+ ? { ...STRICT_TOOL_SCHEMA_PROFILE, providerName: 'openai' }
256
+ : { ...PERMISSIVE_TOOL_SCHEMA_PROFILE, providerName: 'openai' };
257
+ }
258
+
259
+ /**
260
+ * Project `chatOptions.tools` through `projectionProfile()` before either request-building module
261
+ * (`responses-chat.ts`, `chat-completions-chat.ts`) sees them — the module helpers receive the
262
+ * projected array as a parameter; `options.tools` itself is never mutated. An empty projected array
263
+ * (every tool quarantined) becomes `undefined` so the downstream `tools &&` truthiness checks treat
264
+ * it the same as "no tools", not as a zero-length `tools: []` request field.
265
+ */
266
+ private projectChatOptions(chatOptions: IChatOptions | undefined): IChatOptions | undefined {
267
+ if (!chatOptions?.tools) {
268
+ return chatOptions;
269
+ }
270
+ const model = chatOptions.model ?? this.options.defaultModel ?? '';
271
+ const projected = this.projectTools(chatOptions.tools, model);
272
+ return {
273
+ ...chatOptions,
274
+ tools: projected && projected.length > 0 ? projected : undefined,
275
+ };
276
+ }
277
+
278
+ private publishModelEffortOutcome(
279
+ options: IChatOptions | undefined,
280
+ opaqueReason?: string,
281
+ ): void {
282
+ const resolution = options?.effortResolution;
283
+ const observer = options?.onModelEffortOutcome;
284
+ if (resolution === undefined || observer === undefined) return;
285
+
286
+ const nativeControl =
287
+ opaqueReason !== undefined
288
+ ? { state: 'omitted' as const, reason: opaqueReason }
289
+ : resolution.effective !== null && resolution.disposition !== 'model-default'
290
+ ? { state: 'sent' as const, id: 'responses.reasoning.effort' }
291
+ : {
292
+ state: 'omitted' as const,
293
+ reason:
294
+ resolution.disposition === 'model-default'
295
+ ? 'provider-default-selection'
296
+ : 'model-effort-not-applied',
297
+ };
298
+ const providerDispatch =
299
+ opaqueReason !== undefined
300
+ ? { state: 'not-dispatched' as const, reason: opaqueReason }
301
+ : { state: 'sent' as const };
302
+ try {
303
+ observer(createModelEffortOutcome(resolution, { nativeControl, providerDispatch }));
304
+ } catch (error) {
305
+ this.logger.warn('Model-effort outcome observer failed', {
306
+ error: error instanceof Error ? error.message : String(error),
307
+ });
308
+ }
309
+ }
310
+
311
+ override supportsTools(): boolean {
312
+ return true;
313
+ }
314
+
315
+ override getCapabilities(): IProviderCapabilities {
316
+ const source =
317
+ this.apiSurface === 'chat-completions'
318
+ ? 'openai-compatible-chat-completions'
319
+ : 'openai-responses';
320
+ return {
321
+ functionCalling: { supported: true },
322
+ nativeWebTools: {
323
+ webSearch: {
324
+ supported: false,
325
+ enabled: false,
326
+ source,
327
+ reason: getOpenAIUnsupportedNativeWebReason(this.apiSurface, 'search'),
328
+ },
329
+ webFetch: {
330
+ supported: false,
331
+ enabled: false,
332
+ source,
333
+ reason: getOpenAIUnsupportedNativeWebReason(this.apiSurface, 'fetch'),
334
+ },
335
+ },
336
+ };
337
+ }
338
+
339
+ override validateConfig(): boolean {
340
+ return !!this.client && !!this.options;
341
+ }
342
+
343
+ override async dispose(): Promise<void> {
344
+ // OpenAI client doesn't need explicit cleanup
345
+ }
346
+
347
+ protected override validateMessages(messages: TUniversalMessage[]): void {
348
+ super.validateMessages(messages);
349
+
350
+ for (const message of messages) {
351
+ if (message.role === 'assistant') {
352
+ const assistantMsg = message as IAssistantMessage;
353
+ if (
354
+ assistantMsg.toolCalls &&
355
+ assistantMsg.toolCalls.length > 0 &&
356
+ assistantMsg.content === ''
357
+ ) {
358
+ continue;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ function resolveApiSurface(options: IOpenAIProviderOptions): TOpenAIApiSurface {
366
+ if (options.apiSurface !== undefined) {
367
+ return options.apiSurface;
368
+ }
369
+ return options.baseURL ? 'chat-completions' : 'responses';
370
+ }
371
+
372
+ function getOpenAIUnsupportedNativeWebReason(
373
+ apiSurface: TOpenAIApiSurface,
374
+ toolKind: 'search' | 'fetch',
375
+ ): string {
376
+ if (apiSurface === 'chat-completions') {
377
+ return `OpenAI-compatible Chat Completions endpoints support declared function tools, not provider-native web ${toolKind}.`;
378
+ }
379
+ return `OpenAI Responses native web ${toolKind} is not wired in this Robota provider version.`;
380
+ }
381
+
382
+ function validateOpenAIProviderNativeWebTools(
383
+ apiSurface: TOpenAIApiSurface,
384
+ nativeWebTools: IOpenAIProviderOptions['nativeWebTools'],
385
+ ): void {
386
+ if (nativeWebTools?.webSearch !== true && nativeWebTools?.webFetch !== true) {
387
+ return;
388
+ }
389
+ throw new Error(
390
+ `Provider openai native web search/fetch is not supported for apiSurface ${apiSurface} in this Robota provider version.`,
391
+ );
392
+ }
@@ -0,0 +1,272 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ import { OpenAIProvider } from './provider';
4
+ import {
5
+ mapEffortToOpenAIReasoningEffort,
6
+ resolveOpenAIReasoningOptions,
7
+ } from './reasoning-effort';
8
+ import { OPENAI_MODEL_EFFORT_TABLE } from './model-effort-table';
9
+
10
+ import {
11
+ resolveModelEffort,
12
+ type IExecutor,
13
+ type IModelEffortOutcome,
14
+ type TUniversalMessage,
15
+ } from '@robota-sdk/agent-core';
16
+
17
+ // Mock OpenAI SDK (mirror provider.test.ts so chat() never hits the network).
18
+ vi.mock('openai', () => {
19
+ const MockOpenAI = vi.fn().mockImplementation(() => ({
20
+ chat: { completions: { create: vi.fn() } },
21
+ responses: { create: vi.fn() },
22
+ }));
23
+ return { default: MockOpenAI };
24
+ });
25
+
26
+ function createUserMessage(content: string): TUniversalMessage {
27
+ return { id: 'msg-1', state: 'complete' as const, role: 'user', content, timestamp: new Date() };
28
+ }
29
+
30
+ function getResponsesClient(provider: OpenAIProvider): {
31
+ responses: { create: ReturnType<typeof vi.fn> };
32
+ } {
33
+ return (provider as unknown as { client: { responses: { create: ReturnType<typeof vi.fn> } } })
34
+ .client;
35
+ }
36
+
37
+ function stubResponsesResolve(create: ReturnType<typeof vi.fn>): void {
38
+ create.mockResolvedValue({
39
+ id: 'resp-effort',
40
+ model: 'gpt-5.1',
41
+ output_text: 'ok',
42
+ output: [],
43
+ status: 'completed',
44
+ });
45
+ }
46
+
47
+ const REMOTE_EXECUTOR_OUTCOME: IModelEffortOutcome = {
48
+ resolution: {
49
+ selection: 'high',
50
+ effective: 'high',
51
+ disposition: 'exact',
52
+ fingerprint: 'gpt-5.1|high|high|exact|responses.reasoning.effort|2026-09-11',
53
+ },
54
+ nativeControl: { state: 'sent', id: 'responses.reasoning.effort' },
55
+ providerDispatch: { state: 'sent' },
56
+ };
57
+
58
+ describe('PRESET-008 reasoning-effort wiring', () => {
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ describe('mapEffortToOpenAIReasoningEffort', () => {
64
+ it('serializes the documented Responses vocabulary without framework-side clamping', () => {
65
+ expect(mapEffortToOpenAIReasoningEffort('none')).toBe('none');
66
+ expect(mapEffortToOpenAIReasoningEffort('minimal')).toBe('minimal');
67
+ expect(mapEffortToOpenAIReasoningEffort('low')).toBe('low');
68
+ expect(mapEffortToOpenAIReasoningEffort('medium')).toBe('medium');
69
+ expect(mapEffortToOpenAIReasoningEffort('high')).toBe('high');
70
+ expect(mapEffortToOpenAIReasoningEffort('xhigh')).toBe('xhigh');
71
+ expect(mapEffortToOpenAIReasoningEffort('max')).toBe('max');
72
+ });
73
+ });
74
+
75
+ describe('resolveOpenAIReasoningOptions', () => {
76
+ it('returns undefined when no effort and no static reasoning', () => {
77
+ expect(resolveOpenAIReasoningOptions(undefined, undefined)).toBeUndefined();
78
+ });
79
+
80
+ it('merges a verified resolution over static non-control reasoning fields', () => {
81
+ expect(
82
+ resolveOpenAIReasoningOptions(
83
+ { summary: 'auto' },
84
+ resolveModelEffort(OPENAI_MODEL_EFFORT_TABLE, 'gpt-5.1', 'max'),
85
+ ),
86
+ ).toEqual({
87
+ summary: 'auto',
88
+ effort: 'high',
89
+ });
90
+ });
91
+
92
+ it('omits the native control for auto and rejects a conflicting static effort', () => {
93
+ expect(
94
+ resolveOpenAIReasoningOptions(
95
+ undefined,
96
+ resolveModelEffort(OPENAI_MODEL_EFFORT_TABLE, 'gpt-5.1', 'auto'),
97
+ ),
98
+ ).toBeUndefined();
99
+ expect(() =>
100
+ resolveOpenAIReasoningOptions(
101
+ { effort: 'low' },
102
+ resolveModelEffort(OPENAI_MODEL_EFFORT_TABLE, 'gpt-5.1', 'high'),
103
+ ),
104
+ ).toThrow('conflicts with static reasoning.effort');
105
+ });
106
+
107
+ it('rejects a static native effort when auto would otherwise falsely report omission', () => {
108
+ expect(() =>
109
+ resolveOpenAIReasoningOptions(
110
+ { effort: 'low', summary: 'concise' },
111
+ resolveModelEffort(OPENAI_MODEL_EFFORT_TABLE, 'gpt-5.1', 'auto'),
112
+ ),
113
+ ).toThrow('provider-default selection');
114
+ });
115
+ });
116
+
117
+ // TC-01: a call with effort set on a native-effort provider → the built request
118
+ // carries that effort value on reasoning.effort.
119
+ it('TC-01: threads effort onto the OpenAI Responses reasoning.effort param', async () => {
120
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
121
+ const client = getResponsesClient(provider);
122
+ stubResponsesResolve(client.responses.create);
123
+
124
+ await provider.chat([createUserMessage('Hello')], { model: 'gpt-5.1', effort: 'max' });
125
+
126
+ expect(client.responses.create).toHaveBeenCalledWith(
127
+ expect.objectContaining({
128
+ reasoning: expect.objectContaining({ effort: 'high' }),
129
+ }),
130
+ undefined,
131
+ );
132
+ });
133
+
134
+ it('TC-01: passes a low effort straight through to reasoning.effort', async () => {
135
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
136
+ const client = getResponsesClient(provider);
137
+ stubResponsesResolve(client.responses.create);
138
+
139
+ await provider.chat([createUserMessage('Hello')], { model: 'gpt-5.1', effort: 'low' });
140
+
141
+ expect(client.responses.create).toHaveBeenCalledWith(
142
+ expect.objectContaining({ reasoning: expect.objectContaining({ effort: 'low' }) }),
143
+ undefined,
144
+ );
145
+ });
146
+
147
+ it('TC-02: an explicit high effort yields reasoning.effort === high', async () => {
148
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
149
+ const client = getResponsesClient(provider);
150
+ stubResponsesResolve(client.responses.create);
151
+
152
+ await provider.chat([createUserMessage('Hello')], { model: 'gpt-5.1', effort: 'high' });
153
+
154
+ expect(client.responses.create).toHaveBeenCalledWith(
155
+ expect.objectContaining({ reasoning: expect.objectContaining({ effort: 'high' }) }),
156
+ undefined,
157
+ );
158
+ });
159
+
160
+ it('TC-02: auto resolves the documented model default while omitting reasoning.effort', async () => {
161
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
162
+ const client = getResponsesClient(provider);
163
+ stubResponsesResolve(client.responses.create);
164
+
165
+ await provider.chat([createUserMessage('Hello')], { model: 'gpt-5.1', effort: 'auto' });
166
+
167
+ const [requestParams] = client.responses.create.mock.calls[0] as [Record<string, unknown>];
168
+ expect(requestParams).not.toHaveProperty('reasoning');
169
+ });
170
+
171
+ it('publishes one terminal outcome with separate resolution, native-control, and dispatch facts', async () => {
172
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
173
+ const client = getResponsesClient(provider);
174
+ stubResponsesResolve(client.responses.create);
175
+ const outcomes: unknown[] = [];
176
+
177
+ await provider.chat([createUserMessage('Hello')], {
178
+ model: 'gpt-5.1',
179
+ effort: 'max',
180
+ onModelEffortOutcome: (outcome) => outcomes.push(outcome),
181
+ });
182
+
183
+ expect(outcomes).toEqual([
184
+ expect.objectContaining({
185
+ resolution: expect.objectContaining({
186
+ selection: 'max',
187
+ effective: 'high',
188
+ disposition: 'clamped',
189
+ }),
190
+ nativeControl: { state: 'sent', id: 'responses.reasoning.effort' },
191
+ providerDispatch: { state: 'sent' },
192
+ }),
193
+ ]);
194
+ });
195
+
196
+ it('does not replace a remote executor outcome with a competing opaque-executor result', async () => {
197
+ const executor: IExecutor = {
198
+ name: 'remote-test',
199
+ version: '1.0.0',
200
+ async executeChat(request) {
201
+ request.options?.onModelEffortOutcome?.(REMOTE_EXECUTOR_OUTCOME);
202
+ return {
203
+ message: {
204
+ id: 'remote-response',
205
+ role: 'assistant',
206
+ content: 'ok',
207
+ state: 'complete',
208
+ timestamp: new Date(),
209
+ },
210
+ modelEffortOutcome: REMOTE_EXECUTOR_OUTCOME,
211
+ };
212
+ },
213
+ supportsTools: () => true,
214
+ validateConfig: () => true,
215
+ };
216
+ const provider = new OpenAIProvider({ executor });
217
+ const outcomes: IModelEffortOutcome[] = [];
218
+
219
+ await provider.chat([createUserMessage('Hello')], {
220
+ model: 'gpt-5.1',
221
+ effort: 'high',
222
+ onModelEffortOutcome: (outcome) => outcomes.push(outcome),
223
+ });
224
+
225
+ expect(outcomes).toEqual([REMOTE_EXECUTOR_OUTCOME]);
226
+ });
227
+
228
+ it('publishes one terminal outcome only after a Responses stream completes', async () => {
229
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
230
+ const client = getResponsesClient(provider);
231
+ async function* responsesStream() {
232
+ yield { type: 'response.output_text.delta', delta: 'ok' };
233
+ yield {
234
+ type: 'response.completed',
235
+ response: {
236
+ id: 'resp-effort-stream',
237
+ output_text: 'ok',
238
+ output: [],
239
+ status: 'completed',
240
+ },
241
+ };
242
+ }
243
+ client.responses.create.mockResolvedValue(responsesStream());
244
+ const outcomes: unknown[] = [];
245
+
246
+ for await (const _chunk of provider.chatStream([createUserMessage('Hello')], {
247
+ model: 'gpt-5.1',
248
+ effort: 'max',
249
+ onModelEffortOutcome: (outcome) => outcomes.push(outcome),
250
+ })) {
251
+ // Consume every chunk so the terminal stream outcome is eligible to publish.
252
+ }
253
+
254
+ expect(outcomes).toEqual([
255
+ expect.objectContaining({
256
+ resolution: expect.objectContaining({ effective: 'high', disposition: 'clamped' }),
257
+ nativeControl: { state: 'sent', id: 'responses.reasoning.effort' },
258
+ providerDispatch: { state: 'sent' },
259
+ }),
260
+ ]);
261
+ });
262
+
263
+ it('reports the source-dated table only for the official Responses endpoint', () => {
264
+ expect(new OpenAIProvider({ apiKey: 'sk-test' }).effortTable()).toBe(OPENAI_MODEL_EFFORT_TABLE);
265
+ expect(
266
+ new OpenAIProvider({
267
+ apiKey: 'sk-test',
268
+ apiSurface: 'chat-completions',
269
+ }).effortTable(),
270
+ ).toBeUndefined();
271
+ });
272
+ });