plugin-ai-api 1.0.28 → 1.1.1
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/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/97.96da323832251796.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +139 -138
- package/dist/locale/vi-VN.json +139 -138
- package/dist/locale/zh-CN.json +139 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/embeddings.js +11 -1
- package/dist/server/usage.js +1 -0
- package/dist/server/utils/direct-llm-context.js +9 -8
- package/dist/server/validation.js +1 -0
- package/package.json +1 -1
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +139 -138
- package/src/locale/vi-VN.json +139 -138
- package/src/locale/zh-CN.json +139 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/usage.test.ts +1 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/embeddings.ts +17 -3
- package/src/server/usage.ts +1 -0
- package/src/server/utils/direct-llm-context.ts +21 -11
- package/src/server/validation.ts +1 -0
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
package/src/locale/zh-CN.json
CHANGED
|
@@ -1,138 +1,139 @@
|
|
|
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
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"Delete
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"Effective
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"Resolved
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"Cost
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"Select
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"Allow
|
|
107
|
-
"
|
|
108
|
-
"Select
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"Member
|
|
123
|
-
"
|
|
124
|
-
"Remove": "
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"User
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"All
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
|
|
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
|
+
"Cache input price / 1M": "缓存输入价格 / 百万令牌",
|
|
24
|
+
"Output price / 1M": "输出价格 / 百万令牌",
|
|
25
|
+
"Fixed request cost": "每次请求固定费用",
|
|
26
|
+
"Currency": "货币",
|
|
27
|
+
"Status": "状态",
|
|
28
|
+
"Enabled": "已启用",
|
|
29
|
+
"Disabled": "已禁用",
|
|
30
|
+
"Actions": "操作",
|
|
31
|
+
"Edit": "编辑",
|
|
32
|
+
"Delete": "删除",
|
|
33
|
+
"Delete this price?": "删除此价格?",
|
|
34
|
+
"Add price": "添加价格",
|
|
35
|
+
"Edit price": "编辑价格",
|
|
36
|
+
"Effective from": "生效时间",
|
|
37
|
+
"Effective to": "失效时间",
|
|
38
|
+
"Notes": "备注",
|
|
39
|
+
"Saved successfully": "保存成功",
|
|
40
|
+
"Deleted successfully": "删除成功",
|
|
41
|
+
"User": "用户",
|
|
42
|
+
"Period": "周期",
|
|
43
|
+
"Request limit": "请求限制",
|
|
44
|
+
"Token limit": "令牌限制",
|
|
45
|
+
"Cost limit": "费用限制",
|
|
46
|
+
"Timezone": "时区",
|
|
47
|
+
"Unlimited": "无限制",
|
|
48
|
+
"Add quota": "添加配额",
|
|
49
|
+
"Edit quota": "编辑配额",
|
|
50
|
+
"Delete this quota?": "删除此配额?",
|
|
51
|
+
"Daily": "每日",
|
|
52
|
+
"Monthly": "每月",
|
|
53
|
+
"Reject unpriced models": "拒绝未定价模型",
|
|
54
|
+
"Missing usage behavior": "缺少用量时的行为",
|
|
55
|
+
"Use reserved estimate": "使用预留估算",
|
|
56
|
+
"Allow without token charge": "允许且不计令牌",
|
|
57
|
+
"Context overflow behavior": "上下文超限处理",
|
|
58
|
+
"Reject request": "拒绝请求",
|
|
59
|
+
"Truncate oldest conversation turns": "截断最早的对话轮次",
|
|
60
|
+
"Started at": "开始时间",
|
|
61
|
+
"Requested model": "请求模型",
|
|
62
|
+
"Resolved service": "解析后的服务",
|
|
63
|
+
"Resolved model": "解析后的模型",
|
|
64
|
+
"Input tokens": "输入令牌",
|
|
65
|
+
"Output tokens": "输出令牌",
|
|
66
|
+
"Total tokens": "总令牌",
|
|
67
|
+
"Prompt cache tokens": "提示缓存令牌",
|
|
68
|
+
"Cost": "费用",
|
|
69
|
+
"Cost status": "费用状态",
|
|
70
|
+
"Request ID": "请求 ID",
|
|
71
|
+
"Failed to load models": "加载模型失败",
|
|
72
|
+
"Select a model": "选择模型",
|
|
73
|
+
"Select an AI Employee": "选择 AI 员工",
|
|
74
|
+
"Usage guide": "使用指南",
|
|
75
|
+
"OpenAI-compatible endpoint": "OpenAI 兼容端点",
|
|
76
|
+
"Base URL": "基础 URL",
|
|
77
|
+
"Use a NocoBase API key as the Bearer token.": "使用 NocoBase API 密钥作为 Bearer Token。",
|
|
78
|
+
"List available models": "列出可用模型",
|
|
79
|
+
"Send a chat completion": "发送聊天补全请求",
|
|
80
|
+
"Usage filters": "用量筛选",
|
|
81
|
+
"Time range": "时间范围",
|
|
82
|
+
"User ID": "用户 ID",
|
|
83
|
+
"Succeeded": "成功",
|
|
84
|
+
"Failed": "失败",
|
|
85
|
+
"Started": "已开始",
|
|
86
|
+
"Apply filters": "应用筛选",
|
|
87
|
+
"Reset": "重置",
|
|
88
|
+
"Requests": "请求数",
|
|
89
|
+
"Total cost": "总费用",
|
|
90
|
+
"Usage records": "用量记录",
|
|
91
|
+
"Model metadata": "模型元数据",
|
|
92
|
+
"Add override": "添加覆盖",
|
|
93
|
+
"Edit override": "编辑覆盖",
|
|
94
|
+
"Delete this override?": "确认删除此覆盖?",
|
|
95
|
+
"Context window": "上下文窗口",
|
|
96
|
+
"Max completion tokens": "最大输出 token 数",
|
|
97
|
+
"Owned by": "所属方",
|
|
98
|
+
"Display name": "显示名称",
|
|
99
|
+
"Description": "描述",
|
|
100
|
+
"Leave empty to not override": "留空则不覆盖",
|
|
101
|
+
"Total input + output token capacity reported to clients.": "返回给客户端的输入+输出 token 总容量。",
|
|
102
|
+
"Maximum output tokens reported to clients.": "返回给客户端的最大输出 token 数。",
|
|
103
|
+
"Initial system prompt": "初始系统提示词",
|
|
104
|
+
"Prepended as the first system message, before any system prompt sent by the client. If the client sends no system prompt, this becomes the system prompt sent to the provider.": "作为第一条 system 消息插入到客户端发送的任何 system 提示词之前。如果客户端未发送 system 提示词,此提示词将作为发送给提供商的 system 提示词。",
|
|
105
|
+
"AI API": "AI API",
|
|
106
|
+
"Allow this role to use the AI API": "允许此角色使用 AI API",
|
|
107
|
+
"Allow all AI Employees": "允许所有 AI 员工",
|
|
108
|
+
"Select which AI Employees this role may use:": "选择此角色可使用的 AI 员工:",
|
|
109
|
+
"Select allowed AI Employees": "选择允许的 AI 员工",
|
|
110
|
+
"Max request body size (MB)": "请求体大小上限(MB)",
|
|
111
|
+
"Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。",
|
|
112
|
+
"Usage groups": "用量组",
|
|
113
|
+
"Add group": "添加分组",
|
|
114
|
+
"Edit group": "编辑分组",
|
|
115
|
+
"Delete this group?": "删除此分组?",
|
|
116
|
+
"Mode": "模式",
|
|
117
|
+
"Share": "共享",
|
|
118
|
+
"Per user": "按用户",
|
|
119
|
+
"Rate limit per minute": "每分钟速率限制",
|
|
120
|
+
"Members": "成员",
|
|
121
|
+
"Add member": "添加成员",
|
|
122
|
+
"Member added": "成员已添加",
|
|
123
|
+
"Member removed": "成员已移除",
|
|
124
|
+
"Remove member?": "移除成员?",
|
|
125
|
+
"Remove": "移除",
|
|
126
|
+
"Search group by user": "按用户搜索分组",
|
|
127
|
+
"User belongs to": "用户属于",
|
|
128
|
+
"User not found": "未找到用户",
|
|
129
|
+
"Default": "默认",
|
|
130
|
+
"Allowed LLM services": "允许的 LLM 服务",
|
|
131
|
+
"Allow all models": "允许所有模型",
|
|
132
|
+
"Allowed models": "允许的模型",
|
|
133
|
+
"Model access": "模型访问",
|
|
134
|
+
"All models": "所有模型",
|
|
135
|
+
"All services": "所有服务",
|
|
136
|
+
"No models": "未允许任何模型",
|
|
137
|
+
"Leave empty to allow every service enabled in the general configuration.": "留空则允许通用配置中已启用的所有服务。",
|
|
138
|
+
"Users who do not belong to any other group automatically use this default group — no need to add members.": "不属于其他分组的用户会自动使用此默认分组,无需手动添加成员。"
|
|
139
|
+
}
|
|
@@ -23,6 +23,7 @@ describe('AI API group quota reservation', () => {
|
|
|
23
23
|
{ name: 'enabled', type: 'boolean' },
|
|
24
24
|
{ name: 'currency', type: 'string' },
|
|
25
25
|
{ name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
26
|
+
{ name: 'cacheInputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
26
27
|
{ name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
27
28
|
{ name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10 },
|
|
28
29
|
{ name: 'effectiveFrom', type: 'datetimeTz' },
|
|
@@ -83,6 +84,7 @@ describe('AI API group quota reservation', () => {
|
|
|
83
84
|
enabled: true,
|
|
84
85
|
currency: 'USD',
|
|
85
86
|
inputPricePerMillionTokens: '5',
|
|
87
|
+
cacheInputPricePerMillionTokens: '1',
|
|
86
88
|
outputPricePerMillionTokens: '15',
|
|
87
89
|
fixedCostPerRequest: '0',
|
|
88
90
|
effectiveFrom: new Date('2020-01-01T00:00:00Z'),
|
|
@@ -140,10 +142,14 @@ describe('AI API group quota reservation', () => {
|
|
|
140
142
|
markLlmProviderAttempted(first);
|
|
141
143
|
const finalized = await finalizeLlmBilling(
|
|
142
144
|
first,
|
|
143
|
-
{ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
145
|
+
{ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, prompt_cache_tokens: 6 },
|
|
144
146
|
true,
|
|
145
147
|
);
|
|
146
|
-
expect(finalized).toMatchObject({
|
|
148
|
+
expect(finalized).toMatchObject({
|
|
149
|
+
estimatedCost: '0.00010100',
|
|
150
|
+
costStatus: 'calculated',
|
|
151
|
+
cacheInputPricePerMillionTokens: '1.0000000000',
|
|
152
|
+
});
|
|
147
153
|
|
|
148
154
|
const bucket = await db.getRepository('aiApiGroupQuotaBuckets').findOne();
|
|
149
155
|
expect(String(bucket?.get('requestCount'))).toBe('1');
|
|
@@ -6,6 +6,7 @@ function price(values: Partial<PriceSnapshot> = {}): PriceSnapshot {
|
|
|
6
6
|
id: 1,
|
|
7
7
|
currency: 'USD',
|
|
8
8
|
inputPricePerMillionTokens: '5.0000000000',
|
|
9
|
+
cacheInputPricePerMillionTokens: '0.0000000000',
|
|
9
10
|
outputPricePerMillionTokens: '15.0000000000',
|
|
10
11
|
fixedCostPerRequest: '0.0000000000',
|
|
11
12
|
...values,
|
|
@@ -17,6 +18,18 @@ describe('AI API LLM cost calculation', () => {
|
|
|
17
18
|
expect(calculateLlmCost(10_000, 2_000, price())).toBe('0.08000000');
|
|
18
19
|
});
|
|
19
20
|
|
|
21
|
+
it('prices cached prompt tokens separately from uncached input tokens', () => {
|
|
22
|
+
expect(calculateLlmCost(10_000, 2_000, price({ cacheInputPricePerMillionTokens: '1.0000000000' }), 6_000)).toBe(
|
|
23
|
+
'0.05600000',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not charge cached tokens beyond the reported input token count', () => {
|
|
28
|
+
expect(calculateLlmCost(100, 0, price({ cacheInputPricePerMillionTokens: '1.0000000000' }), 200)).toBe(
|
|
29
|
+
'0.00010000',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
20
33
|
it('includes a fixed per-request cost', () => {
|
|
21
34
|
expect(calculateLlmCost(0, 0, price({ fixedCostPerRequest: '0.1250000000' }))).toBe('0.12500000');
|
|
22
35
|
});
|
|
@@ -175,6 +175,7 @@ describe('AI API usage persistence', () => {
|
|
|
175
175
|
inputTokens: 20,
|
|
176
176
|
outputTokens: 4,
|
|
177
177
|
totalTokens: 24,
|
|
178
|
+
cacheInputPricePerMillionTokens: null,
|
|
178
179
|
providerRequestId: 'provider-id',
|
|
179
180
|
responseMetadata: { usageSource: 'provider', gatewayResponseId: 'gateway-result-id' },
|
|
180
181
|
}),
|
package/src/server/billing.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface PriceSnapshot {
|
|
|
24
24
|
id: string | number | bigint;
|
|
25
25
|
currency: string;
|
|
26
26
|
inputPricePerMillionTokens: string;
|
|
27
|
+
cacheInputPricePerMillionTokens: string;
|
|
27
28
|
outputPricePerMillionTokens: string;
|
|
28
29
|
fixedCostPerRequest: string;
|
|
29
30
|
}
|
|
@@ -60,6 +61,7 @@ export interface BillingFinalization {
|
|
|
60
61
|
groupId?: string | number | bigint;
|
|
61
62
|
quotaMode?: QuotaMode;
|
|
62
63
|
inputPricePerMillionTokens?: string;
|
|
64
|
+
cacheInputPricePerMillionTokens?: string;
|
|
63
65
|
outputPricePerMillionTokens?: string;
|
|
64
66
|
fixedCostPerRequest?: string;
|
|
65
67
|
}
|
|
@@ -112,9 +114,20 @@ function divideRounded(value: bigint, divisor: bigint): bigint {
|
|
|
112
114
|
return (value + divisor / 2n) / divisor;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
function calculateCostUnits(
|
|
117
|
+
function calculateCostUnits(
|
|
118
|
+
inputTokens: number,
|
|
119
|
+
outputTokens: number,
|
|
120
|
+
promptCacheTokens: number,
|
|
121
|
+
price: PriceSnapshot,
|
|
122
|
+
): bigint {
|
|
123
|
+
const cachedInputTokens = Math.min(Math.max(promptCacheTokens, 0), inputTokens);
|
|
124
|
+
const uncachedInputTokens = inputTokens - cachedInputTokens;
|
|
116
125
|
const input = divideRounded(
|
|
117
|
-
BigInt(
|
|
126
|
+
BigInt(uncachedInputTokens) * decimalUnits(price.inputPricePerMillionTokens, PRICE_SCALE),
|
|
127
|
+
PRICE_TO_COST_DIVISOR,
|
|
128
|
+
);
|
|
129
|
+
const cacheInput = divideRounded(
|
|
130
|
+
BigInt(cachedInputTokens) * decimalUnits(price.cacheInputPricePerMillionTokens, PRICE_SCALE),
|
|
118
131
|
PRICE_TO_COST_DIVISOR,
|
|
119
132
|
);
|
|
120
133
|
const output = divideRounded(
|
|
@@ -122,11 +135,16 @@ function calculateCostUnits(inputTokens: number, outputTokens: number, price: Pr
|
|
|
122
135
|
PRICE_TO_COST_DIVISOR,
|
|
123
136
|
);
|
|
124
137
|
const fixed = divideRounded(decimalUnits(price.fixedCostPerRequest, PRICE_SCALE), 100n);
|
|
125
|
-
return input + output + fixed;
|
|
138
|
+
return input + cacheInput + output + fixed;
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
export function calculateLlmCost(
|
|
129
|
-
|
|
141
|
+
export function calculateLlmCost(
|
|
142
|
+
inputTokens: number,
|
|
143
|
+
outputTokens: number,
|
|
144
|
+
price: PriceSnapshot,
|
|
145
|
+
promptCacheTokens = 0,
|
|
146
|
+
): string {
|
|
147
|
+
return formatUnits(calculateCostUnits(inputTokens, outputTokens, promptCacheTokens, price), COST_SCALE);
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
|
@@ -184,6 +202,7 @@ async function findPrice(
|
|
|
184
202
|
id: valueOf(price, 'id'),
|
|
185
203
|
currency: valueOf<string>(price, 'currency'),
|
|
186
204
|
inputPricePerMillionTokens: decimalString(valueOf(price, 'inputPricePerMillionTokens'), PRICE_SCALE),
|
|
205
|
+
cacheInputPricePerMillionTokens: decimalString(valueOf(price, 'cacheInputPricePerMillionTokens'), PRICE_SCALE),
|
|
187
206
|
outputPricePerMillionTokens: decimalString(valueOf(price, 'outputPricePerMillionTokens'), PRICE_SCALE),
|
|
188
207
|
fixedCostPerRequest: decimalString(valueOf(price, 'fixedCostPerRequest'), PRICE_SCALE),
|
|
189
208
|
};
|
|
@@ -325,7 +344,10 @@ export async function finalizeLlmBilling(
|
|
|
325
344
|
costStatus = billing.price ? 'usage_unavailable' : 'unpriced';
|
|
326
345
|
}
|
|
327
346
|
|
|
328
|
-
const cost =
|
|
347
|
+
const cost =
|
|
348
|
+
numbers && billing.price
|
|
349
|
+
? calculateLlmCost(numbers.input, numbers.output, billing.price, providerUsage?.prompt_cache_tokens ?? 0)
|
|
350
|
+
: undefined;
|
|
329
351
|
const reservation = billing.reservation;
|
|
330
352
|
if (reservation) {
|
|
331
353
|
const Bucket = ctx.db.getModel('aiApiGroupQuotaBuckets');
|
|
@@ -378,6 +400,7 @@ export async function finalizeLlmBilling(
|
|
|
378
400
|
groupId: reservation?.groupId,
|
|
379
401
|
quotaMode: reservation?.quotaMode,
|
|
380
402
|
inputPricePerMillionTokens: billing.price?.inputPricePerMillionTokens,
|
|
403
|
+
cacheInputPricePerMillionTokens: billing.price?.cacheInputPricePerMillionTokens,
|
|
381
404
|
outputPricePerMillionTokens: billing.price?.outputPricePerMillionTokens,
|
|
382
405
|
fixedCostPerRequest: billing.price?.fixedCostPerRequest,
|
|
383
406
|
};
|
|
@@ -9,6 +9,14 @@ export default defineCollection({
|
|
|
9
9
|
{ name: 'model', type: 'string', allowNull: false, index: true },
|
|
10
10
|
{ name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
|
|
11
11
|
{ name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
|
|
12
|
+
{
|
|
13
|
+
name: 'cacheInputPricePerMillionTokens',
|
|
14
|
+
type: 'decimal',
|
|
15
|
+
precision: 20,
|
|
16
|
+
scale: 10,
|
|
17
|
+
allowNull: false,
|
|
18
|
+
defaultValue: 0,
|
|
19
|
+
},
|
|
12
20
|
{ name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
|
|
13
21
|
{ name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10, allowNull: false, defaultValue: 0 },
|
|
14
22
|
{ name: 'effectiveFrom', type: 'datetimeTz', allowNull: false, index: true },
|
|
@@ -40,6 +40,7 @@ export default defineCollection({
|
|
|
40
40
|
{ name: 'quotaPolicyId', type: 'bigInt', allowNull: true, index: true },
|
|
41
41
|
{ name: 'groupId', type: 'bigInt', allowNull: true, index: true },
|
|
42
42
|
{ name: 'inputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
43
|
+
{ name: 'cacheInputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
43
44
|
{ name: 'outputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
44
45
|
{ name: 'fixedCostPerRequest', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
45
46
|
{ name: 'providerRequestId', type: 'string', allowNull: true },
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Migration } from '@nocobase/server';
|
|
2
|
+
|
|
3
|
+
export default class AddCacheInputPriceToModelPrices extends Migration {
|
|
4
|
+
on = 'beforeLoad' as const;
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
await this.addDecimalColumn('aiApiModelPrices', 'cacheInputPricePerMillionTokens', false, 0);
|
|
8
|
+
await this.addDecimalColumn('aiApiUsageRecords', 'cacheInputPricePerMillionTokens', true);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async down() {
|
|
12
|
+
await this.removeColumn('aiApiUsageRecords', 'cacheInputPricePerMillionTokens');
|
|
13
|
+
await this.removeColumn('aiApiModelPrices', 'cacheInputPricePerMillionTokens');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private async addDecimalColumn(
|
|
17
|
+
collectionName: string,
|
|
18
|
+
columnName: string,
|
|
19
|
+
allowNull: boolean,
|
|
20
|
+
defaultValue?: number,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const collection = this.db.getCollection(collectionName);
|
|
23
|
+
if (!collection || !(await collection.existsInDb())) return;
|
|
24
|
+
|
|
25
|
+
const tableName = collection.getTableNameWithSchema();
|
|
26
|
+
if (await this.columnExists(tableName, columnName)) return;
|
|
27
|
+
|
|
28
|
+
await this.queryInterface.addColumn(tableName, columnName, {
|
|
29
|
+
type: 'DECIMAL(20,10)',
|
|
30
|
+
allowNull,
|
|
31
|
+
...(defaultValue === undefined ? {} : { defaultValue }),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async removeColumn(collectionName: string, columnName: string): Promise<void> {
|
|
36
|
+
const collection = this.db.getCollection(collectionName);
|
|
37
|
+
if (!collection || !(await collection.existsInDb())) return;
|
|
38
|
+
|
|
39
|
+
const tableName = collection.getTableNameWithSchema();
|
|
40
|
+
if (await this.columnExists(tableName, columnName)) {
|
|
41
|
+
await this.queryInterface.removeColumn(tableName, columnName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async columnExists(tableName: string, columnName: string): Promise<boolean> {
|
|
46
|
+
const columns = await this.queryInterface.describeTable(tableName);
|
|
47
|
+
return Object.prototype.hasOwnProperty.call(columns, columnName);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -75,7 +75,7 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
|
75
75
|
return false;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
let decoded: { userId?: string | number; roleName?: string; temp?: boolean };
|
|
78
|
+
let decoded: { userId?: string | number; roleName?: string; temp?: boolean; jti?: string };
|
|
79
79
|
try {
|
|
80
80
|
decoded = await jwt.decode(token);
|
|
81
81
|
} catch (e) {
|
|
@@ -90,6 +90,26 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Enforce the token blacklist here as well. Revoked API keys are blocked by
|
|
94
|
+
// the core NocoBase auth path (Auth.checkToken), but this API-key fallback
|
|
95
|
+
// decodes the JWT directly and would otherwise keep accepting them.
|
|
96
|
+
if (jwt.blacklist) {
|
|
97
|
+
let blocked = false;
|
|
98
|
+
try {
|
|
99
|
+
blocked = await jwt.blacklist.has(decoded.jti ?? token);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
ctx.log.error('AI API token blacklist check failed:', err);
|
|
102
|
+
ctx.status = 401;
|
|
103
|
+
ctx.body = toOpenAIError(401, 'Unable to verify API key status', 'server_error');
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (blocked) {
|
|
107
|
+
ctx.status = 401;
|
|
108
|
+
ctx.body = toOpenAIError(401, 'API key has been revoked', 'invalid_request_error', 'invalid_api_key');
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
// This fallback is for API keys only. A normal login/OIDC token should have
|
|
94
114
|
// been resolved by NocoBase auth middleware and must not be treated as the
|
|
95
115
|
// member role merely because it has no roleName claim.
|
|
@@ -12,8 +12,9 @@ import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-forma
|
|
|
12
12
|
import { resolveModelString } from '../utils/resolve-service';
|
|
13
13
|
import { enforceModelAccess } from '../utils/user-permissions';
|
|
14
14
|
import { getAiApiConfig } from '../utils/request-cache';
|
|
15
|
-
import {
|
|
15
|
+
import { setAiApiUsageResult } from '../usage';
|
|
16
16
|
import type PluginAiApiServer from '../plugin';
|
|
17
|
+
import { markLlmProviderAttempted, prepareLlmBilling, finalizeLlmBilling } from '../billing';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* POST /api/ai-llm/v1/embeddings
|
|
@@ -103,6 +104,9 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
103
104
|
|
|
104
105
|
const { service, modelId } = resolved;
|
|
105
106
|
|
|
107
|
+
// ─── Prepare billing/quota ────────────────────────────────────────────────
|
|
108
|
+
await prepareLlmBilling(ctx, resolved);
|
|
109
|
+
|
|
106
110
|
if (service.enabled === false) {
|
|
107
111
|
ctx.status = 404;
|
|
108
112
|
ctx.body = toOpenAIError(
|
|
@@ -168,11 +172,21 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
168
172
|
// createEmbedding() returns a LangChain EmbeddingsInterface.
|
|
169
173
|
// embedDocuments() accepts string[] and returns number[][] (one vector per input).
|
|
170
174
|
const embeddingModel = embeddingProvider.createEmbedding();
|
|
175
|
+
markLlmProviderAttempted(ctx);
|
|
171
176
|
const vectors: number[][] = await embeddingModel.embedDocuments(inputs);
|
|
172
177
|
|
|
178
|
+
// Estimate input tokens: average 4 chars per token (conservative).
|
|
179
|
+
const estimatedInputTokens = Math.ceil(inputs.reduce((sum, s) => sum + s.length, 0) / 4);
|
|
180
|
+
const usage = {
|
|
181
|
+
prompt_tokens: estimatedInputTokens,
|
|
182
|
+
completion_tokens: 0,
|
|
183
|
+
total_tokens: estimatedInputTokens,
|
|
184
|
+
};
|
|
185
|
+
setAiApiUsageResult(ctx, usage);
|
|
186
|
+
await finalizeLlmBilling(ctx, usage, true);
|
|
187
|
+
|
|
173
188
|
ctx.status = 200;
|
|
174
189
|
ctx.set('Content-Type', 'application/json');
|
|
175
|
-
setAiApiUsageUnavailable(ctx);
|
|
176
190
|
ctx.body = toOpenAIEmbeddingsResponse({
|
|
177
191
|
model: body.model,
|
|
178
192
|
embeddings: vectors,
|
|
@@ -186,4 +200,4 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
186
200
|
ctx.body = toOpenAIError(500, err.message || 'Failed to generate embeddings', 'server_error');
|
|
187
201
|
}
|
|
188
202
|
}
|
|
189
|
-
}
|
|
203
|
+
}
|
package/src/server/usage.ts
CHANGED
|
@@ -233,6 +233,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
233
233
|
quotaPolicyId: billing.quotaPolicyId ?? null,
|
|
234
234
|
groupId: billing.groupId ?? null,
|
|
235
235
|
inputPricePerMillionTokens: billing.inputPricePerMillionTokens ?? null,
|
|
236
|
+
cacheInputPricePerMillionTokens: billing.cacheInputPricePerMillionTokens ?? null,
|
|
236
237
|
outputPricePerMillionTokens: billing.outputPricePerMillionTokens ?? null,
|
|
237
238
|
fixedCostPerRequest: billing.fixedCostPerRequest ?? null,
|
|
238
239
|
providerRequestId: usageResult.providerRequestId ?? null,
|