plugin-ai-api 1.1.1 → 1.1.2

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 (108) hide show
  1. package/README.md +51 -12
  2. package/dist/client/185.c47663fefaeb0e5b.js +10 -0
  3. package/dist/client/562.9012cfd1fa04303d.js +10 -0
  4. package/dist/client/685.b5b1e0a5b825d253.js +10 -0
  5. package/dist/client/index.js +1 -1
  6. package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
  7. package/dist/client-v2/562.db2984167250b1be.js +10 -0
  8. package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
  9. package/dist/client-v2/index.js +1 -1
  10. package/dist/externalVersion.js +8 -8
  11. package/dist/locale/en-US.json +175 -139
  12. package/dist/locale/vi-VN.json +40 -2
  13. package/dist/locale/zh-CN.json +40 -2
  14. package/dist/server/collections/ai-api-model-metadata.js +26 -0
  15. package/dist/server/collections/ai-api-response-records.js +101 -0
  16. package/dist/server/collections/ai-api-virtual-models.js +68 -0
  17. package/dist/server/middleware/response-record-resource.js +66 -0
  18. package/dist/server/middleware/role-permission.js +43 -18
  19. package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
  20. package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
  21. package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
  22. package/dist/server/plugin.js +66 -16
  23. package/dist/server/routes/chat-completions.js +38 -6
  24. package/dist/server/routes/completions.js +16 -4
  25. package/dist/server/routes/embeddings.js +25 -6
  26. package/dist/server/routes/models.js +29 -0
  27. package/dist/server/routes/responses.js +530 -0
  28. package/dist/server/routes/router.js +65 -10
  29. package/dist/server/usage.js +25 -4
  30. package/dist/server/utils/direct-llm-context.js +1 -1
  31. package/dist/server/utils/resolve-service.js +24 -0
  32. package/dist/server/utils/response-store.js +138 -0
  33. package/dist/server/utils/responses-format.js +686 -0
  34. package/dist/server/utils/responses-stream.js +330 -0
  35. package/dist/server/utils/virtual-models.js +238 -0
  36. package/dist/server/validation.js +44 -2
  37. package/dist/swagger.js +137 -0
  38. package/package.json +34 -32
  39. package/src/__tests__/locale.test.ts +43 -0
  40. package/src/client/__tests__/settings-registration.test.tsx +1 -0
  41. package/src/client/plugin.tsx +9 -1
  42. package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
  44. package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
  45. package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
  46. package/src/client-v2/plugin.tsx +8 -0
  47. package/src/locale/en-US.json +175 -139
  48. package/src/locale/vi-VN.json +40 -2
  49. package/src/locale/zh-CN.json +40 -2
  50. package/src/server/__tests__/embeddings.test.ts +184 -0
  51. package/src/server/__tests__/models.test.ts +21 -1
  52. package/src/server/__tests__/response-record-resource.test.ts +50 -0
  53. package/src/server/__tests__/response-store-integration.test.ts +341 -0
  54. package/src/server/__tests__/response-store.test.ts +195 -0
  55. package/src/server/__tests__/responses-contract.test.ts +469 -0
  56. package/src/server/__tests__/responses-format.test.ts +299 -0
  57. package/src/server/__tests__/responses-router.test.ts +182 -0
  58. package/src/server/__tests__/responses-streaming.test.ts +368 -0
  59. package/src/server/__tests__/responses.test.ts +462 -0
  60. package/src/server/__tests__/role-permission.test.ts +139 -0
  61. package/src/server/__tests__/seed-role-permission.test.ts +88 -0
  62. package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
  63. package/src/server/__tests__/usage-groups.test.ts +96 -0
  64. package/src/server/__tests__/usage-route.test.ts +1 -0
  65. package/src/server/__tests__/usage.test.ts +14 -0
  66. package/src/server/__tests__/validation.test.ts +66 -7
  67. package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
  68. package/src/server/collections/ai-api-model-metadata.ts +26 -0
  69. package/src/server/collections/ai-api-response-records.ts +77 -0
  70. package/src/server/collections/ai-api-virtual-models.ts +58 -0
  71. package/src/server/middleware/response-record-resource.ts +44 -0
  72. package/src/server/middleware/role-permission.ts +69 -35
  73. package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
  74. package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
  75. package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
  76. package/src/server/plugin.ts +84 -20
  77. package/src/server/resource/ai-api-config.ts +2 -1
  78. package/src/server/routes/agent-completions.ts +3 -0
  79. package/src/server/routes/chat-completions.ts +34 -10
  80. package/src/server/routes/completions.ts +16 -4
  81. package/src/server/routes/embeddings.ts +32 -10
  82. package/src/server/routes/models.ts +34 -0
  83. package/src/server/routes/responses.ts +640 -0
  84. package/src/server/routes/router.ts +81 -12
  85. package/src/server/services/__tests__/file-processor.test.ts +1 -0
  86. package/src/server/usage.ts +29 -2
  87. package/src/server/utils/app-observability.ts +1 -1
  88. package/src/server/utils/direct-llm-context.ts +2 -1
  89. package/src/server/utils/openai-format.ts +1 -0
  90. package/src/server/utils/resolve-service.ts +39 -1
  91. package/src/server/utils/response-store.ts +148 -0
  92. package/src/server/utils/responses-format.ts +974 -0
  93. package/src/server/utils/responses-stream.ts +384 -0
  94. package/src/server/utils/virtual-models.ts +320 -0
  95. package/src/server/validation.ts +49 -0
  96. package/src/swagger.ts +139 -0
  97. package/dist/client/562.44b16aad4718b4c7.js +0 -10
  98. package/dist/client/685.ae483e17b6b49c98.js +0 -10
  99. package/dist/client-v2/562.45d5c504433be38b.js +0 -10
  100. package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
  101. package/dist/server/collections/ai-api-user-permissions.js +0 -67
  102. package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
  103. package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
  104. package/dist/server/resource/ai-api-usage-groups.js +0 -168
  105. package/src/server/collections/ai-api-user-permissions.ts +0 -46
  106. package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
  107. package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
  108. package/src/server/resource/ai-api-usage-groups.ts +0 -171
@@ -0,0 +1,195 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ deleteResponseRecord,
5
+ getResponseRecord,
6
+ loadConversationChain,
7
+ cleanupExpiredResponseRecords,
8
+ RESPONSE_RETENTION_MS,
9
+ storeResponseRecord,
10
+ } from '../utils/response-store';
11
+ import { chatResultToResponse } from '../utils/responses-format';
12
+
13
+ function row(values: Record<string, unknown>) {
14
+ return { get: (key: string) => values[key] };
15
+ }
16
+
17
+ function context(findOne: ReturnType<typeof vi.fn>, create = vi.fn()) {
18
+ return {
19
+ db: {
20
+ getRepository: vi.fn().mockReturnValue({ findOne, create }),
21
+ },
22
+ } as unknown as Context;
23
+ }
24
+
25
+ describe('Responses API response store', () => {
26
+ it('scopes response retrieval by both response ID and user ID', async () => {
27
+ const findOne = vi.fn().mockResolvedValue(null);
28
+ const ctx = context(findOne);
29
+
30
+ await getResponseRecord(ctx, 'resp_private', 42);
31
+
32
+ expect(findOne).toHaveBeenCalledWith({ filter: { responseId: 'resp_private', userId: 42 } });
33
+ });
34
+
35
+ it('treats expired records as missing', async () => {
36
+ const findOne = vi.fn().mockResolvedValue(
37
+ row({
38
+ id: 1,
39
+ responseId: 'resp_expired',
40
+ userId: 42,
41
+ model: 'service/model',
42
+ input: 'Hi',
43
+ output: {},
44
+ expiresAt: new Date(Date.now() - 1000),
45
+ }),
46
+ );
47
+
48
+ await expect(getResponseRecord(context(findOne), 'resp_expired', 42)).resolves.toBeNull();
49
+ });
50
+
51
+ it('treats missing or malformed expiry timestamps as missing', async () => {
52
+ for (const expiresAt of [undefined, 'not-a-date']) {
53
+ const findOne = vi.fn().mockResolvedValue(
54
+ row({
55
+ id: 1,
56
+ responseId: 'resp_invalid_expiry',
57
+ userId: 42,
58
+ model: 'service/model',
59
+ input: 'Hi',
60
+ output: {},
61
+ expiresAt,
62
+ }),
63
+ );
64
+ await expect(getResponseRecord(context(findOne), 'resp_invalid_expiry', 42)).resolves.toBeNull();
65
+ }
66
+ });
67
+
68
+ it('loads a chain in chronological input/output order', async () => {
69
+ const first = chatResultToResponse({
70
+ id: 'resp_first',
71
+ model: 'service/model',
72
+ content: 'First answer',
73
+ requestBody: { input: 'First question' },
74
+ });
75
+ const second = chatResultToResponse({
76
+ id: 'resp_second',
77
+ model: 'service/model',
78
+ content: 'Second answer',
79
+ requestBody: { input: 'Second question', previous_response_id: 'resp_first' },
80
+ });
81
+ const records: Record<string, unknown> = {
82
+ resp_first: row({
83
+ id: 1,
84
+ responseId: 'resp_first',
85
+ userId: 42,
86
+ model: 'service/model',
87
+ input: 'First question',
88
+ output: first,
89
+ expiresAt: new Date(Date.now() + 10_000),
90
+ }),
91
+ resp_second: row({
92
+ id: 2,
93
+ responseId: 'resp_second',
94
+ userId: 42,
95
+ model: 'service/model',
96
+ input: 'Second question',
97
+ output: second,
98
+ previousResponseId: 'resp_first',
99
+ expiresAt: new Date(Date.now() + 10_000),
100
+ }),
101
+ };
102
+ const findOne = vi.fn().mockImplementation(({ filter }) => records[filter.responseId] ?? null);
103
+
104
+ await expect(loadConversationChain(context(findOne), 'resp_second', 42)).resolves.toEqual([
105
+ { role: 'user', content: 'First question' },
106
+ { role: 'assistant', content: [{ type: 'text', text: 'First answer' }] },
107
+ { role: 'user', content: 'Second question' },
108
+ { role: 'assistant', content: [{ type: 'text', text: 'Second answer' }] },
109
+ ]);
110
+ });
111
+
112
+ it('returns null for a broken or cyclic chain', async () => {
113
+ const output = chatResultToResponse({
114
+ id: 'resp_cycle',
115
+ model: 'service/model',
116
+ content: 'Loop',
117
+ requestBody: { input: 'Loop' },
118
+ });
119
+ const findOne = vi.fn().mockResolvedValue(
120
+ row({
121
+ id: 1,
122
+ responseId: 'resp_cycle',
123
+ userId: 42,
124
+ model: 'service/model',
125
+ input: 'Loop',
126
+ output,
127
+ previousResponseId: 'resp_cycle',
128
+ expiresAt: new Date(Date.now() + 10_000),
129
+ }),
130
+ );
131
+
132
+ await expect(loadConversationChain(context(findOne), 'resp_cycle', 42)).resolves.toBeNull();
133
+ });
134
+
135
+ it('deletes by an owner-scoped primary key lookup', async () => {
136
+ const destroy = vi.fn().mockResolvedValue(undefined);
137
+ const record = row({
138
+ id: 9,
139
+ responseId: 'resp_delete',
140
+ userId: 42,
141
+ model: 'service/model',
142
+ input: 'Delete',
143
+ output: {},
144
+ expiresAt: new Date(Date.now() + 10_000),
145
+ });
146
+ const ctx = {
147
+ db: { getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(record), destroy }) },
148
+ } as unknown as Context;
149
+
150
+ await expect(deleteResponseRecord(ctx, 'resp_delete', 42)).resolves.toBe(true);
151
+ expect(destroy).toHaveBeenCalledWith({ filterByTk: 9 });
152
+ });
153
+ it('stores by default with a 30-day expiry and honors store=false', async () => {
154
+ const create = vi.fn().mockResolvedValue(undefined);
155
+ const ctx = context(vi.fn(), create);
156
+ const response = chatResultToResponse({
157
+ id: 'resp_store',
158
+ model: 'service/model',
159
+ content: 'Stored',
160
+ requestBody: { input: 'Store me' },
161
+ });
162
+ const startedAt = Date.now();
163
+
164
+ await storeResponseRecord(ctx, response, { input: 'Store me' }, 42);
165
+ const expiresAt = create.mock.calls[0][0].values.expiresAt as Date;
166
+
167
+ expect(expiresAt.getTime()).toBeGreaterThanOrEqual(startedAt + RESPONSE_RETENTION_MS);
168
+ expect(create).toHaveBeenCalledWith({
169
+ values: expect.objectContaining({ responseId: 'resp_store', userId: 42, input: 'Store me' }),
170
+ });
171
+
172
+ create.mockClear();
173
+ await storeResponseRecord(ctx, response, { input: 'Do not store', store: false }, 42);
174
+ expect(create).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('cleans expired records in bounded batches', async () => {
178
+ const firstBatch = Array.from({ length: 1000 }, (_, id) => row({ id: id + 1 }));
179
+ const secondBatch = [row({ id: 1001 })];
180
+ const find = vi.fn().mockResolvedValueOnce(firstBatch).mockResolvedValueOnce(secondBatch).mockResolvedValueOnce([]);
181
+ const destroy = vi.fn().mockImplementation(async ({ filterByTk }) => filterByTk.length);
182
+ const ctx = {
183
+ db: { getRepository: vi.fn().mockReturnValue({ find, destroy }) },
184
+ } as unknown as Context;
185
+
186
+ await expect(cleanupExpiredResponseRecords(ctx)).resolves.toBe(1001);
187
+ expect(find).toHaveBeenCalledTimes(3);
188
+ expect(find).toHaveBeenCalledWith(expect.objectContaining({ fields: ['id'], limit: 1000, sort: 'id' }));
189
+ expect(destroy).toHaveBeenNthCalledWith(1, {
190
+ filterByTk: Array.from({ length: 1000 }, (_, index) => index + 1),
191
+ individualHooks: false,
192
+ });
193
+ expect(destroy).toHaveBeenNthCalledWith(2, { filterByTk: [1001], individualHooks: false });
194
+ });
195
+ });
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Golden contract tests for the Responses API wire format.
3
+ *
4
+ * These tests validate that the gateway's Response objects and SSE events conform to the
5
+ * OpenAI SDK type definitions. They do NOT call a live provider — they exercise the
6
+ * gateway's own formatting code against the installed openai package's TypeScript types.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import type {
11
+ Response as OpenAIResponse,
12
+ ResponseCompletedEvent,
13
+ ResponseCreatedEvent,
14
+ ResponseFailedEvent,
15
+ ResponseFunctionCallArgumentsDeltaEvent,
16
+ ResponseFunctionCallArgumentsDoneEvent,
17
+ ResponseInProgressEvent,
18
+ ResponseIncompleteEvent,
19
+ ResponseOutputItemAddedEvent,
20
+ ResponseOutputItemDoneEvent,
21
+ ResponseContentPartAddedEvent,
22
+ ResponseContentPartDoneEvent,
23
+ ResponseTextDeltaEvent,
24
+ ResponseTextDoneEvent,
25
+ ResponseReasoningTextDeltaEvent,
26
+ ResponseReasoningTextDoneEvent,
27
+ ResponseErrorEvent,
28
+ ResponseUsage,
29
+ } from 'openai/resources/responses/responses';
30
+ import { formatSSEDone, type OpenAIUsage } from '../utils/openai-format';
31
+ import {
32
+ chatResultToResponse,
33
+ createResponseOutputMessage,
34
+ createResponseReasoningItem,
35
+ generateResponseId,
36
+ generateResponseItemId,
37
+ responseUsageFromOpenAIUsage,
38
+ type ResponseObject,
39
+ } from '../utils/responses-format';
40
+ import {
41
+ appendResponseStreamChunk,
42
+ createResponseErrorEvent,
43
+ createResponseFailedEvent,
44
+ createResponseStartEvents,
45
+ createResponseStreamState,
46
+ finalizeResponseStream,
47
+ setResponseStreamUsage,
48
+ type ResponseStreamEvent,
49
+ } from '../utils/responses-stream';
50
+
51
+ // ─── Helpers ────────────────────────────────────────────────────────────────
52
+
53
+ /** Assert that a value satisfies the OpenAI SDK Response type at compile time. */
54
+ function assertOpenAIResponse(value: unknown): asserts value is OpenAIResponse {
55
+ // Runtime shape checks mirror the SDK's required fields.
56
+ const r = value as Record<string, unknown>;
57
+ expect(typeof r.id).toBe('string');
58
+ expect(r.object).toBe('response');
59
+ expect(typeof r.created_at).toBe('number');
60
+ expect(['completed', 'failed', 'in_progress', 'cancelled', 'queued', 'incomplete']).toContain(r.status);
61
+ expect(Array.isArray(r.output)).toBe(true);
62
+ expect(typeof r.output_text).toBe('string');
63
+ expect(typeof r.model).toBe('string');
64
+ expect(r.error === null || typeof r.error === 'object').toBe(true);
65
+ expect(r.incomplete_details === null || typeof r.incomplete_details === 'object').toBe(true);
66
+ expect(typeof r.parallel_tool_calls).toBe('boolean');
67
+ expect(['auto', 'default', 'flex', 'scale', 'priority']).toContain(r.service_tier);
68
+ expect(r.truncation === 'auto' || r.truncation === 'disabled').toBe(true);
69
+ }
70
+
71
+ function assertOpenAIUsage(value: unknown): asserts value is ResponseUsage {
72
+ const u = value as Record<string, unknown>;
73
+ expect(typeof u.input_tokens).toBe('number');
74
+ expect(typeof u.output_tokens).toBe('number');
75
+ expect(typeof u.total_tokens).toBe('number');
76
+ expect(typeof (u.input_tokens_details as Record<string, unknown>)?.cached_tokens).toBe('number');
77
+ expect(typeof (u.output_tokens_details as Record<string, unknown>)?.reasoning_tokens).toBe('number');
78
+ }
79
+
80
+ function assertStreamEvent<T>(event: unknown, expectedType: string): T {
81
+ const e = event as Record<string, unknown>;
82
+ expect(e.type).toBe(expectedType);
83
+ expect(typeof e.sequence_number).toBe('number');
84
+ return event as T;
85
+ }
86
+
87
+ // ─── Non-streaming Response object contract ─────────────────────────────────
88
+
89
+ describe('Responses API contract: non-streaming Response object', () => {
90
+ it('produces a Response that satisfies the OpenAI SDK type', () => {
91
+ const response = chatResultToResponse({
92
+ id: 'resp_contract_1',
93
+ model: 'test-service/gpt-4o',
94
+ content: 'Hello world',
95
+ reasoningText: 'Let me think about this',
96
+ usage: {
97
+ prompt_tokens: 10,
98
+ completion_tokens: 5,
99
+ total_tokens: 15,
100
+ prompt_cache_tokens: 2,
101
+ reasoning_tokens: 3,
102
+ },
103
+ toolCalls: [
104
+ {
105
+ id: 'call_abc',
106
+ type: 'function',
107
+ function: { name: 'get_weather', arguments: '{"city":"Hanoi"}' },
108
+ },
109
+ ],
110
+ finishReason: 'stop',
111
+ serviceTier: 'default',
112
+ requestBody: {
113
+ input: 'What is the weather?',
114
+ model: 'test-service/gpt-4o',
115
+ tools: [{ type: 'function', name: 'get_weather', parameters: { type: 'object' } }],
116
+ tool_choice: 'auto',
117
+ parallel_tool_calls: true,
118
+ truncation: 'disabled',
119
+ service_tier: 'default',
120
+ metadata: { env: 'test' },
121
+ },
122
+ });
123
+
124
+ assertOpenAIResponse(response);
125
+ assertOpenAIUsage(response.usage);
126
+
127
+ // Verify output structure: reasoning item + message item + function_call item
128
+ expect(response.output).toHaveLength(3);
129
+ expect(response.output[0].type).toBe('reasoning');
130
+ expect(response.output[1].type).toBe('message');
131
+ expect(response.output[2].type).toBe('function_call');
132
+
133
+ // Reasoning item shape
134
+ const reasoning = response.output[0] as { type: string; summary: unknown[]; content?: unknown[] };
135
+ expect(reasoning.summary).toEqual([]);
136
+ expect(Array.isArray(reasoning.content)).toBe(true);
137
+
138
+ // Message item shape
139
+ const message = response.output[1] as { type: string; role: string; content: unknown[]; status: string };
140
+ expect(message.role).toBe('assistant');
141
+ expect(message.status).toBe('completed');
142
+ expect((message.content[0] as Record<string, unknown>).type).toBe('output_text');
143
+
144
+ // Function call item shape
145
+ const fc = response.output[2] as { type: string; call_id: string; name: string; arguments: string; status: string };
146
+ expect(fc.call_id).toBe('call_abc');
147
+ expect(fc.name).toBe('get_weather');
148
+ expect(fc.arguments).toBe('{"city":"Hanoi"}');
149
+ expect(fc.status).toBe('completed');
150
+
151
+ // Metadata passthrough
152
+ expect(response.metadata).toEqual({ env: 'test' });
153
+ });
154
+
155
+ it('produces an incomplete Response when finish reason indicates truncation', () => {
156
+ const response = chatResultToResponse({
157
+ model: 'test-service/gpt-4o',
158
+ content: 'Partial answer',
159
+ finishReason: 'length',
160
+ requestBody: { input: 'Explain everything', max_output_tokens: 10 },
161
+ });
162
+
163
+ assertOpenAIResponse(response);
164
+ expect(response.status).toBe('incomplete');
165
+ expect(response.completed_at).toBeNull();
166
+ expect(response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
167
+ });
168
+
169
+ it('produces an incomplete Response for content_filter finish reason', () => {
170
+ const response = chatResultToResponse({
171
+ model: 'test-service/gpt-4o',
172
+ content: 'Filtered',
173
+ finishReason: 'content_filter',
174
+ requestBody: { input: 'Something sensitive' },
175
+ });
176
+
177
+ assertOpenAIResponse(response);
178
+ expect(response.status).toBe('incomplete');
179
+ expect(response.incomplete_details).toEqual({ reason: 'content_filter' });
180
+ });
181
+
182
+ it('maps usage with all detail fields', () => {
183
+ const usage = responseUsageFromOpenAIUsage({
184
+ prompt_tokens: 100,
185
+ completion_tokens: 50,
186
+ total_tokens: 150,
187
+ prompt_cache_tokens: 80,
188
+ reasoning_tokens: 10,
189
+ } as OpenAIUsage);
190
+
191
+ assertOpenAIUsage(usage);
192
+ expect(usage.input_tokens).toBe(100);
193
+ expect(usage.output_tokens).toBe(50);
194
+ expect(usage.total_tokens).toBe(150);
195
+ expect(usage.input_tokens_details.cached_tokens).toBe(80);
196
+ expect(usage.output_tokens_details.reasoning_tokens).toBe(10);
197
+ });
198
+
199
+ it('defaults missing usage fields to zero', () => {
200
+ const usage = responseUsageFromOpenAIUsage(undefined);
201
+ assertOpenAIUsage(usage);
202
+ expect(usage.input_tokens).toBe(0);
203
+ expect(usage.output_tokens).toBe(0);
204
+ expect(usage.total_tokens).toBe(0);
205
+ expect(usage.input_tokens_details.cached_tokens).toBe(0);
206
+ expect(usage.output_tokens_details.reasoning_tokens).toBe(0);
207
+ });
208
+ });
209
+
210
+ // ─── Streaming SSE event contract ───────────────────────────────────────────
211
+
212
+ describe('Responses API contract: streaming SSE events', () => {
213
+ it('emits response.created and response.in_progress with valid Response snapshots', () => {
214
+ const state = createResponseStreamState('resp_stream_1', 'test-service/gpt-4o', { input: 'Hello' });
215
+ const startEvents = createResponseStartEvents(state);
216
+
217
+ expect(startEvents).toHaveLength(2);
218
+
219
+ const created = assertStreamEvent<ResponseCreatedEvent>(startEvents[0], 'response.created');
220
+ assertOpenAIResponse(created.response);
221
+ expect(created.response.status).toBe('in_progress');
222
+
223
+ const inProgress = assertStreamEvent<ResponseInProgressEvent>(startEvents[1], 'response.in_progress');
224
+ assertOpenAIResponse(inProgress.response);
225
+ expect(inProgress.response.status).toBe('in_progress');
226
+ });
227
+
228
+ it('emits text delta events with correct shape and monotonic sequence numbers', () => {
229
+ const state = createResponseStreamState('resp_stream_2', 'test-service/gpt-4o', { input: 'Hello' });
230
+ createResponseStartEvents(state);
231
+
232
+ const chunk1 = appendResponseStreamChunk(state, { content: 'Hel' });
233
+ const chunk2 = appendResponseStreamChunk(state, { content: 'lo' });
234
+
235
+ // First chunk should add item + part + delta
236
+ expect(chunk1.length).toBeGreaterThanOrEqual(3);
237
+ assertStreamEvent<ResponseOutputItemAddedEvent>(chunk1[0], 'response.output_item.added');
238
+ assertStreamEvent<ResponseContentPartAddedEvent>(chunk1[1], 'response.content_part.added');
239
+ assertStreamEvent<ResponseTextDeltaEvent>(chunk1[2], 'response.output_text.delta');
240
+
241
+ // Second chunk should be just a delta
242
+ expect(chunk2).toHaveLength(1);
243
+ const delta = assertStreamEvent<ResponseTextDeltaEvent>(chunk2[0], 'response.output_text.delta');
244
+ expect(delta.delta).toBe('lo');
245
+ expect(typeof delta.item_id).toBe('string');
246
+ expect(typeof delta.output_index).toBe('number');
247
+ expect(typeof delta.content_index).toBe('number');
248
+ expect(Array.isArray(delta.logprobs)).toBe(true);
249
+
250
+ // Sequence numbers must be strictly increasing across all events
251
+ const allEvents = [...chunk1, ...chunk2];
252
+ for (let i = 1; i < allEvents.length; i++) {
253
+ expect((allEvents[i] as ResponseStreamEvent).sequence_number).toBeGreaterThan(
254
+ (allEvents[i - 1] as ResponseStreamEvent).sequence_number,
255
+ );
256
+ }
257
+ });
258
+
259
+ it('emits reasoning text delta events with correct shape', () => {
260
+ const state = createResponseStreamState('resp_stream_reasoning', 'test-service/o3', { input: 'Think' });
261
+ createResponseStartEvents(state);
262
+
263
+ const chunks = appendResponseStreamChunk(state, {
264
+ reasoning_content: 'Step one: analyze the problem',
265
+ });
266
+
267
+ // Should add reasoning item + part + delta
268
+ expect(chunks.length).toBeGreaterThanOrEqual(3);
269
+ assertStreamEvent<ResponseOutputItemAddedEvent>(chunks[0], 'response.output_item.added');
270
+ assertStreamEvent<ResponseContentPartAddedEvent>(chunks[1], 'response.content_part.added');
271
+ const delta = assertStreamEvent<ResponseReasoningTextDeltaEvent>(chunks[2], 'response.reasoning_text.delta');
272
+ expect(delta.delta).toBe('Step one: analyze the problem');
273
+ expect(typeof delta.item_id).toBe('string');
274
+ expect(typeof delta.output_index).toBe('number');
275
+ expect(typeof delta.content_index).toBe('number');
276
+ });
277
+
278
+ it('emits function call argument events with correct shape', () => {
279
+ const state = createResponseStreamState('resp_stream_fc', 'test-service/gpt-4o', { input: 'Weather?' });
280
+ createResponseStartEvents(state);
281
+
282
+ const chunk1 = appendResponseStreamChunk(state, {
283
+ tool_call_chunks: [{ index: 0, id: 'call_xyz', name: 'get_weather', args: '{"city"' }],
284
+ });
285
+ const chunk2 = appendResponseStreamChunk(state, {
286
+ tool_call_chunks: [{ index: 0, args: ':"Hanoi"}' }],
287
+ });
288
+
289
+ // First chunk: item added + arguments delta
290
+ const addedEvent = chunk1.find((e) => (e as ResponseStreamEvent).type === 'response.output_item.added');
291
+ expect(addedEvent).toBeDefined();
292
+ const added = addedEvent as ResponseOutputItemAddedEvent;
293
+ expect((added.item as Record<string, unknown>).type).toBe('function_call');
294
+
295
+ const delta1 = chunk1.find((e) => (e as ResponseStreamEvent).type === 'response.function_call_arguments.delta');
296
+ expect(delta1).toBeDefined();
297
+ const d1 = delta1 as ResponseFunctionCallArgumentsDeltaEvent;
298
+ expect(d1.delta).toBe('{"city"');
299
+ expect(typeof d1.item_id).toBe('string');
300
+ expect(typeof d1.output_index).toBe('number');
301
+
302
+ // Second chunk: just arguments delta
303
+ expect(chunk2).toHaveLength(1);
304
+ const d2 = assertStreamEvent<ResponseFunctionCallArgumentsDeltaEvent>(
305
+ chunk2[0],
306
+ 'response.function_call_arguments.delta',
307
+ );
308
+ expect(d2.delta).toBe(':"Hanoi"}');
309
+ });
310
+
311
+ it('emits terminal events with completed Response and [DONE] marker', () => {
312
+ const state = createResponseStreamState('resp_stream_done', 'test-service/gpt-4o', { input: 'Hello' });
313
+ createResponseStartEvents(state);
314
+ appendResponseStreamChunk(state, { content: 'Hi there' });
315
+ setResponseStreamUsage(state, {
316
+ prompt_tokens: 5,
317
+ completion_tokens: 2,
318
+ total_tokens: 7,
319
+ } as OpenAIUsage);
320
+
321
+ const finalEvents = finalizeResponseStream(state);
322
+
323
+ // Should contain: text.done, content_part.done, output_item.done, response.completed
324
+ const types = finalEvents.map((e) => (e as ResponseStreamEvent).type);
325
+ expect(types).toContain('response.output_text.done');
326
+ expect(types).toContain('response.content_part.done');
327
+ expect(types).toContain('response.output_item.done');
328
+ expect(types).toContain('response.completed');
329
+ // [DONE] sentinel is written by the route layer after all terminal events.
330
+ expect(formatSSEDone()).toBe('data: [DONE]\n\n');
331
+
332
+ const completed = finalEvents.find(
333
+ (e) => (e as ResponseStreamEvent).type === 'response.completed',
334
+ ) as ResponseCompletedEvent;
335
+ assertOpenAIResponse(completed.response);
336
+ expect(completed.response.status).toBe('completed');
337
+ expect(completed.response.output_text).toBe('Hi there');
338
+ assertOpenAIUsage(completed.response.usage);
339
+ });
340
+
341
+ it('emits response.incomplete when stream ends with length finish reason', () => {
342
+ const state = createResponseStreamState('resp_stream_incomplete', 'test-service/gpt-4o', { input: 'Long' });
343
+ createResponseStartEvents(state);
344
+ appendResponseStreamChunk(state, {
345
+ content: 'Partial',
346
+ response_metadata: { finish_reason: 'length' },
347
+ });
348
+
349
+ const finalEvents = finalizeResponseStream(state);
350
+ const types = finalEvents.map((e) => (e as ResponseStreamEvent).type);
351
+ expect(types).toContain('response.incomplete');
352
+ expect(types).not.toContain('response.completed');
353
+
354
+ const incomplete = finalEvents.find(
355
+ (e) => (e as ResponseStreamEvent).type === 'response.incomplete',
356
+ ) as ResponseIncompleteEvent;
357
+ assertOpenAIResponse(incomplete.response);
358
+ expect(incomplete.response.status).toBe('incomplete');
359
+ expect(incomplete.response.completed_at).toBeNull();
360
+ expect(incomplete.response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
361
+ });
362
+
363
+ it('emits error and response.failed events on stream failure', () => {
364
+ const state = createResponseStreamState('resp_stream_fail', 'test-service/gpt-4o', { input: 'Fail' });
365
+ createResponseStartEvents(state);
366
+ appendResponseStreamChunk(state, { content: 'Partial before error' });
367
+
368
+ const errorEvent = createResponseErrorEvent(state, { message: 'Provider timeout', code: 'server_error' });
369
+ const failedEvent = createResponseFailedEvent(state, { message: 'Provider timeout', code: 'server_error' });
370
+
371
+ const err = assertStreamEvent<ResponseErrorEvent>(errorEvent, 'error');
372
+ expect(err.code).toBe('server_error');
373
+ expect(err.message).toBe('Provider timeout');
374
+
375
+ const failed = assertStreamEvent<ResponseFailedEvent>(failedEvent, 'response.failed');
376
+ assertOpenAIResponse(failed.response);
377
+ expect(failed.response.status).toBe('failed');
378
+ expect(failed.response.error).toEqual({ code: 'server_error', message: 'Provider timeout' });
379
+ expect(failed.response.completed_at).toBeNull();
380
+ });
381
+
382
+ it('preserves stable item IDs across the entire stream lifecycle', () => {
383
+ const state = createResponseStreamState('resp_stream_ids', 'test-service/gpt-4o', { input: 'IDs' });
384
+ const startEvents = createResponseStartEvents(state);
385
+
386
+ const textChunks = appendResponseStreamChunk(state, { content: 'Hello' });
387
+ const fcChunks = appendResponseStreamChunk(state, {
388
+ tool_call_chunks: [{ index: 0, id: 'call_stable', name: 'fn', args: '{}' }],
389
+ });
390
+ const finalEvents = finalizeResponseStream(state);
391
+
392
+ const allEvents = [...startEvents, ...textChunks, ...fcChunks, ...finalEvents];
393
+
394
+ // Collect all item_id references from output_item.added events
395
+ const addedItems = allEvents
396
+ .filter((e) => (e as ResponseStreamEvent).type === 'response.output_item.added')
397
+ .map((e) => {
398
+ const item = (e as ResponseOutputItemAddedEvent).item as Record<string, unknown>;
399
+ return { id: item.id, type: item.type };
400
+ });
401
+
402
+ // Each item ID should appear consistently in its delta/done events
403
+ for (const added of addedItems) {
404
+ const relatedEvents = allEvents.filter((e) => {
405
+ const ev = e as Record<string, unknown>;
406
+ return ev.item_id === added.id;
407
+ });
408
+ expect(relatedEvents.length).toBeGreaterThan(0);
409
+ }
410
+ });
411
+
412
+ it('emits function_call_arguments.done with complete arguments string', () => {
413
+ const state = createResponseStreamState('resp_stream_fc_done', 'test-service/gpt-4o', { input: 'FC' });
414
+ createResponseStartEvents(state);
415
+ appendResponseStreamChunk(state, {
416
+ tool_call_chunks: [{ index: 0, id: 'call_done', name: 'lookup', args: '{"q":"' }],
417
+ });
418
+ appendResponseStreamChunk(state, {
419
+ tool_call_chunks: [{ index: 0, args: 'test"}' }],
420
+ });
421
+
422
+ const finalEvents = finalizeResponseStream(state);
423
+ const doneEvent = finalEvents.find(
424
+ (e) => (e as ResponseStreamEvent).type === 'response.function_call_arguments.done',
425
+ ) as ResponseFunctionCallArgumentsDoneEvent;
426
+
427
+ expect(doneEvent).toBeDefined();
428
+ expect(doneEvent.name).toBe('lookup');
429
+ expect(doneEvent.arguments).toBe('{"q":"test"}');
430
+ expect(typeof doneEvent.item_id).toBe('string');
431
+ expect(typeof doneEvent.output_index).toBe('number');
432
+ });
433
+ });
434
+
435
+ // ─── Output item constructors ───────────────────────────────────────────────
436
+
437
+ describe('Responses API contract: output item constructors', () => {
438
+ it('creates output messages with the correct shape', () => {
439
+ const msg = createResponseOutputMessage('Hello', 'completed', 'msg_test_1');
440
+ expect(msg.id).toBe('msg_test_1');
441
+ expect(msg.type).toBe('message');
442
+ expect(msg.role).toBe('assistant');
443
+ expect(msg.status).toBe('completed');
444
+ expect(msg.content).toEqual([{ type: 'output_text', text: 'Hello', annotations: [] }]);
445
+ });
446
+
447
+ it('creates reasoning items with the correct shape', () => {
448
+ const item = createResponseReasoningItem('Thinking step 1');
449
+ expect(item.id).toMatch(/^rs_/);
450
+ expect(item.type).toBe('reasoning');
451
+ expect(item.status).toBe('completed');
452
+ expect(item.summary).toEqual([]);
453
+ expect(item.content).toEqual([{ type: 'reasoning_text', text: 'Thinking step 1' }]);
454
+ });
455
+
456
+ it('generates unique response IDs with resp_ prefix', () => {
457
+ const id1 = generateResponseId();
458
+ const id2 = generateResponseId();
459
+ expect(id1).toMatch(/^resp_[0-9a-f]{32}$/);
460
+ expect(id2).toMatch(/^resp_[0-9a-f]{32}$/);
461
+ expect(id1).not.toBe(id2);
462
+ });
463
+
464
+ it('generates unique item IDs with correct prefixes', () => {
465
+ expect(generateResponseItemId('msg')).toMatch(/^msg_[0-9a-f]{32}$/);
466
+ expect(generateResponseItemId('fc')).toMatch(/^fc_[0-9a-f]{32}$/);
467
+ expect(generateResponseItemId('rs')).toMatch(/^rs_[0-9a-f]{32}$/);
468
+ });
469
+ });