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,173 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React, { useCallback, useEffect, useState } from 'react';
11
+ import { useApp } from '@nocobase/client-v2';
12
+ import { Card, Switch, Select, Space, Typography, Spin, Divider } from 'antd';
13
+ import { useT } from '../locale';
14
+
15
+ const { Text } = Typography;
16
+
17
+ interface AiEmployee {
18
+ username: string;
19
+ nickname: string;
20
+ }
21
+
22
+ interface RolePermRecord {
23
+ id?: number;
24
+ roleName: string;
25
+ enabled: boolean;
26
+ allowAllEmployees: boolean;
27
+ allowedEmployees: string[];
28
+ }
29
+
30
+ /**
31
+ * Permission tab shown in Settings → Users & Permissions → [Role] → "AI API" tab.
32
+ * Lets admins control whether a role can use the AI API and which AI Employees it may access.
33
+ */
34
+ export function AiApiRolePermissions({ role }: { role?: { name?: string } | null }) {
35
+ const api = useApp().apiClient;
36
+ const t = useT();
37
+ const [loading, setLoading] = useState(true);
38
+ const [saving, setSaving] = useState(false);
39
+ const [employees, setEmployees] = useState<AiEmployee[]>([]);
40
+ const [record, setRecord] = useState<RolePermRecord | null>(null);
41
+
42
+ const roleName = role?.name;
43
+
44
+ const loadData = useCallback(
45
+ async (name: string) => {
46
+ setLoading(true);
47
+ try {
48
+ const [permRes, empRes] = await Promise.all([
49
+ api.request({
50
+ url: 'aiApiRolePermissions',
51
+ params: { filter: { roleName: name }, paginate: false },
52
+ }),
53
+ api.request({ url: 'aiEmployees:list', params: { paginate: false } }),
54
+ ]);
55
+
56
+ const existing = permRes?.data?.data?.[0];
57
+ setRecord(
58
+ existing
59
+ ? {
60
+ id: existing.id,
61
+ roleName: existing.roleName,
62
+ enabled: !!existing.enabled,
63
+ allowAllEmployees: existing.allowAllEmployees !== false,
64
+ allowedEmployees: existing.allowedEmployees || [],
65
+ }
66
+ : {
67
+ roleName: name,
68
+ enabled: false,
69
+ allowAllEmployees: true,
70
+ allowedEmployees: [],
71
+ },
72
+ );
73
+
74
+ setEmployees(
75
+ (empRes?.data?.data || []).map((employee: AiEmployee) => ({
76
+ username: employee.username,
77
+ nickname: employee.nickname || employee.username,
78
+ })),
79
+ );
80
+ } catch (err) {
81
+ console.error('Failed to load AI API role permissions:', err);
82
+ } finally {
83
+ setLoading(false);
84
+ }
85
+ },
86
+ [api],
87
+ );
88
+
89
+ useEffect(() => {
90
+ if (!roleName) return;
91
+ loadData(roleName);
92
+ }, [roleName, loadData]);
93
+
94
+ const save = async (patch: Partial<RolePermRecord>) => {
95
+ if (!record) return;
96
+ const next = { ...record, ...patch };
97
+ setRecord(next);
98
+ setSaving(true);
99
+ try {
100
+ if (next.id) {
101
+ await api.request({
102
+ url: `aiApiRolePermissions/${next.id}`,
103
+ method: 'PUT',
104
+ data: next,
105
+ });
106
+ } else {
107
+ const res = await api.request({
108
+ url: 'aiApiRolePermissions',
109
+ method: 'POST',
110
+ data: next,
111
+ });
112
+ const created = res?.data?.data;
113
+ if (created?.id) {
114
+ setRecord({ ...next, id: created.id });
115
+ }
116
+ }
117
+ } catch (err) {
118
+ console.error('Failed to save AI API role permissions:', err);
119
+ // Revert on error
120
+ setRecord(record);
121
+ } finally {
122
+ setSaving(false);
123
+ }
124
+ };
125
+
126
+ if (loading) return <Spin />;
127
+
128
+ return (
129
+ <Card bordered={false}>
130
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
131
+ <Space>
132
+ <Switch checked={!!record?.enabled} loading={saving} onChange={(checked) => save({ enabled: checked })} />
133
+ <Text strong>{t('Allow this role to use the AI API')}</Text>
134
+ </Space>
135
+
136
+ {record?.enabled && (
137
+ <>
138
+ <Divider style={{ margin: '8px 0' }} />
139
+ <Space>
140
+ <Switch
141
+ checked={!!record?.allowAllEmployees}
142
+ loading={saving}
143
+ onChange={(checked) => save({ allowAllEmployees: checked })}
144
+ />
145
+ <Text>{t('Allow all AI Employees')}</Text>
146
+ </Space>
147
+
148
+ {!record?.allowAllEmployees && (
149
+ <div>
150
+ <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
151
+ {t('Select which AI Employees this role may use:')}
152
+ </Text>
153
+ <Select
154
+ mode="multiple"
155
+ allowClear
156
+ style={{ width: '100%', maxWidth: 480 }}
157
+ placeholder={t('Select allowed AI Employees')}
158
+ value={record?.allowedEmployees || []}
159
+ options={employees.map((employee) => ({
160
+ label: `${employee.nickname} (${employee.username})`,
161
+ value: employee.username,
162
+ }))}
163
+ onChange={(vals) => save({ allowedEmployees: vals })}
164
+ disabled={saving}
165
+ />
166
+ </div>
167
+ )}
168
+ </>
169
+ )}
170
+ </Space>
171
+ </Card>
172
+ );
173
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { tExpr as _tExpr, useFlowEngine } from '@nocobase/flow-engine';
11
+ // @ts-ignore
12
+ import pkg from './../../package.json';
13
+
14
+ export function useT() {
15
+ const engine = useFlowEngine();
16
+ return (str: string) => engine.context.t(str, { ns: [pkg.name, 'client'] });
17
+ }
18
+
19
+ export function tExpr(key: string) {
20
+ return _tExpr(key, { ns: [pkg.name, 'client'] });
21
+ }
@@ -0,0 +1,183 @@
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
+ maxRequestBodyMb: number;
14
+ quotaEnabled: boolean;
15
+ defaultReservationOutputTokens: number;
16
+ }
17
+
18
+ interface LlmService {
19
+ name: string;
20
+ title?: string;
21
+ }
22
+
23
+ interface AiEmployee {
24
+ username: string;
25
+ nickname?: string;
26
+ }
27
+
28
+ const defaults: GeneralSettings = {
29
+ mode: 'llm',
30
+ enabledLlmServices: [],
31
+ rateLimitPerMinute: 60,
32
+ maxRequestBodyMb: 10,
33
+ quotaEnabled: false,
34
+ defaultReservationOutputTokens: 4096,
35
+ };
36
+
37
+ /** Mirrors MAX_REQUEST_BODY_MB_LIMIT in server/routes/router.ts, which rejects anything higher. */
38
+ const MAX_REQUEST_BODY_MB = 100;
39
+
40
+ export default function GeneralPage() {
41
+ const ctx = useFlowContext();
42
+ const t = useT();
43
+ const [form] = Form.useForm<GeneralSettings>();
44
+ const mode = Form.useWatch('mode', form);
45
+ const [loading, setLoading] = useState(true);
46
+ const [saving, setSaving] = useState(false);
47
+ const [services, setServices] = useState<LlmService[]>([]);
48
+ const [employees, setEmployees] = useState<AiEmployee[]>([]);
49
+ const [loadError, setLoadError] = useState<string>();
50
+
51
+ const load = useCallback(async () => {
52
+ setLoading(true);
53
+ setLoadError(undefined);
54
+ try {
55
+ const [configResponse, servicesResponse, employeesResponse] = await Promise.all([
56
+ ctx.api.request({ url: 'aiApiConfig:get', method: 'get' }),
57
+ ctx.api.request({ url: 'ai:listLLMServices', method: 'get' }),
58
+ ctx.api.request({ url: 'aiEmployees:list', method: 'get', params: { paginate: false } }),
59
+ ]);
60
+ form.setFieldsValue({ ...defaults, ...unwrapData<Partial<GeneralSettings>>(configResponse, {}) });
61
+ setServices(unwrapData<LlmService[]>(servicesResponse, []));
62
+ setEmployees(unwrapData<AiEmployee[]>(employeesResponse, []));
63
+ } catch (error) {
64
+ setLoadError(errorMessage(error));
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ }, [ctx.api, form]);
69
+
70
+ useEffect(() => {
71
+ load();
72
+ }, [load]);
73
+
74
+ const save = async () => {
75
+ const values = await form.validateFields();
76
+ setSaving(true);
77
+ try {
78
+ await ctx.api.request({ url: 'aiApiConfig:save', method: 'post', data: values });
79
+ message.success(t('Configuration saved'));
80
+ } catch (error) {
81
+ message.error(`${t('Failed to save configuration')}: ${errorMessage(error)}`);
82
+ } finally {
83
+ setSaving(false);
84
+ }
85
+ };
86
+
87
+ const serviceOptions = services.map((service) => ({
88
+ label: service.title || service.name,
89
+ value: service.name,
90
+ }));
91
+ const employeeOptions = employees.map((employee) => ({
92
+ label: employee.nickname ? `${employee.nickname} (${employee.username})` : employee.username,
93
+ value: employee.username,
94
+ }));
95
+ const baseUrl = `${window.location.origin}/api/ai-llm/v1`;
96
+
97
+ return (
98
+ <Card title={t('Configuration')} loading={loading}>
99
+ {loadError ? <Alert type="error" showIcon message={loadError} style={{ marginBottom: 16 }} /> : null}
100
+ <Form form={form} layout="vertical" style={{ maxWidth: 720 }} initialValues={defaults}>
101
+ <Form.Item name="mode" label={t('API mode')} rules={[{ required: true }]}>
102
+ <Select
103
+ options={[
104
+ { label: t('Direct LLM'), value: 'llm' },
105
+ { label: t('AI Employee agent'), value: 'agent' },
106
+ ]}
107
+ />
108
+ </Form.Item>
109
+ <Form.Item name="defaultLlmService" label={t('Default LLM service')}>
110
+ <Select allowClear showSearch optionFilterProp="label" options={serviceOptions} />
111
+ </Form.Item>
112
+ <Form.Item name="enabledLlmServices" label={t('Enabled LLM Services')}>
113
+ <Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
114
+ </Form.Item>
115
+ {mode === 'agent' ? (
116
+ <Form.Item name="defaultAiEmployee" label={t('Default AI Employee')}>
117
+ <Select
118
+ allowClear
119
+ showSearch
120
+ optionFilterProp="label"
121
+ placeholder={t('Select an AI Employee')}
122
+ options={employeeOptions}
123
+ />
124
+ </Form.Item>
125
+ ) : null}
126
+ <Form.Item name="rateLimitPerMinute" label={t('Rate Limit')} rules={[{ required: true }]}>
127
+ <InputNumber min={1} style={{ width: '100%' }} />
128
+ </Form.Item>
129
+ <Form.Item
130
+ name="maxRequestBodyMb"
131
+ label={t('Max request body size (MB)')}
132
+ extra={t('Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.')}
133
+ rules={[{ required: true }]}
134
+ >
135
+ <InputNumber min={1} max={MAX_REQUEST_BODY_MB} precision={0} style={{ width: '100%' }} />
136
+ </Form.Item>
137
+ <Form.Item name="quotaEnabled" label={t('Enable user quotas')} valuePropName="checked">
138
+ <Switch />
139
+ </Form.Item>
140
+ <Form.Item
141
+ name="defaultReservationOutputTokens"
142
+ label={t('Default reserved output tokens')}
143
+ rules={[{ required: true }]}
144
+ >
145
+ <InputNumber min={1} style={{ width: '100%' }} />
146
+ </Form.Item>
147
+ <Space>
148
+ <Button type="primary" loading={saving} onClick={save}>
149
+ {t('Save Configuration')}
150
+ </Button>
151
+ <Button onClick={load}>{t('Refresh')}</Button>
152
+ </Space>
153
+ </Form>
154
+ <Card title={t('Usage guide')} size="small" style={{ marginTop: 24 }}>
155
+ <Alert
156
+ type="info"
157
+ showIcon
158
+ message={t('OpenAI-compatible endpoint')}
159
+ description={
160
+ <Space direction="vertical" size={4}>
161
+ <Typography.Text>{t('Base URL')}</Typography.Text>
162
+ <Typography.Text code copyable>
163
+ {baseUrl}
164
+ </Typography.Text>
165
+ <Typography.Text>{t('Use a NocoBase API key as the Bearer token.')}</Typography.Text>
166
+ </Space>
167
+ }
168
+ style={{ marginBottom: 16 }}
169
+ />
170
+ <Typography.Paragraph>{t('List available models')}</Typography.Paragraph>
171
+ <Typography.Paragraph
172
+ code
173
+ copyable
174
+ >{`curl ${baseUrl}/models -H "Authorization: Bearer <your-api-key>"`}</Typography.Paragraph>
175
+ <Typography.Paragraph>{t('Send a chat completion')}</Typography.Paragraph>
176
+ <Typography.Paragraph code copyable>{`curl ${baseUrl}/chat/completions \\
177
+ -H "Authorization: Bearer <your-api-key>" \\
178
+ -H "Content-Type: application/json" \\
179
+ -d '{"model":"<service>/<model>","messages":[{"role":"user","content":"Hello"}]}'`}</Typography.Paragraph>
180
+ </Card>
181
+ </Card>
182
+ );
183
+ }
@@ -0,0 +1,280 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ Button,
4
+ Card,
5
+ Form,
6
+ Input,
7
+ InputNumber,
8
+ Modal,
9
+ Popconfirm,
10
+ Select,
11
+ Space,
12
+ Switch,
13
+ Table,
14
+ Tag,
15
+ message,
16
+ } from 'antd';
17
+ import type { ColumnsType } from 'antd/es/table';
18
+ import { useFlowContext } from '@nocobase/flow-engine';
19
+ import { useT } from '../locale';
20
+ import { errorMessage, unwrapData } from './api';
21
+
22
+ interface ModelMetadata {
23
+ id: string | number;
24
+ llmService: string;
25
+ model: string;
26
+ contextWindow?: number | null;
27
+ maxCompletionTokens?: number | null;
28
+ ownedByOverride?: string | null;
29
+ displayName?: string | null;
30
+ description?: string | null;
31
+ enabled: boolean;
32
+ }
33
+
34
+ type MetadataFormValues = Omit<ModelMetadata, 'id'>;
35
+
36
+ interface LlmService {
37
+ name: string;
38
+ title?: string;
39
+ provider: string;
40
+ }
41
+
42
+ interface LlmModel {
43
+ id: string;
44
+ }
45
+
46
+ export default function ModelMetadataPage() {
47
+ const ctx = useFlowContext();
48
+ const t = useT();
49
+ const [form] = Form.useForm<MetadataFormValues>();
50
+ const selectedService = Form.useWatch('llmService', form);
51
+ const [rows, setRows] = useState<ModelMetadata[]>([]);
52
+ const [services, setServices] = useState<LlmService[]>([]);
53
+ const [models, setModels] = useState<LlmModel[]>([]);
54
+ const [modelsLoading, setModelsLoading] = useState(false);
55
+ const [loading, setLoading] = useState(false);
56
+ const [saving, setSaving] = useState(false);
57
+ const [editing, setEditing] = useState<ModelMetadata>();
58
+ const [open, setOpen] = useState(false);
59
+
60
+ const load = useCallback(async () => {
61
+ setLoading(true);
62
+ try {
63
+ const [metadataResponse, servicesResponse] = await Promise.all([
64
+ ctx.api.request({
65
+ url: 'aiApiModelMetadata:list',
66
+ method: 'get',
67
+ params: { pageSize: 200, sort: 'llmService' },
68
+ }),
69
+ ctx.api.request({ url: 'ai:listLLMServices', method: 'get' }),
70
+ ]);
71
+ setRows(unwrapData<ModelMetadata[]>(metadataResponse, []));
72
+ setServices(unwrapData<LlmService[]>(servicesResponse, []));
73
+ } catch (error) {
74
+ message.error(errorMessage(error));
75
+ } finally {
76
+ setLoading(false);
77
+ }
78
+ }, [ctx.api]);
79
+
80
+ useEffect(() => {
81
+ load();
82
+ }, [load]);
83
+
84
+ const loadModels = useCallback(
85
+ async (llmService: string, currentModel?: string) => {
86
+ setModelsLoading(true);
87
+ try {
88
+ const response = await ctx.api.request({
89
+ url: 'ai:listModels',
90
+ method: 'get',
91
+ params: { llmService },
92
+ });
93
+ const loadedModels = unwrapData<LlmModel[]>(response, []);
94
+ setModels(
95
+ currentModel && !loadedModels.some((item) => item.id === currentModel)
96
+ ? [{ id: currentModel }, ...loadedModels]
97
+ : loadedModels,
98
+ );
99
+ } catch (error) {
100
+ setModels(currentModel ? [{ id: currentModel }] : []);
101
+ message.error(`${t('Failed to load models')}: ${errorMessage(error)}`);
102
+ } finally {
103
+ setModelsLoading(false);
104
+ }
105
+ },
106
+ [ctx.api, t],
107
+ );
108
+
109
+ const showCreate = () => {
110
+ setEditing(undefined);
111
+ setModels([]);
112
+ form.resetFields();
113
+ form.setFieldsValue({ enabled: true } as MetadataFormValues);
114
+ setOpen(true);
115
+ };
116
+
117
+ const showEdit = (record: ModelMetadata) => {
118
+ setEditing(record);
119
+ form.setFieldsValue({ ...record });
120
+ setOpen(true);
121
+ loadModels(record.llmService, record.model);
122
+ };
123
+
124
+ const changeService = (llmService: string) => {
125
+ form.setFieldValue('model', undefined);
126
+ setModels([]);
127
+ loadModels(llmService);
128
+ };
129
+
130
+ const save = async () => {
131
+ const values = await form.validateFields();
132
+ const data = {
133
+ ...values,
134
+ contextWindow: values.contextWindow ?? null,
135
+ maxCompletionTokens: values.maxCompletionTokens ?? null,
136
+ ownedByOverride: values.ownedByOverride?.trim() || null,
137
+ displayName: values.displayName?.trim() || null,
138
+ description: values.description?.trim() || null,
139
+ };
140
+ setSaving(true);
141
+ try {
142
+ await ctx.api.request({
143
+ url: editing ? `aiApiModelMetadata:update/${editing.id}` : 'aiApiModelMetadata:create',
144
+ method: 'post',
145
+ data,
146
+ });
147
+ message.success(t('Saved successfully'));
148
+ setOpen(false);
149
+ await load();
150
+ } catch (error) {
151
+ message.error(errorMessage(error));
152
+ } finally {
153
+ setSaving(false);
154
+ }
155
+ };
156
+
157
+ const remove = async (id: string | number) => {
158
+ try {
159
+ await ctx.api.request({ url: `aiApiModelMetadata:destroy/${id}`, method: 'post' });
160
+ message.success(t('Deleted successfully'));
161
+ await load();
162
+ } catch (error) {
163
+ message.error(errorMessage(error));
164
+ }
165
+ };
166
+
167
+ const columns: ColumnsType<ModelMetadata> = [
168
+ { title: t('LLM service'), dataIndex: 'llmService', key: 'llmService', width: 160 },
169
+ { title: t('Model'), dataIndex: 'model', key: 'model', width: 180 },
170
+ { title: t('Context window'), dataIndex: 'contextWindow', key: 'contextWindow', width: 140 },
171
+ { title: t('Max completion tokens'), dataIndex: 'maxCompletionTokens', key: 'maxCompletionTokens', width: 170 },
172
+ { title: t('Owned by'), dataIndex: 'ownedByOverride', key: 'ownedByOverride', width: 140 },
173
+ { title: t('Display name'), dataIndex: 'displayName', key: 'displayName', width: 160 },
174
+ {
175
+ title: t('Status'),
176
+ dataIndex: 'enabled',
177
+ key: 'enabled',
178
+ width: 100,
179
+ render: (enabled: boolean) => (
180
+ <Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
181
+ ),
182
+ },
183
+ {
184
+ title: t('Actions'),
185
+ key: 'actions',
186
+ fixed: 'right',
187
+ width: 150,
188
+ render: (_, record) => (
189
+ <Space>
190
+ <Button type="link" onClick={() => showEdit(record)}>
191
+ {t('Edit')}
192
+ </Button>
193
+ <Popconfirm title={t('Delete this override?')} onConfirm={() => remove(record.id)}>
194
+ <Button type="link" danger>
195
+ {t('Delete')}
196
+ </Button>
197
+ </Popconfirm>
198
+ </Space>
199
+ ),
200
+ },
201
+ ];
202
+
203
+ return (
204
+ <Card
205
+ title={t('Model metadata')}
206
+ extra={
207
+ <Button type="primary" onClick={showCreate}>
208
+ {t('Add override')}
209
+ </Button>
210
+ }
211
+ >
212
+ <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1200 }} />
213
+ <Modal
214
+ title={editing ? t('Edit override') : t('Add override')}
215
+ open={open}
216
+ onCancel={() => setOpen(false)}
217
+ onOk={save}
218
+ confirmLoading={saving}
219
+ destroyOnClose
220
+ >
221
+ <Form form={form} layout="vertical" preserve={false}>
222
+ <Form.Item name="llmService" label={t('LLM service')} rules={[{ required: true }]}>
223
+ <Select
224
+ showSearch
225
+ optionFilterProp="label"
226
+ onChange={changeService}
227
+ options={services.map((service) => ({ label: service.title || service.name, value: service.name }))}
228
+ />
229
+ </Form.Item>
230
+ <Form.Item name="model" label={t('Model')} rules={[{ required: true }]}>
231
+ <Select
232
+ showSearch
233
+ optionFilterProp="label"
234
+ loading={modelsLoading}
235
+ disabled={!selectedService}
236
+ placeholder={t('Select a model')}
237
+ options={models.map((model) => ({ label: model.id, value: model.id }))}
238
+ />
239
+ </Form.Item>
240
+ <Form.Item
241
+ name="contextWindow"
242
+ label={t('Context window')}
243
+ tooltip={t('Total input + output token capacity reported to clients.')}
244
+ >
245
+ <InputNumber
246
+ min={1}
247
+ precision={0}
248
+ style={{ width: '100%' }}
249
+ placeholder={t('Leave empty to not override')}
250
+ />
251
+ </Form.Item>
252
+ <Form.Item
253
+ name="maxCompletionTokens"
254
+ label={t('Max completion tokens')}
255
+ tooltip={t('Maximum output tokens reported to clients.')}
256
+ >
257
+ <InputNumber
258
+ min={1}
259
+ precision={0}
260
+ style={{ width: '100%' }}
261
+ placeholder={t('Leave empty to not override')}
262
+ />
263
+ </Form.Item>
264
+ <Form.Item name="ownedByOverride" label={t('Owned by')}>
265
+ <Input placeholder={t('Leave empty to not override')} />
266
+ </Form.Item>
267
+ <Form.Item name="displayName" label={t('Display name')}>
268
+ <Input placeholder={t('Leave empty to not override')} />
269
+ </Form.Item>
270
+ <Form.Item name="description" label={t('Description')}>
271
+ <Input.TextArea rows={3} />
272
+ </Form.Item>
273
+ <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
274
+ <Switch />
275
+ </Form.Item>
276
+ </Form>
277
+ </Modal>
278
+ </Card>
279
+ );
280
+ }