@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
@@ -1,10 +1,10 @@
1
- import { n as IPayloadLoggerOptions, r as IOpenAILogData, t as IPayloadLogger } from "../payload-logger-BaW0K8yI.cjs";
1
+ import { n as IPayloadLoggerOptions, r as IOpenAILogData, t as IPayloadLogger } from "../payload-logger-n89AQdUn.cjs";
2
2
  import { ILogger } from "@robota-sdk/agent-core";
3
3
  //#region src/openai/loggers/file-payload-logger.d.ts
4
4
  /**
5
5
  * File-based payload logger for Node.js environments
6
6
  *
7
- * This logger saves API request/response payloads to JSON files on disk.
7
+ * This logger saves a summary of each Chat Completions request to a JSON file on disk.
8
8
  * It's designed specifically for Node.js environments with filesystem access.
9
9
  *
10
10
  * @example
@@ -39,8 +39,8 @@ declare class FilePayloadLogger implements IPayloadLogger {
39
39
  */
40
40
  isEnabled(): boolean;
41
41
  /**
42
- * Log API payload to file
43
- * @param payload - The API request payload
42
+ * Log a request summary to file
43
+ * @param payload - Summary of the outgoing Chat Completions request
44
44
  * @param type - Type of request ('chat' or 'stream')
45
45
  */
46
46
  logPayload(payload: IOpenAILogData, type?: 'chat' | 'stream'): Promise<void>;
@@ -54,7 +54,7 @@ declare class FilePayloadLogger implements IPayloadLogger {
54
54
  /**
55
55
  * Console-based payload logger for browser environments
56
56
  *
57
- * This logger outputs API request/response payloads to the browser console
57
+ * This logger outputs a summary of each Chat Completions request to the browser console
58
58
  * using structured logging. It's designed specifically for browser environments
59
59
  * and development/debugging scenarios.
60
60
  *
@@ -84,7 +84,7 @@ declare class ConsolePayloadLogger implements IPayloadLogger {
84
84
  isEnabled(): boolean;
85
85
  /**
86
86
  * Log API payload to browser console
87
- * @param payload - The API request payload
87
+ * @param payload - Summary of the outgoing Chat Completions request
88
88
  * @param type - Type of request ('chat' or 'stream')
89
89
  */
90
90
  logPayload(payload: IOpenAILogData, type?: 'chat' | 'stream'): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../../src/openai/loggers/file-payload-logger.ts","../../../src/openai/loggers/console-payload-logger.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;cAqCa,6BAA6B;mBACvB;mBACA;mBACA;mBACA;EAEL,YAAA;IACV;IACA;IACA;IACA,SAAS;;;;;EAeX;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC;;;;UAwCrE;;;;;;;;;;;;;;;;;;;;;;;;;;cClFG,gCAAgC;mBAC1B;mBACA;mBACA;EAEL,YAAA,UAAS;;;;EASrB;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../../src/openai/loggers/file-payload-logger.ts","../../../src/openai/loggers/console-payload-logger.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;cAqCa,6BAA6B;mBACvB;mBACA;mBACA;mBACA;EAEL,YAAA;IACV;IACA;IACA;IACA,SAAS;;;;;EAeX;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC;;;;UAyCrE;;;;;;;;;;;;;;;;;;;;;;;;;;cCnFG,gCAAgC;mBAC1B;mBACA;mBACA;EAEL,YAAA,UAAS;;;;EASrB;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC"}
@@ -1,10 +1,10 @@
1
- import { n as IPayloadLoggerOptions, r as IOpenAILogData, t as IPayloadLogger } from "../payload-logger-BaW0K8yI.js";
1
+ import { n as IPayloadLoggerOptions, r as IOpenAILogData, t as IPayloadLogger } from "../payload-logger-n89AQdUn.js";
2
2
  import { ILogger } from "@robota-sdk/agent-core";
3
3
  //#region src/openai/loggers/file-payload-logger.d.ts
4
4
  /**
5
5
  * File-based payload logger for Node.js environments
6
6
  *
7
- * This logger saves API request/response payloads to JSON files on disk.
7
+ * This logger saves a summary of each Chat Completions request to a JSON file on disk.
8
8
  * It's designed specifically for Node.js environments with filesystem access.
9
9
  *
10
10
  * @example
@@ -39,8 +39,8 @@ declare class FilePayloadLogger implements IPayloadLogger {
39
39
  */
40
40
  isEnabled(): boolean;
41
41
  /**
42
- * Log API payload to file
43
- * @param payload - The API request payload
42
+ * Log a request summary to file
43
+ * @param payload - Summary of the outgoing Chat Completions request
44
44
  * @param type - Type of request ('chat' or 'stream')
45
45
  */
46
46
  logPayload(payload: IOpenAILogData, type?: 'chat' | 'stream'): Promise<void>;
@@ -54,7 +54,7 @@ declare class FilePayloadLogger implements IPayloadLogger {
54
54
  /**
55
55
  * Console-based payload logger for browser environments
56
56
  *
57
- * This logger outputs API request/response payloads to the browser console
57
+ * This logger outputs a summary of each Chat Completions request to the browser console
58
58
  * using structured logging. It's designed specifically for browser environments
59
59
  * and development/debugging scenarios.
60
60
  *
@@ -84,7 +84,7 @@ declare class ConsolePayloadLogger implements IPayloadLogger {
84
84
  isEnabled(): boolean;
85
85
  /**
86
86
  * Log API payload to browser console
87
- * @param payload - The API request payload
87
+ * @param payload - Summary of the outgoing Chat Completions request
88
88
  * @param type - Type of request ('chat' or 'stream')
89
89
  */
90
90
  logPayload(payload: IOpenAILogData, type?: 'chat' | 'stream'): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../../src/openai/loggers/file-payload-logger.ts","../../../src/openai/loggers/console-payload-logger.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;cAqCa,6BAA6B;mBACvB;mBACA;mBACA;mBACA;EAEL,YAAA;IACV;IACA;IACA;IACA,SAAS;;;;;EAeX;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC;;;;UAwCrE;;;;;;;;;;;;;;;;;;;;;;;;;;cClFG,gCAAgC;mBAC1B;mBACA;mBACA;EAEL,YAAA,UAAS;;;;EASrB;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../../src/openai/loggers/file-payload-logger.ts","../../../src/openai/loggers/console-payload-logger.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;cAqCa,6BAA6B;mBACvB;mBACA;mBACA;mBACA;EAEL,YAAA;IACV;IACA;IACA;IACA,SAAS;;;;;EAeX;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC;;;;UAyCrE;;;;;;;;;;;;;;;;;;;;;;;;;;cCnFG,gCAAgC;mBAC1B;mBACA;mBACA;EAEL,YAAA,UAAS;;;;EASrB;;;;;;EASM,WAAW,SAAS,gBAAgB,2BAAmC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/openai/loggers/sanitize-openai-log-data.ts","../../../src/openai/loggers/file-payload-logger.ts","../../../src/openai/loggers/console-payload-logger.ts"],"sourcesContent":["import type { IOpenAILogData } from '../types/api-types';\n\n/**\n * Creates a defensive deep copy of OpenAI log data.\n * SSOT utility shared by payload loggers.\n */\nexport function sanitizeOpenAILogData(payload: IOpenAILogData): IOpenAILogData {\n // Create a deep copy to avoid modifying original\n const sanitized = JSON.parse(JSON.stringify(payload)) as IOpenAILogData;\n\n // Remove or mask sensitive data if needed.\n // For now, we keep everything as OpenAI payloads don't contain API keys.\n return sanitized;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { SilentLogger } from '@robota-sdk/agent-core';\n\nimport { sanitizeOpenAILogData } from './sanitize-openai-log-data';\n\nimport type { IPayloadLogger } from '../interfaces/payload-logger';\nimport type { IOpenAILogData } from '../types/api-types';\nimport type { ILogger } from '@robota-sdk/agent-core';\n\n/** Owner-only access — no group or other bits (SEC-003 / CWE-377). */\nconst OWNER_ONLY_FILE_MODE = 0o600;\nconst OWNER_ONLY_DIR_MODE = 0o700;\n\n/**\n * File-based payload logger for Node.js environments\n *\n * This logger saves API request/response payloads to JSON files on disk.\n * It's designed specifically for Node.js environments with filesystem access.\n *\n * @example\n * ```typescript\n * import { FilePayloadLogger } from '@robota-sdk/agent-provider-openai/loggers';\n *\n * const logger = new FilePayloadLogger({\n * logDir: './logs/api-payloads',\n * enabled: true,\n * includeTimestamp: true\n * });\n *\n * const provider = new OpenAIProvider({\n * client: openaiClient,\n * payloadLogger: logger\n * });\n * ```\n */\nexport class FilePayloadLogger implements IPayloadLogger {\n private readonly enabled: boolean;\n private readonly logDir: string;\n private readonly includeTimestamp: boolean;\n private readonly logger: ILogger;\n\n constructor(options: {\n logDir: string;\n enabled?: boolean;\n includeTimestamp?: boolean;\n logger?: ILogger;\n }) {\n this.enabled = options.enabled ?? true;\n this.logDir = options.logDir;\n this.includeTimestamp = options.includeTimestamp ?? true;\n this.logger = options.logger || SilentLogger;\n\n if (this.enabled) {\n this.ensureLogDirectoryExists();\n }\n }\n\n /**\n * Check if logging is enabled\n */\n isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Log API payload to file\n * @param payload - The API request payload\n * @param type - Type of request ('chat' or 'stream')\n */\n async logPayload(payload: IOpenAILogData, type: 'chat' | 'stream' = 'chat'): Promise<void> {\n if (!this.enabled) {\n return;\n }\n\n try {\n const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n const filename = this.includeTimestamp\n ? `openai-${type}-${timestamp}.json`\n : `openai-${type}-${Date.now()}.json`;\n\n const filepath = path.join(this.logDir, filename);\n\n const logData = {\n timestamp: new Date().toISOString(),\n type,\n provider: 'openai',\n payload: sanitizeOpenAILogData(payload),\n };\n\n // SEC-003: payload logs contain prompt/response content and `logDir` is\n // caller-supplied, so create them owner-only rather than under the process umask.\n await fs.promises.writeFile(filepath, JSON.stringify(logData, null, 2), {\n encoding: 'utf8',\n mode: OWNER_ONLY_FILE_MODE,\n });\n\n // Payload saved successfully (silent operation)\n } catch (error) {\n // Don't throw errors - just log them and continue\n // This ensures that API logging failures don't break the main functionality\n this.logger.error('[FilePayloadLogger] Failed to save payload log:', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n /**\n * Ensure log directory exists\n */\n private ensureLogDirectoryExists(): void {\n try {\n if (!fs.existsSync(this.logDir)) {\n fs.mkdirSync(this.logDir, { recursive: true, mode: OWNER_ONLY_DIR_MODE });\n }\n } catch (error) {\n this.logger.error('[FilePayloadLogger] Failed to create log directory:', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n // Sanitization intentionally lives in ./sanitize-openai-log-data.ts (SSOT utility).\n}\n","import { SilentLogger, type ILogger } from '@robota-sdk/agent-core';\n\nimport { sanitizeOpenAILogData } from './sanitize-openai-log-data';\n\nimport type { IPayloadLogger, IPayloadLoggerOptions } from '../interfaces/payload-logger';\nimport type { IOpenAILogData } from '../types/api-types';\n\n/**\n * Console-based payload logger for browser environments\n *\n * This logger outputs API request/response payloads to the browser console\n * using structured logging. It's designed specifically for browser environments\n * and development/debugging scenarios.\n *\n * @example\n * ```typescript\n * import { ConsolePayloadLogger } from '@robota-sdk/agent-provider-openai/loggers';\n *\n * const logger = new ConsolePayloadLogger({\n * enabled: true,\n * includeTimestamp: true\n * });\n *\n * const provider = new OpenAIProvider({\n * client: openaiClient,\n * payloadLogger: logger\n * });\n * ```\n */\nexport class ConsolePayloadLogger implements IPayloadLogger {\n private readonly enabled: boolean;\n private readonly includeTimestamp: boolean;\n private readonly logger: ILogger;\n\n constructor(options: IPayloadLoggerOptions = {}) {\n this.enabled = options.enabled ?? true;\n this.includeTimestamp = options.includeTimestamp ?? true;\n this.logger = options.logger || SilentLogger;\n }\n\n /**\n * Check if logging is enabled\n */\n isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Log API payload to browser console\n * @param payload - The API request payload\n * @param type - Type of request ('chat' or 'stream')\n */\n async logPayload(payload: IOpenAILogData, type: 'chat' | 'stream' = 'chat'): Promise<void> {\n if (!this.enabled) {\n return;\n }\n\n try {\n const sanitizedPayload = sanitizeOpenAILogData(payload);\n\n // Use structured console logging for better browser developer tools integration\n const title = `[OpenAI ${type.toUpperCase()}] API Payload`;\n const timeInfo = this.includeTimestamp ? ` (${sanitizedPayload.timestamp})` : '';\n\n // Group related log entries for better organization\n this.logger.group?.(`${title}${timeInfo}`);\n\n // Log different aspects with appropriate console methods\n this.logger.info('📋 Request Details:', {\n model: payload.model,\n messagesCount: payload.messagesCount,\n hasTools: payload.hasTools,\n temperature: payload.temperature,\n maxTokens: payload.maxTokens,\n });\n\n this.logger.debug('🔍 Full Payload:', { type, provider: 'openai', ...sanitizedPayload });\n\n this.logger.groupEnd?.();\n } catch (error) {\n // Don't throw errors - just log them and continue\n // This ensures that API logging failures don't break the main functionality\n this.logger.error(\n '[ConsolePayloadLogger] Failed to log payload:',\n error instanceof Error ? error.message : 'Unknown error',\n );\n }\n }\n\n /**\n * Sanitize payload to remove sensitive information\n * @param payload - Raw payload object\n * @returns Sanitized payload\n */\n // Sanitization intentionally lives in ./sanitize-openai-log-data.ts (SSOT utility).\n}\n"],"mappings":"kGAMA,SAAgB,EAAsB,EAAyC,CAM7E,OAJkB,KAAK,MAAM,KAAK,UAAU,CAAO,CAIpC,CACjB,CCwBA,IAAa,EAAb,KAAyD,CACvD,QACA,OACA,iBACA,OAEA,YAAY,EAKT,CACD,KAAK,QAAU,EAAQ,SAAW,GAClC,KAAK,OAAS,EAAQ,OACtB,KAAK,iBAAmB,EAAQ,kBAAoB,GACpD,KAAK,OAAS,EAAQ,QAAU,EAE5B,KAAK,SACP,KAAK,yBAAyB,CAElC,CAKA,WAAqB,CACnB,OAAO,KAAK,OACd,CAOA,MAAM,WAAW,EAAyB,EAA0B,OAAuB,CACpF,QAAK,QAIV,GAAI,CACF,IAAM,EAAY,IAAI,KAAK,CAAA,CAAE,YAAY,CAAC,CAAC,QAAQ,QAAS,GAAG,EACzD,EAAW,KAAK,iBAClB,UAAU,EAAK,GAAG,EAAU,OAC5B,UAAU,EAAK,GAAG,KAAK,IAAI,EAAE,OAE3B,EAAW,EAAK,KAAK,KAAK,OAAQ,CAAQ,EAE1C,EAAU,CACd,UAAW,IAAI,KAAK,CAAA,CAAE,YAAY,EAClC,OACA,SAAU,SACV,QAAS,EAAsB,CAAO,CACxC,EAIA,MAAM,EAAG,SAAS,UAAU,EAAU,KAAK,UAAU,EAAS,KAAM,CAAC,EAAG,CACtE,SAAU,OACV,KAAM,GACR,CAAC,CAGH,OAAS,EAAO,CAGd,KAAK,OAAO,MAAM,kDAAmD,CACnE,MAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAC9D,CAAC,CACH,CACF,CAKA,0BAAyC,CACvC,GAAI,CACG,EAAG,WAAW,KAAK,MAAM,GAC5B,EAAG,UAAU,KAAK,OAAQ,CAAE,UAAW,GAAM,KAAM,GAAoB,CAAC,CAE5E,OAAS,EAAO,CACd,KAAK,OAAO,MAAM,sDAAuD,CACvE,MAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAC9D,CAAC,CACH,CACF,CAEF,EC9Fa,EAAb,KAA4D,CAC1D,QACA,iBACA,OAEA,YAAY,EAAiC,CAAC,EAAG,CAC/C,KAAK,QAAU,EAAQ,SAAW,GAClC,KAAK,iBAAmB,EAAQ,kBAAoB,GACpD,KAAK,OAAS,EAAQ,QAAU,CAClC,CAKA,WAAqB,CACnB,OAAO,KAAK,OACd,CAOA,MAAM,WAAW,EAAyB,EAA0B,OAAuB,CACpF,QAAK,QAIV,GAAI,CACF,IAAM,EAAmB,EAAsB,CAAO,EAGhD,EAAQ,WAAW,EAAK,YAAY,EAAE,eACtC,EAAW,KAAK,iBAAmB,KAAK,EAAiB,UAAU,GAAK,GAG9E,KAAK,OAAO,QAAQ,GAAG,IAAQ,GAAU,EAGzC,KAAK,OAAO,KAAK,sBAAuB,CACtC,MAAO,EAAQ,MACf,cAAe,EAAQ,cACvB,SAAU,EAAQ,SAClB,YAAa,EAAQ,YACrB,UAAW,EAAQ,SACrB,CAAC,EAED,KAAK,OAAO,MAAM,mBAAoB,CAAE,OAAM,SAAU,SAAU,GAAG,CAAiB,CAAC,EAEvF,KAAK,OAAO,WAAW,CACzB,OAAS,EAAO,CAGd,KAAK,OAAO,MACV,gDACA,aAAiB,MAAQ,EAAM,QAAU,eAC3C,CACF,CACF,CAQF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/openai/loggers/sanitize-openai-log-data.ts","../../../src/openai/loggers/file-payload-logger.ts","../../../src/openai/loggers/console-payload-logger.ts"],"sourcesContent":["import type { IOpenAILogData } from '../types/api-types';\n\n/**\n * Creates a defensive deep copy of OpenAI log data.\n * SSOT utility shared by payload loggers.\n */\nexport function sanitizeOpenAILogData(payload: IOpenAILogData): IOpenAILogData {\n // Create a deep copy to avoid modifying original\n const sanitized = JSON.parse(JSON.stringify(payload)) as IOpenAILogData;\n\n // Remove or mask sensitive data if needed.\n // For now, we keep everything as OpenAI payloads don't contain API keys.\n return sanitized;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { SilentLogger } from '@robota-sdk/agent-core';\n\nimport { sanitizeOpenAILogData } from './sanitize-openai-log-data';\n\nimport type { IPayloadLogger } from '../interfaces/payload-logger';\nimport type { IOpenAILogData } from '../types/api-types';\nimport type { ILogger } from '@robota-sdk/agent-core';\n\n/** Owner-only access — no group or other bits (SEC-003 / CWE-377). */\nconst OWNER_ONLY_FILE_MODE = 0o600;\nconst OWNER_ONLY_DIR_MODE = 0o700;\n\n/**\n * File-based payload logger for Node.js environments\n *\n * This logger saves a summary of each Chat Completions request to a JSON file on disk.\n * It's designed specifically for Node.js environments with filesystem access.\n *\n * @example\n * ```typescript\n * import { FilePayloadLogger } from '@robota-sdk/agent-provider-openai/loggers';\n *\n * const logger = new FilePayloadLogger({\n * logDir: './logs/api-payloads',\n * enabled: true,\n * includeTimestamp: true\n * });\n *\n * const provider = new OpenAIProvider({\n * client: openaiClient,\n * payloadLogger: logger\n * });\n * ```\n */\nexport class FilePayloadLogger implements IPayloadLogger {\n private readonly enabled: boolean;\n private readonly logDir: string;\n private readonly includeTimestamp: boolean;\n private readonly logger: ILogger;\n\n constructor(options: {\n logDir: string;\n enabled?: boolean;\n includeTimestamp?: boolean;\n logger?: ILogger;\n }) {\n this.enabled = options.enabled ?? true;\n this.logDir = options.logDir;\n this.includeTimestamp = options.includeTimestamp ?? true;\n this.logger = options.logger || SilentLogger;\n\n if (this.enabled) {\n this.ensureLogDirectoryExists();\n }\n }\n\n /**\n * Check if logging is enabled\n */\n isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Log a request summary to file\n * @param payload - Summary of the outgoing Chat Completions request\n * @param type - Type of request ('chat' or 'stream')\n */\n async logPayload(payload: IOpenAILogData, type: 'chat' | 'stream' = 'chat'): Promise<void> {\n if (!this.enabled) {\n return;\n }\n\n try {\n const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n const filename = this.includeTimestamp\n ? `openai-${type}-${timestamp}.json`\n : `openai-${type}-${Date.now()}.json`;\n\n const filepath = path.join(this.logDir, filename);\n\n const logData = {\n timestamp: new Date().toISOString(),\n type,\n provider: 'openai',\n payload: sanitizeOpenAILogData(payload),\n };\n\n // SEC-003: payload logs record the caller's request history (a request summary, not\n // prompt/response content) and `logDir` is caller-supplied, so create them owner-only\n // rather than under the process umask.\n await fs.promises.writeFile(filepath, JSON.stringify(logData, null, 2), {\n encoding: 'utf8',\n mode: OWNER_ONLY_FILE_MODE,\n });\n\n // Payload saved successfully (silent operation)\n } catch (error) {\n // Don't throw errors - just log them and continue\n // This ensures that API logging failures don't break the main functionality\n this.logger.error('[FilePayloadLogger] Failed to save payload log:', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n /**\n * Ensure log directory exists\n */\n private ensureLogDirectoryExists(): void {\n try {\n if (!fs.existsSync(this.logDir)) {\n fs.mkdirSync(this.logDir, { recursive: true, mode: OWNER_ONLY_DIR_MODE });\n }\n } catch (error) {\n this.logger.error('[FilePayloadLogger] Failed to create log directory:', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n // Sanitization intentionally lives in ./sanitize-openai-log-data.ts (SSOT utility).\n}\n","import { SilentLogger, type ILogger } from '@robota-sdk/agent-core';\n\nimport { sanitizeOpenAILogData } from './sanitize-openai-log-data';\n\nimport type { IPayloadLogger, IPayloadLoggerOptions } from '../interfaces/payload-logger';\nimport type { IOpenAILogData } from '../types/api-types';\n\n/**\n * Console-based payload logger for browser environments\n *\n * This logger outputs a summary of each Chat Completions request to the browser console\n * using structured logging. It's designed specifically for browser environments\n * and development/debugging scenarios.\n *\n * @example\n * ```typescript\n * import { ConsolePayloadLogger } from '@robota-sdk/agent-provider-openai/loggers';\n *\n * const logger = new ConsolePayloadLogger({\n * enabled: true,\n * includeTimestamp: true\n * });\n *\n * const provider = new OpenAIProvider({\n * client: openaiClient,\n * payloadLogger: logger\n * });\n * ```\n */\nexport class ConsolePayloadLogger implements IPayloadLogger {\n private readonly enabled: boolean;\n private readonly includeTimestamp: boolean;\n private readonly logger: ILogger;\n\n constructor(options: IPayloadLoggerOptions = {}) {\n this.enabled = options.enabled ?? true;\n this.includeTimestamp = options.includeTimestamp ?? true;\n this.logger = options.logger || SilentLogger;\n }\n\n /**\n * Check if logging is enabled\n */\n isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Log API payload to browser console\n * @param payload - Summary of the outgoing Chat Completions request\n * @param type - Type of request ('chat' or 'stream')\n */\n async logPayload(payload: IOpenAILogData, type: 'chat' | 'stream' = 'chat'): Promise<void> {\n if (!this.enabled) {\n return;\n }\n\n try {\n const sanitizedPayload = sanitizeOpenAILogData(payload);\n\n // Use structured console logging for better browser developer tools integration\n const title = `[OpenAI ${type.toUpperCase()}] API Payload`;\n const timeInfo = this.includeTimestamp ? ` (${sanitizedPayload.timestamp})` : '';\n\n // Group related log entries for better organization\n this.logger.group?.(`${title}${timeInfo}`);\n\n // Log different aspects with appropriate console methods\n this.logger.info('📋 Request Details:', {\n model: payload.model,\n messagesCount: payload.messagesCount,\n hasTools: payload.hasTools,\n temperature: payload.temperature,\n maxTokens: payload.maxTokens,\n });\n\n this.logger.debug('🔍 Full Payload:', { type, provider: 'openai', ...sanitizedPayload });\n\n this.logger.groupEnd?.();\n } catch (error) {\n // Don't throw errors - just log them and continue\n // This ensures that API logging failures don't break the main functionality\n this.logger.error(\n '[ConsolePayloadLogger] Failed to log payload:',\n error instanceof Error ? error.message : 'Unknown error',\n );\n }\n }\n\n /**\n * Sanitize payload to remove sensitive information\n * @param payload - Raw payload object\n * @returns Sanitized payload\n */\n // Sanitization intentionally lives in ./sanitize-openai-log-data.ts (SSOT utility).\n}\n"],"mappings":"kGAMA,SAAgB,EAAsB,EAAyC,CAM7E,OAJkB,KAAK,MAAM,KAAK,UAAU,CAAO,CAIpC,CACjB,CCwBA,IAAa,EAAb,KAAyD,CACvD,QACA,OACA,iBACA,OAEA,YAAY,EAKT,CACD,KAAK,QAAU,EAAQ,SAAW,GAClC,KAAK,OAAS,EAAQ,OACtB,KAAK,iBAAmB,EAAQ,kBAAoB,GACpD,KAAK,OAAS,EAAQ,QAAU,EAE5B,KAAK,SACP,KAAK,yBAAyB,CAElC,CAKA,WAAqB,CACnB,OAAO,KAAK,OACd,CAOA,MAAM,WAAW,EAAyB,EAA0B,OAAuB,CACpF,QAAK,QAIV,GAAI,CACF,IAAM,EAAY,IAAI,KAAK,CAAA,CAAE,YAAY,CAAC,CAAC,QAAQ,QAAS,GAAG,EACzD,EAAW,KAAK,iBAClB,UAAU,EAAK,GAAG,EAAU,OAC5B,UAAU,EAAK,GAAG,KAAK,IAAI,EAAE,OAE3B,EAAW,EAAK,KAAK,KAAK,OAAQ,CAAQ,EAE1C,EAAU,CACd,UAAW,IAAI,KAAK,CAAA,CAAE,YAAY,EAClC,OACA,SAAU,SACV,QAAS,EAAsB,CAAO,CACxC,EAKA,MAAM,EAAG,SAAS,UAAU,EAAU,KAAK,UAAU,EAAS,KAAM,CAAC,EAAG,CACtE,SAAU,OACV,KAAM,GACR,CAAC,CAGH,OAAS,EAAO,CAGd,KAAK,OAAO,MAAM,kDAAmD,CACnE,MAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAC9D,CAAC,CACH,CACF,CAKA,0BAAyC,CACvC,GAAI,CACG,EAAG,WAAW,KAAK,MAAM,GAC5B,EAAG,UAAU,KAAK,OAAQ,CAAE,UAAW,GAAM,KAAM,GAAoB,CAAC,CAE5E,OAAS,EAAO,CACd,KAAK,OAAO,MAAM,sDAAuD,CACvE,MAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAC9D,CAAC,CACH,CACF,CAEF,EC/Fa,EAAb,KAA4D,CAC1D,QACA,iBACA,OAEA,YAAY,EAAiC,CAAC,EAAG,CAC/C,KAAK,QAAU,EAAQ,SAAW,GAClC,KAAK,iBAAmB,EAAQ,kBAAoB,GACpD,KAAK,OAAS,EAAQ,QAAU,CAClC,CAKA,WAAqB,CACnB,OAAO,KAAK,OACd,CAOA,MAAM,WAAW,EAAyB,EAA0B,OAAuB,CACpF,QAAK,QAIV,GAAI,CACF,IAAM,EAAmB,EAAsB,CAAO,EAGhD,EAAQ,WAAW,EAAK,YAAY,EAAE,eACtC,EAAW,KAAK,iBAAmB,KAAK,EAAiB,UAAU,GAAK,GAG9E,KAAK,OAAO,QAAQ,GAAG,IAAQ,GAAU,EAGzC,KAAK,OAAO,KAAK,sBAAuB,CACtC,MAAO,EAAQ,MACf,cAAe,EAAQ,cACvB,SAAU,EAAQ,SAClB,YAAa,EAAQ,YACrB,UAAW,EAAQ,SACrB,CAAC,EAED,KAAK,OAAO,MAAM,mBAAoB,CAAE,OAAM,SAAU,SAAU,GAAG,CAAiB,CAAC,EAEvF,KAAK,OAAO,WAAW,CACzB,OAAS,EAAO,CAGd,KAAK,OAAO,MACV,gDACA,aAAiB,MAAQ,EAAM,QAAU,eAC3C,CACF,CACF,CAQF"}
@@ -16,7 +16,9 @@ interface IOpenAILogData {
16
16
  //#endregion
17
17
  //#region src/openai/interfaces/payload-logger.d.ts
18
18
  /**
19
- * IPayloadLogger interface for logging OpenAI API payloads
19
+ * IPayloadLogger interface for logging a summary of each OpenAI Chat Completions request
20
+ * (request metadata such as model, message count and whether tools were sent — not prompt or
21
+ * response content).
20
22
  *
21
23
  * This interface provides a contract for different logging implementations:
22
24
  * - FilePayloadLogger: Node.js file-based logging
@@ -30,8 +32,8 @@ interface IPayloadLogger {
30
32
  */
31
33
  isEnabled(): boolean;
32
34
  /**
33
- * Log API payload data
34
- * @param payload - The API request/response payload data
35
+ * Log a request summary
36
+ * @param payload - Summary of the outgoing Chat Completions request
35
37
  * @param type - Type of operation ('chat' or 'stream')
36
38
  */
37
39
  logPayload(payload: IOpenAILogData, type: 'chat' | 'stream'): Promise<void>;
@@ -58,4 +60,4 @@ interface IPayloadLoggerOptions {
58
60
  }
59
61
  //#endregion
60
62
  export { IPayloadLoggerOptions as n, IOpenAILogData as r, IPayloadLogger as t };
61
- //# sourceMappingURL=payload-logger-BaW0K8yI.d.ts.map
63
+ //# sourceMappingURL=payload-logger-n89AQdUn.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payload-logger-n89AQdUn.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;;;;;;;;;;;;;;UClGe;;;;;EAKf;;;;;;EAOA,WAAW,SAAS,gBAAgB,0BAA0B;;;;;UAM/C;;;;;EAKf;;;;;EAMA;;;;;EAMA,SAAS"}
@@ -16,7 +16,9 @@ interface IOpenAILogData {
16
16
  //#endregion
17
17
  //#region src/openai/interfaces/payload-logger.d.ts
18
18
  /**
19
- * IPayloadLogger interface for logging OpenAI API payloads
19
+ * IPayloadLogger interface for logging a summary of each OpenAI Chat Completions request
20
+ * (request metadata such as model, message count and whether tools were sent — not prompt or
21
+ * response content).
20
22
  *
21
23
  * This interface provides a contract for different logging implementations:
22
24
  * - FilePayloadLogger: Node.js file-based logging
@@ -30,8 +32,8 @@ interface IPayloadLogger {
30
32
  */
31
33
  isEnabled(): boolean;
32
34
  /**
33
- * Log API payload data
34
- * @param payload - The API request/response payload data
35
+ * Log a request summary
36
+ * @param payload - Summary of the outgoing Chat Completions request
35
37
  * @param type - Type of operation ('chat' or 'stream')
36
38
  */
37
39
  logPayload(payload: IOpenAILogData, type: 'chat' | 'stream'): Promise<void>;
@@ -58,4 +60,4 @@ interface IPayloadLoggerOptions {
58
60
  }
59
61
  //#endregion
60
62
  export { IPayloadLoggerOptions as n, IOpenAILogData as r, IPayloadLogger as t };
61
- //# sourceMappingURL=payload-logger-BaW0K8yI.d.cts.map
63
+ //# sourceMappingURL=payload-logger-n89AQdUn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payload-logger-n89AQdUn.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;;;;;;;;;;;;;;UClGe;;;;;EAKf;;;;;;EAOA,WAAW,SAAS,gBAAgB,0BAA0B;;;;;UAM/C;;;;;EAKf;;;;;EAMA;;;;;EAMA,SAAS"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-provider-openai",
3
- "version": "3.0.0-beta.82",
3
+ "version": "3.0.0-beta.84",
4
4
  "description": "OpenAI provider implementation for Robota SDK",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -54,11 +54,16 @@
54
54
  "default": "./dist/node/loggers/index.cjs"
55
55
  }
56
56
  }
57
- }
57
+ },
58
+ "./package.json": "./package.json"
59
+ },
60
+ "engines": {
61
+ "node": ">=22.12.0"
58
62
  },
59
63
  "files": [
60
64
  "dist",
61
- "src"
65
+ "src",
66
+ "CHANGELOG.md"
62
67
  ],
63
68
  "devDependencies": {
64
69
  "@types/node": "^22.19.21",
@@ -73,10 +78,11 @@
73
78
  "url": "https://github.com/woojubb/robota.git",
74
79
  "directory": "packages/agent-provider-openai"
75
80
  },
81
+ "homepage": "https://docs.robota.io/en/packages/agent-provider-openai/",
76
82
  "dependencies": {
77
83
  "openai": "^4.104.0",
78
- "@robota-sdk/agent-core": "3.0.0-beta.82",
79
- "@robota-sdk/agent-provider-openai-compatible": "3.0.0-beta.82"
84
+ "@robota-sdk/agent-core": "3.0.0-beta.84",
85
+ "@robota-sdk/agent-provider-openai-compatible": "3.0.0-beta.84"
80
86
  },
81
87
  "volta": {
82
88
  "extends": "../../package.json"
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The run's `AbortSignal` reaches the SDK request on every call site of both API surfaces
3
+ * (agent-core SPEC, Cancellation Contract). The non-streaming Chat Completions path is the one the
4
+ * forced summary takes, and it used to send no signal, so an aborted run left that request running.
5
+ */
6
+ import { createServer } from 'node:http';
7
+
8
+ import { classifyProviderFailure } from '@robota-sdk/agent-core';
9
+ import { APIUserAbortError } from 'openai';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+
12
+ import { OpenAIProvider } from '../provider';
13
+
14
+ import type { IChatOptions, TUniversalMessage } from '@robota-sdk/agent-core';
15
+ import type { AddressInfo } from 'node:net';
16
+ import type OpenAI from 'openai';
17
+
18
+ const completion = {
19
+ id: 'c1',
20
+ object: 'chat.completion',
21
+ created: 0,
22
+ model: 'gpt-test',
23
+ choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
24
+ };
25
+ const completionChunks = [
26
+ {
27
+ id: 'c1',
28
+ object: 'chat.completion.chunk',
29
+ created: 0,
30
+ model: 'gpt-test',
31
+ choices: [{ index: 0, delta: { role: 'assistant', content: 'hi' }, finish_reason: null }],
32
+ },
33
+ {
34
+ id: 'c1',
35
+ object: 'chat.completion.chunk',
36
+ created: 0,
37
+ model: 'gpt-test',
38
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
39
+ },
40
+ ];
41
+ const response = {
42
+ id: 'resp_1',
43
+ object: 'response',
44
+ created_at: 0,
45
+ model: 'gpt-test',
46
+ status: 'completed',
47
+ output: [
48
+ {
49
+ type: 'message',
50
+ id: 'msg_1',
51
+ role: 'assistant',
52
+ status: 'completed',
53
+ content: [{ type: 'output_text', text: 'hi', annotations: [] }],
54
+ },
55
+ ],
56
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
57
+ };
58
+ const responseEvents = [
59
+ {
60
+ type: 'response.output_text.delta',
61
+ item_id: 'msg_1',
62
+ output_index: 0,
63
+ content_index: 0,
64
+ delta: 'hi',
65
+ sequence_number: 1,
66
+ },
67
+ { type: 'response.completed', response, sequence_number: 2 },
68
+ ];
69
+
70
+ const messages: TUniversalMessage[] = [
71
+ { id: 'u1', role: 'user', content: 'hello', state: 'complete', timestamp: new Date() },
72
+ ];
73
+
74
+ async function* iterate(items: readonly object[]): AsyncGenerator<object> {
75
+ for (const item of items) yield item;
76
+ }
77
+
78
+ /** An SDK client double whose `create` answers like the SDK and records the request options. */
79
+ function fakeClient() {
80
+ const chatCreate = vi.fn(
81
+ (params: { stream?: boolean }, _requestOptions?: OpenAI.RequestOptions) =>
82
+ Promise.resolve(params.stream ? iterate(completionChunks) : completion),
83
+ );
84
+ const responsesCreate = vi.fn(
85
+ (params: { stream?: boolean }, _requestOptions?: OpenAI.RequestOptions) =>
86
+ Promise.resolve(params.stream ? iterate(responseEvents) : response),
87
+ );
88
+ const client = {
89
+ chat: { completions: { create: chatCreate } },
90
+ responses: { create: responsesCreate },
91
+ } as unknown as OpenAI;
92
+ return { client, chatCreate, responsesCreate };
93
+ }
94
+
95
+ const CALL_SITES: ReadonlyArray<
96
+ readonly [string, (provider: OpenAIProvider, options: IChatOptions) => Promise<void>]
97
+ > = [
98
+ [
99
+ 'chat() without a delta callback (non-streaming request, the forced-summary path)',
100
+ async (provider, options) => {
101
+ await provider.chat(messages, options);
102
+ },
103
+ ],
104
+ [
105
+ 'chat() with a delta callback (streaming assembly)',
106
+ async (provider, options) => {
107
+ await provider.chat(messages, { ...options, onTextDelta: () => undefined });
108
+ },
109
+ ],
110
+ [
111
+ 'chatStream()',
112
+ async (provider, options) => {
113
+ for await (const _chunk of provider.chatStream(messages, options)) {
114
+ // drain
115
+ }
116
+ },
117
+ ],
118
+ ];
119
+
120
+ describe('OpenAI run AbortSignal on the wire', () => {
121
+ for (const apiSurface of ['chat-completions', 'responses'] as const) {
122
+ for (const [callSite, run] of CALL_SITES) {
123
+ it(`${apiSurface}: ${callSite} hands the run's own signal to the SDK request`, async () => {
124
+ const { client, chatCreate, responsesCreate } = fakeClient();
125
+ const create = apiSurface === 'responses' ? responsesCreate : chatCreate;
126
+ const controller = new AbortController();
127
+
128
+ await run(new OpenAIProvider({ client, apiSurface }), {
129
+ model: 'gpt-test',
130
+ signal: controller.signal,
131
+ });
132
+
133
+ expect(create).toHaveBeenCalledTimes(1);
134
+ expect(create.mock.calls[0]?.[1]?.signal).toBe(controller.signal);
135
+ });
136
+ }
137
+ }
138
+
139
+ it('closes the HTTP request when the run aborts a non-streaming Chat Completions call', async () => {
140
+ const server = await startUnansweringServer();
141
+ let bound: ReturnType<typeof setTimeout> | undefined;
142
+ try {
143
+ const provider = new OpenAIProvider({
144
+ apiKey: 'test-key',
145
+ baseURL: server.baseURL,
146
+ apiSurface: 'chat-completions',
147
+ });
148
+ const controller = new AbortController();
149
+ const pending = provider.chat(messages, { model: 'gpt-test', signal: controller.signal });
150
+ const outcome = pending.then(
151
+ () => 'resolved' as const,
152
+ (error: unknown) => error,
153
+ );
154
+
155
+ await server.requestReceived;
156
+ controller.abort();
157
+
158
+ // The server never answers, so only the client hanging up can close the request. Without the
159
+ // signal it stays open until the SDK's own 10-minute timeout; the bound only decides failure.
160
+ const closed = await Promise.race([
161
+ server.clientHungUp.then(() => 'closed' as const),
162
+ new Promise<'still open'>((resolve) => {
163
+ bound = setTimeout(() => resolve('still open'), 2_000);
164
+ }),
165
+ ]);
166
+ expect(closed).toBe('closed');
167
+
168
+ // The SDK's own abort error, passed through rather than wrapped as a provider failure, so the
169
+ // run layer classifies it as an interruption — without needing the signal to tell it so.
170
+ const error = await outcome;
171
+ expect(error).toBeInstanceOf(APIUserAbortError);
172
+ expect(classifyProviderFailure(error)).toEqual({ switchable: false, reason: 'aborted' });
173
+ } finally {
174
+ clearTimeout(bound);
175
+ await server.close();
176
+ }
177
+ });
178
+ });
179
+
180
+ /** A local endpoint that accepts a request and never answers it, reporting when the client hangs up. */
181
+ async function startUnansweringServer(): Promise<{
182
+ baseURL: string;
183
+ requestReceived: Promise<void>;
184
+ clientHungUp: Promise<void>;
185
+ close: () => Promise<void>;
186
+ }> {
187
+ let onRequest!: () => void;
188
+ let onHangUp!: () => void;
189
+ const requestReceived = new Promise<void>((resolve) => (onRequest = resolve));
190
+ const clientHungUp = new Promise<void>((resolve) => (onHangUp = resolve));
191
+ const server = createServer((request, reply) => {
192
+ request.resume();
193
+ reply.on('close', () => {
194
+ if (!reply.writableEnded) onHangUp();
195
+ });
196
+ onRequest();
197
+ });
198
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
199
+ const { port } = server.address() as AddressInfo;
200
+ return {
201
+ baseURL: `http://127.0.0.1:${port}/v1`,
202
+ requestReceived,
203
+ clientHungUp,
204
+ close: () =>
205
+ new Promise<void>((resolve) => {
206
+ server.closeAllConnections();
207
+ server.close(() => resolve());
208
+ }),
209
+ };
210
+ }
@@ -3,7 +3,14 @@
3
3
  * collapsing into a bare `Error` whose only trace of the status is its message text.
4
4
  */
5
5
 
6
- import { ProviderError, RateLimitError, classifyProviderFailure } from '@robota-sdk/agent-core';
6
+ import {
7
+ AuthenticationError,
8
+ ModelNotAvailableError,
9
+ NetworkError,
10
+ ProviderError,
11
+ RateLimitError,
12
+ classifyProviderFailure,
13
+ } from '@robota-sdk/agent-core';
7
14
  import { APIConnectionError, APIError, APIUserAbortError } from 'openai';
8
15
  import { describe, expect, it, vi } from 'vitest';
9
16
 
@@ -44,7 +51,6 @@ const statuses: Array<[number, string]> = [
44
51
  [529, 'overloaded_error'],
45
52
  [503, 'server_error'],
46
53
  [500, 'server_error'],
47
- [401, 'invalid_api_key'],
48
54
  ];
49
55
 
50
56
  describe('OpenAI provider errors', () => {
@@ -73,7 +79,7 @@ describe('OpenAI provider errors', () => {
73
79
  expect(classifyProviderFailure(error)).toEqual({ switchable: false, reason: 'aborted' });
74
80
  });
75
81
 
76
- it(`${surface} classifies an SDK connection failure as network`, async () => {
82
+ it(`${surface} classifies an SDK connection failure as a NetworkError`, async () => {
77
83
  const provider = providerWith(
78
84
  surface,
79
85
  new APIConnectionError({
@@ -82,11 +88,11 @@ describe('OpenAI provider errors', () => {
82
88
  }),
83
89
  );
84
90
  const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
85
- expect(error).toBeInstanceOf(ProviderError);
91
+ expect(error).toBeInstanceOf(NetworkError);
86
92
  expect(classifyProviderFailure(error)).toEqual({ switchable: false, reason: 'network' });
87
93
  });
88
94
 
89
- it(`${surface} classifies a 400 model_not_found as model-unavailable`, async () => {
95
+ it(`${surface} maps a 400 model_not_found to ModelNotAvailableError`, async () => {
90
96
  const provider = providerWith(
91
97
  surface,
92
98
  APIError.generate(
@@ -99,6 +105,7 @@ describe('OpenAI provider errors', () => {
99
105
  ),
100
106
  );
101
107
  const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
108
+ expect(error).toBeInstanceOf(ModelNotAvailableError);
102
109
  expect(classifyProviderFailure(error)).toEqual({
103
110
  switchable: true,
104
111
  reason: 'model-unavailable',
@@ -110,5 +117,27 @@ describe('OpenAI provider errors', () => {
110
117
  const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
111
118
  expect(error).toBeInstanceOf(RateLimitError);
112
119
  });
120
+
121
+ it(`${surface} chat maps 401 to AuthenticationError, carrying the provider name`, async () => {
122
+ const provider = providerWith(surface, httpError(401, 'invalid_api_key'));
123
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
124
+ expect(error).toBeInstanceOf(AuthenticationError);
125
+ expect((error as AuthenticationError).provider).toBe('openai');
126
+ });
127
+
128
+ it(`${surface} chat reads the retry-after header off a 429 into RateLimitError.retryAfter`, async () => {
129
+ const provider = providerWith(
130
+ surface,
131
+ APIError.generate(
132
+ 429,
133
+ { error: { type: 'rate_limit_exceeded', message: 'slow down' } },
134
+ undefined,
135
+ { 'retry-after': '12' },
136
+ ),
137
+ );
138
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
139
+ expect(error).toBeInstanceOf(RateLimitError);
140
+ expect((error as RateLimitError).retryAfter).toBe(12);
141
+ });
113
142
  }
114
143
  });
@@ -27,8 +27,9 @@ vi.mock('openai', () => {
27
27
  return { default: MockOpenAI };
28
28
  });
29
29
 
30
- interface IFakeResponsesClient {
30
+ interface IFakeOpenAIClient {
31
31
  responses: { create: ReturnType<typeof vi.fn> };
32
+ chat: { completions: { create: ReturnType<typeof vi.fn> } };
32
33
  }
33
34
 
34
35
  function createUserMessage(content: string): TUniversalMessage {
@@ -49,7 +50,7 @@ async function sendChat(
49
50
  provider: OpenAIProvider,
50
51
  tools: IToolSchema[],
51
52
  ): Promise<Record<string, unknown>> {
52
- const client = (provider as unknown as { client: IFakeResponsesClient }).client;
53
+ const client = (provider as unknown as { client: IFakeOpenAIClient }).client;
53
54
  client.responses.create.mockResolvedValue(fakeResponsesResult());
54
55
  await provider.chat([createUserMessage('hello')], { model: 'gpt-4o', tools });
55
56
  const [requestParams] = client.responses.create.mock.calls[
@@ -58,6 +59,25 @@ async function sendChat(
58
59
  return requestParams;
59
60
  }
60
61
 
62
+ /** The same request through the Chat Completions surface (the default once `baseURL` is set). */
63
+ async function sendChatCompletions(
64
+ provider: OpenAIProvider,
65
+ tools: IToolSchema[],
66
+ ): Promise<Record<string, unknown>> {
67
+ const client = (provider as unknown as { client: IFakeOpenAIClient }).client;
68
+ client.chat.completions.create.mockResolvedValue({
69
+ id: 'chatcmpl-strict-tools',
70
+ object: 'chat.completion',
71
+ created: 1,
72
+ model: 'gpt-4o',
73
+ choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
74
+ });
75
+ await provider.chat([createUserMessage('hello')], { model: 'gpt-4o', tools });
76
+ const calls = client.chat.completions.create.mock.calls;
77
+ const [requestParams] = calls[calls.length - 1] as [Record<string, unknown>];
78
+ return requestParams;
79
+ }
80
+
61
81
  const NESTED_TOOL: IToolSchema = {
62
82
  name: 'create_user',
63
83
  description: 'Creates a user',
@@ -134,3 +154,30 @@ describe('MCP-005 TC-08 — the non-strict path carries the permissive projectio
134
154
  expect(tool.parameters).toEqual(NESTED_TOOL.parameters);
135
155
  });
136
156
  });
157
+
158
+ describe('strictTools on the Chat Completions surface (#3209)', () => {
159
+ const GATEWAY = { apiKey: 'sk-test', baseURL: 'https://gateway.example/v1' };
160
+
161
+ it('declares each function strict, with the same closed schema the Responses surface sends', async () => {
162
+ const provider = new OpenAIProvider({ ...GATEWAY, strictTools: true });
163
+ const requestParams = await sendChatCompletions(provider, [NESTED_TOOL]);
164
+ const [tool] = requestParams.tools as Array<{ function: Record<string, unknown> }>;
165
+
166
+ expect(tool?.function.strict).toBe(true);
167
+ expect(tool?.function.parameters).toEqual(
168
+ closeObjectSchemas(NESTED_TOOL.parameters, {
169
+ requireAllProperties: true,
170
+ optionalAsNullable: true,
171
+ }),
172
+ );
173
+ });
174
+
175
+ it('sends no strict key when strictTools is off', async () => {
176
+ const provider = new OpenAIProvider(GATEWAY);
177
+ const requestParams = await sendChatCompletions(provider, [NESTED_TOOL]);
178
+ const [tool] = requestParams.tools as Array<{ function: Record<string, unknown> }>;
179
+
180
+ expect(tool?.function).not.toHaveProperty('strict');
181
+ expect(tool?.function.parameters).toEqual(NESTED_TOOL.parameters);
182
+ });
183
+ });