plugin-ai-api 1.0.13 → 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.
@@ -26,34 +26,73 @@ var __copyProps = (to, from, except, desc) => {
26
26
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
27
  var usage_exports = {};
28
28
  __export(usage_exports, {
29
+ extractProviderRequestId: () => extractProviderRequestId,
29
30
  finishUsageRecord: () => finishUsageRecord,
31
+ normalizeUsage: () => normalizeUsage,
32
+ setAiApiUsageResult: () => setAiApiUsageResult,
33
+ setAiApiUsageUnavailable: () => setAiApiUsageUnavailable,
30
34
  startUsageRecord: () => startUsageRecord
31
35
  });
32
36
  module.exports = __toCommonJS(usage_exports);
37
+ function getAiApiState(ctx) {
38
+ return ctx.state;
39
+ }
40
+ function normalizeTokenCount(value) {
41
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
42
+ }
33
43
  function normalizeUsage(value) {
34
44
  if (!value || typeof value !== "object") return void 0;
35
45
  const source = value;
36
- const prompt = source.prompt_tokens ?? source.input_tokens;
37
- const completion = source.completion_tokens ?? source.output_tokens;
38
- const total = source.total_tokens;
39
- if (prompt == null && completion == null && total == null) return void 0;
46
+ const prompt = normalizeTokenCount(source.prompt_tokens ?? source.input_tokens);
47
+ const completion = normalizeTokenCount(source.completion_tokens ?? source.output_tokens);
48
+ let total = normalizeTokenCount(source.total_tokens);
49
+ if (total === null && prompt !== null && completion !== null) {
50
+ total = prompt + completion;
51
+ }
52
+ if (prompt === null && completion === null && total === null) return void 0;
40
53
  return {
41
- prompt_tokens: typeof prompt === "number" ? prompt : null,
42
- completion_tokens: typeof completion === "number" ? completion : null,
43
- total_tokens: typeof total === "number" ? total : null
54
+ prompt_tokens: prompt,
55
+ completion_tokens: completion,
56
+ total_tokens: total
57
+ };
58
+ }
59
+ function setAiApiUsageResult(ctx, value, metadata = {}) {
60
+ const usage = normalizeUsage(value);
61
+ getAiApiState(ctx).aiApiUsageResult = usage ? { source: "provider", usage, ...metadata } : { source: "unavailable", ...metadata };
62
+ return usage;
63
+ }
64
+ function setAiApiUsageUnavailable(ctx, gatewayResponseId) {
65
+ getAiApiState(ctx).aiApiUsageResult = {
66
+ source: "unavailable",
67
+ ...gatewayResponseId ? { gatewayResponseId } : {}
44
68
  };
45
69
  }
70
+ function extractProviderRequestId(value) {
71
+ if (!value || typeof value !== "object") return void 0;
72
+ const source = value;
73
+ const responseMetadata = source.response_metadata && typeof source.response_metadata === "object" ? source.response_metadata : void 0;
74
+ const headers = (responseMetadata == null ? void 0 : responseMetadata.headers) && typeof responseMetadata.headers === "object" ? responseMetadata.headers : void 0;
75
+ const candidate = (responseMetadata == null ? void 0 : responseMetadata.request_id) ?? (responseMetadata == null ? void 0 : responseMetadata.requestId) ?? (responseMetadata == null ? void 0 : responseMetadata.id) ?? (headers == null ? void 0 : headers["x-request-id"]) ?? (headers == null ? void 0 : headers["request-id"]);
76
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
77
+ }
46
78
  async function startUsageRecord(ctx, requestId, endpoint, model, streaming, mode) {
47
79
  var _a, _b;
48
80
  const body = ctx.request.body || {};
49
81
  const messages = Array.isArray(body.messages) ? body.messages : void 0;
50
- const oauth = ctx.state.oauthPrincipal;
82
+ const promptCount = Array.isArray(body.prompt) ? body.prompt.length : body.prompt === void 0 ? void 0 : 1;
83
+ const embeddingInputCount = Array.isArray(body.input) ? body.input.length : body.input === void 0 ? void 0 : 1;
84
+ const state = getAiApiState(ctx);
85
+ const userId = (_a = state.currentUser) == null ? void 0 : _a.id;
86
+ if (userId === void 0 || userId === null) {
87
+ throw new Error("AI API usage record requires an authenticated NocoBase user ID.");
88
+ }
89
+ const oauth = state.oauthPrincipal;
51
90
  const record = await ctx.db.getRepository("aiApiUsageRecords").create({
52
91
  values: {
53
92
  requestId,
54
- userId: String((_a = ctx.state.currentUser) == null ? void 0 : _a.id),
55
- roleName: ctx.state.currentRole || ((_b = ctx.state.currentRoles) == null ? void 0 : _b[0]) || "unknown",
56
- authType: ctx.state.aiApiAuthType || (oauth ? "oidc" : "session"),
93
+ userId,
94
+ roleName: state.currentRole || ((_b = state.currentRoles) == null ? void 0 : _b[0]) || "unknown",
95
+ authType: state.aiApiAuthType || (oauth ? "oidc" : "unknown"),
57
96
  oauthClientId: oauth == null ? void 0 : oauth.clientId,
58
97
  oauthSubject: oauth == null ? void 0 : oauth.subject,
59
98
  oauthScopes: oauth == null ? void 0 : oauth.scopes,
@@ -63,32 +102,47 @@ async function startUsageRecord(ctx, requestId, endpoint, model, streaming, mode
63
102
  status: "pending",
64
103
  streaming,
65
104
  startedAt: /* @__PURE__ */ new Date(),
66
- requestMetadata: { messageCount: messages == null ? void 0 : messages.length, requestedMaxTokens: body.max_tokens }
105
+ requestMetadata: {
106
+ messageCount: messages == null ? void 0 : messages.length,
107
+ promptCount,
108
+ embeddingInputCount,
109
+ requestedMaxTokens: body.max_completion_tokens ?? body.max_tokens
110
+ }
67
111
  }
68
112
  });
69
113
  return record.id;
70
114
  }
71
115
  async function finishUsageRecord(ctx, id, startedAt, status) {
72
- var _a, _b;
116
+ var _a;
73
117
  const response = ctx.body || {};
74
- const streamResult = ctx.state.aiApiStreamResult;
75
- const usage = normalizeUsage(response.usage) || normalizeUsage((_a = response.response_metadata) == null ? void 0 : _a.usage) || normalizeUsage(streamResult == null ? void 0 : streamResult.usage);
118
+ const state = getAiApiState(ctx);
119
+ const streamResult = state.aiApiStreamResult;
120
+ const usageResult = state.aiApiUsageResult ?? { source: "unavailable" };
121
+ const usage = usageResult.source === "provider" ? usageResult.usage : void 0;
122
+ const gatewayResponseId = usageResult.gatewayResponseId || response.id || (streamResult == null ? void 0 : streamResult.id);
76
123
  const values = {
77
124
  status: streamResult ? streamResult.succeeded ? "succeeded" : "failed" : status,
78
125
  httpStatus: ctx.status,
79
- errorCode: ((_b = response.error) == null ? void 0 : _b.code) || (streamResult == null ? void 0 : streamResult.errorCode),
80
- inputTokens: usage == null ? void 0 : usage.prompt_tokens,
81
- outputTokens: usage == null ? void 0 : usage.completion_tokens,
82
- totalTokens: usage == null ? void 0 : usage.total_tokens,
83
- providerRequestId: response.id || (streamResult == null ? void 0 : streamResult.id),
126
+ errorCode: ((_a = response.error) == null ? void 0 : _a.code) || (streamResult == null ? void 0 : streamResult.errorCode),
127
+ inputTokens: (usage == null ? void 0 : usage.prompt_tokens) ?? null,
128
+ outputTokens: (usage == null ? void 0 : usage.completion_tokens) ?? null,
129
+ totalTokens: (usage == null ? void 0 : usage.total_tokens) ?? null,
130
+ providerRequestId: usageResult.providerRequestId ?? null,
84
131
  completedAt: /* @__PURE__ */ new Date(),
85
132
  durationMs: Date.now() - startedAt,
86
- responseMetadata: { usageSource: usage ? "provider" : "unavailable" }
133
+ responseMetadata: {
134
+ usageSource: usageResult.source,
135
+ ...gatewayResponseId ? { gatewayResponseId } : {}
136
+ }
87
137
  };
88
138
  await ctx.db.getRepository("aiApiUsageRecords").update({ filterByTk: id, values });
89
139
  }
90
140
  // Annotate the CommonJS export names for ESM import in node:
91
141
  0 && (module.exports = {
142
+ extractProviderRequestId,
92
143
  finishUsageRecord,
144
+ normalizeUsage,
145
+ setAiApiUsageResult,
146
+ setAiApiUsageUnavailable,
93
147
  startUsageRecord
94
148
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -0,0 +1,171 @@
1
+ import { DataTypes, type Database } from '@nocobase/database';
2
+ import type { MigrationContext } from '@nocobase/database';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import ChangeUsageUserIdToBigInt, {
5
+ isBigIntColumnType,
6
+ normalizeLegacyUserId,
7
+ } from '../migrations/20260727140000-change-usage-user-id-to-bigint';
8
+
9
+ interface HarnessOptions {
10
+ allowNull?: boolean;
11
+ collectionExists?: boolean;
12
+ collectionRegistered?: boolean;
13
+ columnType?: string;
14
+ dialect?: 'mysql' | 'postgres' | 'sqlite';
15
+ rows?: Array<{ recordId: string | number; userId: unknown }>;
16
+ }
17
+
18
+ function createHarness(options: HarnessOptions = {}) {
19
+ const rows = options.rows ?? [];
20
+ const dialect = options.dialect ?? 'sqlite';
21
+ const changeColumn = vi.fn().mockResolvedValue(undefined);
22
+ const describeTable = vi.fn().mockResolvedValue({
23
+ userId: {
24
+ type: options.columnType ?? 'VARCHAR(255)',
25
+ allowNull: options.allowNull ?? true,
26
+ },
27
+ });
28
+ const queryInterface = { changeColumn, describeTable };
29
+ const query = vi.fn(async (sql: string) => {
30
+ if (sql.startsWith('SELECT')) return rows;
31
+ return undefined;
32
+ });
33
+ const transaction = vi.fn(async (callback: (value: object) => Promise<void>) => callback({}));
34
+ const collection = {
35
+ existsInDb: vi.fn().mockResolvedValue(options.collectionExists ?? true),
36
+ getField: vi.fn().mockReturnValue({ columnName: () => 'userId' }),
37
+ getTableNameWithSchema: () => 'aiApiUsageRecords',
38
+ quotedTableName: () => '"aiApiUsageRecords"',
39
+ };
40
+ const registerCollection = vi.fn().mockReturnValue(collection);
41
+ const removeCollection = vi.fn();
42
+ const db = {
43
+ collection: registerCollection,
44
+ getCollection: vi.fn().mockReturnValue(options.collectionRegistered ? collection : undefined),
45
+ inDialect: (...dialects: string[]) => dialects.includes(dialect),
46
+ quoteIdentifier: (identifier: string) => `"${identifier}"`,
47
+ removeCollection,
48
+ sequelize: {
49
+ getQueryInterface: () => queryInterface,
50
+ query,
51
+ transaction,
52
+ },
53
+ };
54
+ const migration = new ChangeUsageUserIdToBigInt({
55
+ db: db as unknown as Database,
56
+ queryInterface,
57
+ sequelize: db.sequelize,
58
+ } as unknown as MigrationContext);
59
+
60
+ return {
61
+ changeColumn,
62
+ collection,
63
+ describeTable,
64
+ migration,
65
+ query,
66
+ registerCollection,
67
+ removeCollection,
68
+ transaction,
69
+ };
70
+ }
71
+
72
+ describe('AI API usage userId BIGINT migration', () => {
73
+ it('accepts numeric strings without converting them through JavaScript Number', () => {
74
+ expect(normalizeLegacyUserId('42')).toBe('42');
75
+ expect(normalizeLegacyUserId(' 00042 ')).toBe('00042');
76
+ expect(normalizeLegacyUserId('9007199254740993')).toBe('9007199254740993');
77
+ expect(normalizeLegacyUserId('9223372036854775808')).toBeUndefined();
78
+ expect(normalizeLegacyUserId('undefined')).toBeUndefined();
79
+ expect(normalizeLegacyUserId('')).toBeUndefined();
80
+ });
81
+
82
+ it('changes a valid legacy column to a non-null BIGINT', async () => {
83
+ const harness = createHarness({
84
+ dialect: 'mysql',
85
+ rows: [
86
+ { recordId: 1, userId: '42' },
87
+ { recordId: 2, userId: '0007' },
88
+ { recordId: 3, userId: '9007199254740993' },
89
+ ],
90
+ });
91
+
92
+ await harness.migration.up();
93
+
94
+ expect(harness.changeColumn).toHaveBeenCalledWith('aiApiUsageRecords', 'userId', {
95
+ type: DataTypes.BIGINT,
96
+ allowNull: false,
97
+ });
98
+ expect(harness.query).toHaveBeenCalledTimes(1);
99
+ expect(String(harness.query.mock.calls[0][0])).toContain('SELECT');
100
+ expect(harness.registerCollection).toHaveBeenCalledOnce();
101
+ expect(harness.removeCollection).toHaveBeenCalledWith('aiApiUsageRecords');
102
+ });
103
+
104
+ it('uses an explicit PostgreSQL USING cast inside a transaction', async () => {
105
+ const harness = createHarness({ dialect: 'postgres', rows: [{ recordId: 1, userId: '42' }] });
106
+
107
+ await harness.migration.up();
108
+
109
+ const ddl = harness.query.mock.calls.find(([sql]) => String(sql).startsWith('ALTER TABLE'))?.[0];
110
+ expect(String(ddl)).toContain('TYPE BIGINT USING BTRIM("userId")::BIGINT');
111
+ expect(String(ddl)).toContain('SET NOT NULL');
112
+ expect(harness.transaction).toHaveBeenCalledOnce();
113
+ expect(harness.changeColumn).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('rejects invalid values before running any DDL', async () => {
117
+ const harness = createHarness({
118
+ dialect: 'postgres',
119
+ rows: [
120
+ { recordId: 4, userId: 'undefined' },
121
+ { recordId: 5, userId: '' },
122
+ ],
123
+ });
124
+
125
+ await expect(harness.migration.up()).rejects.toThrow('2 invalid userId value(s)');
126
+
127
+ expect(harness.query.mock.calls.some(([sql]) => String(sql).startsWith('ALTER TABLE'))).toBe(false);
128
+ expect(harness.changeColumn).not.toHaveBeenCalled();
129
+ expect(harness.transaction).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('rejects an unsupported legacy column type before scanning data', async () => {
133
+ const harness = createHarness({ columnType: 'INTEGER' });
134
+
135
+ await expect(harness.migration.up()).rejects.toThrow('does not support converting INTEGER to BIGINT');
136
+
137
+ expect(harness.query).not.toHaveBeenCalled();
138
+ expect(harness.changeColumn).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('is idempotent when the column is already a non-null BIGINT', async () => {
142
+ const harness = createHarness({ columnType: 'BIGINT', allowNull: false, rows: [{ recordId: 1, userId: '42' }] });
143
+
144
+ await harness.migration.up();
145
+
146
+ expect(isBigIntColumnType('BIGINT')).toBe(true);
147
+ expect(harness.changeColumn).not.toHaveBeenCalled();
148
+ expect(harness.query.mock.calls.some(([sql]) => String(sql).startsWith('ALTER TABLE'))).toBe(false);
149
+ });
150
+
151
+ it('enforces non-null when a BIGINT column was migrated incompletely', async () => {
152
+ const harness = createHarness({
153
+ columnType: 'BIGINT',
154
+ allowNull: true,
155
+ dialect: 'postgres',
156
+ rows: [{ recordId: 1, userId: '42' }],
157
+ });
158
+
159
+ await harness.migration.up();
160
+
161
+ const ddl = harness.query.mock.calls.find(([sql]) => String(sql).startsWith('ALTER TABLE'))?.[0];
162
+ expect(String(ddl)).toContain('ALTER COLUMN "userId" SET NOT NULL');
163
+ });
164
+
165
+ it('is a no-op when the collection has not been installed', async () => {
166
+ const harness = createHarness({ collectionExists: false });
167
+ await expect(harness.migration.up()).resolves.toBeUndefined();
168
+ expect(harness.describeTable).not.toHaveBeenCalled();
169
+ expect(harness.removeCollection).toHaveBeenCalledWith('aiApiUsageRecords');
170
+ });
171
+ });
@@ -0,0 +1,105 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type PluginAiApiServer from '../plugin';
4
+ import { handleChatCompletions } from '../routes/chat-completions';
5
+ import { resolveModelString } from '../utils/resolve-service';
6
+
7
+ vi.mock('../utils/resolve-service', () => ({
8
+ resolveModelString: vi.fn(),
9
+ }));
10
+
11
+ interface ModelResult {
12
+ content: string;
13
+ response_metadata?: Record<string, unknown>;
14
+ usage_metadata?: Record<string, unknown>;
15
+ }
16
+
17
+ function createContext(result: ModelResult): Context {
18
+ const model = {
19
+ invoke: vi.fn().mockResolvedValue(result),
20
+ modelKwargs: {},
21
+ };
22
+ class TestProvider {
23
+ createModel() {
24
+ return model;
25
+ }
26
+ }
27
+
28
+ return {
29
+ app: {
30
+ pm: {
31
+ get: vi.fn().mockReturnValue({
32
+ aiManager: {
33
+ llmProviders: new Map([['test-provider', { provider: TestProvider }]]),
34
+ },
35
+ }),
36
+ },
37
+ },
38
+ db: {
39
+ getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
40
+ },
41
+ log: { error: vi.fn() },
42
+ request: {
43
+ body: {
44
+ model: 'test-service/test-model',
45
+ messages: [{ role: 'user', content: 'Hello' }],
46
+ stream: false,
47
+ },
48
+ },
49
+ state: {},
50
+ set: vi.fn(),
51
+ } as unknown as Context;
52
+ }
53
+
54
+ describe('AI API chat usage collection', () => {
55
+ beforeEach(() => {
56
+ vi.mocked(resolveModelString).mockResolvedValue({
57
+ service: {
58
+ enabled: true,
59
+ name: 'test-service',
60
+ options: {},
61
+ provider: 'test-provider',
62
+ },
63
+ modelId: 'test-model',
64
+ });
65
+ });
66
+
67
+ it('keeps the public zero fallback but marks missing provider usage as unavailable internally', async () => {
68
+ const ctx = createContext({ content: 'Hello back' });
69
+
70
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
71
+
72
+ expect(ctx.status).toBe(200);
73
+ expect((ctx.body as { usage: object }).usage).toEqual({
74
+ prompt_tokens: 0,
75
+ completion_tokens: 0,
76
+ total_tokens: 0,
77
+ });
78
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
79
+ source: 'unavailable',
80
+ gatewayResponseId: expect.stringMatching(/^chatcmpl-/),
81
+ });
82
+ });
83
+
84
+ it('stores provider usage and provider request ID separately from the gateway response ID', async () => {
85
+ const ctx = createContext({
86
+ content: 'Hello back',
87
+ response_metadata: { request_id: 'provider-request-1' },
88
+ usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
89
+ });
90
+
91
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
92
+
93
+ expect((ctx.body as { usage: object }).usage).toEqual({
94
+ prompt_tokens: 8,
95
+ completion_tokens: 3,
96
+ total_tokens: 11,
97
+ });
98
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
99
+ source: 'provider',
100
+ providerRequestId: 'provider-request-1',
101
+ gatewayResponseId: expect.stringMatching(/^chatcmpl-/),
102
+ usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,201 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { authenticateBearer } from '../routes/auth';
4
+ import {
5
+ extractProviderRequestId,
6
+ finishUsageRecord,
7
+ normalizeUsage,
8
+ setAiApiUsageResult,
9
+ setAiApiUsageUnavailable,
10
+ startUsageRecord,
11
+ } from '../usage';
12
+
13
+ function createContext(overrides: Record<string, unknown> = {}): Context {
14
+ return {
15
+ state: {},
16
+ request: { body: {} },
17
+ ...overrides,
18
+ } as unknown as Context;
19
+ }
20
+
21
+ describe('AI API usage normalization', () => {
22
+ it('normalizes LangChain fields and derives a missing total', () => {
23
+ expect(normalizeUsage({ input_tokens: 12, output_tokens: 5 })).toEqual({
24
+ prompt_tokens: 12,
25
+ completion_tokens: 5,
26
+ total_tokens: 17,
27
+ });
28
+ });
29
+
30
+ it('preserves explicit zero usage from a provider', () => {
31
+ expect(normalizeUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 })).toEqual({
32
+ prompt_tokens: 0,
33
+ completion_tokens: 0,
34
+ total_tokens: 0,
35
+ });
36
+ });
37
+
38
+ it('rejects synthetic or invalid usage values', () => {
39
+ expect(normalizeUsage(undefined)).toBeUndefined();
40
+ expect(normalizeUsage({ prompt_tokens: '12', completion_tokens: -1, total_tokens: Number.NaN })).toBeUndefined();
41
+ expect(normalizeUsage({ prompt_tokens: Number.MAX_SAFE_INTEGER + 1 })).toBeUndefined();
42
+ });
43
+
44
+ it('extracts a provider request ID only from provider metadata', () => {
45
+ expect(extractProviderRequestId({ id: 'gateway-id', response_metadata: { request_id: 'provider-id' } })).toBe(
46
+ 'provider-id',
47
+ );
48
+ expect(extractProviderRequestId({ id: 'gateway-id' })).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ describe('AI API usage persistence', () => {
53
+ it('preserves the authenticated BIGINT identifier without Number or String coercion', async () => {
54
+ const create = vi.fn().mockResolvedValue({ id: 91 });
55
+ const ctx = createContext({
56
+ state: {
57
+ currentUser: { id: '9007199254740993' },
58
+ currentRole: 'member',
59
+ aiApiAuthType: 'bearer',
60
+ },
61
+ request: {
62
+ body: {
63
+ messages: [{ role: 'user', content: 'Hello' }],
64
+ max_tokens: 100,
65
+ max_completion_tokens: 200,
66
+ },
67
+ },
68
+ db: { getRepository: vi.fn().mockReturnValue({ create }) },
69
+ });
70
+
71
+ await expect(startUsageRecord(ctx, 'req-1', '/chat/completions', 'service/model', false, 'llm')).resolves.toBe(91);
72
+ expect(create).toHaveBeenCalledWith({
73
+ values: expect.objectContaining({
74
+ userId: '9007199254740993',
75
+ authType: 'bearer',
76
+ requestMetadata: {
77
+ messageCount: 1,
78
+ promptCount: undefined,
79
+ embeddingInputCount: undefined,
80
+ requestedMaxTokens: 200,
81
+ },
82
+ }),
83
+ });
84
+ });
85
+
86
+ it('refuses to create a usage record without an authenticated user ID', async () => {
87
+ const ctx = createContext({
88
+ db: { getRepository: vi.fn() },
89
+ });
90
+
91
+ await expect(startUsageRecord(ctx, 'req-1', '/chat/completions', 'service/model', false, 'llm')).rejects.toThrow(
92
+ 'requires an authenticated NocoBase user ID',
93
+ );
94
+ });
95
+
96
+ it('stores real provider usage and separates provider and gateway IDs', async () => {
97
+ const update = vi.fn().mockResolvedValue(undefined);
98
+ const ctx = createContext({
99
+ status: 200,
100
+ body: { id: 'gateway-body-id' },
101
+ db: { getRepository: vi.fn().mockReturnValue({ update }) },
102
+ });
103
+
104
+ setAiApiUsageResult(
105
+ ctx,
106
+ { input_tokens: 20, output_tokens: 4 },
107
+ {
108
+ gatewayResponseId: 'gateway-result-id',
109
+ providerRequestId: 'provider-id',
110
+ },
111
+ );
112
+ await finishUsageRecord(ctx, 1, Date.now() - 10, 'succeeded');
113
+
114
+ expect(update).toHaveBeenCalledWith({
115
+ filterByTk: 1,
116
+ values: expect.objectContaining({
117
+ status: 'succeeded',
118
+ inputTokens: 20,
119
+ outputTokens: 4,
120
+ totalTokens: 24,
121
+ providerRequestId: 'provider-id',
122
+ responseMetadata: { usageSource: 'provider', gatewayResponseId: 'gateway-result-id' },
123
+ }),
124
+ });
125
+ });
126
+
127
+ it('does not persist synthetic zeroes when provider usage is unavailable', async () => {
128
+ const update = vi.fn().mockResolvedValue(undefined);
129
+ const ctx = createContext({
130
+ status: 200,
131
+ body: {
132
+ id: 'gateway-id',
133
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
134
+ },
135
+ db: { getRepository: vi.fn().mockReturnValue({ update }) },
136
+ });
137
+
138
+ setAiApiUsageUnavailable(ctx, 'gateway-id');
139
+ await finishUsageRecord(ctx, 2, Date.now() - 10, 'succeeded');
140
+
141
+ expect(update).toHaveBeenCalledWith({
142
+ filterByTk: 2,
143
+ values: expect.objectContaining({
144
+ inputTokens: null,
145
+ outputTokens: null,
146
+ totalTokens: null,
147
+ providerRequestId: null,
148
+ responseMetadata: { usageSource: 'unavailable', gatewayResponseId: 'gateway-id' },
149
+ }),
150
+ });
151
+ });
152
+
153
+ it('uses streaming execution state to persist a failed stream', async () => {
154
+ const update = vi.fn().mockResolvedValue(undefined);
155
+ const ctx = createContext({
156
+ status: 200,
157
+ state: {
158
+ aiApiStreamResult: { succeeded: false, id: 'gateway-stream-id', errorCode: 'stream_error' },
159
+ },
160
+ db: { getRepository: vi.fn().mockReturnValue({ update }) },
161
+ });
162
+
163
+ await finishUsageRecord(ctx, 3, Date.now() - 10, 'succeeded');
164
+
165
+ expect(update).toHaveBeenCalledWith({
166
+ filterByTk: 3,
167
+ values: expect.objectContaining({
168
+ status: 'failed',
169
+ httpStatus: 200,
170
+ errorCode: 'stream_error',
171
+ responseMetadata: { usageSource: 'unavailable', gatewayResponseId: 'gateway-stream-id' },
172
+ }),
173
+ });
174
+ });
175
+ });
176
+
177
+ describe('AI API authentication provenance', () => {
178
+ it('classifies a pre-authenticated NocoBase principal as bearer rather than session', async () => {
179
+ const ctx = createContext({
180
+ state: { currentUser: { id: 1 }, currentRole: 'member' },
181
+ get: vi.fn((name: string) => (name === 'Authorization' ? 'Bearer token' : '')),
182
+ });
183
+
184
+ await expect(authenticateBearer(ctx)).resolves.toBe(true);
185
+ expect(ctx.state.aiApiAuthType).toBe('bearer');
186
+ });
187
+
188
+ it('classifies a principal as OIDC only when verified OAuth state is present', async () => {
189
+ const ctx = createContext({
190
+ state: {
191
+ currentUser: { id: 1 },
192
+ currentRole: 'member',
193
+ oauthPrincipal: { subject: '1', clientId: 'client-1' },
194
+ },
195
+ get: vi.fn((name: string) => (name === 'Authorization' ? 'Bearer token' : '')),
196
+ });
197
+
198
+ await expect(authenticateBearer(ctx)).resolves.toBe(true);
199
+ expect(ctx.state.aiApiAuthType).toBe('oidc');
200
+ });
201
+ });
@@ -5,7 +5,15 @@ export default defineCollection({
5
5
  autoGenId: true,
6
6
  fields: [
7
7
  { name: 'requestId', type: 'string', unique: true, index: true },
8
- { name: 'userId', type: 'string', index: true },
8
+ { name: 'userId', type: 'bigInt', allowNull: false, index: true },
9
+ {
10
+ name: 'user',
11
+ type: 'belongsTo',
12
+ target: 'users',
13
+ targetKey: 'id',
14
+ foreignKey: 'userId',
15
+ constraints: false,
16
+ },
9
17
  { name: 'roleName', type: 'string', index: true },
10
18
  { name: 'authType', type: 'string', index: true },
11
19
  { name: 'oauthClientId', type: 'string', allowNull: true, index: true },