@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,61 @@
1
+ import { ILogger } from "@robota-sdk/agent-core";
2
+ import OpenAI from "openai";
3
+ //#region src/openai/types/api-types.d.ts
4
+ /**
5
+ * Payload logging data structure
6
+ */
7
+ interface IOpenAILogData {
8
+ model: string;
9
+ messagesCount: number;
10
+ hasTools: boolean;
11
+ temperature?: number | undefined;
12
+ maxTokens?: number | undefined;
13
+ timestamp: string;
14
+ requestId?: string | undefined;
15
+ }
16
+ //#endregion
17
+ //#region src/openai/interfaces/payload-logger.d.ts
18
+ /**
19
+ * IPayloadLogger interface for logging OpenAI API payloads
20
+ *
21
+ * This interface provides a contract for different logging implementations:
22
+ * - FilePayloadLogger: Node.js file-based logging
23
+ * - ConsolePayloadLogger: Browser console-based logging
24
+ * - Custom implementations: User-defined loggers
25
+ */
26
+ interface IPayloadLogger {
27
+ /**
28
+ * Check if logging is enabled
29
+ * @returns true if logging is active, false otherwise
30
+ */
31
+ isEnabled(): boolean;
32
+ /**
33
+ * Log API payload data
34
+ * @param payload - The API request/response payload data
35
+ * @param type - Type of operation ('chat' or 'stream')
36
+ */
37
+ logPayload(payload: IOpenAILogData, type: 'chat' | 'stream'): Promise<void>;
38
+ }
39
+ /**
40
+ * Configuration options for payload loggers
41
+ */
42
+ interface IPayloadLoggerOptions {
43
+ /**
44
+ * Whether logging is enabled
45
+ * @defaultValue true
46
+ */
47
+ enabled?: boolean;
48
+ /**
49
+ * Include timestamp in log entries
50
+ * @defaultValue true
51
+ */
52
+ includeTimestamp?: boolean;
53
+ /**
54
+ * Logger instance for console output
55
+ * @defaultValue SilentLogger
56
+ */
57
+ logger?: ILogger;
58
+ }
59
+ //#endregion
60
+ export { IPayloadLoggerOptions as n, IOpenAILogData as r, IPayloadLogger as t };
61
+ //# sourceMappingURL=payload-logger-BaW0K8yI.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payload-logger-BaW0K8yI.d.cts","names":[],"sources":["../../src/openai/types/api-types.ts","../../src/openai/interfaces/payload-logger.ts"],"mappings":";;;;;;UAwGiB;EACf;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;UCpGe;;;;;EAKf;;;;;;EAOA,WAAW,SAAS,gBAAgB,0BAA0B;;;;;UAM/C;;;;;EAKf;;;;;EAMA;;;;;EAMA,SAAS"}
@@ -0,0 +1,61 @@
1
+ import { ILogger } from "@robota-sdk/agent-core";
2
+ import OpenAI from "openai";
3
+ //#region src/openai/types/api-types.d.ts
4
+ /**
5
+ * Payload logging data structure
6
+ */
7
+ interface IOpenAILogData {
8
+ model: string;
9
+ messagesCount: number;
10
+ hasTools: boolean;
11
+ temperature?: number | undefined;
12
+ maxTokens?: number | undefined;
13
+ timestamp: string;
14
+ requestId?: string | undefined;
15
+ }
16
+ //#endregion
17
+ //#region src/openai/interfaces/payload-logger.d.ts
18
+ /**
19
+ * IPayloadLogger interface for logging OpenAI API payloads
20
+ *
21
+ * This interface provides a contract for different logging implementations:
22
+ * - FilePayloadLogger: Node.js file-based logging
23
+ * - ConsolePayloadLogger: Browser console-based logging
24
+ * - Custom implementations: User-defined loggers
25
+ */
26
+ interface IPayloadLogger {
27
+ /**
28
+ * Check if logging is enabled
29
+ * @returns true if logging is active, false otherwise
30
+ */
31
+ isEnabled(): boolean;
32
+ /**
33
+ * Log API payload data
34
+ * @param payload - The API request/response payload data
35
+ * @param type - Type of operation ('chat' or 'stream')
36
+ */
37
+ logPayload(payload: IOpenAILogData, type: 'chat' | 'stream'): Promise<void>;
38
+ }
39
+ /**
40
+ * Configuration options for payload loggers
41
+ */
42
+ interface IPayloadLoggerOptions {
43
+ /**
44
+ * Whether logging is enabled
45
+ * @defaultValue true
46
+ */
47
+ enabled?: boolean;
48
+ /**
49
+ * Include timestamp in log entries
50
+ * @defaultValue true
51
+ */
52
+ includeTimestamp?: boolean;
53
+ /**
54
+ * Logger instance for console output
55
+ * @defaultValue SilentLogger
56
+ */
57
+ logger?: ILogger;
58
+ }
59
+ //#endregion
60
+ export { IPayloadLoggerOptions as n, IOpenAILogData as r, IPayloadLogger as t };
61
+ //# sourceMappingURL=payload-logger-BaW0K8yI.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payload-logger-BaW0K8yI.d.ts","names":[],"sources":["../../src/openai/types/api-types.ts","../../src/openai/interfaces/payload-logger.ts"],"mappings":";;;;;;UAwGiB;EACf;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;UCpGe;;;;;EAKf;;;;;;EAOA,WAAW,SAAS,gBAAgB,0BAA0B;;;;;UAM/C;;;;;EAKf;;;;;EAMA;;;;;EAMA,SAAS"}
@@ -0,0 +1 @@
1
+ var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return s}});
package/package.json CHANGED
@@ -1,113 +1,91 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-provider-openai",
3
- "version": "3.0.0-beta.63",
4
- "description": "OpenAI integration for Robota SDK using the Responses API, structured outputs, streaming, and tool calling",
3
+ "version": "3.0.0-beta.81",
4
+ "description": "OpenAI provider implementation for Robota SDK",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
6
9
  "main": "dist/node/index.js",
7
- "module": "dist/node/index.mjs",
8
10
  "types": "dist/node/index.d.ts",
9
11
  "exports": {
10
12
  ".": {
11
- "types": "./dist/node/index.d.ts",
12
13
  "source": "./src/index.ts",
13
14
  "node": {
14
- "import": "./dist/node/index.js",
15
- "require": "./dist/node/index.cjs"
16
- },
17
- "browser": {
18
- "import": "./dist/browser/index.js"
15
+ "import": {
16
+ "types": "./dist/node/index.d.ts",
17
+ "default": "./dist/node/index.js"
18
+ },
19
+ "require": {
20
+ "types": "./dist/node/index.d.cts",
21
+ "default": "./dist/node/index.cjs"
22
+ }
19
23
  },
20
24
  "default": {
21
- "import": "./dist/node/index.js",
22
- "require": "./dist/node/index.cjs"
25
+ "import": {
26
+ "types": "./dist/node/index.d.ts",
27
+ "default": "./dist/node/index.js"
28
+ },
29
+ "require": {
30
+ "types": "./dist/node/index.d.cts",
31
+ "default": "./dist/node/index.cjs"
32
+ }
23
33
  }
24
34
  },
25
- "./loggers/file": {
26
- "types": "./dist/loggers/file.d.ts",
27
- "import": "./dist/loggers/file.js",
28
- "require": "./dist/loggers/file.cjs"
29
- },
30
- "./loggers/console": {
31
- "types": "./dist/loggers/console.d.ts",
32
- "import": "./dist/loggers/console.js",
33
- "require": "./dist/loggers/console.cjs"
35
+ "./loggers": {
36
+ "source": "./src/openai/loggers/index.ts",
37
+ "node": {
38
+ "import": {
39
+ "types": "./dist/node/loggers/index.d.ts",
40
+ "default": "./dist/node/loggers/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/node/loggers/index.d.cts",
44
+ "default": "./dist/node/loggers/index.cjs"
45
+ }
46
+ },
47
+ "default": {
48
+ "import": {
49
+ "types": "./dist/node/loggers/index.d.ts",
50
+ "default": "./dist/node/loggers/index.js"
51
+ },
52
+ "require": {
53
+ "types": "./dist/node/loggers/index.d.cts",
54
+ "default": "./dist/node/loggers/index.cjs"
55
+ }
56
+ }
34
57
  }
35
58
  },
36
- "repository": {
37
- "type": "git",
38
- "url": "https://github.com/woojubb/robota.git",
39
- "directory": "packages/agent-provider-openai"
40
- },
41
- "homepage": "https://robota.io/",
42
- "bugs": {
43
- "url": "https://github.com/woojubb/robota/issues"
44
- },
45
59
  "files": [
46
60
  "dist",
47
- "README.md",
48
- "CHANGELOG.md"
49
- ],
50
- "keywords": [
51
- "ai",
52
- "agent",
53
- "llm",
54
- "openai",
55
- "gpt",
56
- "gpt-4",
57
- "responses-api",
58
- "structured-outputs",
59
- "chatgpt",
60
- "function-calling",
61
- "typescript",
62
- "javascript",
63
- "chatbot",
64
- "conversational-ai",
65
- "artificial-intelligence",
66
- "machine-learning",
67
- "nlp",
68
- "natural-language-processing",
69
- "sdk",
70
- "library",
71
- "tool-integration",
72
- "robota",
73
- "openai-api",
74
- "ai-integration"
61
+ "src"
75
62
  ],
76
- "author": "Robota SDK Team",
77
- "license": "MIT",
78
- "dependencies": {
79
- "openai": "^4.98.0",
80
- "@robota-sdk/agent-provider-openai-compatible": "3.0.0-beta.63"
81
- },
82
- "peerDependencies": {
83
- "@robota-sdk/agent-core": "3.0.0-beta.63"
84
- },
85
63
  "devDependencies": {
86
- "@typescript-eslint/eslint-plugin": "^6.18.0",
87
- "@typescript-eslint/parser": "^6.18.0",
88
- "eslint": "^8.56.0",
89
- "rimraf": "^5.0.5",
90
- "tsup": "^8.0.1",
91
- "typescript": "^5.3.3",
92
- "vitest": "^1.1.0",
93
- "@robota-sdk/agent-core": "3.0.0-beta.63"
64
+ "@types/node": "^22.19.21",
65
+ "rimraf": "^5.0.10",
66
+ "tsdown": "^0.22.14",
67
+ "typescript": "^6.0.3",
68
+ "vitest": "^3.2.6"
94
69
  },
95
- "publishConfig": {
96
- "access": "public"
70
+ "license": "AGPL-3.0-only OR LicenseRef-Commercial",
71
+ "dependencies": {
72
+ "openai": "^4.104.0",
73
+ "@robota-sdk/agent-core": "3.0.0-beta.81",
74
+ "@robota-sdk/agent-provider-openai-compatible": "3.0.0-beta.81"
75
+ },
76
+ "volta": {
77
+ "extends": "../../package.json"
97
78
  },
98
79
  "scripts": {
99
- "build": "tsup",
100
- "build:js": "tsup --no-dts",
101
- "build:types": "tsup --dts-only",
102
- "dev": "tsup --watch",
103
- "clean": "rimraf dist",
80
+ "build": "rimraf dist && tsdown",
81
+ "build:js": "pnpm run build",
82
+ "build:types": "pnpm run build",
83
+ "typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit",
84
+ "scenario:verify": "pnpm typecheck",
85
+ "scenario:verify:tool-schema-projection": "pnpm exec tsx --conditions=source examples/verify-tool-schema-projection.ts",
104
86
  "test": "vitest run --passWithNoTests",
105
- "test:watch": "vitest",
106
- "test:coverage": "vitest run --coverage",
107
- "lint": "eslint src --ext .ts,.tsx",
108
- "typecheck": "tsc -p tsconfig.json --noEmit",
109
- "lint:fix": "eslint src --ext .ts,.tsx --fix",
110
- "scenario:verify": "pnpm --filter @robota-sdk/agent-core build && pnpm exec tsx examples/verify-executor.ts",
111
- "scenario:record": "node ../../scripts/harness/record-owner-scenario.mjs --scope packages/agent-provider-openai --output examples/scenarios/executor-verify.record.json -- pnpm scenario:verify"
87
+ "test:coverage": "vitest run --coverage --passWithNoTests",
88
+ "lint": "eslint src/ --ext .ts",
89
+ "clean": "rimraf dist"
112
90
  }
113
91
  }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './openai/index.js';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * CORE-043 decision (1): the advertised gateway configuration must not report enforcement it has not
3
+ * got.
4
+ *
5
+ * `llms.txt` and `types.ts` advertise `baseURL` for OpenAI-compatible gateways. Setting it also
6
+ * switches the API surface to `chat-completions` (`resolveApiSurface`), so it is precisely the
7
+ * configuration where whatever is on the far end is least likely to honour a structured-output
8
+ * parameter — and the runtime claimed early enforcement on it anyway.
9
+ *
10
+ * This provider declares no capability table by choice; nobody has verified one, and inventing one
11
+ * would be a fabricated claim. It can still answer this question, which is why the endpoint signal is
12
+ * a separate member rather than a field on the table.
13
+ */
14
+
15
+ import { describe, expect, it } from 'vitest';
16
+
17
+ import { OpenAIProvider } from '../provider';
18
+
19
+ import type { IAIProvider } from '@robota-sdk/agent-core';
20
+
21
+ describe('CORE-043 — OpenAI endpoint provenance', () => {
22
+ it('reports the vendor endpoint when no baseURL is configured', () => {
23
+ const provider = new OpenAIProvider({ apiKey: 'test-key' });
24
+ expect(provider.endpointIsVendorDefault()).toBe(true);
25
+ });
26
+
27
+ it('reports a gateway when baseURL is configured', () => {
28
+ const provider = new OpenAIProvider({ apiKey: 'test-key', baseURL: 'https://gateway.test/v1' });
29
+ expect(provider.endpointIsVendorDefault()).toBe(false);
30
+ });
31
+
32
+ it('still declares no capability table, so the signal cannot depend on one', () => {
33
+ // If the endpoint signal had been a field on the table, this provider could only report its
34
+ // endpoint by first inventing capability claims nobody verified. Read through `IAIProvider`
35
+ // rather than the class: `capabilityTable` is optional on the contract, and the point is that
36
+ // this provider leaves it unimplemented while still answering the endpoint question.
37
+ const provider: IAIProvider = new OpenAIProvider({
38
+ apiKey: 'test-key',
39
+ baseURL: 'https://gateway.test/v1',
40
+ });
41
+ expect(provider.capabilityTable).toBeUndefined();
42
+ expect(provider.endpointIsVendorDefault?.()).toBe(false);
43
+ });
44
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * A failed OpenAI call keeps the HTTP status and OpenAI's error type on every API surface, instead of
3
+ * collapsing into a bare `Error` whose only trace of the status is its message text.
4
+ */
5
+
6
+ import { ProviderError, RateLimitError, classifyProviderFailure } from '@robota-sdk/agent-core';
7
+ import { APIConnectionError, APIError, APIUserAbortError } from 'openai';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+
10
+ import { OpenAIProvider } from '../provider';
11
+
12
+ import type { TUniversalMessage } from '@robota-sdk/agent-core';
13
+ import type OpenAI from 'openai';
14
+
15
+ const messages: TUniversalMessage[] = [
16
+ { id: '1', role: 'user', content: 'hi', state: 'complete', timestamp: new Date() },
17
+ ];
18
+
19
+ function httpError(status: number, type: string): Error {
20
+ return APIError.generate(status, { error: { type, message: `${type} happened` } }, undefined, {});
21
+ }
22
+
23
+ function providerWith(apiSurface: 'chat-completions' | 'responses', error: Error): OpenAIProvider {
24
+ const create = vi.fn().mockRejectedValue(error);
25
+ const client = { chat: { completions: { create } }, responses: { create } };
26
+ return new OpenAIProvider({ client: client as unknown as OpenAI, apiSurface });
27
+ }
28
+
29
+ async function failure(run: () => Promise<unknown>): Promise<Error> {
30
+ try {
31
+ await run();
32
+ } catch (error) {
33
+ return error as Error;
34
+ }
35
+ throw new Error('expected the call to fail');
36
+ }
37
+
38
+ async function drain(stream: AsyncIterable<TUniversalMessage>): Promise<void> {
39
+ for await (const chunk of stream) void chunk;
40
+ }
41
+
42
+ const surfaces = ['chat-completions', 'responses'] as const;
43
+ const statuses: Array<[number, string]> = [
44
+ [529, 'overloaded_error'],
45
+ [503, 'server_error'],
46
+ [500, 'server_error'],
47
+ [401, 'invalid_api_key'],
48
+ ];
49
+
50
+ describe('OpenAI provider errors', () => {
51
+ for (const surface of surfaces) {
52
+ it.each(statuses)(`${surface} chat keeps HTTP %i`, async (status, type) => {
53
+ const provider = providerWith(surface, httpError(status, type));
54
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
55
+ expect(error).toBeInstanceOf(ProviderError);
56
+ expect((error as ProviderError).status).toBe(status);
57
+ expect((error as ProviderError).type).toBe(type);
58
+ expect(error.message).toContain(`${status}`);
59
+ });
60
+
61
+ it(`${surface} chatStream keeps HTTP 503`, async () => {
62
+ const provider = providerWith(surface, httpError(503, 'server_error'));
63
+ const error = await failure(() => drain(provider.chatStream(messages, { model: 'gpt-4o' })));
64
+ expect(error).toBeInstanceOf(ProviderError);
65
+ expect((error as ProviderError).status).toBe(503);
66
+ });
67
+
68
+ it(`${surface} passes the SDK abort error through unchanged`, async () => {
69
+ const abort = new APIUserAbortError();
70
+ const provider = providerWith(surface, abort);
71
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
72
+ expect(error).toBe(abort);
73
+ expect(classifyProviderFailure(error)).toEqual({ switchable: false, reason: 'aborted' });
74
+ });
75
+
76
+ it(`${surface} classifies an SDK connection failure as network`, async () => {
77
+ const provider = providerWith(
78
+ surface,
79
+ new APIConnectionError({
80
+ message: 'Connection error.',
81
+ cause: new TypeError('fetch failed'),
82
+ }),
83
+ );
84
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
85
+ expect(error).toBeInstanceOf(ProviderError);
86
+ expect(classifyProviderFailure(error)).toEqual({ switchable: false, reason: 'network' });
87
+ });
88
+
89
+ it(`${surface} classifies a 400 model_not_found as model-unavailable`, async () => {
90
+ const provider = providerWith(
91
+ surface,
92
+ APIError.generate(
93
+ 400,
94
+ {
95
+ error: { type: 'invalid_request_error', code: 'model_not_found', message: 'no model' },
96
+ },
97
+ undefined,
98
+ {},
99
+ ),
100
+ );
101
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
102
+ expect(classifyProviderFailure(error)).toEqual({
103
+ switchable: true,
104
+ reason: 'model-unavailable',
105
+ });
106
+ });
107
+
108
+ it(`${surface} chat maps 429 to RateLimitError`, async () => {
109
+ const provider = providerWith(surface, httpError(429, 'rate_limit_exceeded'));
110
+ const error = await failure(() => provider.chat(messages, { model: 'gpt-4o' }));
111
+ expect(error).toBeInstanceOf(RateLimitError);
112
+ });
113
+ }
114
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { buildOpenAIChatResponseFormat, mergeChatResponseFormat } from '../openai-request-format';
4
+
5
+ import type { IOpenAIProviderOptions } from '../types';
6
+
7
+ const providerOptions: IOpenAIProviderOptions = { apiKey: 'test-key' };
8
+
9
+ describe('mergeChatResponseFormat (CORE-015)', () => {
10
+ it('returns provider options untouched when no chat responseFormat is set', () => {
11
+ expect(mergeChatResponseFormat(providerOptions, undefined)).toBe(providerOptions);
12
+ });
13
+
14
+ it('carries a json_schema chat option with its schema payload into jsonSchema', () => {
15
+ const schema = {
16
+ type: 'object',
17
+ properties: { title: { type: 'string' } },
18
+ required: ['title'],
19
+ };
20
+ const merged = mergeChatResponseFormat(providerOptions, {
21
+ type: 'json_schema',
22
+ name: 'report',
23
+ schema,
24
+ });
25
+
26
+ expect(merged.responseFormat).toBe('json_schema');
27
+ expect(merged.jsonSchema).toEqual({ name: 'report', schema });
28
+ });
29
+
30
+ it('defaults the schema name when omitted', () => {
31
+ const merged = mergeChatResponseFormat(providerOptions, {
32
+ type: 'json_schema',
33
+ schema: { type: 'object', properties: {} },
34
+ });
35
+ expect(merged.jsonSchema?.name).toBe('structured_output');
36
+ });
37
+
38
+ it('maps text/json_object formats without a schema payload', () => {
39
+ expect(mergeChatResponseFormat(providerOptions, { type: 'json_object' }).responseFormat).toBe(
40
+ 'json_object',
41
+ );
42
+ expect(mergeChatResponseFormat(providerOptions, { type: 'text' }).responseFormat).toBe('text');
43
+ });
44
+
45
+ it('composes with buildOpenAIChatResponseFormat into response_format json_schema', () => {
46
+ const schema = {
47
+ type: 'object',
48
+ properties: { title: { type: 'string' } },
49
+ required: ['title'],
50
+ };
51
+ const format = buildOpenAIChatResponseFormat(
52
+ mergeChatResponseFormat(providerOptions, { type: 'json_schema', name: 'report', schema }),
53
+ );
54
+ expect(format).toEqual({
55
+ type: 'json_schema',
56
+ json_schema: { name: 'report', schema },
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,136 @@
1
+ import { closeObjectSchemas } from '@robota-sdk/agent-core';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { OpenAIProvider } from '../provider';
5
+
6
+ import type { IToolSchema, TUniversalMessage } from '@robota-sdk/agent-core';
7
+
8
+ /**
9
+ * MCP-005 (TC-08) — `strictTools` no longer closes schemas inside `responses-converter.ts`; the
10
+ * projection step (`AbstractAIProvider.projectTools()`, `STRICT_TOOL_SCHEMA_PROFILE`) runs BEFORE the
11
+ * converter now. This test drives the real `OpenAIProvider` end to end (profile → projector →
12
+ * converter) over a fake Responses client and asserts the request's `parameters` is byte-identical to
13
+ * today's `closeObjectSchemas(input, { requireAllProperties: true, optionalAsNullable: true })` for a
14
+ * fixture the projector does not reject — the capability-preservation premise GATE-WRITE recorded.
15
+ *
16
+ * PROV-007's original story stays true below: strict mode does not accept an arbitrary JSON Schema —
17
+ * every object node, nested included, must carry `additionalProperties: false` and list all of its
18
+ * properties in `required` — so a schema that is correct for this repository was rejected whenever a
19
+ * caller opted in, root included.
20
+ */
21
+
22
+ vi.mock('openai', () => {
23
+ const MockOpenAI = vi.fn().mockImplementation(() => ({
24
+ chat: { completions: { create: vi.fn() } },
25
+ responses: { create: vi.fn() },
26
+ }));
27
+ return { default: MockOpenAI };
28
+ });
29
+
30
+ interface IFakeResponsesClient {
31
+ responses: { create: ReturnType<typeof vi.fn> };
32
+ }
33
+
34
+ function createUserMessage(content: string): TUniversalMessage {
35
+ return { id: 'msg-1', state: 'complete' as const, role: 'user', content, timestamp: new Date() };
36
+ }
37
+
38
+ function fakeResponsesResult(): Record<string, unknown> {
39
+ return {
40
+ id: 'resp-strict-tools-closure',
41
+ model: 'gpt-4o',
42
+ output_text: '',
43
+ output: [],
44
+ status: 'completed',
45
+ };
46
+ }
47
+
48
+ async function sendChat(
49
+ provider: OpenAIProvider,
50
+ tools: IToolSchema[],
51
+ ): Promise<Record<string, unknown>> {
52
+ const client = (provider as unknown as { client: IFakeResponsesClient }).client;
53
+ client.responses.create.mockResolvedValue(fakeResponsesResult());
54
+ await provider.chat([createUserMessage('hello')], { model: 'gpt-4o', tools });
55
+ const [requestParams] = client.responses.create.mock.calls[
56
+ client.responses.create.mock.calls.length - 1
57
+ ] as [Record<string, unknown>];
58
+ return requestParams;
59
+ }
60
+
61
+ const NESTED_TOOL: IToolSchema = {
62
+ name: 'create_user',
63
+ description: 'Creates a user',
64
+ parameters: {
65
+ type: 'object',
66
+ additionalProperties: true,
67
+ properties: {
68
+ email: { type: 'string' },
69
+ profile: {
70
+ type: 'object',
71
+ additionalProperties: true,
72
+ properties: { nickname: { type: 'string' }, bio: { type: 'string' } },
73
+ required: ['nickname'],
74
+ },
75
+ },
76
+ required: ['email'],
77
+ },
78
+ };
79
+
80
+ const FLAT_TOOL: IToolSchema = {
81
+ name: 'ping',
82
+ description: 'Pings',
83
+ parameters: { type: 'object', additionalProperties: true, properties: {}, required: [] },
84
+ };
85
+
86
+ describe('MCP-005 TC-08 — the strict path (profile → projector → converter)', () => {
87
+ it('closes and completes every object node, root and nested, byte-identical to closeObjectSchemas', async () => {
88
+ const provider = new OpenAIProvider({ apiKey: 'sk-test', strictTools: true });
89
+ const requestParams = await sendChat(provider, [NESTED_TOOL]);
90
+ const [tool] = requestParams.tools as Array<Record<string, unknown>>;
91
+
92
+ expect(tool.strict).toBe(true);
93
+ expect(tool.parameters).toEqual(
94
+ closeObjectSchemas(NESTED_TOOL.parameters, {
95
+ requireAllProperties: true,
96
+ optionalAsNullable: true,
97
+ }),
98
+ );
99
+ });
100
+
101
+ it('is not only about nesting — an open ROOT is refused too', async () => {
102
+ const provider = new OpenAIProvider({ apiKey: 'sk-test', strictTools: true });
103
+ const requestParams = await sendChat(provider, [FLAT_TOOL]);
104
+ const [tool] = requestParams.tools as Array<Record<string, unknown>>;
105
+
106
+ expect((tool.parameters as Record<string, unknown>).additionalProperties).toBe(false);
107
+ });
108
+
109
+ it('leaves the description byte-identical for a fixture with no lossy strip', async () => {
110
+ const provider = new OpenAIProvider({ apiKey: 'sk-test', strictTools: true });
111
+ const requestParams = await sendChat(provider, [NESTED_TOOL]);
112
+ const [tool] = requestParams.tools as Array<Record<string, unknown>>;
113
+
114
+ expect(tool.description).toBe(NESTED_TOOL.description);
115
+ });
116
+ });
117
+
118
+ describe('MCP-005 TC-08 — the non-strict path carries the permissive projection', () => {
119
+ it('forwards the schema unchanged and sends strict: false', async () => {
120
+ const provider = new OpenAIProvider({ apiKey: 'sk-test', strictTools: false });
121
+ const requestParams = await sendChat(provider, [NESTED_TOOL]);
122
+ const [tool] = requestParams.tools as Array<Record<string, unknown>>;
123
+
124
+ expect(tool.strict).toBe(false);
125
+ expect(tool.parameters).toEqual(NESTED_TOOL.parameters);
126
+ });
127
+
128
+ it('treats an unset strictTools as non-strict', async () => {
129
+ const provider = new OpenAIProvider({ apiKey: 'sk-test' });
130
+ const requestParams = await sendChat(provider, [NESTED_TOOL]);
131
+ const [tool] = requestParams.tools as Array<Record<string, unknown>>;
132
+
133
+ expect(tool.strict).toBe(false);
134
+ expect(tool.parameters).toEqual(NESTED_TOOL.parameters);
135
+ });
136
+ });