plugin-ai-api 1.0.12 → 1.0.14
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/dist/externalVersion.js +10 -9
- package/dist/server/collections/ai-api-usage-records.js +9 -1
- package/dist/server/migrations/20260727140000-change-usage-user-id-to-bigint.js +208 -0
- package/dist/server/routes/agent-completions.js +4 -0
- package/dist/server/routes/auth.js +1 -0
- package/dist/server/routes/chat-completions.js +13 -13
- package/dist/server/routes/completions.js +13 -13
- package/dist/server/routes/embeddings.js +3 -1
- package/dist/server/routes/router.js +26 -17
- package/dist/server/usage.js +81 -14
- package/dist/server/utils/openai-format.js +2 -2
- package/package.json +1 -1
- package/src/server/__tests__/usage-migration.test.ts +171 -0
- package/src/server/__tests__/usage-route.test.ts +105 -0
- package/src/server/__tests__/usage.test.ts +201 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -1
- package/src/server/migrations/20260727140000-change-usage-user-id-to-bigint.ts +222 -0
- package/src/server/routes/agent-completions.ts +4 -0
- package/src/server/routes/auth.ts +1 -0
- package/src/server/routes/chat-completions.ts +14 -16
- package/src/server/routes/completions.ts +14 -16
- package/src/server/routes/embeddings.ts +193 -191
- package/src/server/routes/router.ts +31 -16
- package/src/server/usage.ts +141 -17
- package/src/server/utils/openai-format.ts +13 -5
package/src/server/usage.ts
CHANGED
|
@@ -1,6 +1,109 @@
|
|
|
1
1
|
import { Context } from '@nocobase/actions';
|
|
2
2
|
|
|
3
|
-
type Usage = {
|
|
3
|
+
export type Usage = {
|
|
4
|
+
prompt_tokens: number | null;
|
|
5
|
+
completion_tokens: number | null;
|
|
6
|
+
total_tokens: number | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type AiApiAuthType = 'apiKey' | 'bearer' | 'oidc' | 'unknown';
|
|
10
|
+
|
|
11
|
+
export interface AiApiUsageResult {
|
|
12
|
+
source: 'provider' | 'unavailable';
|
|
13
|
+
usage?: Usage;
|
|
14
|
+
gatewayResponseId?: string;
|
|
15
|
+
providerRequestId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AiApiStreamResult {
|
|
19
|
+
succeeded: boolean;
|
|
20
|
+
id?: string;
|
|
21
|
+
errorCode?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface OAuthPrincipal {
|
|
25
|
+
clientId?: string;
|
|
26
|
+
subject?: string;
|
|
27
|
+
scopes?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AiApiContextState {
|
|
31
|
+
aiApiAuthType?: AiApiAuthType;
|
|
32
|
+
aiApiUsageResult?: AiApiUsageResult;
|
|
33
|
+
aiApiStreamResult?: AiApiStreamResult;
|
|
34
|
+
currentRole?: string;
|
|
35
|
+
currentRoles?: string[];
|
|
36
|
+
currentUser?: { id?: string | number | bigint };
|
|
37
|
+
oauthPrincipal?: OAuthPrincipal;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getAiApiState(ctx: Context): AiApiContextState {
|
|
41
|
+
return ctx.state as AiApiContextState;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeTokenCount(value: unknown): number | null {
|
|
45
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeUsage(value: unknown): Usage | undefined {
|
|
49
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
50
|
+
const source = value as Record<string, unknown>;
|
|
51
|
+
const prompt = normalizeTokenCount(source.prompt_tokens ?? source.input_tokens);
|
|
52
|
+
const completion = normalizeTokenCount(source.completion_tokens ?? source.output_tokens);
|
|
53
|
+
let total = normalizeTokenCount(source.total_tokens);
|
|
54
|
+
|
|
55
|
+
if (total === null && prompt !== null && completion !== null) {
|
|
56
|
+
total = prompt + completion;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (prompt === null && completion === null && total === null) return undefined;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
prompt_tokens: prompt,
|
|
63
|
+
completion_tokens: completion,
|
|
64
|
+
total_tokens: total,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setAiApiUsageResult(
|
|
69
|
+
ctx: Context,
|
|
70
|
+
value: unknown,
|
|
71
|
+
metadata: Pick<AiApiUsageResult, 'gatewayResponseId' | 'providerRequestId'> = {},
|
|
72
|
+
): Usage | undefined {
|
|
73
|
+
const usage = normalizeUsage(value);
|
|
74
|
+
getAiApiState(ctx).aiApiUsageResult = usage
|
|
75
|
+
? { source: 'provider', usage, ...metadata }
|
|
76
|
+
: { source: 'unavailable', ...metadata };
|
|
77
|
+
return usage;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setAiApiUsageUnavailable(ctx: Context, gatewayResponseId?: string): void {
|
|
81
|
+
getAiApiState(ctx).aiApiUsageResult = {
|
|
82
|
+
source: 'unavailable',
|
|
83
|
+
...(gatewayResponseId ? { gatewayResponseId } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function extractProviderRequestId(value: unknown): string | undefined {
|
|
88
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
89
|
+
const source = value as Record<string, unknown>;
|
|
90
|
+
const responseMetadata =
|
|
91
|
+
source.response_metadata && typeof source.response_metadata === 'object'
|
|
92
|
+
? (source.response_metadata as Record<string, unknown>)
|
|
93
|
+
: undefined;
|
|
94
|
+
const headers =
|
|
95
|
+
responseMetadata?.headers && typeof responseMetadata.headers === 'object'
|
|
96
|
+
? (responseMetadata.headers as Record<string, unknown>)
|
|
97
|
+
: undefined;
|
|
98
|
+
const candidate =
|
|
99
|
+
responseMetadata?.request_id ??
|
|
100
|
+
responseMetadata?.requestId ??
|
|
101
|
+
responseMetadata?.id ??
|
|
102
|
+
headers?.['x-request-id'] ??
|
|
103
|
+
headers?.['request-id'];
|
|
104
|
+
|
|
105
|
+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
|
|
106
|
+
}
|
|
4
107
|
|
|
5
108
|
export async function startUsageRecord(
|
|
6
109
|
ctx: Context,
|
|
@@ -8,48 +111,69 @@ export async function startUsageRecord(
|
|
|
8
111
|
endpoint: string,
|
|
9
112
|
model: string,
|
|
10
113
|
streaming: boolean,
|
|
114
|
+
mode: 'llm' | 'agent',
|
|
11
115
|
) {
|
|
12
116
|
const body = (ctx.request.body || {}) as Record<string, unknown>;
|
|
13
117
|
const messages = Array.isArray(body.messages) ? body.messages : undefined;
|
|
14
|
-
const
|
|
118
|
+
const promptCount = Array.isArray(body.prompt) ? body.prompt.length : body.prompt === undefined ? undefined : 1;
|
|
119
|
+
const embeddingInputCount = Array.isArray(body.input) ? body.input.length : body.input === undefined ? undefined : 1;
|
|
120
|
+
const state = getAiApiState(ctx);
|
|
121
|
+
const userId = state.currentUser?.id;
|
|
122
|
+
if (userId === undefined || userId === null) {
|
|
123
|
+
throw new Error('AI API usage record requires an authenticated NocoBase user ID.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const oauth = state.oauthPrincipal;
|
|
15
127
|
const record = await ctx.db.getRepository('aiApiUsageRecords').create({
|
|
16
128
|
values: {
|
|
17
129
|
requestId,
|
|
18
|
-
userId
|
|
19
|
-
roleName:
|
|
20
|
-
authType:
|
|
130
|
+
userId,
|
|
131
|
+
roleName: state.currentRole || state.currentRoles?.[0] || 'unknown',
|
|
132
|
+
authType: state.aiApiAuthType || (oauth ? 'oidc' : 'unknown'),
|
|
21
133
|
oauthClientId: oauth?.clientId,
|
|
22
134
|
oauthSubject: oauth?.subject,
|
|
23
135
|
oauthScopes: oauth?.scopes,
|
|
24
136
|
endpoint,
|
|
25
|
-
mode
|
|
137
|
+
mode,
|
|
26
138
|
model: model === '-' ? undefined : model,
|
|
27
139
|
status: 'pending',
|
|
28
140
|
streaming,
|
|
29
141
|
startedAt: new Date(),
|
|
30
|
-
requestMetadata: {
|
|
142
|
+
requestMetadata: {
|
|
143
|
+
messageCount: messages?.length,
|
|
144
|
+
promptCount,
|
|
145
|
+
embeddingInputCount,
|
|
146
|
+
requestedMaxTokens: body.max_completion_tokens ?? body.max_tokens,
|
|
147
|
+
},
|
|
31
148
|
},
|
|
32
149
|
});
|
|
33
150
|
return record.id;
|
|
34
151
|
}
|
|
35
152
|
|
|
36
153
|
export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: number, status: 'succeeded' | 'failed') {
|
|
37
|
-
const response = (ctx.body || {}) as {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
154
|
+
const response = (ctx.body || {}) as {
|
|
155
|
+
id?: string;
|
|
156
|
+
error?: { code?: string };
|
|
157
|
+
};
|
|
158
|
+
const state = getAiApiState(ctx);
|
|
159
|
+
const streamResult = state.aiApiStreamResult;
|
|
160
|
+
const usageResult = state.aiApiUsageResult ?? { source: 'unavailable' as const };
|
|
161
|
+
const usage = usageResult.source === 'provider' ? usageResult.usage : undefined;
|
|
162
|
+
const gatewayResponseId = usageResult.gatewayResponseId || response.id || streamResult?.id;
|
|
42
163
|
const values = {
|
|
43
164
|
status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
|
|
44
165
|
httpStatus: ctx.status,
|
|
45
166
|
errorCode: response.error?.code || streamResult?.errorCode,
|
|
46
|
-
inputTokens: usage?.prompt_tokens,
|
|
47
|
-
outputTokens: usage?.completion_tokens,
|
|
48
|
-
totalTokens: usage?.total_tokens,
|
|
49
|
-
providerRequestId:
|
|
167
|
+
inputTokens: usage?.prompt_tokens ?? null,
|
|
168
|
+
outputTokens: usage?.completion_tokens ?? null,
|
|
169
|
+
totalTokens: usage?.total_tokens ?? null,
|
|
170
|
+
providerRequestId: usageResult.providerRequestId ?? null,
|
|
50
171
|
completedAt: new Date(),
|
|
51
172
|
durationMs: Date.now() - startedAt,
|
|
52
|
-
responseMetadata: {
|
|
173
|
+
responseMetadata: {
|
|
174
|
+
usageSource: usageResult.source,
|
|
175
|
+
...(gatewayResponseId ? { gatewayResponseId } : {}),
|
|
176
|
+
},
|
|
53
177
|
};
|
|
54
178
|
await ctx.db.getRepository('aiApiUsageRecords').update({ filterByTk: id, values });
|
|
55
179
|
}
|
|
@@ -58,7 +58,11 @@ export function toOpenAIResponse(options: {
|
|
|
58
58
|
model: string;
|
|
59
59
|
content: string;
|
|
60
60
|
finishReason?: string;
|
|
61
|
-
usage?: {
|
|
61
|
+
usage?: {
|
|
62
|
+
prompt_tokens?: number | null;
|
|
63
|
+
completion_tokens?: number | null;
|
|
64
|
+
total_tokens?: number | null;
|
|
65
|
+
};
|
|
62
66
|
toolCalls?: OpenAIToolCall[];
|
|
63
67
|
}) {
|
|
64
68
|
const {
|
|
@@ -87,7 +91,7 @@ export function toOpenAIResponse(options: {
|
|
|
87
91
|
finish_reason: finishReason,
|
|
88
92
|
},
|
|
89
93
|
],
|
|
90
|
-
usage: usage || { prompt_tokens:
|
|
94
|
+
usage: usage || { prompt_tokens: null, completion_tokens: null, total_tokens: null },
|
|
91
95
|
};
|
|
92
96
|
}
|
|
93
97
|
|
|
@@ -132,8 +136,12 @@ export interface OpenAIToolCallChunk {
|
|
|
132
136
|
|
|
133
137
|
// ─── OpenAI Embeddings response ───
|
|
134
138
|
|
|
135
|
-
export function toOpenAIEmbeddingsResponse(options: {
|
|
136
|
-
|
|
139
|
+
export function toOpenAIEmbeddingsResponse(options: {
|
|
140
|
+
model: string;
|
|
141
|
+
embeddings: number[][];
|
|
142
|
+
promptTokens?: number | null;
|
|
143
|
+
}) {
|
|
144
|
+
const { model, embeddings, promptTokens = null } = options;
|
|
137
145
|
return {
|
|
138
146
|
object: 'list' as const,
|
|
139
147
|
data: embeddings.map((embedding, index) => ({
|
|
@@ -152,7 +160,7 @@ export function toOpenAIEmbeddingsResponse(options: { model: string; embeddings:
|
|
|
152
160
|
/**
|
|
153
161
|
* Format a streaming chunk as an SSE data line.
|
|
154
162
|
*/
|
|
155
|
-
export function formatSSE(data:
|
|
163
|
+
export function formatSSE(data: unknown): string {
|
|
156
164
|
return `data: ${JSON.stringify(data)}\n\n`;
|
|
157
165
|
}
|
|
158
166
|
|