plugin-ai-api 1.0.9 → 1.0.11

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.
@@ -139,7 +139,13 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
139
139
  if (method === 'POST' && subPath === '/chat/completions') {
140
140
  const mode = await resolveMode(ctx);
141
141
  await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
142
- logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
142
+ logRequest(
143
+ ctx,
144
+ requestId,
145
+ model,
146
+ ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
147
+ Date.now() - t0,
148
+ );
143
149
  return;
144
150
  }
145
151
 
@@ -169,7 +175,13 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
169
175
  } else {
170
176
  await handleCompletions(ctx, plugin);
171
177
  }
172
- logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
178
+ logRequest(
179
+ ctx,
180
+ requestId,
181
+ model,
182
+ ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
183
+ Date.now() - t0,
184
+ );
173
185
  return;
174
186
  }
175
187
 
@@ -219,7 +231,11 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
219
231
  logRequest(ctx, requestId, model, 'error', Date.now() - t0);
220
232
  if (!ctx.res.headersSent) {
221
233
  ctx.status = 500;
222
- ctx.body = toOpenAIError(500, err.message || 'Internal server error', 'server_error');
234
+ ctx.body = toOpenAIError(
235
+ 500,
236
+ err instanceof Error && err.message ? err.message : 'Internal server error',
237
+ 'server_error',
238
+ );
223
239
  }
224
240
  } finally {
225
241
  if (usageId !== undefined) {
@@ -35,15 +35,18 @@ export async function startUsageRecord(
35
35
 
36
36
  export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: number, status: 'succeeded' | 'failed') {
37
37
  const response = (ctx.body || {}) as { usage?: Usage; id?: string; error?: { code?: string } };
38
- const usage = response.usage;
38
+ const streamResult = ctx.state.aiApiStreamResult as
39
+ | { usage?: Usage; id?: string; errorCode?: string; succeeded: boolean }
40
+ | undefined;
41
+ const usage = response.usage || streamResult?.usage;
39
42
  const values = {
40
- status,
43
+ status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
41
44
  httpStatus: ctx.status,
42
- errorCode: response.error?.code,
45
+ errorCode: response.error?.code || streamResult?.errorCode,
43
46
  inputTokens: usage?.prompt_tokens,
44
47
  outputTokens: usage?.completion_tokens,
45
48
  totalTokens: usage?.total_tokens,
46
- providerRequestId: response.id,
49
+ providerRequestId: response.id || streamResult?.id,
47
50
  completedAt: new Date(),
48
51
  durationMs: Date.now() - startedAt,
49
52
  responseMetadata: { usageSource: usage ? 'response' : 'unavailable' },
@@ -15,7 +15,7 @@ interface AIEmployeeConstructorOptions {
15
15
  }
16
16
 
17
17
  interface AIEmployeeRuntime {
18
- stream(options: { userMessages: unknown[] }): Promise<void>;
18
+ stream(options: { userMessages: unknown[] }): Promise<boolean>;
19
19
  invoke(options: { userMessages: unknown[] }): Promise<unknown>;
20
20
  }
21
21
 
@@ -1,142 +1,164 @@
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
-
12
- // ─── Model string parsing ───
13
-
14
- export interface ParsedModel {
15
- llmService: string;
16
- modelId: string;
17
- }
18
-
19
- /**
20
- * Parse OpenAI-style model string into NocoBase llmService + modelId.
21
- * Format: "llmServiceName/modelId" (e.g. "my-openai/gpt-4o")
22
- * If no "/" is present, the entire string is treated as modelId and llmService is empty.
23
- */
24
- export function parseModelString(model: string): ParsedModel {
25
- const slashIndex = model.indexOf('/');
26
- if (slashIndex === -1) {
27
- return { llmService: '', modelId: model };
28
- }
29
- return {
30
- llmService: model.substring(0, slashIndex),
31
- modelId: model.substring(slashIndex + 1),
32
- };
33
- }
34
-
35
- // ─── ID generation ───
36
-
37
- export function generateCompletionId(): string {
38
- return `chatcmpl-${crypto.randomBytes(16).toString('hex').substring(0, 29)}`;
39
- }
40
-
41
- // ─── OpenAI error format ───
42
-
43
- export function toOpenAIError(statusCode: number, message: string, type = 'invalid_request_error', code?: string) {
44
- return {
45
- error: {
46
- message,
47
- type,
48
- param: null,
49
- code: code || null,
50
- },
51
- };
52
- }
53
-
54
- // ─── OpenAI Chat Completion response (non-streaming) ───
55
-
56
- export function toOpenAIResponse(options: {
57
- id: string;
58
- model: string;
59
- content: string;
60
- finishReason?: string;
61
- usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
62
- }) {
63
- const { id, model, content, finishReason = 'stop', usage } = options;
64
- return {
65
- id,
66
- object: 'chat.completion',
67
- created: Math.floor(Date.now() / 1000),
68
- model,
69
- system_fingerprint: null,
70
- choices: [
71
- {
72
- index: 0,
73
- message: {
74
- role: 'assistant',
75
- content,
76
- },
77
- logprobs: null,
78
- finish_reason: finishReason,
79
- },
80
- ],
81
- usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
82
- };
83
- }
84
-
85
- // ─── OpenAI Streaming chunk format ───
86
-
87
- export function toOpenAIStreamChunk(options: {
88
- id: string;
89
- model: string;
90
- delta: { role?: string; content?: string };
91
- finishReason?: string | null;
92
- }) {
93
- const { id, model, delta, finishReason = null } = options;
94
- return {
95
- id,
96
- object: 'chat.completion.chunk',
97
- created: Math.floor(Date.now() / 1000),
98
- model,
99
- system_fingerprint: null,
100
- choices: [
101
- {
102
- index: 0,
103
- delta,
104
- logprobs: null,
105
- finish_reason: finishReason,
106
- },
107
- ],
108
- };
109
- }
110
-
111
- // ─── OpenAI Embeddings response ───
112
-
113
- export function toOpenAIEmbeddingsResponse(options: { model: string; embeddings: number[][]; promptTokens?: number }) {
114
- const { model, embeddings, promptTokens = 0 } = options;
115
- return {
116
- object: 'list' as const,
117
- data: embeddings.map((embedding, index) => ({
118
- object: 'embedding' as const,
119
- embedding,
120
- index,
121
- })),
122
- model,
123
- usage: {
124
- prompt_tokens: promptTokens,
125
- total_tokens: promptTokens,
126
- },
127
- };
128
- }
129
-
130
- /**
131
- * Format a streaming chunk as an SSE data line.
132
- */
133
- export function formatSSE(data: any): string {
134
- return `data: ${JSON.stringify(data)}\n\n`;
135
- }
136
-
137
- /**
138
- * Format the terminal SSE [DONE] signal.
139
- */
140
- export function formatSSEDone(): string {
141
- return `data: [DONE]\n\n`;
142
- }
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
+
12
+ // ─── Model string parsing ───
13
+
14
+ export interface ParsedModel {
15
+ llmService: string;
16
+ modelId: string;
17
+ }
18
+
19
+ /**
20
+ * Parse OpenAI-style model string into NocoBase llmService + modelId.
21
+ * Format: "llmServiceName/modelId" (e.g. "my-openai/gpt-4o")
22
+ * If no "/" is present, the entire string is treated as modelId and llmService is empty.
23
+ */
24
+ export function parseModelString(model: string): ParsedModel {
25
+ const slashIndex = model.indexOf('/');
26
+ if (slashIndex === -1) {
27
+ return { llmService: '', modelId: model };
28
+ }
29
+ return {
30
+ llmService: model.substring(0, slashIndex),
31
+ modelId: model.substring(slashIndex + 1),
32
+ };
33
+ }
34
+
35
+ // ─── ID generation ───
36
+
37
+ export function generateCompletionId(): string {
38
+ return `chatcmpl-${crypto.randomBytes(16).toString('hex').substring(0, 29)}`;
39
+ }
40
+
41
+ // ─── OpenAI error format ───
42
+
43
+ export function toOpenAIError(statusCode: number, message: string, type = 'invalid_request_error', code?: string) {
44
+ return {
45
+ error: {
46
+ message,
47
+ type,
48
+ param: null,
49
+ code: code || null,
50
+ },
51
+ };
52
+ }
53
+
54
+ // ─── OpenAI Chat Completion response (non-streaming) ───
55
+
56
+ export function toOpenAIResponse(options: {
57
+ id: string;
58
+ model: string;
59
+ content: string;
60
+ finishReason?: string;
61
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
62
+ toolCalls?: OpenAIToolCall[];
63
+ }) {
64
+ const {
65
+ id,
66
+ model,
67
+ content,
68
+ finishReason = options.toolCalls?.length ? 'tool_calls' : 'stop',
69
+ usage,
70
+ toolCalls,
71
+ } = options;
72
+ return {
73
+ id,
74
+ object: 'chat.completion',
75
+ created: Math.floor(Date.now() / 1000),
76
+ model,
77
+ system_fingerprint: null,
78
+ choices: [
79
+ {
80
+ index: 0,
81
+ message: {
82
+ role: 'assistant',
83
+ content,
84
+ ...(toolCalls?.length ? { tool_calls: toolCalls } : {}),
85
+ },
86
+ logprobs: null,
87
+ finish_reason: finishReason,
88
+ },
89
+ ],
90
+ usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
91
+ };
92
+ }
93
+
94
+ // ─── OpenAI Streaming chunk format ───
95
+
96
+ export function toOpenAIStreamChunk(options: {
97
+ id: string;
98
+ model: string;
99
+ delta: { role?: string; content?: string; tool_calls?: OpenAIToolCallChunk[] };
100
+ finishReason?: string | null;
101
+ }) {
102
+ const { id, model, delta, finishReason = null } = options;
103
+ return {
104
+ id,
105
+ object: 'chat.completion.chunk',
106
+ created: Math.floor(Date.now() / 1000),
107
+ model,
108
+ system_fingerprint: null,
109
+ choices: [
110
+ {
111
+ index: 0,
112
+ delta,
113
+ logprobs: null,
114
+ finish_reason: finishReason,
115
+ },
116
+ ],
117
+ };
118
+ }
119
+
120
+ export interface OpenAIToolCall {
121
+ id: string;
122
+ type: 'function';
123
+ function: { name: string; arguments: string };
124
+ }
125
+
126
+ export interface OpenAIToolCallChunk {
127
+ index: number;
128
+ id?: string;
129
+ type?: 'function';
130
+ function?: { name?: string; arguments?: string };
131
+ }
132
+
133
+ // ─── OpenAI Embeddings response ───
134
+
135
+ export function toOpenAIEmbeddingsResponse(options: { model: string; embeddings: number[][]; promptTokens?: number }) {
136
+ const { model, embeddings, promptTokens = 0 } = options;
137
+ return {
138
+ object: 'list' as const,
139
+ data: embeddings.map((embedding, index) => ({
140
+ object: 'embedding' as const,
141
+ embedding,
142
+ index,
143
+ })),
144
+ model,
145
+ usage: {
146
+ prompt_tokens: promptTokens,
147
+ total_tokens: promptTokens,
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Format a streaming chunk as an SSE data line.
154
+ */
155
+ export function formatSSE(data: any): string {
156
+ return `data: ${JSON.stringify(data)}\n\n`;
157
+ }
158
+
159
+ /**
160
+ * Format the terminal SSE [DONE] signal.
161
+ */
162
+ export function formatSSEDone(): string {
163
+ return `data: [DONE]\n\n`;
164
+ }
@@ -0,0 +1,46 @@
1
+ import type { Context } from '@nocobase/actions';
2
+
3
+ export function isStreamingRequested(value: unknown) {
4
+ return value !== false;
5
+ }
6
+
7
+ export function createRequestAbortController(ctx: Context) {
8
+ const controller = new AbortController();
9
+ const abort = () => {
10
+ if (!ctx.res.writableEnded) controller.abort(new Error('Client disconnected'));
11
+ };
12
+ ctx.req.once('aborted', abort);
13
+ ctx.res.once('close', abort);
14
+ return {
15
+ signal: controller.signal,
16
+ dispose() {
17
+ ctx.req.off('aborted', abort);
18
+ ctx.res.off('close', abort);
19
+ },
20
+ };
21
+ }
22
+
23
+ export async function writeResponse(ctx: Context, data: string) {
24
+ if (ctx.res.writableEnded || ctx.res.destroyed) return false;
25
+ if (!ctx.res.write(data)) await waitForDrain(ctx);
26
+ return true;
27
+ }
28
+
29
+ function waitForDrain(ctx: Context) {
30
+ return new Promise<void>((resolve, reject) => {
31
+ const cleanup = () => {
32
+ ctx.res.off('drain', onDrain);
33
+ ctx.res.off('close', onClose);
34
+ };
35
+ const onDrain = () => {
36
+ cleanup();
37
+ resolve();
38
+ };
39
+ const onClose = () => {
40
+ cleanup();
41
+ reject(new Error('Client disconnected'));
42
+ };
43
+ ctx.res.once('drain', onDrain);
44
+ ctx.res.once('close', onClose);
45
+ });
46
+ }