plugin-ai-api 1.0.13 → 1.0.15
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 +2 -0
- package/dist/server/routes/router.js +12 -11
- package/dist/server/usage.js +75 -21
- 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 +2 -0
- package/src/server/routes/router.ts +14 -10
- package/src/server/usage.ts +129 -28
- package/src/server/utils/openai-format.ts +6 -2
package/src/server/usage.ts
CHANGED
|
@@ -1,21 +1,110 @@
|
|
|
1
1
|
import { Context } from '@nocobase/actions';
|
|
2
2
|
|
|
3
|
-
export type Usage = {
|
|
3
|
+
export type Usage = {
|
|
4
|
+
prompt_tokens: number | null;
|
|
5
|
+
completion_tokens: number | null;
|
|
6
|
+
total_tokens: number | null;
|
|
7
|
+
};
|
|
4
8
|
|
|
5
|
-
|
|
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 {
|
|
6
49
|
if (!value || typeof value !== 'object') return undefined;
|
|
7
50
|
const source = value as Record<string, unknown>;
|
|
8
|
-
const prompt = source.prompt_tokens ?? source.input_tokens;
|
|
9
|
-
const completion = source.completion_tokens ?? source.output_tokens;
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
|
|
12
61
|
return {
|
|
13
|
-
prompt_tokens:
|
|
14
|
-
completion_tokens:
|
|
15
|
-
total_tokens:
|
|
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 } : {}),
|
|
16
84
|
};
|
|
17
85
|
}
|
|
18
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
|
+
}
|
|
107
|
+
|
|
19
108
|
export async function startUsageRecord(
|
|
20
109
|
ctx: Context,
|
|
21
110
|
requestId: string,
|
|
@@ -26,13 +115,21 @@ export async function startUsageRecord(
|
|
|
26
115
|
) {
|
|
27
116
|
const body = (ctx.request.body || {}) as Record<string, unknown>;
|
|
28
117
|
const messages = Array.isArray(body.messages) ? body.messages : undefined;
|
|
29
|
-
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;
|
|
30
127
|
const record = await ctx.db.getRepository('aiApiUsageRecords').create({
|
|
31
128
|
values: {
|
|
32
129
|
requestId,
|
|
33
|
-
userId
|
|
34
|
-
roleName:
|
|
35
|
-
authType:
|
|
130
|
+
userId,
|
|
131
|
+
roleName: state.currentRole || state.currentRoles?.[0] || 'unknown',
|
|
132
|
+
authType: state.aiApiAuthType || (oauth ? 'oidc' : 'unknown'),
|
|
36
133
|
oauthClientId: oauth?.clientId,
|
|
37
134
|
oauthSubject: oauth?.subject,
|
|
38
135
|
oauthScopes: oauth?.scopes,
|
|
@@ -42,7 +139,12 @@ export async function startUsageRecord(
|
|
|
42
139
|
status: 'pending',
|
|
43
140
|
streaming,
|
|
44
141
|
startedAt: new Date(),
|
|
45
|
-
requestMetadata: {
|
|
142
|
+
requestMetadata: {
|
|
143
|
+
messageCount: messages?.length,
|
|
144
|
+
promptCount,
|
|
145
|
+
embeddingInputCount,
|
|
146
|
+
requestedMaxTokens: body.max_completion_tokens ?? body.max_tokens,
|
|
147
|
+
},
|
|
46
148
|
},
|
|
47
149
|
});
|
|
48
150
|
return record.id;
|
|
@@ -50,29 +152,28 @@ export async function startUsageRecord(
|
|
|
50
152
|
|
|
51
153
|
export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: number, status: 'succeeded' | 'failed') {
|
|
52
154
|
const response = (ctx.body || {}) as {
|
|
53
|
-
usage?: Usage;
|
|
54
|
-
response_metadata?: { usage?: unknown };
|
|
55
155
|
id?: string;
|
|
56
156
|
error?: { code?: string };
|
|
57
157
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const usage =
|
|
62
|
-
|
|
63
|
-
normalizeUsage(response.response_metadata?.usage) ||
|
|
64
|
-
normalizeUsage(streamResult?.usage);
|
|
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;
|
|
65
163
|
const values = {
|
|
66
164
|
status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
|
|
67
165
|
httpStatus: ctx.status,
|
|
68
166
|
errorCode: response.error?.code || streamResult?.errorCode,
|
|
69
|
-
inputTokens: usage?.prompt_tokens,
|
|
70
|
-
outputTokens: usage?.completion_tokens,
|
|
71
|
-
totalTokens: usage?.total_tokens,
|
|
72
|
-
providerRequestId:
|
|
167
|
+
inputTokens: usage?.prompt_tokens ?? null,
|
|
168
|
+
outputTokens: usage?.completion_tokens ?? null,
|
|
169
|
+
totalTokens: usage?.total_tokens ?? null,
|
|
170
|
+
providerRequestId: usageResult.providerRequestId ?? null,
|
|
73
171
|
completedAt: new Date(),
|
|
74
172
|
durationMs: Date.now() - startedAt,
|
|
75
|
-
responseMetadata: {
|
|
173
|
+
responseMetadata: {
|
|
174
|
+
usageSource: usageResult.source,
|
|
175
|
+
...(gatewayResponseId ? { gatewayResponseId } : {}),
|
|
176
|
+
},
|
|
76
177
|
};
|
|
77
178
|
await ctx.db.getRepository('aiApiUsageRecords').update({ filterByTk: id, values });
|
|
78
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 {
|
|
@@ -156,7 +160,7 @@ export function toOpenAIEmbeddingsResponse(options: {
|
|
|
156
160
|
/**
|
|
157
161
|
* Format a streaming chunk as an SSE data line.
|
|
158
162
|
*/
|
|
159
|
-
export function formatSSE(data:
|
|
163
|
+
export function formatSSE(data: unknown): string {
|
|
160
164
|
return `data: ${JSON.stringify(data)}\n\n`;
|
|
161
165
|
}
|
|
162
166
|
|