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.
@@ -1,6 +1,109 @@
1
1
  import { Context } from '@nocobase/actions';
2
2
 
3
- type Usage = { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
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 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;
15
127
  const record = await ctx.db.getRepository('aiApiUsageRecords').create({
16
128
  values: {
17
129
  requestId,
18
- userId: String(ctx.state.currentUser?.id),
19
- roleName: ctx.state.currentRole || ctx.state.currentRoles?.[0] || 'unknown',
20
- authType: ctx.state.aiApiAuthType || (oauth ? 'oidc' : 'session'),
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: ctx.get('X-AI-Mode') || undefined,
137
+ mode,
26
138
  model: model === '-' ? undefined : model,
27
139
  status: 'pending',
28
140
  streaming,
29
141
  startedAt: new Date(),
30
- 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
+ },
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 { usage?: Usage; id?: string; error?: { code?: string } };
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;
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: 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,
50
171
  completedAt: new Date(),
51
172
  durationMs: Date.now() - startedAt,
52
- responseMetadata: { usageSource: usage ? 'response' : 'unavailable' },
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?: { 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 {
@@ -87,7 +91,7 @@ export function toOpenAIResponse(options: {
87
91
  finish_reason: finishReason,
88
92
  },
89
93
  ],
90
- usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
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: { model: string; embeddings: number[][]; promptTokens?: number }) {
136
- const { model, embeddings, promptTokens = 0 } = options;
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: any): string {
163
+ export function formatSSE(data: unknown): string {
156
164
  return `data: ${JSON.stringify(data)}\n\n`;
157
165
  }
158
166