plugin-ai-api 1.0.15 → 1.0.20

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.
Files changed (60) hide show
  1. package/dist/client/302.25edd5d75460acbf.js +10 -0
  2. package/dist/client/757.71e30f2a1306562d.js +10 -0
  3. package/dist/client/902.4238b04ac667c30a.js +10 -0
  4. package/dist/client/97.37cda285d7da3a26.js +10 -0
  5. package/dist/client/index.js +1 -1
  6. package/dist/client-v2/302.9b27a263901d54d8.js +10 -0
  7. package/dist/client-v2/757.c377e2f2b054d89d.js +10 -0
  8. package/dist/client-v2/902.d40d7bda106124c8.js +10 -0
  9. package/dist/client-v2/97.fc922c37ced86831.js +10 -0
  10. package/dist/client-v2/index.js +1 -1
  11. package/dist/externalVersion.js +9 -9
  12. package/dist/locale/en-US.json +78 -2
  13. package/dist/locale/vi-VN.json +86 -0
  14. package/dist/locale/zh-CN.json +86 -10
  15. package/dist/server/billing.js +331 -0
  16. package/dist/server/collections/ai-api-config.js +12 -0
  17. package/dist/server/collections/ai-api-model-prices.js +55 -0
  18. package/dist/server/collections/ai-api-usage-records.js +9 -0
  19. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  20. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  21. package/dist/server/plugin.js +23 -2
  22. package/dist/server/resource/ai-api-config.js +8 -0
  23. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  24. package/dist/server/routes/chat-completions.js +12 -2
  25. package/dist/server/routes/completions.js +12 -2
  26. package/dist/server/routes/router.js +14 -1
  27. package/dist/server/usage.js +17 -2
  28. package/dist/server/validation.js +102 -0
  29. package/package.json +1 -1
  30. package/src/client/plugin.tsx +73 -48
  31. package/src/client-v2/locale.ts +1 -0
  32. package/src/client-v2/pages/GeneralPage.tsx +170 -0
  33. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  34. package/src/client-v2/pages/UsagePage.tsx +248 -0
  35. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  36. package/src/client-v2/pages/api.ts +16 -0
  37. package/src/client-v2/plugin.tsx +21 -3
  38. package/src/locale/en-US.json +78 -2
  39. package/src/locale/vi-VN.json +86 -0
  40. package/src/locale/zh-CN.json +86 -10
  41. package/src/server/__tests__/billing-quota.test.ts +134 -0
  42. package/src/server/__tests__/billing.test.ts +33 -0
  43. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  44. package/src/server/__tests__/usage-route.test.ts +4 -0
  45. package/src/server/billing.ts +387 -0
  46. package/src/server/collections/ai-api-config.ts +63 -51
  47. package/src/server/collections/ai-api-model-prices.ts +25 -0
  48. package/src/server/collections/ai-api-usage-records.ts +9 -0
  49. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  50. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  51. package/src/server/plugin.ts +24 -2
  52. package/src/server/resource/ai-api-config.ts +82 -74
  53. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  54. package/src/server/routes/chat-completions.ts +13 -2
  55. package/src/server/routes/completions.ts +13 -2
  56. package/src/server/routes/router.ts +16 -1
  57. package/src/server/usage.ts +17 -1
  58. package/src/server/validation.ts +62 -0
  59. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  60. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
@@ -0,0 +1,170 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { Alert, Button, Card, Form, InputNumber, Select, Space, Switch, Typography, message } from 'antd';
3
+ import { useFlowContext } from '@nocobase/flow-engine';
4
+ import { useT } from '../locale';
5
+ import { errorMessage, unwrapData } from './api';
6
+
7
+ interface GeneralSettings {
8
+ mode: 'llm' | 'agent';
9
+ defaultAiEmployee?: string;
10
+ defaultLlmService?: string;
11
+ enabledLlmServices: string[];
12
+ rateLimitPerMinute: number;
13
+ quotaEnabled: boolean;
14
+ defaultReservationOutputTokens: number;
15
+ }
16
+
17
+ interface LlmService {
18
+ name: string;
19
+ title?: string;
20
+ }
21
+
22
+ interface AiEmployee {
23
+ username: string;
24
+ nickname?: string;
25
+ }
26
+
27
+ const defaults: GeneralSettings = {
28
+ mode: 'llm',
29
+ enabledLlmServices: [],
30
+ rateLimitPerMinute: 60,
31
+ quotaEnabled: false,
32
+ defaultReservationOutputTokens: 4096,
33
+ };
34
+
35
+ export default function GeneralPage() {
36
+ const ctx = useFlowContext();
37
+ const t = useT();
38
+ const [form] = Form.useForm<GeneralSettings>();
39
+ const mode = Form.useWatch('mode', form);
40
+ const [loading, setLoading] = useState(true);
41
+ const [saving, setSaving] = useState(false);
42
+ const [services, setServices] = useState<LlmService[]>([]);
43
+ const [employees, setEmployees] = useState<AiEmployee[]>([]);
44
+ const [loadError, setLoadError] = useState<string>();
45
+
46
+ const load = useCallback(async () => {
47
+ setLoading(true);
48
+ setLoadError(undefined);
49
+ try {
50
+ const [configResponse, servicesResponse, employeesResponse] = await Promise.all([
51
+ ctx.api.request({ url: 'aiApiConfig:get', method: 'get' }),
52
+ ctx.api.request({ url: 'ai:listLLMServices', method: 'get' }),
53
+ ctx.api.request({ url: 'aiEmployees:list', method: 'get', params: { paginate: false } }),
54
+ ]);
55
+ form.setFieldsValue({ ...defaults, ...unwrapData<Partial<GeneralSettings>>(configResponse, {}) });
56
+ setServices(unwrapData<LlmService[]>(servicesResponse, []));
57
+ setEmployees(unwrapData<AiEmployee[]>(employeesResponse, []));
58
+ } catch (error) {
59
+ setLoadError(errorMessage(error));
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ }, [ctx.api, form]);
64
+
65
+ useEffect(() => {
66
+ load();
67
+ }, [load]);
68
+
69
+ const save = async () => {
70
+ const values = await form.validateFields();
71
+ setSaving(true);
72
+ try {
73
+ await ctx.api.request({ url: 'aiApiConfig:save', method: 'post', data: values });
74
+ message.success(t('Configuration saved'));
75
+ } catch (error) {
76
+ message.error(`${t('Failed to save configuration')}: ${errorMessage(error)}`);
77
+ } finally {
78
+ setSaving(false);
79
+ }
80
+ };
81
+
82
+ const serviceOptions = services.map((service) => ({
83
+ label: service.title || service.name,
84
+ value: service.name,
85
+ }));
86
+ const employeeOptions = employees.map((employee) => ({
87
+ label: employee.nickname ? `${employee.nickname} (${employee.username})` : employee.username,
88
+ value: employee.username,
89
+ }));
90
+ const baseUrl = `${window.location.origin}/api/ai-llm/v1`;
91
+
92
+ return (
93
+ <Card title={t('Configuration')} loading={loading}>
94
+ {loadError ? <Alert type="error" showIcon message={loadError} style={{ marginBottom: 16 }} /> : null}
95
+ <Form form={form} layout="vertical" style={{ maxWidth: 720 }} initialValues={defaults}>
96
+ <Form.Item name="mode" label={t('API mode')} rules={[{ required: true }]}>
97
+ <Select
98
+ options={[
99
+ { label: t('Direct LLM'), value: 'llm' },
100
+ { label: t('AI Employee agent'), value: 'agent' },
101
+ ]}
102
+ />
103
+ </Form.Item>
104
+ <Form.Item name="defaultLlmService" label={t('Default LLM service')}>
105
+ <Select allowClear showSearch optionFilterProp="label" options={serviceOptions} />
106
+ </Form.Item>
107
+ <Form.Item name="enabledLlmServices" label={t('Enabled LLM Services')}>
108
+ <Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
109
+ </Form.Item>
110
+ {mode === 'agent' ? (
111
+ <Form.Item name="defaultAiEmployee" label={t('Default AI Employee')}>
112
+ <Select
113
+ allowClear
114
+ showSearch
115
+ optionFilterProp="label"
116
+ placeholder={t('Select an AI Employee')}
117
+ options={employeeOptions}
118
+ />
119
+ </Form.Item>
120
+ ) : null}
121
+ <Form.Item name="rateLimitPerMinute" label={t('Rate Limit')} rules={[{ required: true }]}>
122
+ <InputNumber min={1} style={{ width: '100%' }} />
123
+ </Form.Item>
124
+ <Form.Item name="quotaEnabled" label={t('Enable user quotas')} valuePropName="checked">
125
+ <Switch />
126
+ </Form.Item>
127
+ <Form.Item
128
+ name="defaultReservationOutputTokens"
129
+ label={t('Default reserved output tokens')}
130
+ rules={[{ required: true }]}
131
+ >
132
+ <InputNumber min={1} style={{ width: '100%' }} />
133
+ </Form.Item>
134
+ <Space>
135
+ <Button type="primary" loading={saving} onClick={save}>
136
+ {t('Save Configuration')}
137
+ </Button>
138
+ <Button onClick={load}>{t('Refresh')}</Button>
139
+ </Space>
140
+ </Form>
141
+ <Card title={t('Usage guide')} size="small" style={{ marginTop: 24 }}>
142
+ <Alert
143
+ type="info"
144
+ showIcon
145
+ message={t('OpenAI-compatible endpoint')}
146
+ description={
147
+ <Space direction="vertical" size={4}>
148
+ <Typography.Text>{t('Base URL')}</Typography.Text>
149
+ <Typography.Text code copyable>
150
+ {baseUrl}
151
+ </Typography.Text>
152
+ <Typography.Text>{t('Use a NocoBase API key as the Bearer token.')}</Typography.Text>
153
+ </Space>
154
+ }
155
+ style={{ marginBottom: 16 }}
156
+ />
157
+ <Typography.Paragraph>{t('List available models')}</Typography.Paragraph>
158
+ <Typography.Paragraph
159
+ code
160
+ copyable
161
+ >{`curl ${baseUrl}/models -H "Authorization: Bearer <your-api-key>"`}</Typography.Paragraph>
162
+ <Typography.Paragraph>{t('Send a chat completion')}</Typography.Paragraph>
163
+ <Typography.Paragraph code copyable>{`curl ${baseUrl}/chat/completions \\
164
+ -H "Authorization: Bearer <your-api-key>" \\
165
+ -H "Content-Type: application/json" \\
166
+ -d '{"model":"<service>/<model>","messages":[{"role":"user","content":"Hello"}]}'`}</Typography.Paragraph>
167
+ </Card>
168
+ </Card>
169
+ );
170
+ }
@@ -0,0 +1,285 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ Button,
4
+ Card,
5
+ DatePicker,
6
+ Form,
7
+ Input,
8
+ InputNumber,
9
+ Modal,
10
+ Popconfirm,
11
+ Select,
12
+ Space,
13
+ Switch,
14
+ Table,
15
+ Tag,
16
+ message,
17
+ } from 'antd';
18
+ import type { ColumnsType } from 'antd/es/table';
19
+ import dayjs, { type Dayjs } from 'dayjs';
20
+ import { useFlowContext } from '@nocobase/flow-engine';
21
+ import { useT } from '../locale';
22
+ import { errorMessage, unwrapData } from './api';
23
+
24
+ interface ModelPrice {
25
+ id: string | number;
26
+ llmService: string;
27
+ provider: string;
28
+ model: string;
29
+ currency: string;
30
+ inputPricePerMillionTokens: string;
31
+ outputPricePerMillionTokens: string;
32
+ fixedCostPerRequest: string;
33
+ effectiveFrom: string;
34
+ effectiveTo?: string;
35
+ enabled: boolean;
36
+ notes?: string;
37
+ }
38
+
39
+ interface PriceFormValues extends Omit<ModelPrice, 'id' | 'effectiveFrom' | 'effectiveTo'> {
40
+ effectiveFrom: Dayjs;
41
+ effectiveTo?: Dayjs;
42
+ }
43
+
44
+ interface LlmService {
45
+ name: string;
46
+ title?: string;
47
+ provider: string;
48
+ }
49
+
50
+ interface LlmModel {
51
+ id: string;
52
+ }
53
+
54
+ export default function ModelPricingPage() {
55
+ const ctx = useFlowContext();
56
+ const t = useT();
57
+ const [form] = Form.useForm<PriceFormValues>();
58
+ const selectedService = Form.useWatch('llmService', form);
59
+ const [rows, setRows] = useState<ModelPrice[]>([]);
60
+ const [services, setServices] = useState<LlmService[]>([]);
61
+ const [models, setModels] = useState<LlmModel[]>([]);
62
+ const [modelsLoading, setModelsLoading] = useState(false);
63
+ const [loading, setLoading] = useState(false);
64
+ const [saving, setSaving] = useState(false);
65
+ const [editing, setEditing] = useState<ModelPrice>();
66
+ const [open, setOpen] = useState(false);
67
+
68
+ const load = useCallback(async () => {
69
+ setLoading(true);
70
+ try {
71
+ const [pricesResponse, servicesResponse] = await Promise.all([
72
+ ctx.api.request({
73
+ url: 'aiApiModelPrices:list',
74
+ method: 'get',
75
+ params: { pageSize: 200, sort: '-effectiveFrom' },
76
+ }),
77
+ ctx.api.request({ url: 'ai:listLLMServices', method: 'get' }),
78
+ ]);
79
+ setRows(unwrapData<ModelPrice[]>(pricesResponse, []));
80
+ setServices(unwrapData<LlmService[]>(servicesResponse, []));
81
+ } catch (error) {
82
+ message.error(errorMessage(error));
83
+ } finally {
84
+ setLoading(false);
85
+ }
86
+ }, [ctx.api]);
87
+
88
+ useEffect(() => {
89
+ load();
90
+ }, [load]);
91
+
92
+ const showCreate = () => {
93
+ setEditing(undefined);
94
+ setModels([]);
95
+ form.setFieldsValue({
96
+ currency: 'USD',
97
+ inputPricePerMillionTokens: '0',
98
+ outputPricePerMillionTokens: '0',
99
+ fixedCostPerRequest: '0',
100
+ effectiveFrom: dayjs(),
101
+ enabled: true,
102
+ } as PriceFormValues);
103
+ setOpen(true);
104
+ };
105
+
106
+ const showEdit = (record: ModelPrice) => {
107
+ setEditing(record);
108
+ form.setFieldsValue({
109
+ ...record,
110
+ effectiveFrom: dayjs(record.effectiveFrom),
111
+ effectiveTo: record.effectiveTo ? dayjs(record.effectiveTo) : undefined,
112
+ });
113
+ setOpen(true);
114
+ loadModels(record.llmService, record.model);
115
+ };
116
+
117
+ const loadModels = async (llmService: string, currentModel?: string) => {
118
+ setModelsLoading(true);
119
+ try {
120
+ const response = await ctx.api.request({
121
+ url: 'ai:listModels',
122
+ method: 'get',
123
+ params: { llmService },
124
+ });
125
+ const loadedModels = unwrapData<LlmModel[]>(response, []);
126
+ setModels(
127
+ currentModel && !loadedModels.some((item) => item.id === currentModel)
128
+ ? [{ id: currentModel }, ...loadedModels]
129
+ : loadedModels,
130
+ );
131
+ } catch (error) {
132
+ setModels(currentModel ? [{ id: currentModel }] : []);
133
+ message.error(`${t('Failed to load models')}: ${errorMessage(error)}`);
134
+ } finally {
135
+ setModelsLoading(false);
136
+ }
137
+ };
138
+
139
+ const changeService = (llmService: string) => {
140
+ form.setFieldValue('model', undefined);
141
+ setModels([]);
142
+ loadModels(llmService);
143
+ };
144
+
145
+ const save = async () => {
146
+ const values = await form.validateFields();
147
+ const service = services.find((item) => item.name === values.llmService);
148
+ const data = {
149
+ ...values,
150
+ provider: service?.provider ?? values.provider,
151
+ effectiveFrom: values.effectiveFrom.toISOString(),
152
+ effectiveTo: values.effectiveTo?.toISOString() ?? null,
153
+ };
154
+ setSaving(true);
155
+ try {
156
+ await ctx.api.request({
157
+ url: editing ? `aiApiModelPrices:update/${editing.id}` : 'aiApiModelPrices:create',
158
+ method: 'post',
159
+ data,
160
+ });
161
+ message.success(t('Saved successfully'));
162
+ setOpen(false);
163
+ await load();
164
+ } catch (error) {
165
+ message.error(errorMessage(error));
166
+ } finally {
167
+ setSaving(false);
168
+ }
169
+ };
170
+
171
+ const remove = async (id: string | number) => {
172
+ try {
173
+ await ctx.api.request({ url: `aiApiModelPrices:destroy/${id}`, method: 'post' });
174
+ message.success(t('Deleted successfully'));
175
+ await load();
176
+ } catch (error) {
177
+ message.error(errorMessage(error));
178
+ }
179
+ };
180
+
181
+ const columns: ColumnsType<ModelPrice> = [
182
+ { title: t('LLM service'), dataIndex: 'llmService', key: 'llmService', width: 180 },
183
+ { title: t('Model'), dataIndex: 'model', key: 'model', width: 180 },
184
+ { title: t('Input price / 1M'), dataIndex: 'inputPricePerMillionTokens', key: 'inputPrice', width: 150 },
185
+ { title: t('Output price / 1M'), dataIndex: 'outputPricePerMillionTokens', key: 'outputPrice', width: 150 },
186
+ { title: t('Fixed request cost'), dataIndex: 'fixedCostPerRequest', key: 'fixedCost', width: 150 },
187
+ { title: t('Currency'), dataIndex: 'currency', key: 'currency', width: 90 },
188
+ {
189
+ title: t('Status'),
190
+ dataIndex: 'enabled',
191
+ key: 'enabled',
192
+ width: 100,
193
+ render: (enabled: boolean) => (
194
+ <Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
195
+ ),
196
+ },
197
+ {
198
+ title: t('Actions'),
199
+ key: 'actions',
200
+ fixed: 'right',
201
+ width: 150,
202
+ render: (_, record) => (
203
+ <Space>
204
+ <Button type="link" onClick={() => showEdit(record)}>
205
+ {t('Edit')}
206
+ </Button>
207
+ <Popconfirm title={t('Delete this price?')} onConfirm={() => remove(record.id)}>
208
+ <Button type="link" danger>
209
+ {t('Delete')}
210
+ </Button>
211
+ </Popconfirm>
212
+ </Space>
213
+ ),
214
+ },
215
+ ];
216
+
217
+ return (
218
+ <Card
219
+ title={t('Model pricing')}
220
+ extra={
221
+ <Button type="primary" onClick={showCreate}>
222
+ {t('Add price')}
223
+ </Button>
224
+ }
225
+ >
226
+ <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1200 }} />
227
+ <Modal
228
+ title={editing ? t('Edit price') : t('Add price')}
229
+ open={open}
230
+ onCancel={() => setOpen(false)}
231
+ onOk={save}
232
+ confirmLoading={saving}
233
+ destroyOnClose
234
+ >
235
+ <Form form={form} layout="vertical" preserve={false}>
236
+ <Form.Item name="llmService" label={t('LLM service')} rules={[{ required: true }]}>
237
+ <Select
238
+ showSearch
239
+ optionFilterProp="label"
240
+ onChange={changeService}
241
+ options={services.map((service) => ({ label: service.title || service.name, value: service.name }))}
242
+ />
243
+ </Form.Item>
244
+ <Form.Item name="provider" hidden>
245
+ <Input />
246
+ </Form.Item>
247
+ <Form.Item name="model" label={t('Model')} rules={[{ required: true }]}>
248
+ <Select
249
+ showSearch
250
+ optionFilterProp="label"
251
+ loading={modelsLoading}
252
+ disabled={!selectedService}
253
+ placeholder={t('Select a model')}
254
+ options={models.map((model) => ({ label: model.id, value: model.id }))}
255
+ />
256
+ </Form.Item>
257
+ <Form.Item name="currency" label={t('Currency')} rules={[{ required: true }]}>
258
+ <Input />
259
+ </Form.Item>
260
+ <Form.Item name="inputPricePerMillionTokens" label={t('Input price / 1M')} rules={[{ required: true }]}>
261
+ <InputNumber min={0} stringMode style={{ width: '100%' }} />
262
+ </Form.Item>
263
+ <Form.Item name="outputPricePerMillionTokens" label={t('Output price / 1M')} rules={[{ required: true }]}>
264
+ <InputNumber min={0} stringMode style={{ width: '100%' }} />
265
+ </Form.Item>
266
+ <Form.Item name="fixedCostPerRequest" label={t('Fixed request cost')} rules={[{ required: true }]}>
267
+ <InputNumber min={0} stringMode style={{ width: '100%' }} />
268
+ </Form.Item>
269
+ <Form.Item name="effectiveFrom" label={t('Effective from')} rules={[{ required: true }]}>
270
+ <DatePicker showTime style={{ width: '100%' }} />
271
+ </Form.Item>
272
+ <Form.Item name="effectiveTo" label={t('Effective to')}>
273
+ <DatePicker showTime style={{ width: '100%' }} />
274
+ </Form.Item>
275
+ <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
276
+ <Switch />
277
+ </Form.Item>
278
+ <Form.Item name="notes" label={t('Notes')}>
279
+ <Input.TextArea rows={3} />
280
+ </Form.Item>
281
+ </Form>
282
+ </Modal>
283
+ </Card>
284
+ );
285
+ }
@@ -0,0 +1,248 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ Button,
4
+ Card,
5
+ Col,
6
+ DatePicker,
7
+ Form,
8
+ Input,
9
+ InputNumber,
10
+ Row,
11
+ Select,
12
+ Space,
13
+ Statistic,
14
+ Table,
15
+ Tag,
16
+ message,
17
+ } from 'antd';
18
+ import type { ColumnsType } from 'antd/es/table';
19
+ import dayjs, { type Dayjs } from 'dayjs';
20
+ import { useFlowContext } from '@nocobase/flow-engine';
21
+ import { useT } from '../locale';
22
+ import { errorMessage, unwrapData } from './api';
23
+
24
+ interface UsageRecord {
25
+ id: string | number;
26
+ requestId: string;
27
+ userId: string | number;
28
+ model?: string;
29
+ resolvedService?: string;
30
+ resolvedModel?: string;
31
+ status: string;
32
+ inputTokens?: number;
33
+ outputTokens?: number;
34
+ totalTokens?: number;
35
+ estimatedCost?: string;
36
+ currency?: string;
37
+ costStatus?: string;
38
+ startedAt?: string;
39
+ }
40
+
41
+ interface UsageFilters {
42
+ range: [Dayjs, Dayjs];
43
+ userId?: number;
44
+ resolvedService?: string;
45
+ resolvedModel?: string;
46
+ status?: string;
47
+ }
48
+
49
+ interface UsageSummary {
50
+ requestCount: number;
51
+ inputTokens: number;
52
+ outputTokens: number;
53
+ totalTokens: number;
54
+ costsByCurrency: { currency: string; totalCost: string }[];
55
+ }
56
+
57
+ interface LlmService {
58
+ name: string;
59
+ title?: string;
60
+ }
61
+
62
+ const initialRange = (): [Dayjs, Dayjs] => [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')];
63
+ const emptySummary: UsageSummary = {
64
+ requestCount: 0,
65
+ inputTokens: 0,
66
+ outputTokens: 0,
67
+ totalTokens: 0,
68
+ costsByCurrency: [],
69
+ };
70
+
71
+ export default function UsagePage() {
72
+ const ctx = useFlowContext();
73
+ const t = useT();
74
+ const [form] = Form.useForm<UsageFilters>();
75
+ const [rows, setRows] = useState<UsageRecord[]>([]);
76
+ const [summary, setSummary] = useState<UsageSummary>(emptySummary);
77
+ const [services, setServices] = useState<LlmService[]>([]);
78
+ const [loading, setLoading] = useState(false);
79
+
80
+ const load = useCallback(
81
+ async (override?: UsageFilters) => {
82
+ const values = override ?? form.getFieldsValue();
83
+ const [start, end] = values.range ?? initialRange();
84
+ const conditions: Record<string, unknown>[] = [
85
+ { startedAt: { $gte: start.toISOString(), $lte: end.toISOString() } },
86
+ ];
87
+ if (values.userId !== undefined) conditions.push({ userId: values.userId });
88
+ if (values.resolvedService) conditions.push({ resolvedService: values.resolvedService });
89
+ if (values.resolvedModel) conditions.push({ resolvedModel: values.resolvedModel });
90
+ if (values.status) conditions.push({ status: values.status });
91
+ const summaryParams = {
92
+ start: start.toISOString(),
93
+ end: end.toISOString(),
94
+ userId: values.userId,
95
+ resolvedService: values.resolvedService,
96
+ resolvedModel: values.resolvedModel,
97
+ status: values.status,
98
+ };
99
+
100
+ setLoading(true);
101
+ try {
102
+ const [recordsResponse, summaryResponse] = await Promise.all([
103
+ ctx.api.request({
104
+ url: 'aiApiUsageRecords:list',
105
+ method: 'get',
106
+ params: { pageSize: 100, sort: '-startedAt', filter: { $and: conditions } },
107
+ }),
108
+ ctx.api.request({ url: 'aiApiUsageMonitor:summary', method: 'get', params: summaryParams }),
109
+ ]);
110
+ setRows(unwrapData<UsageRecord[]>(recordsResponse, []));
111
+ setSummary(unwrapData<UsageSummary>(summaryResponse, emptySummary));
112
+ } catch (error) {
113
+ message.error(errorMessage(error));
114
+ } finally {
115
+ setLoading(false);
116
+ }
117
+ },
118
+ [ctx.api, form],
119
+ );
120
+
121
+ useEffect(() => {
122
+ const range = initialRange();
123
+ form.setFieldsValue({ range });
124
+ load({ range });
125
+ ctx.api
126
+ .request({ url: 'ai:listLLMServices', method: 'get' })
127
+ .then((response) => setServices(unwrapData<LlmService[]>(response, [])))
128
+ .catch((error: unknown) => message.error(errorMessage(error)));
129
+ }, [ctx.api, form, load]);
130
+
131
+ const reset = () => {
132
+ const range = initialRange();
133
+ form.setFieldsValue({
134
+ range,
135
+ userId: undefined,
136
+ resolvedService: undefined,
137
+ resolvedModel: undefined,
138
+ status: undefined,
139
+ });
140
+ load({ range });
141
+ };
142
+
143
+ const totalCost = summary.costsByCurrency.length
144
+ ? summary.costsByCurrency.map((item) => `${item.totalCost} ${item.currency}`).join(' + ')
145
+ : '0';
146
+
147
+ const columns: ColumnsType<UsageRecord> = [
148
+ { title: t('Started at'), dataIndex: 'startedAt', key: 'startedAt', width: 180 },
149
+ { title: t('User'), dataIndex: 'userId', key: 'userId', width: 90 },
150
+ { title: t('Requested model'), dataIndex: 'model', key: 'model', width: 170 },
151
+ { title: t('Resolved service'), dataIndex: 'resolvedService', key: 'resolvedService', width: 170 },
152
+ { title: t('Resolved model'), dataIndex: 'resolvedModel', key: 'resolvedModel', width: 170 },
153
+ { title: t('Input tokens'), dataIndex: 'inputTokens', key: 'inputTokens', width: 110 },
154
+ { title: t('Output tokens'), dataIndex: 'outputTokens', key: 'outputTokens', width: 110 },
155
+ { title: t('Total tokens'), dataIndex: 'totalTokens', key: 'totalTokens', width: 110 },
156
+ {
157
+ title: t('Cost'),
158
+ key: 'cost',
159
+ width: 130,
160
+ render: (_, record) => (record.estimatedCost == null ? '-' : `${record.estimatedCost} ${record.currency ?? ''}`),
161
+ },
162
+ {
163
+ title: t('Cost status'),
164
+ dataIndex: 'costStatus',
165
+ key: 'costStatus',
166
+ width: 130,
167
+ render: (status?: string) => (status ? <Tag>{status}</Tag> : '-'),
168
+ },
169
+ { title: t('Status'), dataIndex: 'status', key: 'status', width: 100 },
170
+ { title: t('Request ID'), dataIndex: 'requestId', key: 'requestId', width: 240 },
171
+ ];
172
+
173
+ return (
174
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
175
+ <Card title={t('Usage filters')} size="small">
176
+ <Form form={form} layout="inline" onFinish={(values) => load(values)}>
177
+ <Form.Item name="range" label={t('Time range')} rules={[{ required: true }]}>
178
+ <DatePicker.RangePicker showTime />
179
+ </Form.Item>
180
+ <Form.Item name="userId" label={t('User ID')}>
181
+ <InputNumber min={1} style={{ width: 110 }} />
182
+ </Form.Item>
183
+ <Form.Item name="resolvedService" label={t('Resolved service')}>
184
+ <Select
185
+ allowClear
186
+ showSearch
187
+ optionFilterProp="label"
188
+ style={{ width: 190 }}
189
+ options={services.map((service) => ({ label: service.title || service.name, value: service.name }))}
190
+ />
191
+ </Form.Item>
192
+ <Form.Item name="resolvedModel" label={t('Resolved model')}>
193
+ <Input allowClear style={{ width: 180 }} />
194
+ </Form.Item>
195
+ <Form.Item name="status" label={t('Status')}>
196
+ <Select
197
+ allowClear
198
+ style={{ width: 130 }}
199
+ options={[
200
+ { label: t('Succeeded'), value: 'succeeded' },
201
+ { label: t('Failed'), value: 'failed' },
202
+ { label: t('Started'), value: 'started' },
203
+ ]}
204
+ />
205
+ </Form.Item>
206
+ <Form.Item>
207
+ <Space>
208
+ <Button type="primary" htmlType="submit" loading={loading}>
209
+ {t('Apply filters')}
210
+ </Button>
211
+ <Button onClick={reset}>{t('Reset')}</Button>
212
+ </Space>
213
+ </Form.Item>
214
+ </Form>
215
+ </Card>
216
+ <Row gutter={[16, 16]}>
217
+ <Col xs={24} sm={12} lg={6}>
218
+ <Card size="small">
219
+ <Statistic title={t('Requests')} value={summary.requestCount} />
220
+ </Card>
221
+ </Col>
222
+ <Col xs={24} sm={12} lg={6}>
223
+ <Card size="small">
224
+ <Statistic title={t('Input tokens')} value={summary.inputTokens} />
225
+ </Card>
226
+ </Col>
227
+ <Col xs={24} sm={12} lg={6}>
228
+ <Card size="small">
229
+ <Statistic title={t('Output tokens')} value={summary.outputTokens} />
230
+ </Card>
231
+ </Col>
232
+ <Col xs={24} sm={12} lg={6}>
233
+ <Card size="small">
234
+ <Statistic title={t('Total tokens')} value={summary.totalTokens} />
235
+ </Card>
236
+ </Col>
237
+ <Col xs={24}>
238
+ <Card size="small">
239
+ <Statistic title={t('Total cost')} value={totalCost} />
240
+ </Card>
241
+ </Col>
242
+ </Row>
243
+ <Card title={t('Usage records')} extra={<Button onClick={() => load()}>{t('Refresh')}</Button>}>
244
+ <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1600 }} />
245
+ </Card>
246
+ </Space>
247
+ );
248
+ }