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
@@ -15,6 +15,7 @@ import { handleChatCompletions } from './chat-completions';
15
15
  import { handleCompletions } from './completions';
16
16
  import { handleAgentCompletions } from './agent-completions';
17
17
  import { handleEmbeddings } from './embeddings';
18
+ import { handleDeleteResponse, handleGetResponse, handleResponses } from './responses';
18
19
  import { toOpenAIError } from '../utils/openai-format';
19
20
  import { createRateLimitMiddleware } from '../middleware/rate-limit';
20
21
  import { checkRolePermission } from '../middleware/role-permission';
@@ -51,6 +52,8 @@ type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
51
52
  * POST /v1/chat/completions — OpenAI chat completions (LLM or agent mode)
52
53
  * POST /v1/completions — Legacy text completions (LiteLLM compat)
53
54
  * POST /v1/embeddings — OpenAI embeddings
55
+ * POST /v1/responses — OpenAI Responses API
56
+ * GET/DELETE /v1/responses/:id — Retrieve or delete a stored response
54
57
  * GET /v1/models — List available models
55
58
  * GET /v1/models/:id — Get a single model
56
59
  * DELETE /v1/models/:id — Not implemented (501 stub)
@@ -148,11 +151,18 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
148
151
  const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
149
152
  const model = requestBody.model === undefined || requestBody.model === null ? '-' : String(requestBody.model);
150
153
  const isUsageEndpoint =
151
- method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions' || subPath === '/embeddings');
152
- const isStreamingEndpoint = method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions');
153
- const resolvedMode = isUsageEndpoint ? await resolveMode(ctx) : 'llm';
154
- const streaming = isStreamingEndpoint && isStreamingRequested(requestBody.stream);
155
- if (streaming) {
154
+ method === 'POST' &&
155
+ (subPath === '/chat/completions' ||
156
+ subPath === '/completions' ||
157
+ subPath === '/embeddings' ||
158
+ subPath === '/responses');
159
+ const isStreamingEndpoint =
160
+ method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions' || subPath === '/responses');
161
+ const resolvedMode = subPath === '/responses' ? 'llm' : isUsageEndpoint ? await resolveMode(ctx) : 'llm';
162
+ const streaming =
163
+ isStreamingEndpoint &&
164
+ (subPath === '/responses' ? requestBody.stream === true : isStreamingRequested(requestBody.stream));
165
+ if (streaming && subPath !== '/responses') {
156
166
  const streamOptions = requestBody.stream_options;
157
167
  ctx.request.body = {
158
168
  ...requestBody,
@@ -167,11 +177,13 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
167
177
  const service =
168
178
  subPath === '/embeddings'
169
179
  ? 'llm.embedding'
170
- : resolvedMode === 'agent'
171
- ? 'llm.agent'
172
- : subPath === '/completions'
173
- ? 'llm.completion'
174
- : 'llm.chat';
180
+ : subPath === '/responses'
181
+ ? 'llm.responses'
182
+ : resolvedMode === 'agent'
183
+ ? 'llm.agent'
184
+ : subPath === '/completions'
185
+ ? 'llm.completion'
186
+ : 'llm.chat';
175
187
  startAiApiObservation(ctx, {
176
188
  service,
177
189
  operation: subPath,
@@ -210,6 +222,54 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
210
222
  return;
211
223
  }
212
224
 
225
+ // POST /v1/responses — OpenAI Responses API
226
+ if (method === 'POST' && subPath === '/responses') {
227
+ await handleResponses(ctx, plugin);
228
+ logRequest(
229
+ ctx,
230
+ requestId,
231
+ model,
232
+ ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
233
+ Date.now() - t0,
234
+ );
235
+ return;
236
+ }
237
+
238
+ // GET/DELETE /v1/responses/:id — stored Responses API objects, scoped to the authenticated user
239
+ if ((method === 'GET' || method === 'DELETE') && subPath.startsWith('/responses/')) {
240
+ const responseId = subPath.substring('/responses/'.length);
241
+ if (responseId) {
242
+ let decodedResponseId: string;
243
+ try {
244
+ decodedResponseId = decodeURIComponent(responseId);
245
+ } catch {
246
+ ctx.status = 400;
247
+ ctx.body = toOpenAIError(
248
+ 400,
249
+ 'Response ID contains invalid URL encoding',
250
+ 'invalid_request_error',
251
+ 'invalid_response_id',
252
+ );
253
+ logRequest(ctx, requestId, '-', 'invalid_response_id', Date.now() - t0);
254
+ return;
255
+ }
256
+ if (decodedResponseId.includes('/')) {
257
+ ctx.status = 404;
258
+ ctx.body = toOpenAIError(
259
+ 404,
260
+ `Unknown endpoint: ${method} ${path}`,
261
+ 'invalid_request_error',
262
+ 'unknown_url',
263
+ );
264
+ logRequest(ctx, requestId, '-', 'not_found', Date.now() - t0);
265
+ return;
266
+ }
267
+ if (method === 'GET') await handleGetResponse(ctx, decodedResponseId);
268
+ else await handleDeleteResponse(ctx, decodedResponseId);
269
+ logRequest(ctx, requestId, '-', ctx.status === 404 ? 'not_found' : 'ok', Date.now() - t0);
270
+ return;
271
+ }
272
+ }
213
273
  // POST /v1/completions (legacy text completions — used by LiteLLM)
214
274
  if (method === 'POST' && subPath === '/completions') {
215
275
  const completionsMode = resolvedMode;
@@ -250,7 +310,16 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
250
310
  if (method === 'GET' && subPath.startsWith('/models/')) {
251
311
  const modelId = subPath.substring('/models/'.length);
252
312
  if (modelId) {
253
- await handleGetModel(ctx, decodeURIComponent(modelId), plugin);
313
+ let decodedModelId: string;
314
+ try {
315
+ decodedModelId = decodeURIComponent(modelId);
316
+ } catch {
317
+ ctx.status = 400;
318
+ ctx.body = toOpenAIError(400, 'Model ID contains invalid URL encoding', 'invalid_request_error');
319
+ logRequest(ctx, requestId, modelId, 'bad_request', Date.now() - t0);
320
+ return;
321
+ }
322
+ await handleGetModel(ctx, decodedModelId, plugin);
254
323
  logRequest(ctx, requestId, modelId, 'ok', Date.now() - t0);
255
324
  return;
256
325
  }
@@ -275,7 +344,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
275
344
  ctx.body = toOpenAIError(
276
345
  404,
277
346
  `Unknown endpoint: ${method} ${path}. ` +
278
- `Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, GET /v1/models`,
347
+ `Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, POST /v1/responses, GET/DELETE /v1/responses/:id, GET /v1/models`,
279
348
  'invalid_request_error',
280
349
  'unknown_url',
281
350
  );
@@ -139,6 +139,7 @@ describe('pdfFileProcessor', () => {
139
139
  },
140
140
  },
141
141
  log: { warn: vi.fn() },
142
+ state: {},
142
143
  },
143
144
  } as any;
144
145
  }
@@ -7,6 +7,7 @@ export type Usage = {
7
7
  completion_tokens: number | null;
8
8
  total_tokens: number | null;
9
9
  prompt_cache_tokens?: number | null;
10
+ reasoning_tokens?: number | null;
10
11
  };
11
12
 
12
13
  export type AiApiAuthType = 'apiKey' | 'bearer' | 'oidc' | 'unknown';
@@ -39,6 +40,8 @@ interface AiApiContextState {
39
40
  currentUser?: { id?: string | number | bigint };
40
41
  oauthPrincipal?: OAuthPrincipal;
41
42
  aiApiLlmBilling?: LlmBillingState;
43
+ aiApiVirtualModel?: string;
44
+ aiApiRoutingReason?: string;
42
45
  }
43
46
 
44
47
  function getAiApiState(ctx: Context): AiApiContextState {
@@ -76,6 +79,21 @@ function extractPromptCacheTokens(source: Record<string, unknown>): number | nul
76
79
  return null;
77
80
  }
78
81
 
82
+ function extractReasoningTokens(source: Record<string, unknown>): number | null {
83
+ const detailCandidates = [
84
+ source.output_token_details,
85
+ source.output_tokens_details,
86
+ source.completion_tokens_details,
87
+ ];
88
+ for (const details of detailCandidates) {
89
+ if (!details || typeof details !== 'object') continue;
90
+ const record = details as Record<string, unknown>;
91
+ const value = normalizeTokenCount(record.reasoning ?? record.reasoning_tokens);
92
+ if (value !== null) return value;
93
+ }
94
+ return normalizeTokenCount(source.reasoning_tokens ?? source.reasoningTokens);
95
+ }
96
+
79
97
  export function normalizeUsage(value: unknown): Usage | undefined {
80
98
  if (!value || typeof value !== 'object') return undefined;
81
99
  const source = value as Record<string, unknown>;
@@ -97,6 +115,7 @@ export function normalizeUsage(value: unknown): Usage | undefined {
97
115
  // usage once and hand the result to setAiApiUsageResult, which normalizes
98
116
  // again — an already-extracted prompt_cache_tokens must survive that pass.
99
117
  prompt_cache_tokens: extractPromptCacheTokens(source) ?? normalizeTokenCount(source.prompt_cache_tokens),
118
+ reasoning_tokens: extractReasoningTokens(source) ?? normalizeTokenCount(source.reasoning_tokens),
100
119
  };
101
120
  }
102
121
 
@@ -180,7 +199,11 @@ export async function startUsageRecord(
180
199
  values: {
181
200
  requestId,
182
201
  userId,
183
- roleName: state.currentRole || state.currentRoles?.[0] || 'unknown',
202
+ // When several roles granted access (union semantics), record all of them so the
203
+ // audit trail shows the full set, not just the first role in the list.
204
+ roleName: state.currentRoles?.length
205
+ ? (state.currentRoles as string[]).join(',')
206
+ : state.currentRole || 'unknown',
184
207
  authType: state.aiApiAuthType || (oauth ? 'oidc' : 'unknown'),
185
208
  oauthClientId: oauth?.clientId,
186
209
  oauthSubject: oauth?.subject,
@@ -195,7 +218,7 @@ export async function startUsageRecord(
195
218
  messageCount: messages?.length,
196
219
  promptCount,
197
220
  embeddingInputCount,
198
- requestedMaxTokens: body.max_completion_tokens ?? body.max_tokens,
221
+ requestedMaxTokens: body.max_output_tokens ?? body.max_completion_tokens ?? body.max_tokens,
199
222
  },
200
223
  },
201
224
  });
@@ -242,6 +265,10 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
242
265
  responseMetadata: {
243
266
  usageSource: usageResult.source,
244
267
  ...(gatewayResponseId ? { gatewayResponseId } : {}),
268
+ // When the request went through a virtual alias, keep both the alias and why
269
+ // it routed there; resolvedModel (from billing state) already holds the concrete model.
270
+ ...(state.aiApiVirtualModel ? { virtualModel: state.aiApiVirtualModel } : {}),
271
+ ...(state.aiApiRoutingReason ? { routingReason: state.aiApiRoutingReason } : {}),
245
272
  },
246
273
  };
247
274
  await ctx.db.getRepository('aiApiUsageRecords').update({ filterByTk: id, values });
@@ -48,7 +48,7 @@ function safely(ctx: Context, callback: () => void): void {
48
48
  export function startAiApiObservation(
49
49
  ctx: Context,
50
50
  input: {
51
- service: 'llm.chat' | 'llm.agent' | 'llm.completion' | 'llm.embedding';
51
+ service: 'llm.chat' | 'llm.agent' | 'llm.completion' | 'llm.embedding' | 'llm.responses';
52
52
  operation: string;
53
53
  streaming: boolean;
54
54
  model?: string;
@@ -24,6 +24,7 @@ interface ContextPreparationOptions {
24
24
  tools?: unknown;
25
25
  maxCompletionTokens?: unknown;
26
26
  maxTokens?: unknown;
27
+ overflowBehavior?: ContextOverflowBehavior;
27
28
  }
28
29
 
29
30
  export interface PreparedDirectLlmContext {
@@ -348,7 +349,7 @@ export async function prepareDirectLlmContext(
348
349
  ): Promise<PreparedDirectLlmContext> {
349
350
  const [metadata, behavior] = await Promise.all([
350
351
  loadModelMetadata(ctx, options.serviceName, options.modelId),
351
- resolveOverflowBehavior(ctx),
352
+ options.overflowBehavior ? Promise.resolve(options.overflowBehavior) : resolveOverflowBehavior(ctx),
352
353
  ]);
353
354
  const reservedOutputTokens = resolveReservedOutputTokens(options, metadata);
354
355
  const inputTokenBudget = metadata.contextWindow - reservedOutputTokens;
@@ -117,6 +117,7 @@ export type OpenAIUsage = {
117
117
  completion_tokens: number | null;
118
118
  total_tokens: number | null;
119
119
  prompt_cache_tokens?: number | null;
120
+ reasoning_tokens?: number | null;
120
121
  };
121
122
 
122
123
  export type OpenAIStreamObject = 'chat.completion.chunk' | 'text_completion';
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Context } from '@nocobase/actions';
11
+ import type { Model } from '@nocobase/database';
11
12
  import { getAiApiConfig } from './request-cache';
12
13
 
13
14
  /**
@@ -36,7 +37,7 @@ export async function resolveLlmService(ctx: Context, serviceKey: string) {
36
37
  export async function resolveModelString(
37
38
  ctx: Context,
38
39
  modelString: string,
39
- ): Promise<{ service: any; modelId: string } | null> {
40
+ ): Promise<{ service: Model; modelId: string } | null> {
40
41
  const repo = ctx.db.getRepository('llmServices');
41
42
 
42
43
  // ─── Strategy 1: Try splitting at "/" positions ───
@@ -81,3 +82,40 @@ export async function resolveModelString(
81
82
 
82
83
  return null;
83
84
  }
85
+ /**
86
+ * Resolve a configured model reference strictly. Unlike resolveModelString, this never falls
87
+ * back to the default or single enabled service: a reference like "missing-service/gpt-4o"
88
+ * must fail instead of being reinterpreted as a model id on an unrelated service. Use this for
89
+ * admin-configured references (virtual model buckets and fallbacks), where silent misrouting
90
+ * would route traffic to the wrong model.
91
+ */
92
+ export async function resolveModelReference(
93
+ ctx: Context,
94
+ reference: string,
95
+ ): Promise<{ service: Model; modelId: string } | null> {
96
+ const repo = ctx.db.getRepository('llmServices');
97
+
98
+ const slashPositions: number[] = [];
99
+ for (let i = 0; i < reference.length; i++) {
100
+ if (reference[i] === '/') {
101
+ slashPositions.push(i);
102
+ }
103
+ }
104
+
105
+ for (const pos of slashPositions) {
106
+ const serviceKey = reference.substring(0, pos);
107
+ const modelId = reference.substring(pos + 1);
108
+ if (!serviceKey || !modelId) continue;
109
+
110
+ let service = await repo.findOne({ filter: { name: serviceKey } });
111
+ if (!service) {
112
+ service = await repo.findOne({ filter: { title: serviceKey } });
113
+ }
114
+
115
+ if (service) {
116
+ return { service, modelId };
117
+ }
118
+ }
119
+
120
+ return null;
121
+ }
@@ -0,0 +1,148 @@
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 type { Model } from '@nocobase/database';
12
+ import type { OpenAIMessage } from './direct-llm-context';
13
+ import { responsesInputToMessages, type ResponseObject } from './responses-format';
14
+
15
+ const RETENTION_DAYS = 30;
16
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
17
+ export const RESPONSE_RETENTION_MS = RETENTION_DAYS * MS_PER_DAY;
18
+
19
+ function valueOf<T>(model: unknown, key: string): T | undefined {
20
+ if (!model) return undefined;
21
+ if (typeof (model as { get?: unknown }).get === 'function') {
22
+ return (model as { get: (k: string) => unknown }).get(key) as T | undefined;
23
+ }
24
+ return (model as Record<string, unknown>)[key] as T | undefined;
25
+ }
26
+
27
+ function isExpired(value: unknown): boolean {
28
+ if (value === undefined || value === null || value === '') return true;
29
+ const timestamp = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
30
+ return !Number.isFinite(timestamp) || timestamp <= Date.now();
31
+ }
32
+
33
+ export interface ResponseRecord {
34
+ id: string | number | bigint;
35
+ responseId: string;
36
+ userId: string | number | bigint;
37
+ model: string;
38
+ input: unknown;
39
+ output: ResponseObject;
40
+ previousResponseId?: string;
41
+ metadata?: Record<string, unknown>;
42
+ expiresAt: Date | string;
43
+ }
44
+
45
+ export async function storeResponseRecord(
46
+ ctx: Context,
47
+ response: ResponseObject,
48
+ requestBody: Record<string, unknown>,
49
+ userId: string | number | bigint,
50
+ ): Promise<void> {
51
+ if (requestBody.store === false) return;
52
+
53
+ await ctx.db.getRepository('aiApiResponseRecords').create({
54
+ values: {
55
+ responseId: response.id,
56
+ userId,
57
+ model: response.model,
58
+ input: requestBody.input,
59
+ output: response,
60
+ previousResponseId: response.previous_response_id,
61
+ metadata: response.metadata,
62
+ expiresAt: new Date(Date.now() + RESPONSE_RETENTION_MS),
63
+ },
64
+ });
65
+ }
66
+
67
+ export async function getResponseRecord(
68
+ ctx: Pick<Context, 'db'>,
69
+ responseId: string,
70
+ userId: string | number | bigint,
71
+ ): Promise<ResponseRecord | null> {
72
+ const row = await ctx.db.getRepository('aiApiResponseRecords').findOne({
73
+ filter: { responseId, userId },
74
+ });
75
+ if (!row || isExpired(valueOf(row, 'expiresAt'))) return null;
76
+
77
+ return {
78
+ id: valueOf(row, 'id') as string | number | bigint,
79
+ responseId: valueOf<string>(row, 'responseId') as string,
80
+ userId: valueOf(row, 'userId') as string | number | bigint,
81
+ model: valueOf<string>(row, 'model') as string,
82
+ input: valueOf(row, 'input'),
83
+ output: valueOf<ResponseObject>(row, 'output') as ResponseObject,
84
+ previousResponseId: valueOf<string>(row, 'previousResponseId') ?? undefined,
85
+ metadata: valueOf<Record<string, unknown>>(row, 'metadata'),
86
+ expiresAt: valueOf<Date | string>(row, 'expiresAt') as Date | string,
87
+ };
88
+ }
89
+
90
+ function responseOutputToMessages(output: ResponseObject): OpenAIMessage[] {
91
+ return responsesInputToMessages(output.output);
92
+ }
93
+
94
+ export async function loadConversationChain(
95
+ ctx: Context,
96
+ responseId: string,
97
+ userId: string | number | bigint,
98
+ maxDepth = 100,
99
+ ): Promise<OpenAIMessage[] | null> {
100
+ const records: ResponseRecord[] = [];
101
+ const visited = new Set<string>();
102
+ let currentId: string | undefined = responseId;
103
+
104
+ while (currentId) {
105
+ if (records.length >= maxDepth || visited.has(currentId)) return null;
106
+ visited.add(currentId);
107
+ const record = await getResponseRecord(ctx, currentId, userId);
108
+ if (!record) return null;
109
+ records.unshift(record);
110
+ currentId = record.previousResponseId;
111
+ }
112
+
113
+ return records.flatMap((record) => [
114
+ ...responsesInputToMessages(record.input),
115
+ ...responseOutputToMessages(record.output),
116
+ ]);
117
+ }
118
+
119
+ export async function cleanupExpiredResponseRecords(ctx: Pick<Context, 'db'>): Promise<number> {
120
+ const repo = ctx.db.getRepository('aiApiResponseRecords');
121
+ const now = new Date();
122
+ let deleted = 0;
123
+ for (;;) {
124
+ const expired = (await repo.find({
125
+ filter: { expiresAt: { $lt: now } },
126
+ fields: ['id'],
127
+ limit: 1000,
128
+ sort: 'id',
129
+ })) as Model[];
130
+ const ids = expired.map((record) => record.get('id'));
131
+ if (ids.length === 0) return deleted;
132
+ const count = await repo.destroy({ filterByTk: ids, individualHooks: false });
133
+ if (typeof count === 'number') deleted += count;
134
+ else deleted += ids.length;
135
+ if (count === 0) return deleted;
136
+ }
137
+ }
138
+
139
+ export async function deleteResponseRecord(
140
+ ctx: Pick<Context, 'db'>,
141
+ responseId: string,
142
+ userId: string | number | bigint,
143
+ ): Promise<boolean> {
144
+ const record = await getResponseRecord(ctx, responseId, userId);
145
+ if (!record) return false;
146
+ await ctx.db.getRepository('aiApiResponseRecords').destroy({ filterByTk: record.id });
147
+ return true;
148
+ }