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.
- 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/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/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -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/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/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- 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 +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- 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 +44 -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/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/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- 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 +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -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/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/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- 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 +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- 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 +49 -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-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.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,184 @@
|
|
|
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 } from '@nocobase/actions';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import type PluginAiApiServer from '../plugin';
|
|
13
|
+
import { handleEmbeddings } from '../routes/embeddings';
|
|
14
|
+
import { resolveModelString, resolveModelReference } from '../utils/resolve-service';
|
|
15
|
+
import { resolveVirtualModel } from '../utils/virtual-models';
|
|
16
|
+
import { finalizeLlmBilling, AiApiQuotaError, prepareLlmBilling } from '../billing';
|
|
17
|
+
|
|
18
|
+
vi.mock('../utils/resolve-service', () => ({
|
|
19
|
+
resolveModelString: vi.fn(),
|
|
20
|
+
resolveModelReference: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('../utils/virtual-models', () => ({
|
|
23
|
+
resolveVirtualModel: vi.fn().mockResolvedValue(null),
|
|
24
|
+
respondVirtualModelUnavailable: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('../utils/user-permissions', () => ({
|
|
27
|
+
enforceModelAccess: vi.fn().mockResolvedValue(true),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('../utils/request-cache', () => ({
|
|
30
|
+
getAiApiConfig: vi.fn().mockResolvedValue({ enabledLlmServices: [] }),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('../usage', () => ({
|
|
33
|
+
setAiApiUsageResult: vi.fn().mockReturnValue({
|
|
34
|
+
prompt_tokens: 10,
|
|
35
|
+
completion_tokens: 0,
|
|
36
|
+
total_tokens: 10,
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('../billing', () => ({
|
|
40
|
+
AiApiQuotaError: class extends Error {
|
|
41
|
+
code = 'quota_exceeded';
|
|
42
|
+
},
|
|
43
|
+
markLlmProviderAttempted: vi.fn(),
|
|
44
|
+
prepareLlmBilling: vi.fn(),
|
|
45
|
+
finalizeLlmBilling: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// handleEmbeddings reads service fields as direct properties (service.enabled, service.provider),
|
|
49
|
+
// so the mock must be a plain object, not a Sequelize-style { get() } wrapper.
|
|
50
|
+
function serviceModel(provider = 'test-provider') {
|
|
51
|
+
return {
|
|
52
|
+
name: 'test-service',
|
|
53
|
+
title: 'Test Service',
|
|
54
|
+
enabled: true,
|
|
55
|
+
provider,
|
|
56
|
+
options: {},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createContext(requestBody: Record<string, unknown>) {
|
|
61
|
+
const embeddingModel = {
|
|
62
|
+
embedDocuments: vi.fn().mockResolvedValue([[0.1, 0.2, 0.3]]),
|
|
63
|
+
};
|
|
64
|
+
class EmbeddingProvider {
|
|
65
|
+
createEmbedding() {
|
|
66
|
+
return embeddingModel;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
class TestProvider {
|
|
70
|
+
embedding = EmbeddingProvider;
|
|
71
|
+
provider = TestProvider;
|
|
72
|
+
createModel() {
|
|
73
|
+
return { modelKwargs: {} };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
vi.mocked(resolveModelString).mockResolvedValue({
|
|
78
|
+
service: serviceModel() as never,
|
|
79
|
+
modelId: 'test-model',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ctx = {
|
|
83
|
+
app: {
|
|
84
|
+
pm: {
|
|
85
|
+
get: vi.fn().mockReturnValue({
|
|
86
|
+
aiManager: {
|
|
87
|
+
llmProviders: new Map([['test-provider', { provider: TestProvider, embedding: EmbeddingProvider }]]),
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
request: { body: requestBody },
|
|
93
|
+
state: { currentUser: { id: 42 } },
|
|
94
|
+
log: { error: vi.fn(), warn: vi.fn() },
|
|
95
|
+
res: { headersSent: false },
|
|
96
|
+
set: vi.fn(),
|
|
97
|
+
} as unknown as Context;
|
|
98
|
+
return { ctx, embeddingModel };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('handleEmbeddings error handling', () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
vi.clearAllMocks();
|
|
104
|
+
vi.mocked(resolveVirtualModel).mockResolvedValue(null);
|
|
105
|
+
vi.mocked(prepareLlmBilling).mockResolvedValue(undefined);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns 429 when quota is exceeded (prepareLlmBilling throws AiApiQuotaError)', async () => {
|
|
109
|
+
const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello' });
|
|
110
|
+
vi.mocked(prepareLlmBilling).mockRejectedValueOnce(new AiApiQuotaError('quota exceeded'));
|
|
111
|
+
|
|
112
|
+
await handleEmbeddings(ctx, {} as PluginAiApiServer);
|
|
113
|
+
|
|
114
|
+
expect(ctx.status).toBe(429);
|
|
115
|
+
expect(ctx.body).toMatchObject({
|
|
116
|
+
error: { type: 'quota_error', code: 'quota_exceeded' },
|
|
117
|
+
});
|
|
118
|
+
expect(ctx.set).toHaveBeenCalledWith('X-RateLimit-Reason', 'quota_exceeded');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns 500 when the embedding provider throws', async () => {
|
|
122
|
+
const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello' });
|
|
123
|
+
|
|
124
|
+
class FailingEmbeddingProvider {
|
|
125
|
+
createEmbedding() {
|
|
126
|
+
return {
|
|
127
|
+
embedDocuments: vi.fn().mockRejectedValue(new Error('provider exploded')),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
class FailingProvider {
|
|
132
|
+
embedding = FailingEmbeddingProvider;
|
|
133
|
+
provider = FailingProvider;
|
|
134
|
+
createModel() {
|
|
135
|
+
return { modelKwargs: {} };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
vi.mocked(ctx.app.pm.get as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
139
|
+
aiManager: {
|
|
140
|
+
llmProviders: new Map([['test-provider', { provider: FailingProvider, embedding: FailingEmbeddingProvider }]]),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await handleEmbeddings(ctx, {} as PluginAiApiServer);
|
|
145
|
+
|
|
146
|
+
expect(ctx.status).toBe(500);
|
|
147
|
+
expect(ctx.body).toMatchObject({
|
|
148
|
+
error: { type: 'server_error', message: 'provider exploded' },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns 500 when the error is not an Error instance (regression for getErrorMessage)', async () => {
|
|
153
|
+
const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello' });
|
|
154
|
+
|
|
155
|
+
class ThrowingEmbeddingProvider {
|
|
156
|
+
createEmbedding() {
|
|
157
|
+
return {
|
|
158
|
+
embedDocuments: vi.fn().mockRejectedValue('raw string error'),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
class ThrowingProvider {
|
|
163
|
+
embedding = ThrowingEmbeddingProvider;
|
|
164
|
+
provider = ThrowingProvider;
|
|
165
|
+
createModel() {
|
|
166
|
+
return { modelKwargs: {} };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
vi.mocked(ctx.app.pm.get as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
170
|
+
aiManager: {
|
|
171
|
+
llmProviders: new Map([
|
|
172
|
+
['test-provider', { provider: ThrowingProvider, embedding: ThrowingEmbeddingProvider }],
|
|
173
|
+
]),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await handleEmbeddings(ctx, {} as PluginAiApiServer);
|
|
178
|
+
|
|
179
|
+
expect(ctx.status).toBe(500);
|
|
180
|
+
expect(ctx.body).toMatchObject({
|
|
181
|
+
error: { type: 'server_error', message: 'Failed to generate embeddings' },
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
-
import { buildModelObject, handleGetModel, handleListModels } from '../routes/models';
|
|
12
|
+
import { buildModelObject, buildVirtualModelObject, handleGetModel, handleListModels } from '../routes/models';
|
|
13
13
|
|
|
14
14
|
const CREATED = 1_700_000_000;
|
|
15
15
|
|
|
@@ -95,6 +95,26 @@ describe('buildModelObject', () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
describe('buildVirtualModelObject', () => {
|
|
99
|
+
it('surfaces the endpoint family of an alias', () => {
|
|
100
|
+
expect(
|
|
101
|
+
buildVirtualModelObject(
|
|
102
|
+
{
|
|
103
|
+
name: 'embed-auto',
|
|
104
|
+
mode: 'embedding',
|
|
105
|
+
fallbackModel: 'openai/text-embedding-3-small',
|
|
106
|
+
},
|
|
107
|
+
CREATED,
|
|
108
|
+
),
|
|
109
|
+
).toMatchObject({
|
|
110
|
+
id: 'embed-auto',
|
|
111
|
+
object: 'model',
|
|
112
|
+
virtual: true,
|
|
113
|
+
mode: 'embedding',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
98
118
|
describe('model catalog permission lookup failures', () => {
|
|
99
119
|
it('returns a retryable 503 when listing models', async () => {
|
|
100
120
|
const ctx = permissionLookupFailureContext();
|
|
@@ -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
|
+
});
|