plugin-ai-api 1.1.0 → 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.
- package/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/97.96da323832251796.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +38 -1
- package/dist/locale/vi-VN.json +177 -138
- package/dist/locale/zh-CN.json +177 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +34 -5
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +26 -4
- package/dist/server/utils/direct-llm-context.js +10 -9
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +45 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +38 -1
- package/src/locale/vi-VN.json +177 -138
- package/src/locale/zh-CN.json +177 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +15 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +42 -6
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +30 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +23 -12
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +50 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Context, Next } from '@nocobase/actions';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { blockResponseRecordResource, targetsResponseRecordCollection } from '../middleware/response-record-resource';
|
|
13
|
+
|
|
14
|
+
function context(resourceName: string, params: Record<string, unknown> = {}, targetCollection?: string): Context {
|
|
15
|
+
return {
|
|
16
|
+
action: { resourceName, actionName: 'list', params },
|
|
17
|
+
getCurrentRepository: targetCollection ? () => ({ targetCollection: { name: targetCollection } }) : undefined,
|
|
18
|
+
throw(status: number, message: string) {
|
|
19
|
+
throw Object.assign(new Error(message), { status });
|
|
20
|
+
},
|
|
21
|
+
} as unknown as Context;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('Responses API private storage resource', () => {
|
|
25
|
+
it.each([
|
|
26
|
+
context('aiApiResponseRecords'),
|
|
27
|
+
context('users.responses', { associatedName: 'aiApiResponseRecords' }),
|
|
28
|
+
context('generic', { targetCollection: 'aiApiResponseRecords' }),
|
|
29
|
+
context('generic', {}, 'aiApiResponseRecords'),
|
|
30
|
+
])('detects every generic route to the response record collection', (ctx) => {
|
|
31
|
+
expect(targetsResponseRecordCollection(ctx)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns 404 before generic CRUD can expose stored prompts', async () => {
|
|
35
|
+
const next = vi.fn() as unknown as Next;
|
|
36
|
+
|
|
37
|
+
await expect(blockResponseRecordResource()(context('aiApiResponseRecords'), next)).rejects.toMatchObject({
|
|
38
|
+
status: 404,
|
|
39
|
+
});
|
|
40
|
+
expect(next).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not affect unrelated resources', async () => {
|
|
44
|
+
const next = vi.fn().mockResolvedValue(undefined) as unknown as Next;
|
|
45
|
+
|
|
46
|
+
await blockResponseRecordResource()(context('users'), next);
|
|
47
|
+
|
|
48
|
+
expect(next).toHaveBeenCalledOnce();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database integration tests for the aiApiResponseRecords collection and response store.
|
|
3
|
+
*
|
|
4
|
+
* Uses createMockDatabase (SQLite in-memory) to validate:
|
|
5
|
+
* - Collection schema creation with correct field types
|
|
6
|
+
* - CRUD operations on response records
|
|
7
|
+
* - Owner-scoped retrieval and deletion
|
|
8
|
+
* - Expiry enforcement
|
|
9
|
+
* - Batch cleanup of expired records
|
|
10
|
+
* - Chain loading with previous_response_id
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@nocobase/actions';
|
|
14
|
+
import { createMockDatabase, type Database } from '@nocobase/database';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import responseRecordsCollection from '../collections/ai-api-response-records';
|
|
17
|
+
import SeedSampleResponseRecords from '../migrations/20260903000000-seed-sample-response-records';
|
|
18
|
+
import {
|
|
19
|
+
cleanupExpiredResponseRecords,
|
|
20
|
+
deleteResponseRecord,
|
|
21
|
+
getResponseRecord,
|
|
22
|
+
loadConversationChain,
|
|
23
|
+
RESPONSE_RETENTION_MS,
|
|
24
|
+
storeResponseRecord,
|
|
25
|
+
} from '../utils/response-store';
|
|
26
|
+
|
|
27
|
+
describe('aiApiResponseRecords database integration', () => {
|
|
28
|
+
let db: Database;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
db = await createMockDatabase();
|
|
32
|
+
// Register the actual collection definition so schema drift between the plugin and this
|
|
33
|
+
// test fails here instead of silently passing against a hand-duplicated schema.
|
|
34
|
+
db.collection(responseRecordsCollection);
|
|
35
|
+
await db.sync({ force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
// Truncate to avoid unique constraint collisions across tests (shared SQLite file).
|
|
40
|
+
try {
|
|
41
|
+
await db.getRepository('aiApiResponseRecords').destroy({ filter: {} });
|
|
42
|
+
} catch {
|
|
43
|
+
// Table may not exist in some edge cases.
|
|
44
|
+
}
|
|
45
|
+
await db.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function makeCtx(): Pick<Context, 'db'> {
|
|
49
|
+
return { db } as Pick<Context, 'db'>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeResponse(id: string, model = 'test-service/gpt-4o') {
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
object: 'response' as const,
|
|
56
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
57
|
+
completed_at: Math.floor(Date.now() / 1000),
|
|
58
|
+
status: 'completed' as const,
|
|
59
|
+
error: null,
|
|
60
|
+
incomplete_details: null,
|
|
61
|
+
instructions: null,
|
|
62
|
+
model,
|
|
63
|
+
output: [],
|
|
64
|
+
output_text: `Response ${id}`,
|
|
65
|
+
parallel_tool_calls: true,
|
|
66
|
+
service_tier: 'default' as const,
|
|
67
|
+
temperature: null,
|
|
68
|
+
tool_choice: 'auto',
|
|
69
|
+
tools: [],
|
|
70
|
+
top_p: null,
|
|
71
|
+
truncation: 'disabled' as const,
|
|
72
|
+
usage: {
|
|
73
|
+
input_tokens: 10,
|
|
74
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
75
|
+
output_tokens: 5,
|
|
76
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
77
|
+
total_tokens: 15,
|
|
78
|
+
},
|
|
79
|
+
metadata: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('stores and retrieves a response record by responseId + userId', async () => {
|
|
84
|
+
const ctx = makeCtx();
|
|
85
|
+
const response = makeResponse('resp_store_1');
|
|
86
|
+
|
|
87
|
+
await storeResponseRecord(ctx, response, { input: 'Hello', store: true }, 42);
|
|
88
|
+
|
|
89
|
+
const record = await getResponseRecord(ctx, 'resp_store_1', 42);
|
|
90
|
+
expect(record).toMatchObject({
|
|
91
|
+
responseId: 'resp_store_1',
|
|
92
|
+
userId: 42,
|
|
93
|
+
model: 'test-service/gpt-4o',
|
|
94
|
+
});
|
|
95
|
+
expect((record?.output as Record<string, unknown> | undefined)?.id).toBe('resp_store_1');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not store when store=false', async () => {
|
|
99
|
+
const ctx = makeCtx();
|
|
100
|
+
const response = makeResponse('resp_no_store');
|
|
101
|
+
|
|
102
|
+
await storeResponseRecord(ctx, response, { input: 'Hello', store: false }, 42);
|
|
103
|
+
|
|
104
|
+
const record = await getResponseRecord(ctx, 'resp_no_store', 42);
|
|
105
|
+
expect(record).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('scopes retrieval by userId — cross-user access returns null', async () => {
|
|
109
|
+
const ctx = makeCtx();
|
|
110
|
+
const response = makeResponse('resp_private');
|
|
111
|
+
|
|
112
|
+
await storeResponseRecord(ctx, response, { input: 'Secret', store: true }, 42);
|
|
113
|
+
|
|
114
|
+
// Same user can retrieve
|
|
115
|
+
expect(await getResponseRecord(ctx, 'resp_private', 42)).not.toBeNull();
|
|
116
|
+
// Different user cannot
|
|
117
|
+
expect(await getResponseRecord(ctx, 'resp_private', 99)).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('treats expired records as missing', async () => {
|
|
121
|
+
const ctx = makeCtx();
|
|
122
|
+
const response = makeResponse('resp_expired');
|
|
123
|
+
|
|
124
|
+
// Manually insert with past expiry
|
|
125
|
+
await db.getRepository('aiApiResponseRecords').create({
|
|
126
|
+
values: {
|
|
127
|
+
responseId: 'resp_expired',
|
|
128
|
+
userId: 42,
|
|
129
|
+
model: 'test-service/gpt-4o',
|
|
130
|
+
input: 'Old',
|
|
131
|
+
output: response,
|
|
132
|
+
expiresAt: new Date(Date.now() - 1000),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const record = await getResponseRecord(ctx, 'resp_expired', 42);
|
|
137
|
+
expect(record).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('deletes only an owner-scoped record', async () => {
|
|
141
|
+
const ctx = makeCtx();
|
|
142
|
+
const response = makeResponse('resp_delete');
|
|
143
|
+
|
|
144
|
+
await storeResponseRecord(ctx, response, { input: 'Delete me', store: true }, 42);
|
|
145
|
+
|
|
146
|
+
// Wrong user cannot delete
|
|
147
|
+
const deletedWrong = await deleteResponseRecord(ctx, 'resp_delete', 99);
|
|
148
|
+
expect(deletedWrong).toBe(false);
|
|
149
|
+
|
|
150
|
+
// Correct user can delete
|
|
151
|
+
const deletedRight = await deleteResponseRecord(ctx, 'resp_delete', 42);
|
|
152
|
+
expect(deletedRight).toBe(true);
|
|
153
|
+
|
|
154
|
+
// After deletion, retrieval returns null
|
|
155
|
+
expect(await getResponseRecord(ctx, 'resp_delete', 42)).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('cleans up expired records in batches', async () => {
|
|
159
|
+
const ctx = makeCtx();
|
|
160
|
+
|
|
161
|
+
// Insert 3 expired and 2 unexpired records
|
|
162
|
+
for (let i = 0; i < 3; i++) {
|
|
163
|
+
await db.getRepository('aiApiResponseRecords').create({
|
|
164
|
+
values: {
|
|
165
|
+
responseId: `resp_cleanup_${i}`,
|
|
166
|
+
userId: 42,
|
|
167
|
+
model: 'test-service/gpt-4o',
|
|
168
|
+
input: 'Expired',
|
|
169
|
+
output: makeResponse(`resp_cleanup_${i}`),
|
|
170
|
+
expiresAt: new Date(Date.now() - 1000),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
for (let i = 0; i < 2; i++) {
|
|
175
|
+
await db.getRepository('aiApiResponseRecords').create({
|
|
176
|
+
values: {
|
|
177
|
+
responseId: `resp_keep_${i}`,
|
|
178
|
+
userId: 42,
|
|
179
|
+
model: 'test-service/gpt-4o',
|
|
180
|
+
input: 'Keep',
|
|
181
|
+
output: makeResponse(`resp_keep_${i}`),
|
|
182
|
+
expiresAt: new Date(Date.now() + RESPONSE_RETENTION_MS),
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const deleted = await cleanupExpiredResponseRecords(ctx);
|
|
188
|
+
expect(deleted).toBeGreaterThanOrEqual(3);
|
|
189
|
+
|
|
190
|
+
// Unexpired records remain
|
|
191
|
+
expect(await getResponseRecord(ctx, 'resp_keep_0', 42)).not.toBeNull();
|
|
192
|
+
expect(await getResponseRecord(ctx, 'resp_keep_1', 42)).not.toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('loads a conversation chain via previousResponseId', async () => {
|
|
196
|
+
const ctx = makeCtx();
|
|
197
|
+
|
|
198
|
+
// Create a chain: resp_chain_1 → resp_chain_2 → resp_chain_3
|
|
199
|
+
const r1 = makeResponse('resp_chain_1');
|
|
200
|
+
const r2 = { ...makeResponse('resp_chain_2'), previous_response_id: 'resp_chain_1' };
|
|
201
|
+
const r3 = { ...makeResponse('resp_chain_3'), previous_response_id: 'resp_chain_2' };
|
|
202
|
+
|
|
203
|
+
await storeResponseRecord(ctx, r1, { input: 'First question', store: true }, 42);
|
|
204
|
+
await storeResponseRecord(ctx, r2, { input: 'Follow up', store: true, previous_response_id: 'resp_chain_1' }, 42);
|
|
205
|
+
await storeResponseRecord(ctx, r3, { input: 'And then?', store: true, previous_response_id: 'resp_chain_2' }, 42);
|
|
206
|
+
|
|
207
|
+
const messages = await loadConversationChain(ctx, 'resp_chain_2', 42);
|
|
208
|
+
expect(messages).not.toBeNull();
|
|
209
|
+
// Should contain: first question input + first response output + follow up input + follow up output
|
|
210
|
+
// String input produces 1 user message per record; chain of 2 records = 2 messages minimum
|
|
211
|
+
expect(messages?.length ?? 0).toBeGreaterThanOrEqual(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('returns null for a broken chain (missing link)', async () => {
|
|
215
|
+
const ctx = makeCtx();
|
|
216
|
+
|
|
217
|
+
// Only store the second response pointing to a non-existent first
|
|
218
|
+
const r2 = { ...makeResponse('resp_broken'), previous_response_id: 'resp_missing' };
|
|
219
|
+
await storeResponseRecord(ctx, r2, { input: 'Orphan', store: true, previous_response_id: 'resp_missing' }, 42);
|
|
220
|
+
|
|
221
|
+
const messages = await loadConversationChain(ctx, 'resp_broken', 42);
|
|
222
|
+
expect(messages).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns null for a cyclic chain', async () => {
|
|
226
|
+
const ctx = makeCtx();
|
|
227
|
+
|
|
228
|
+
// Create a cycle: A → B → A
|
|
229
|
+
const rA = { ...makeResponse('resp_cycle_a'), previous_response_id: 'resp_cycle_b' };
|
|
230
|
+
const rB = { ...makeResponse('resp_cycle_b'), previous_response_id: 'resp_cycle_a' };
|
|
231
|
+
|
|
232
|
+
await storeResponseRecord(ctx, rA, { input: 'A', store: true, previous_response_id: 'resp_cycle_b' }, 42);
|
|
233
|
+
await storeResponseRecord(ctx, rB, { input: 'B', store: true, previous_response_id: 'resp_cycle_a' }, 42);
|
|
234
|
+
|
|
235
|
+
const messages = await loadConversationChain(ctx, 'resp_cycle_a', 42);
|
|
236
|
+
expect(messages).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('handles BIGINT userId correctly', async () => {
|
|
240
|
+
const ctx = makeCtx();
|
|
241
|
+
const bigUserId = BigInt('9007199254740991'); // Within safe integer range for SQLite compatibility
|
|
242
|
+
const response = makeResponse('resp_bigint');
|
|
243
|
+
|
|
244
|
+
await storeResponseRecord(ctx, response, { input: 'BigInt test', store: true }, bigUserId);
|
|
245
|
+
|
|
246
|
+
const record = await getResponseRecord(ctx, 'resp_bigint', bigUserId);
|
|
247
|
+
expect(record?.userId.toString()).toBe(bigUserId.toString());
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('preserves metadata through store and retrieve', async () => {
|
|
251
|
+
const ctx = makeCtx();
|
|
252
|
+
const response = { ...makeResponse('resp_meta'), metadata: { env: 'test', version: '1.0' } };
|
|
253
|
+
|
|
254
|
+
await storeResponseRecord(
|
|
255
|
+
ctx,
|
|
256
|
+
response,
|
|
257
|
+
{ input: 'Meta test', store: true, metadata: { env: 'test', version: '1.0' } },
|
|
258
|
+
42,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const record = await getResponseRecord(ctx, 'resp_meta', 42);
|
|
262
|
+
expect(record).not.toBeNull();
|
|
263
|
+
expect(record?.metadata).toEqual({ env: 'test', version: '1.0' });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('uses the runtime data category so records are excluded from backups', () => {
|
|
267
|
+
const collection = db.getCollection('aiApiResponseRecords');
|
|
268
|
+
expect(collection.options.dataCategory).toBe('runtime');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('seed sample response records migration', () => {
|
|
272
|
+
function makeMigration() {
|
|
273
|
+
const rootUser = { get: (key: string) => (key === 'id' ? 42 : undefined) };
|
|
274
|
+
const usersRepo = { findOne: async () => rootUser };
|
|
275
|
+
const app = { logger: { info: vi.fn(), warn: vi.fn() } };
|
|
276
|
+
db.getRepository = ((original) =>
|
|
277
|
+
vi.fn((name: string) => (name === 'users' ? usersRepo : original.call(db, name))))(
|
|
278
|
+
db.getRepository.bind(db),
|
|
279
|
+
) as Database['getRepository'];
|
|
280
|
+
const MigrationClass = SeedSampleResponseRecords as unknown as new (context: { db: Database; app: unknown }) => {
|
|
281
|
+
up: () => Promise<void>;
|
|
282
|
+
down: () => Promise<void>;
|
|
283
|
+
};
|
|
284
|
+
return { migration: new MigrationClass({ db, app }), app };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it('seeds both samples, is idempotent, and recovers partial execution', async () => {
|
|
288
|
+
const { migration } = makeMigration();
|
|
289
|
+
|
|
290
|
+
await migration.up();
|
|
291
|
+
const repo = db.getRepository('aiApiResponseRecords');
|
|
292
|
+
const countWelcome = await repo.count({ filter: { responseId: 'resp_sample_welcome' } });
|
|
293
|
+
const countFollowup = await repo.count({ filter: { responseId: 'resp_sample_followup' } });
|
|
294
|
+
if (countWelcome !== 1 || countFollowup !== 1) {
|
|
295
|
+
throw new Error(`after up: welcome=${countWelcome} followup=${countFollowup}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Rerun: no duplicates.
|
|
299
|
+
await migration.up();
|
|
300
|
+
expect(await repo.count({ filter: { responseId: 'resp_sample_welcome' } })).toBe(1);
|
|
301
|
+
expect(await repo.count({ filter: { responseId: 'resp_sample_followup' } })).toBe(1);
|
|
302
|
+
|
|
303
|
+
// Partial execution: first sample exists, second was lost — rerun must create the missing one.
|
|
304
|
+
await repo.destroy({ filter: { responseId: 'resp_sample_followup' } });
|
|
305
|
+
await migration.up();
|
|
306
|
+
expect(await repo.count({ filter: { responseId: 'resp_sample_followup' } })).toBe(1);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('down() removes exactly the two sample records', async () => {
|
|
310
|
+
const { migration } = makeMigration();
|
|
311
|
+
|
|
312
|
+
await migration.up();
|
|
313
|
+
await storeResponseRecord(makeCtx(), makeResponse('resp_user_owned'), { input: 'Keep me', store: true }, 42);
|
|
314
|
+
|
|
315
|
+
await migration.down();
|
|
316
|
+
|
|
317
|
+
const repo = db.getRepository('aiApiResponseRecords');
|
|
318
|
+
expect(await repo.count({ filter: { responseId: 'resp_sample_welcome' } })).toBe(0);
|
|
319
|
+
expect(await repo.count({ filter: { responseId: 'resp_sample_followup' } })).toBe(0);
|
|
320
|
+
// Unrelated records are untouched.
|
|
321
|
+
expect(await repo.count({ filter: { responseId: 'resp_user_owned' } })).toBe(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('skips seeding when no root user exists', async () => {
|
|
325
|
+
const originalGetRepository = db.getRepository.bind(db);
|
|
326
|
+
db.getRepository = vi.fn((name: string) => {
|
|
327
|
+
if (name === 'users') return { findOne: async () => null };
|
|
328
|
+
return originalGetRepository(name);
|
|
329
|
+
}) as Database['getRepository'];
|
|
330
|
+
const app = { logger: { info: vi.fn(), warn: vi.fn() } };
|
|
331
|
+
const MigrationClass = SeedSampleResponseRecords as unknown as new (context: { db: Database; app: unknown }) => {
|
|
332
|
+
up: () => Promise<void>;
|
|
333
|
+
};
|
|
334
|
+
const migration = new MigrationClass({ db, app });
|
|
335
|
+
|
|
336
|
+
await migration.up();
|
|
337
|
+
|
|
338
|
+
expect(await originalGetRepository('aiApiResponseRecords').count()).toBe(0);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -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
|
+
});
|