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.
@@ -1,21 +1,110 @@
1
1
  import { Context } from '@nocobase/actions';
2
2
 
3
- export type Usage = { prompt_tokens?: number | null; completion_tokens?: number | null; total_tokens?: number | null };
3
+ export type Usage = {
4
+ prompt_tokens: number | null;
5
+ completion_tokens: number | null;
6
+ total_tokens: number | null;
7
+ };
4
8
 
5
- function normalizeUsage(value: unknown): Usage | undefined {
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
- const total = source.total_tokens;
11
- if (prompt == null && completion == null && total == null) return undefined;
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: typeof prompt === 'number' ? prompt : null,
14
- completion_tokens: typeof completion === 'number' ? completion : null,
15
- total_tokens: typeof total === 'number' ? total : null,
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 oauth = ctx.state.oauthPrincipal as { clientId?: string; subject?: string; scopes?: string[] } | undefined;
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: String(ctx.state.currentUser?.id),
34
- roleName: ctx.state.currentRole || ctx.state.currentRoles?.[0] || 'unknown',
35
- authType: ctx.state.aiApiAuthType || (oauth ? 'oidc' : 'session'),
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: { messageCount: messages?.length, requestedMaxTokens: body.max_tokens },
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 streamResult = ctx.state.aiApiStreamResult as
59
- | { usage?: Usage; id?: string; errorCode?: string; succeeded: boolean }
60
- | undefined;
61
- const usage =
62
- normalizeUsage(response.usage) ||
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: response.id || streamResult?.id,
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: { usageSource: usage ? 'provider' : 'unavailable' },
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?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
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: any): string {
163
+ export function formatSSE(data: unknown): string {
160
164
  return `data: ${JSON.stringify(data)}\n\n`;
161
165
  }
162
166