plugin-ai-api 1.0.15 → 1.0.21

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 (92) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/757.a01403fb7a1bea01.js +10 -0
  5. package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
  6. package/dist/client/97.72979a11a067a7c9.js +10 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
  11. package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/97.29c663318eebbd57.js +10 -0
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +105 -10
  18. package/dist/locale/vi-VN.json +105 -0
  19. package/dist/locale/zh-CN.json +105 -10
  20. package/dist/server/billing.js +331 -0
  21. package/dist/server/collections/ai-api-config.js +18 -0
  22. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  23. package/dist/server/collections/ai-api-model-prices.js +55 -0
  24. package/dist/server/collections/ai-api-usage-records.js +9 -0
  25. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  26. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  27. package/dist/server/plugin.js +36 -3
  28. package/dist/server/resource/ai-api-config.js +25 -0
  29. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  30. package/dist/server/routes/agent-completions.js +62 -51
  31. package/dist/server/routes/auth.js +11 -1
  32. package/dist/server/routes/chat-completions.js +157 -6
  33. package/dist/server/routes/completions.js +20 -3
  34. package/dist/server/routes/models.js +78 -20
  35. package/dist/server/routes/router.js +108 -23
  36. package/dist/server/usage.js +19 -2
  37. package/dist/server/utils/app-observability.js +110 -0
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/validation.js +120 -0
  40. package/dist/swagger.js +32 -1
  41. package/package.json +1 -1
  42. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  43. package/src/client/locale.ts +11 -21
  44. package/src/client/plugin.tsx +82 -48
  45. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  46. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  47. package/src/client-v2/locale.ts +21 -0
  48. package/src/client-v2/pages/GeneralPage.tsx +183 -0
  49. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  50. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  51. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  52. package/src/client-v2/pages/UsagePage.tsx +248 -0
  53. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  54. package/src/client-v2/pages/api.ts +16 -0
  55. package/src/client-v2/plugin.tsx +62 -4
  56. package/src/constants.ts +21 -0
  57. package/src/locale/en-US.json +105 -10
  58. package/src/locale/vi-VN.json +105 -0
  59. package/src/locale/zh-CN.json +105 -10
  60. package/src/server/__tests__/app-observability.test.ts +98 -0
  61. package/src/server/__tests__/billing-quota.test.ts +134 -0
  62. package/src/server/__tests__/billing.test.ts +33 -0
  63. package/src/server/__tests__/models.test.ts +74 -0
  64. package/src/server/__tests__/request-body.test.ts +310 -0
  65. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  66. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  67. package/src/server/__tests__/usage-route.test.ts +4 -0
  68. package/src/server/billing.ts +387 -0
  69. package/src/server/collections/ai-api-config.ts +69 -51
  70. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  71. package/src/server/collections/ai-api-model-prices.ts +25 -0
  72. package/src/server/collections/ai-api-usage-records.ts +9 -0
  73. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  74. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  75. package/src/server/plugin.ts +47 -5
  76. package/src/server/resource/ai-api-config.ts +105 -74
  77. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  78. package/src/server/routes/agent-completions.ts +77 -62
  79. package/src/server/routes/auth.ts +14 -1
  80. package/src/server/routes/chat-completions.ts +275 -6
  81. package/src/server/routes/completions.ts +27 -4
  82. package/src/server/routes/models.ts +290 -195
  83. package/src/server/routes/router.ts +152 -27
  84. package/src/server/usage.ts +19 -1
  85. package/src/server/utils/app-observability.ts +105 -0
  86. package/src/server/utils/streaming.ts +13 -1
  87. package/src/server/validation.ts +89 -0
  88. package/src/swagger.ts +38 -1
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  91. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
  92. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -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,14 @@
1
+ import React from 'react';
2
+ import { AiApiRolePermissions } from '../components/AiApiRolePermissions';
3
+
4
+ /**
5
+ * v2 ACL permission tab wrapper.
6
+ *
7
+ * The v2 `settingsUI.addPermissionsTab` loads a component via `componentLoader` and
8
+ * passes it `PermissionTabProps` ({ activeRole, ... }), whereas AiApiRolePermissions
9
+ * takes { role }. This wrapper bridges the prop shape so the shared component can be
10
+ * reused unchanged across both client runtimes.
11
+ */
12
+ export default function RolePermissionsTab({ activeRole }: { activeRole?: { name?: string } | null }) {
13
+ return <AiApiRolePermissions role={activeRole} />;
14
+ }
@@ -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
+ }