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.
- package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/757.a01403fb7a1bea01.js +10 -0
- package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
- package/dist/client/97.72979a11a067a7c9.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
- package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/97.29c663318eebbd57.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +36 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +105 -10
- package/dist/locale/vi-VN.json +105 -0
- package/dist/locale/zh-CN.json +105 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +18 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-model-prices.js +55 -0
- package/dist/server/collections/ai-api-usage-records.js +9 -0
- package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
- package/dist/server/plugin.js +36 -3
- package/dist/server/resource/ai-api-config.js +25 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -0
- package/dist/server/routes/agent-completions.js +62 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +157 -6
- package/dist/server/routes/completions.js +20 -3
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +108 -23
- package/dist/server/usage.js +19 -2
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +120 -0
- package/dist/swagger.js +32 -1
- package/package.json +1 -1
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +82 -48
- package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -0
- package/src/client-v2/pages/GeneralPage.tsx +183 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UsagePage.tsx +248 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
- package/src/client-v2/pages/api.ts +16 -0
- package/src/client-v2/plugin.tsx +62 -4
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +105 -10
- package/src/locale/vi-VN.json +105 -0
- package/src/locale/zh-CN.json +105 -10
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -0
- package/src/server/__tests__/models.test.ts +74 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-monitor.test.ts +63 -0
- package/src/server/__tests__/usage-route.test.ts +4 -0
- package/src/server/billing.ts +387 -0
- package/src/server/collections/ai-api-config.ts +69 -51
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-model-prices.ts +25 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -0
- package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
- package/src/server/plugin.ts +47 -5
- package/src/server/resource/ai-api-config.ts +105 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -0
- package/src/server/routes/agent-completions.ts +77 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +275 -6
- package/src/server/routes/completions.ts +27 -4
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +152 -27
- package/src/server/usage.ts +19 -1
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +89 -0
- package/src/swagger.ts +38 -1
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
package/src/locale/zh-CN.json
CHANGED
|
@@ -1,10 +1,105 @@
|
|
|
1
|
-
{
|
|
2
|
-
"AI API Gateway": "AI API 网关",
|
|
3
|
-
"Configuration": "配置",
|
|
4
|
-
"Default AI Employee": "默认 AI 员工",
|
|
5
|
-
"Enabled LLM Services": "已启用 LLM 服务",
|
|
6
|
-
"Rate Limit": "速率限制",
|
|
7
|
-
"Save Configuration": "保存配置",
|
|
8
|
-
"Configuration saved": "配置已保存",
|
|
9
|
-
"Failed to save configuration": "保存配置失败"
|
|
10
|
-
|
|
1
|
+
{
|
|
2
|
+
"AI API Gateway": "AI API 网关",
|
|
3
|
+
"Configuration": "配置",
|
|
4
|
+
"Default AI Employee": "默认 AI 员工",
|
|
5
|
+
"Enabled LLM Services": "已启用 LLM 服务",
|
|
6
|
+
"Rate Limit": "速率限制",
|
|
7
|
+
"Save Configuration": "保存配置",
|
|
8
|
+
"Configuration saved": "配置已保存",
|
|
9
|
+
"Failed to save configuration": "保存配置失败",
|
|
10
|
+
"API mode": "API 模式",
|
|
11
|
+
"Direct LLM": "直接 LLM",
|
|
12
|
+
"AI Employee agent": "AI 员工代理",
|
|
13
|
+
"Default LLM service": "默认 LLM 服务",
|
|
14
|
+
"Enable user quotas": "启用用户配额",
|
|
15
|
+
"Default reserved output tokens": "默认预留输出令牌数",
|
|
16
|
+
"Refresh": "刷新",
|
|
17
|
+
"Model pricing": "模型定价",
|
|
18
|
+
"User quotas": "用户配额",
|
|
19
|
+
"Usage": "用量",
|
|
20
|
+
"LLM service": "LLM 服务",
|
|
21
|
+
"Model": "模型",
|
|
22
|
+
"Input price / 1M": "输入价格 / 百万令牌",
|
|
23
|
+
"Output price / 1M": "输出价格 / 百万令牌",
|
|
24
|
+
"Fixed request cost": "每次请求固定费用",
|
|
25
|
+
"Currency": "货币",
|
|
26
|
+
"Status": "状态",
|
|
27
|
+
"Enabled": "已启用",
|
|
28
|
+
"Disabled": "已禁用",
|
|
29
|
+
"Actions": "操作",
|
|
30
|
+
"Edit": "编辑",
|
|
31
|
+
"Delete": "删除",
|
|
32
|
+
"Delete this price?": "删除此价格?",
|
|
33
|
+
"Add price": "添加价格",
|
|
34
|
+
"Edit price": "编辑价格",
|
|
35
|
+
"Effective from": "生效时间",
|
|
36
|
+
"Effective to": "失效时间",
|
|
37
|
+
"Notes": "备注",
|
|
38
|
+
"Saved successfully": "保存成功",
|
|
39
|
+
"Deleted successfully": "删除成功",
|
|
40
|
+
"User": "用户",
|
|
41
|
+
"Period": "周期",
|
|
42
|
+
"Request limit": "请求限制",
|
|
43
|
+
"Token limit": "令牌限制",
|
|
44
|
+
"Cost limit": "费用限制",
|
|
45
|
+
"Timezone": "时区",
|
|
46
|
+
"Unlimited": "无限制",
|
|
47
|
+
"Add quota": "添加配额",
|
|
48
|
+
"Edit quota": "编辑配额",
|
|
49
|
+
"Delete this quota?": "删除此配额?",
|
|
50
|
+
"Daily": "每日",
|
|
51
|
+
"Monthly": "每月",
|
|
52
|
+
"Reject unpriced models": "拒绝未定价模型",
|
|
53
|
+
"Missing usage behavior": "缺少用量时的行为",
|
|
54
|
+
"Use reserved estimate": "使用预留估算",
|
|
55
|
+
"Allow without token charge": "允许且不计令牌",
|
|
56
|
+
"Started at": "开始时间",
|
|
57
|
+
"Requested model": "请求模型",
|
|
58
|
+
"Resolved service": "解析后的服务",
|
|
59
|
+
"Resolved model": "解析后的模型",
|
|
60
|
+
"Input tokens": "输入令牌",
|
|
61
|
+
"Output tokens": "输出令牌",
|
|
62
|
+
"Total tokens": "总令牌",
|
|
63
|
+
"Cost": "费用",
|
|
64
|
+
"Cost status": "费用状态",
|
|
65
|
+
"Request ID": "请求 ID",
|
|
66
|
+
"Failed to load models": "加载模型失败",
|
|
67
|
+
"Select a model": "选择模型",
|
|
68
|
+
"Select an AI Employee": "选择 AI 员工",
|
|
69
|
+
"Usage guide": "使用指南",
|
|
70
|
+
"OpenAI-compatible endpoint": "OpenAI 兼容端点",
|
|
71
|
+
"Base URL": "基础 URL",
|
|
72
|
+
"Use a NocoBase API key as the Bearer token.": "使用 NocoBase API 密钥作为 Bearer Token。",
|
|
73
|
+
"List available models": "列出可用模型",
|
|
74
|
+
"Send a chat completion": "发送聊天补全请求",
|
|
75
|
+
"Usage filters": "用量筛选",
|
|
76
|
+
"Time range": "时间范围",
|
|
77
|
+
"User ID": "用户 ID",
|
|
78
|
+
"Succeeded": "成功",
|
|
79
|
+
"Failed": "失败",
|
|
80
|
+
"Started": "已开始",
|
|
81
|
+
"Apply filters": "应用筛选",
|
|
82
|
+
"Reset": "重置",
|
|
83
|
+
"Requests": "请求数",
|
|
84
|
+
"Total cost": "总费用",
|
|
85
|
+
"Usage records": "用量记录",
|
|
86
|
+
"Model metadata": "模型元数据",
|
|
87
|
+
"Add override": "添加覆盖",
|
|
88
|
+
"Edit override": "编辑覆盖",
|
|
89
|
+
"Delete this override?": "确认删除此覆盖?",
|
|
90
|
+
"Context window": "上下文窗口",
|
|
91
|
+
"Max completion tokens": "最大输出 token 数",
|
|
92
|
+
"Owned by": "所属方",
|
|
93
|
+
"Display name": "显示名称",
|
|
94
|
+
"Description": "描述",
|
|
95
|
+
"Leave empty to not override": "留空则不覆盖",
|
|
96
|
+
"Total input + output token capacity reported to clients.": "返回给客户端的输入+输出 token 总容量。",
|
|
97
|
+
"Maximum output tokens reported to clients.": "返回给客户端的最大输出 token 数。",
|
|
98
|
+
"AI API": "AI API",
|
|
99
|
+
"Allow this role to use the AI API": "允许此角色使用 AI API",
|
|
100
|
+
"Allow all AI Employees": "允许所有 AI 员工",
|
|
101
|
+
"Select which AI Employees this role may use:": "选择此角色可使用的 AI 员工:",
|
|
102
|
+
"Select allowed AI Employees": "选择允许的 AI 员工",
|
|
103
|
+
"Max request body size (MB)": "请求体大小上限(MB)",
|
|
104
|
+
"Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。"
|
|
105
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import {
|
|
3
|
+
addAiApiUsage,
|
|
4
|
+
finishAiApiObservation,
|
|
5
|
+
markAiApiFirstProviderOutput,
|
|
6
|
+
startAiApiObservation,
|
|
7
|
+
} from '../utils/app-observability';
|
|
8
|
+
|
|
9
|
+
const CONTRACT_SYMBOL = Symbol.for('nocobase.app-observability.contract');
|
|
10
|
+
|
|
11
|
+
function createContext(contract?: object): Context {
|
|
12
|
+
const app = {
|
|
13
|
+
logger: { warn: vi.fn() },
|
|
14
|
+
...(contract ? { [CONTRACT_SYMBOL]: contract } : {}),
|
|
15
|
+
};
|
|
16
|
+
return { app, state: {} } as unknown as Context;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('AI API app observability bridge', () => {
|
|
20
|
+
it('is a no-op when observability is absent', () => {
|
|
21
|
+
const ctx = createContext();
|
|
22
|
+
expect(() => {
|
|
23
|
+
startAiApiObservation(ctx, {
|
|
24
|
+
service: 'llm.chat',
|
|
25
|
+
operation: '/chat/completions',
|
|
26
|
+
streaming: true,
|
|
27
|
+
mode: 'llm',
|
|
28
|
+
});
|
|
29
|
+
markAiApiFirstProviderOutput(ctx);
|
|
30
|
+
addAiApiUsage(ctx, { prompt_tokens: 2, completion_tokens: 3 });
|
|
31
|
+
finishAiApiObservation(ctx, { status: 'succeeded' });
|
|
32
|
+
}).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('records TTFT, normalized usage and finalizes exactly once', () => {
|
|
36
|
+
const handle = {
|
|
37
|
+
markFirstByte: vi.fn(),
|
|
38
|
+
addInputTokens: vi.fn(),
|
|
39
|
+
addOutputTokens: vi.fn(),
|
|
40
|
+
finish: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
const start = vi.fn(() => handle);
|
|
43
|
+
const ctx = createContext({ start });
|
|
44
|
+
|
|
45
|
+
startAiApiObservation(ctx, {
|
|
46
|
+
service: 'llm.agent',
|
|
47
|
+
operation: '/chat/completions',
|
|
48
|
+
streaming: true,
|
|
49
|
+
model: 'service/model',
|
|
50
|
+
mode: 'agent',
|
|
51
|
+
});
|
|
52
|
+
markAiApiFirstProviderOutput(ctx);
|
|
53
|
+
addAiApiUsage(ctx, { prompt_tokens: 11, completion_tokens: 7 });
|
|
54
|
+
finishAiApiObservation(ctx, { status: 'succeeded' });
|
|
55
|
+
finishAiApiObservation(ctx, { status: 'failed' });
|
|
56
|
+
|
|
57
|
+
expect(start).toHaveBeenCalledWith(
|
|
58
|
+
expect.objectContaining({
|
|
59
|
+
service: 'llm.agent',
|
|
60
|
+
streaming: true,
|
|
61
|
+
attributes: expect.objectContaining({ mode: 'agent', model: 'service/model' }),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(handle.markFirstByte).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(handle.addInputTokens).toHaveBeenCalledWith(11);
|
|
66
|
+
expect(handle.addOutputTokens).toHaveBeenCalledWith(7);
|
|
67
|
+
expect(handle.finish).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(handle.finish).toHaveBeenCalledWith({ status: 'succeeded' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('fails open when contract callbacks throw', () => {
|
|
72
|
+
const ctx = createContext({
|
|
73
|
+
start: () => ({
|
|
74
|
+
markFirstByte: () => {
|
|
75
|
+
throw new Error('ttft failed');
|
|
76
|
+
},
|
|
77
|
+
addInputTokens: () => {
|
|
78
|
+
throw new Error('tokens failed');
|
|
79
|
+
},
|
|
80
|
+
addOutputTokens: () => {},
|
|
81
|
+
finish: () => {
|
|
82
|
+
throw new Error('finish failed');
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
expect(() => {
|
|
87
|
+
startAiApiObservation(ctx, {
|
|
88
|
+
service: 'llm.completion',
|
|
89
|
+
operation: '/completions',
|
|
90
|
+
streaming: true,
|
|
91
|
+
mode: 'llm',
|
|
92
|
+
});
|
|
93
|
+
markAiApiFirstProviderOutput(ctx);
|
|
94
|
+
addAiApiUsage(ctx, { prompt_tokens: 1, completion_tokens: null });
|
|
95
|
+
finishAiApiObservation(ctx, { status: 'failed', errorCode: 'stream_error' });
|
|
96
|
+
}).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import { createMockDatabase, type Database } from '@nocobase/database';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { AiApiQuotaError, finalizeLlmBilling, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
5
|
+
|
|
6
|
+
describe('AI API user quota reservation', () => {
|
|
7
|
+
let db: Database;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
db = await createMockDatabase();
|
|
11
|
+
db.collection({
|
|
12
|
+
name: 'aiApiConfig',
|
|
13
|
+
fields: [
|
|
14
|
+
{ name: 'quotaEnabled', type: 'boolean' },
|
|
15
|
+
{ name: 'defaultReservationOutputTokens', type: 'integer' },
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
db.collection({
|
|
19
|
+
name: 'aiApiModelPrices',
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'llmService', type: 'string' },
|
|
22
|
+
{ name: 'model', type: 'string' },
|
|
23
|
+
{ name: 'enabled', type: 'boolean' },
|
|
24
|
+
{ name: 'currency', type: 'string' },
|
|
25
|
+
{ name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
26
|
+
{ name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
27
|
+
{ name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10 },
|
|
28
|
+
{ name: 'effectiveFrom', type: 'datetimeTz' },
|
|
29
|
+
{ name: 'effectiveTo', type: 'datetimeTz' },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
db.collection({
|
|
33
|
+
name: 'aiApiUserQuotaPolicies',
|
|
34
|
+
fields: [
|
|
35
|
+
{ name: 'userId', type: 'bigInt' },
|
|
36
|
+
{ name: 'enabled', type: 'boolean' },
|
|
37
|
+
{ name: 'periodType', type: 'string' },
|
|
38
|
+
{ name: 'timezone', type: 'string' },
|
|
39
|
+
{ name: 'requestLimit', type: 'bigInt' },
|
|
40
|
+
{ name: 'totalTokenLimit', type: 'bigInt' },
|
|
41
|
+
{ name: 'costLimit', type: 'decimal', precision: 20, scale: 8 },
|
|
42
|
+
{ name: 'currency', type: 'string' },
|
|
43
|
+
{ name: 'rejectUnpricedModel', type: 'boolean' },
|
|
44
|
+
{ name: 'missingUsageBehavior', type: 'string' },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
db.collection({
|
|
48
|
+
name: 'aiApiUserQuotaBuckets',
|
|
49
|
+
fields: [
|
|
50
|
+
{ name: 'policyId', type: 'bigInt' },
|
|
51
|
+
{ name: 'userId', type: 'bigInt' },
|
|
52
|
+
{ name: 'periodStart', type: 'datetimeTz' },
|
|
53
|
+
{ name: 'periodEnd', type: 'datetimeTz' },
|
|
54
|
+
{ name: 'requestCount', type: 'bigInt' },
|
|
55
|
+
{ name: 'totalTokens', type: 'bigInt' },
|
|
56
|
+
{ name: 'cost', type: 'decimal', precision: 20, scale: 8 },
|
|
57
|
+
{ name: 'reservedRequests', type: 'bigInt' },
|
|
58
|
+
{ name: 'reservedTokens', type: 'bigInt' },
|
|
59
|
+
{ name: 'reservedCost', type: 'decimal', precision: 20, scale: 8 },
|
|
60
|
+
],
|
|
61
|
+
indexes: [{ fields: ['policyId', 'periodStart'], unique: true }],
|
|
62
|
+
});
|
|
63
|
+
await db.sync({ force: true });
|
|
64
|
+
await db.getRepository('aiApiConfig').create({
|
|
65
|
+
values: { quotaEnabled: true, defaultReservationOutputTokens: 100 },
|
|
66
|
+
});
|
|
67
|
+
await db.getRepository('aiApiModelPrices').create({
|
|
68
|
+
values: {
|
|
69
|
+
llmService: 'service-a',
|
|
70
|
+
model: 'model-a',
|
|
71
|
+
enabled: true,
|
|
72
|
+
currency: 'USD',
|
|
73
|
+
inputPricePerMillionTokens: '5',
|
|
74
|
+
outputPricePerMillionTokens: '15',
|
|
75
|
+
fixedCostPerRequest: '0',
|
|
76
|
+
effectiveFrom: new Date('2020-01-01T00:00:00Z'),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
await db.getRepository('aiApiUserQuotaPolicies').create({
|
|
80
|
+
values: {
|
|
81
|
+
userId: 7,
|
|
82
|
+
enabled: true,
|
|
83
|
+
periodType: 'monthly',
|
|
84
|
+
timezone: 'UTC',
|
|
85
|
+
requestLimit: 1,
|
|
86
|
+
totalTokenLimit: 1000,
|
|
87
|
+
costLimit: '10',
|
|
88
|
+
currency: 'USD',
|
|
89
|
+
rejectUnpricedModel: true,
|
|
90
|
+
missingUsageBehavior: 'use_reserved',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await db.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function context(): Context {
|
|
100
|
+
return {
|
|
101
|
+
db,
|
|
102
|
+
request: { body: { messages: [{ role: 'user', content: 'hello' }], max_tokens: 100 } },
|
|
103
|
+
state: { currentUser: { id: 7 } },
|
|
104
|
+
} as unknown as Context;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const resolved = {
|
|
108
|
+
service: { name: 'service-a', provider: 'custom-llm' },
|
|
109
|
+
modelId: 'model-a',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
it('reserves atomically and reconciles actual provider usage', async () => {
|
|
113
|
+
const first = context();
|
|
114
|
+
await prepareLlmBilling(first, resolved);
|
|
115
|
+
|
|
116
|
+
const competing = context();
|
|
117
|
+
await expect(prepareLlmBilling(competing, resolved)).rejects.toMatchObject<Partial<AiApiQuotaError>>({
|
|
118
|
+
code: 'request_quota_exceeded',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
markLlmProviderAttempted(first);
|
|
122
|
+
const finalized = await finalizeLlmBilling(
|
|
123
|
+
first,
|
|
124
|
+
{ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
expect(finalized).toMatchObject({ estimatedCost: '0.00012500', costStatus: 'calculated' });
|
|
128
|
+
|
|
129
|
+
const bucket = await db.getRepository('aiApiUserQuotaBuckets').findOne();
|
|
130
|
+
expect(String(bucket?.get('requestCount'))).toBe('1');
|
|
131
|
+
expect(String(bucket?.get('totalTokens'))).toBe('15');
|
|
132
|
+
expect(String(bucket?.get('reservedRequests'))).toBe('0');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateLlmCost, type PriceSnapshot } from '../billing';
|
|
3
|
+
|
|
4
|
+
function price(values: Partial<PriceSnapshot> = {}): PriceSnapshot {
|
|
5
|
+
return {
|
|
6
|
+
id: 1,
|
|
7
|
+
currency: 'USD',
|
|
8
|
+
inputPricePerMillionTokens: '5.0000000000',
|
|
9
|
+
outputPricePerMillionTokens: '15.0000000000',
|
|
10
|
+
fixedCostPerRequest: '0.0000000000',
|
|
11
|
+
...values,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('AI API LLM cost calculation', () => {
|
|
16
|
+
it('calculates input and output token cost using decimal arithmetic', () => {
|
|
17
|
+
expect(calculateLlmCost(10_000, 2_000, price())).toBe('0.08000000');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('includes a fixed per-request cost', () => {
|
|
21
|
+
expect(calculateLlmCost(0, 0, price({ fixedCostPerRequest: '0.1250000000' }))).toBe('0.12500000');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rounds to eight decimal places without floating-point drift', () => {
|
|
25
|
+
expect(
|
|
26
|
+
calculateLlmCost(
|
|
27
|
+
1,
|
|
28
|
+
1,
|
|
29
|
+
price({ inputPricePerMillionTokens: '0.1000000000', outputPricePerMillionTokens: '0.2000000000' }),
|
|
30
|
+
),
|
|
31
|
+
).toBe('0.00000030');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
11
|
+
import { buildModelObject } from '../routes/models';
|
|
12
|
+
|
|
13
|
+
const CREATED = 1_700_000_000;
|
|
14
|
+
|
|
15
|
+
describe('buildModelObject', () => {
|
|
16
|
+
it('returns the base OpenAI model shape with no override', () => {
|
|
17
|
+
const model = buildModelObject('svc/gpt-4o', CREATED, 'My Service');
|
|
18
|
+
expect(model).toEqual({
|
|
19
|
+
id: 'svc/gpt-4o',
|
|
20
|
+
object: 'model',
|
|
21
|
+
created: CREATED,
|
|
22
|
+
owned_by: 'My Service',
|
|
23
|
+
});
|
|
24
|
+
// No override → no context_window / active field is added.
|
|
25
|
+
expect(model).not.toHaveProperty('context_window');
|
|
26
|
+
expect(model).not.toHaveProperty('active');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('emits context window under both context_window and context_length', () => {
|
|
30
|
+
const model = buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 100_000 });
|
|
31
|
+
expect(model.context_window).toBe(100_000);
|
|
32
|
+
expect(model.context_length).toBe(100_000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('overrides owned_by, display name and description', () => {
|
|
36
|
+
const model = buildModelObject('svc/m', CREATED, 'Svc', {
|
|
37
|
+
ownedByOverride: 'Acme',
|
|
38
|
+
displayName: 'Acme Turbo',
|
|
39
|
+
description: 'Fast model',
|
|
40
|
+
});
|
|
41
|
+
expect(model.owned_by).toBe('Acme');
|
|
42
|
+
expect(model.display_name).toBe('Acme Turbo');
|
|
43
|
+
expect(model.name).toBe('Acme Turbo');
|
|
44
|
+
expect(model.description).toBe('Fast model');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('falls back to the service label when ownedByOverride is empty', () => {
|
|
48
|
+
const model = buildModelObject('svc/m', CREATED, 'Svc', { ownedByOverride: '' });
|
|
49
|
+
expect(model.owned_by).toBe('Svc');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('emits max_completion_tokens only when positive', () => {
|
|
53
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { maxCompletionTokens: 4096 }).max_completion_tokens).toBe(4096);
|
|
54
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { maxCompletionTokens: 0 })).not.toHaveProperty(
|
|
55
|
+
'max_completion_tokens',
|
|
56
|
+
);
|
|
57
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { maxCompletionTokens: null })).not.toHaveProperty(
|
|
58
|
+
'max_completion_tokens',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('ignores non-positive or non-integer context windows', () => {
|
|
63
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 0 })).not.toHaveProperty('context_window');
|
|
64
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: -5 })).not.toHaveProperty('context_window');
|
|
65
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: null })).not.toHaveProperty('context_window');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('surfaces active flag whenever an override row exists', () => {
|
|
69
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { enabled: true }).active).toBe(true);
|
|
70
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { enabled: false }).active).toBe(false);
|
|
71
|
+
// Override present with enabled undefined → treated as active.
|
|
72
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 10 }).active).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|