plugin-ai-api 1.0.8 → 1.0.10

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.
@@ -1,283 +1,320 @@
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 crypto from 'crypto';
11
- import { Context, Next } from '@nocobase/actions';
12
- import { authenticateBearer } from './auth';
13
- import { handleListModels, handleGetModel } from './models';
14
- import { handleChatCompletions } from './chat-completions';
15
- import { handleCompletions } from './completions';
16
- import { handleAgentCompletions } from './agent-completions';
17
- import { handleEmbeddings } from './embeddings';
18
- import { toOpenAIError } from '../utils/openai-format';
19
- import { createRateLimitMiddleware } from '../middleware/rate-limit';
20
- import { checkRolePermission } from '../middleware/role-permission';
21
- import type PluginAiApiServer from '../plugin';
22
-
23
- const API_PREFIX = '/api/ai-llm/v1';
24
-
25
- /**
26
- * Main Koa middleware router for OpenAI-compatible endpoints.
27
- *
28
- * Intercepts all requests to /api/ai-llm/v1/* and routes them
29
- * to the appropriate handler. Runs before NocoBase's resourcer
30
- * so the URL paths follow OpenAI convention.
31
- *
32
- * Features:
33
- * - CORS support (Access-Control-Allow-Origin: *)
34
- * - OPTIONS preflight handling (204)
35
- * - X-Request-Id on every response
36
- * - Bearer token authentication
37
- * - Sliding window rate limiting (enforces rateLimitPerMinute from config)
38
- * - Structured request logging via app.logger
39
- *
40
- * Supported endpoints:
41
- * POST /v1/chat/completions — OpenAI chat completions (LLM or agent mode)
42
- * POST /v1/completions Legacy text completions (LiteLLM compat)
43
- * POST /v1/embeddings OpenAI embeddings
44
- * GET /v1/models List available models
45
- * GET /v1/models/:id Get a single model
46
- * DELETE /v1/models/:id Not implemented (501 stub)
47
- */
48
- export function createAiLlmRouter(plugin: PluginAiApiServer) {
49
- const checkRateLimit = createRateLimitMiddleware(plugin.rateLimiter);
50
-
51
- return async (ctx: Context, next: Next) => {
52
- const { path, method } = ctx;
53
-
54
- // Only handle our prefix
55
- if (!path.startsWith(API_PREFIX)) {
56
- return next();
57
- }
58
-
59
- // Prevent NocoBase's dataWrapping middleware from wrapping OpenAI-format responses
60
- // in an extra {"data": ...} envelope, which breaks OpenAI-compatible clients like n8n.
61
- (ctx as any).withoutDataWrapping = true;
62
-
63
- // Parse the sub-path after prefix
64
- const subPath = path.substring(API_PREFIX.length);
65
-
66
- // ─── CORS — applies to all requests, including preflight ──────────────
67
- // '*' is safe here because all endpoints require Bearer token auth.
68
- // Browsers cannot send cookies to '*' origins, but Authorization headers work fine.
69
- ctx.set('Access-Control-Allow-Origin', '*');
70
- ctx.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
71
- ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale');
72
- ctx.set('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After');
73
- ctx.set('Access-Control-Max-Age', '86400');
74
-
75
- // ─── OPTIONS preflight — return immediately after CORS headers ────────
76
- if (method === 'OPTIONS') {
77
- ctx.status = 204;
78
- return;
79
- }
80
-
81
- // ─── Request ID — set before any response ─────────────────────────────
82
- const requestId = `req-${crypto.randomBytes(12).toString('hex')}`;
83
- ctx.set('X-Request-Id', requestId);
84
-
85
- // ─── Parse body for POST requests if not already parsed ───────────────
86
- if (method === 'POST' && !ctx.request.body) {
87
- try {
88
- const rawBody = await getRawBody(ctx);
89
- ctx.request.body = JSON.parse(rawBody);
90
- } catch (bodyErr: any) {
91
- const status = bodyErr?.statusCode === 413 ? 413 : 400;
92
- const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
93
- ctx.status = status;
94
- ctx.body = toOpenAIError(status, message, 'invalid_request_error');
95
- return;
96
- }
97
- }
98
-
99
- // ─── Authenticate ─────────────────────────────────────────────────────
100
- const isAuth = await authenticateBearer(ctx);
101
- if (!isAuth) {
102
- logRequest(ctx, requestId, '-', 'auth_failed', 0);
103
- return;
104
- }
105
-
106
- // ─── Role permission check ────────────────────────────────────────────
107
- const permitted = await checkRolePermission(ctx);
108
- if (!permitted) {
109
- logRequest(ctx, requestId, '-', 'forbidden', 0);
110
- return;
111
- }
112
-
113
- // ─── Rate limiting ────────────────────────────────────────────────────
114
- const allowed = await checkRateLimit(ctx);
115
- if (!allowed) {
116
- logRequest(ctx, requestId, '-', 'rate_limited', 0);
117
- return;
118
- }
119
-
120
- // ─── Route matching ───────────────────────────────────────────────────
121
- const model = (ctx.request.body as any)?.model ?? '-';
122
- const t0 = Date.now();
123
-
124
- try {
125
- // POST /v1/chat/completions — route based on mode
126
- if (method === 'POST' && subPath === '/chat/completions') {
127
- const mode = await resolveMode(ctx);
128
- await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
129
- logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
130
- return;
131
- }
132
-
133
- // POST /v1/embeddings
134
- if (method === 'POST' && subPath === '/embeddings') {
135
- await handleEmbeddings(ctx, plugin);
136
- logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
137
- return;
138
- }
139
-
140
- // POST /v1/completions (legacy text completions — used by LiteLLM)
141
- if (method === 'POST' && subPath === '/completions') {
142
- const completionsMode = await resolveMode(ctx);
143
- if (completionsMode === 'agent') {
144
- // Convert legacy prompt → messages format for agent handler
145
- const reqBody = ctx.request.body as any;
146
- if (reqBody?.prompt !== undefined) {
147
- const prompt =
148
- typeof reqBody.prompt === 'string'
149
- ? reqBody.prompt
150
- : Array.isArray(reqBody.prompt)
151
- ? reqBody.prompt.join('\n')
152
- : String(reqBody.prompt);
153
- ctx.request.body = { ...reqBody, messages: [{ role: 'user', content: prompt }] };
154
- }
155
- await handleAgentCompletions(ctx, plugin);
156
- } else {
157
- await handleCompletions(ctx, plugin);
158
- }
159
- logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
160
- return;
161
- }
162
-
163
- // GET /v1/models
164
- if (method === 'GET' && subPath === '/models') {
165
- await handleListModels(ctx, plugin);
166
- logRequest(ctx, requestId, '-', 'ok', Date.now() - t0);
167
- return;
168
- }
169
-
170
- // GET /v1/models/:model (model can contain '/' for service/model format)
171
- if (method === 'GET' && subPath.startsWith('/models/')) {
172
- const modelId = subPath.substring('/models/'.length);
173
- if (modelId) {
174
- await handleGetModel(ctx, decodeURIComponent(modelId), plugin);
175
- logRequest(ctx, requestId, modelId, 'ok', Date.now() - t0);
176
- return;
177
- }
178
- }
179
-
180
- // DELETE /v1/models/:model — stub (OpenAI fine-tune model deletion, not applicable here)
181
- if (method === 'DELETE' && subPath.startsWith('/models/')) {
182
- ctx.status = 501;
183
- ctx.body = toOpenAIError(
184
- 501,
185
- 'Model deletion is not supported by this API gateway. ' +
186
- 'Use the NocoBase admin panel to manage LLM services.',
187
- 'invalid_request_error',
188
- 'not_implemented',
189
- );
190
- logRequest(ctx, requestId, '-', 'not_implemented', Date.now() - t0);
191
- return;
192
- }
193
-
194
- // ─── Unsupported endpoint ──────────────────────────────────────────
195
- ctx.status = 404;
196
- ctx.body = toOpenAIError(
197
- 404,
198
- `Unknown endpoint: ${method} ${path}. ` +
199
- `Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, GET /v1/models`,
200
- 'invalid_request_error',
201
- 'unknown_url',
202
- );
203
- logRequest(ctx, requestId, '-', 'not_found', Date.now() - t0);
204
- } catch (err) {
205
- ctx.log.error('AI API router error:', err);
206
- logRequest(ctx, requestId, model, 'error', Date.now() - t0);
207
- if (!ctx.res.headersSent) {
208
- ctx.status = 500;
209
- ctx.body = toOpenAIError(500, err.message || 'Internal server error', 'server_error');
210
- }
211
- }
212
- };
213
- }
214
-
215
- /** Maximum allowed request body size (10 MB) to prevent OOM DoS attacks. */
216
- const MAX_BODY_BYTES = 10 * 1024 * 1024;
217
-
218
- /**
219
- * Read raw body from request stream (fallback if bodyparser didn't handle it).
220
- * Rejects with a 413-style error if the body exceeds MAX_BODY_BYTES.
221
- */
222
- function getRawBody(ctx: Context): Promise<string> {
223
- return new Promise((resolve, reject) => {
224
- let body = '';
225
- let byteCount = 0;
226
-
227
- ctx.req.on('data', (chunk: Buffer) => {
228
- byteCount += chunk.length;
229
- if (byteCount > MAX_BODY_BYTES) {
230
- ctx.req.destroy();
231
- reject(Object.assign(new Error('Request body too large (max 10 MB)'), { statusCode: 413 }));
232
- return;
233
- }
234
- body += chunk.toString();
235
- });
236
- ctx.req.on('end', () => resolve(body));
237
- ctx.req.on('error', reject);
238
- });
239
- }
240
-
241
- /**
242
- * Determine the API mode for a request.
243
- *
244
- * Priority:
245
- * 1. X-AI-Mode request header ('llm' or 'agent')
246
- * 2. Config `mode` field from aiApiConfig
247
- * 3. Default: 'llm'
248
- */
249
- async function resolveMode(ctx: Context): Promise<'llm' | 'agent'> {
250
- const headerMode = ctx.get('X-AI-Mode')?.toLowerCase();
251
- if (headerMode === 'agent' || headerMode === 'llm') {
252
- ctx.app.logger?.info(`[ai-api] Mode resolved from header: ${headerMode}`);
253
- return headerMode;
254
- }
255
-
256
- try {
257
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
258
- if (config) {
259
- const dbMode = config.get('mode') || config.mode;
260
- if (dbMode === 'agent' || dbMode === 'llm') {
261
- ctx.app.logger?.info(`[ai-api] Mode resolved from DB config: ${dbMode}`);
262
- return dbMode as 'llm' | 'agent';
263
- }
264
- }
265
- } catch (err) {
266
- ctx.app.logger?.error('[ai-api] Failed to get mode from config:', err);
267
- // Ignore config errors — default to llm
268
- }
269
-
270
- ctx.app.logger?.info(`[ai-api] Mode fallback to default: llm`);
271
- return 'llm';
272
- }
273
-
274
- /**
275
- * Write a structured log line for every handled request.
276
- */
277
- function logRequest(ctx: Context, requestId: string, model: string, status: string, durationMs: number): void {
278
- const userId = ctx.state.currentUser?.id ?? 'anon';
279
- ctx.app.logger?.info(
280
- `[ai-api] ${ctx.method} ${ctx.path} requestId=${requestId} userId=${userId} ` +
281
- `model=${model} status=${status} duration=${durationMs}ms`,
282
- );
283
- }
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 crypto from 'crypto';
11
+ import { Context, Next } from '@nocobase/actions';
12
+ import { authenticateBearer } from './auth';
13
+ import { handleListModels, handleGetModel } from './models';
14
+ import { handleChatCompletions } from './chat-completions';
15
+ import { handleCompletions } from './completions';
16
+ import { handleAgentCompletions } from './agent-completions';
17
+ import { handleEmbeddings } from './embeddings';
18
+ import { toOpenAIError } from '../utils/openai-format';
19
+ import { createRateLimitMiddleware } from '../middleware/rate-limit';
20
+ import { checkRolePermission } from '../middleware/role-permission';
21
+ import { startUsageRecord, finishUsageRecord } from '../usage';
22
+ import type PluginAiApiServer from '../plugin';
23
+
24
+ const API_PREFIX = '/api/ai-llm/v1';
25
+
26
+ /**
27
+ * Main Koa middleware router for OpenAI-compatible endpoints.
28
+ *
29
+ * Intercepts all requests to /api/ai-llm/v1/* and routes them
30
+ * to the appropriate handler. Runs before NocoBase's resourcer
31
+ * so the URL paths follow OpenAI convention.
32
+ *
33
+ * Features:
34
+ * - CORS support (Access-Control-Allow-Origin: *)
35
+ * - OPTIONS preflight handling (204)
36
+ * - X-Request-Id on every response
37
+ * - Bearer token authentication
38
+ * - Sliding window rate limiting (enforces rateLimitPerMinute from config)
39
+ * - Structured request logging via app.logger
40
+ *
41
+ * Supported endpoints:
42
+ * POST /v1/chat/completions OpenAI chat completions (LLM or agent mode)
43
+ * POST /v1/completions Legacy text completions (LiteLLM compat)
44
+ * POST /v1/embeddings OpenAI embeddings
45
+ * GET /v1/models List available models
46
+ * GET /v1/models/:id Get a single model
47
+ * DELETE /v1/models/:id — Not implemented (501 stub)
48
+ */
49
+ export function createAiLlmRouter(plugin: PluginAiApiServer) {
50
+ const checkRateLimit = createRateLimitMiddleware(plugin.rateLimiter);
51
+
52
+ return async (ctx: Context, next: Next) => {
53
+ const { path, method } = ctx;
54
+
55
+ // Only handle our prefix
56
+ if (!path.startsWith(API_PREFIX)) {
57
+ return next();
58
+ }
59
+
60
+ // Prevent NocoBase's dataWrapping middleware from wrapping OpenAI-format responses
61
+ // in an extra {"data": ...} envelope, which breaks OpenAI-compatible clients like n8n.
62
+ (ctx as any).withoutDataWrapping = true;
63
+
64
+ // Parse the sub-path after prefix
65
+ const subPath = path.substring(API_PREFIX.length);
66
+
67
+ // ─── CORS applies to all requests, including preflight ──────────────
68
+ // '*' is safe here because all endpoints require Bearer token auth.
69
+ // Browsers cannot send cookies to '*' origins, but Authorization headers work fine.
70
+ ctx.set('Access-Control-Allow-Origin', '*');
71
+ ctx.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
72
+ ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale');
73
+ ctx.set('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After');
74
+ ctx.set('Access-Control-Max-Age', '86400');
75
+
76
+ // ─── OPTIONS preflight — return immediately after CORS headers ────────
77
+ if (method === 'OPTIONS') {
78
+ ctx.status = 204;
79
+ return;
80
+ }
81
+
82
+ // ─── Request ID — set before any response ─────────────────────────────
83
+ const requestId = `req-${crypto.randomBytes(12).toString('hex')}`;
84
+ ctx.set('X-Request-Id', requestId);
85
+
86
+ // ─── Parse body for POST requests if not already parsed ───────────────
87
+ if (method === 'POST' && !ctx.request.body) {
88
+ try {
89
+ const rawBody = await getRawBody(ctx);
90
+ ctx.request.body = JSON.parse(rawBody);
91
+ } catch (bodyErr: any) {
92
+ const status = bodyErr?.statusCode === 413 ? 413 : 400;
93
+ const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
94
+ ctx.status = status;
95
+ ctx.body = toOpenAIError(status, message, 'invalid_request_error');
96
+ return;
97
+ }
98
+ }
99
+
100
+ // ─── Authenticate ─────────────────────────────────────────────────────
101
+ const isAuth = await authenticateBearer(ctx);
102
+ if (!isAuth) {
103
+ logRequest(ctx, requestId, '-', 'auth_failed', 0);
104
+ return;
105
+ }
106
+
107
+ // ─── Role permission check ────────────────────────────────────────────
108
+ const permitted = await checkRolePermission(ctx);
109
+ if (!permitted) {
110
+ logRequest(ctx, requestId, '-', 'forbidden', 0);
111
+ return;
112
+ }
113
+
114
+ // ─── Rate limiting ────────────────────────────────────────────────────
115
+ const allowed = await checkRateLimit(ctx);
116
+ if (!allowed) {
117
+ logRequest(ctx, requestId, '-', 'rate_limited', 0);
118
+ return;
119
+ }
120
+
121
+ // ─── Route matching ───────────────────────────────────────────────────
122
+ const model = (ctx.request.body as any)?.model ?? '-';
123
+ const t0 = Date.now();
124
+ let usageId: unknown;
125
+ try {
126
+ usageId = await startUsageRecord(
127
+ ctx,
128
+ requestId,
129
+ subPath,
130
+ String(model),
131
+ Boolean((ctx.request.body as any)?.stream),
132
+ );
133
+ } catch (usageError) {
134
+ ctx.log.error('AI API usage record could not be created:', usageError);
135
+ }
136
+
137
+ try {
138
+ // POST /v1/chat/completions — route based on mode
139
+ if (method === 'POST' && subPath === '/chat/completions') {
140
+ const mode = await resolveMode(ctx);
141
+ await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
142
+ logRequest(
143
+ ctx,
144
+ requestId,
145
+ model,
146
+ ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
147
+ Date.now() - t0,
148
+ );
149
+ return;
150
+ }
151
+
152
+ // POST /v1/embeddings
153
+ if (method === 'POST' && subPath === '/embeddings') {
154
+ await handleEmbeddings(ctx, plugin);
155
+ logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
156
+ return;
157
+ }
158
+
159
+ // POST /v1/completions (legacy text completions used by LiteLLM)
160
+ if (method === 'POST' && subPath === '/completions') {
161
+ const completionsMode = await resolveMode(ctx);
162
+ if (completionsMode === 'agent') {
163
+ // Convert legacy prompt → messages format for agent handler
164
+ const reqBody = ctx.request.body as any;
165
+ if (reqBody?.prompt !== undefined) {
166
+ const prompt =
167
+ typeof reqBody.prompt === 'string'
168
+ ? reqBody.prompt
169
+ : Array.isArray(reqBody.prompt)
170
+ ? reqBody.prompt.join('\n')
171
+ : String(reqBody.prompt);
172
+ ctx.request.body = { ...reqBody, messages: [{ role: 'user', content: prompt }] };
173
+ }
174
+ await handleAgentCompletions(ctx, plugin);
175
+ } else {
176
+ await handleCompletions(ctx, plugin);
177
+ }
178
+ logRequest(
179
+ ctx,
180
+ requestId,
181
+ model,
182
+ ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
183
+ Date.now() - t0,
184
+ );
185
+ return;
186
+ }
187
+
188
+ // GET /v1/models
189
+ if (method === 'GET' && subPath === '/models') {
190
+ await handleListModels(ctx, plugin);
191
+ logRequest(ctx, requestId, '-', 'ok', Date.now() - t0);
192
+ return;
193
+ }
194
+
195
+ // GET /v1/models/:model (model can contain '/' for service/model format)
196
+ if (method === 'GET' && subPath.startsWith('/models/')) {
197
+ const modelId = subPath.substring('/models/'.length);
198
+ if (modelId) {
199
+ await handleGetModel(ctx, decodeURIComponent(modelId), plugin);
200
+ logRequest(ctx, requestId, modelId, 'ok', Date.now() - t0);
201
+ return;
202
+ }
203
+ }
204
+
205
+ // DELETE /v1/models/:model — stub (OpenAI fine-tune model deletion, not applicable here)
206
+ if (method === 'DELETE' && subPath.startsWith('/models/')) {
207
+ ctx.status = 501;
208
+ ctx.body = toOpenAIError(
209
+ 501,
210
+ 'Model deletion is not supported by this API gateway. ' +
211
+ 'Use the NocoBase admin panel to manage LLM services.',
212
+ 'invalid_request_error',
213
+ 'not_implemented',
214
+ );
215
+ logRequest(ctx, requestId, '-', 'not_implemented', Date.now() - t0);
216
+ return;
217
+ }
218
+
219
+ // ─── Unsupported endpoint ──────────────────────────────────────────
220
+ ctx.status = 404;
221
+ ctx.body = toOpenAIError(
222
+ 404,
223
+ `Unknown endpoint: ${method} ${path}. ` +
224
+ `Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, GET /v1/models`,
225
+ 'invalid_request_error',
226
+ 'unknown_url',
227
+ );
228
+ logRequest(ctx, requestId, '-', 'not_found', Date.now() - t0);
229
+ } catch (err) {
230
+ ctx.log.error('AI API router error:', err);
231
+ logRequest(ctx, requestId, model, 'error', Date.now() - t0);
232
+ if (!ctx.res.headersSent) {
233
+ ctx.status = 500;
234
+ ctx.body = toOpenAIError(
235
+ 500,
236
+ err instanceof Error && err.message ? err.message : 'Internal server error',
237
+ 'server_error',
238
+ );
239
+ }
240
+ } finally {
241
+ if (usageId !== undefined) {
242
+ try {
243
+ await finishUsageRecord(ctx, usageId, t0, ctx.status >= 200 && ctx.status < 400 ? 'succeeded' : 'failed');
244
+ } catch (usageError) {
245
+ ctx.log.error('AI API usage record could not be finalized:', usageError);
246
+ }
247
+ }
248
+ }
249
+ };
250
+ }
251
+
252
+ /** Maximum allowed request body size (10 MB) to prevent OOM DoS attacks. */
253
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
254
+
255
+ /**
256
+ * Read raw body from request stream (fallback if bodyparser didn't handle it).
257
+ * Rejects with a 413-style error if the body exceeds MAX_BODY_BYTES.
258
+ */
259
+ function getRawBody(ctx: Context): Promise<string> {
260
+ return new Promise((resolve, reject) => {
261
+ let body = '';
262
+ let byteCount = 0;
263
+
264
+ ctx.req.on('data', (chunk: Buffer) => {
265
+ byteCount += chunk.length;
266
+ if (byteCount > MAX_BODY_BYTES) {
267
+ ctx.req.destroy();
268
+ reject(Object.assign(new Error('Request body too large (max 10 MB)'), { statusCode: 413 }));
269
+ return;
270
+ }
271
+ body += chunk.toString();
272
+ });
273
+ ctx.req.on('end', () => resolve(body));
274
+ ctx.req.on('error', reject);
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Determine the API mode for a request.
280
+ *
281
+ * Priority:
282
+ * 1. X-AI-Mode request header ('llm' or 'agent')
283
+ * 2. Config `mode` field from aiApiConfig
284
+ * 3. Default: 'llm'
285
+ */
286
+ async function resolveMode(ctx: Context): Promise<'llm' | 'agent'> {
287
+ const headerMode = ctx.get('X-AI-Mode')?.toLowerCase();
288
+ if (headerMode === 'agent' || headerMode === 'llm') {
289
+ ctx.app.logger?.info(`[ai-api] Mode resolved from header: ${headerMode}`);
290
+ return headerMode;
291
+ }
292
+
293
+ try {
294
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
295
+ if (config) {
296
+ const dbMode = config.get('mode') || config.mode;
297
+ if (dbMode === 'agent' || dbMode === 'llm') {
298
+ ctx.app.logger?.info(`[ai-api] Mode resolved from DB config: ${dbMode}`);
299
+ return dbMode as 'llm' | 'agent';
300
+ }
301
+ }
302
+ } catch (err) {
303
+ ctx.app.logger?.error('[ai-api] Failed to get mode from config:', err);
304
+ // Ignore config errors — default to llm
305
+ }
306
+
307
+ ctx.app.logger?.info(`[ai-api] Mode fallback to default: llm`);
308
+ return 'llm';
309
+ }
310
+
311
+ /**
312
+ * Write a structured log line for every handled request.
313
+ */
314
+ function logRequest(ctx: Context, requestId: string, model: string, status: string, durationMs: number): void {
315
+ const userId = ctx.state.currentUser?.id ?? 'anon';
316
+ ctx.app.logger?.info(
317
+ `[ai-api] ${ctx.method} ${ctx.path} requestId=${requestId} userId=${userId} ` +
318
+ `model=${model} status=${status} duration=${durationMs}ms`,
319
+ );
320
+ }