plugin-ai-api 1.0.23 → 1.0.24
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/902.e74518750f1e4201.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/902.c7c00a565085438a.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/locale/en-US.json +3 -0
- package/dist/locale/vi-VN.json +3 -0
- package/dist/locale/zh-CN.json +3 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
- package/dist/server/routes/chat-completions.js +24 -12
- package/dist/server/routes/completions.js +27 -14
- package/dist/server/utils/direct-llm-context.js +184 -0
- package/dist/server/validation.js +3 -0
- package/package.json +1 -1
- package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
- package/src/locale/en-US.json +3 -0
- package/src/locale/vi-VN.json +3 -0
- package/src/locale/zh-CN.json +3 -0
- package/src/server/__tests__/direct-llm-context.test.ts +125 -0
- package/src/server/__tests__/usage-route.test.ts +53 -6
- package/src/server/__tests__/validation.test.ts +36 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
- package/src/server/routes/chat-completions.ts +26 -14
- package/src/server/routes/completions.ts +28 -16
- package/src/server/utils/direct-llm-context.ts +216 -0
- package/src/server/validation.ts +3 -0
- package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
- package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
|
@@ -39,6 +39,7 @@ interface QuotaPolicy {
|
|
|
39
39
|
currency: string;
|
|
40
40
|
rejectUnpricedModel: boolean;
|
|
41
41
|
missingUsageBehavior: 'allow' | 'use_reserved';
|
|
42
|
+
contextOverflowBehavior: 'reject' | 'truncate';
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export default function UserQuotasPage() {
|
|
@@ -85,6 +86,7 @@ export default function UserQuotasPage() {
|
|
|
85
86
|
currency: 'USD',
|
|
86
87
|
rejectUnpricedModel: true,
|
|
87
88
|
missingUsageBehavior: 'use_reserved',
|
|
89
|
+
contextOverflowBehavior: 'reject',
|
|
88
90
|
} as QuotaPolicy);
|
|
89
91
|
setOpen(true);
|
|
90
92
|
};
|
|
@@ -157,6 +159,14 @@ export default function UserQuotasPage() {
|
|
|
157
159
|
width: 120,
|
|
158
160
|
render: (value, record) => (value == null ? t('Unlimited') : `${value} ${record.currency}`),
|
|
159
161
|
},
|
|
162
|
+
{
|
|
163
|
+
title: t('Context overflow behavior'),
|
|
164
|
+
dataIndex: 'contextOverflowBehavior',
|
|
165
|
+
key: 'contextOverflowBehavior',
|
|
166
|
+
width: 150,
|
|
167
|
+
render: (value: QuotaPolicy['contextOverflowBehavior']) =>
|
|
168
|
+
value === 'truncate' ? t('Truncate oldest conversation turns') : t('Reject request'),
|
|
169
|
+
},
|
|
160
170
|
{ title: t('Timezone'), dataIndex: 'timezone', key: 'timezone', width: 140 },
|
|
161
171
|
{
|
|
162
172
|
title: t('Status'),
|
|
@@ -248,6 +258,14 @@ export default function UserQuotasPage() {
|
|
|
248
258
|
]}
|
|
249
259
|
/>
|
|
250
260
|
</Form.Item>
|
|
261
|
+
<Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
|
|
262
|
+
<Select
|
|
263
|
+
options={[
|
|
264
|
+
{ label: t('Reject request'), value: 'reject' },
|
|
265
|
+
{ label: t('Truncate oldest conversation turns'), value: 'truncate' },
|
|
266
|
+
]}
|
|
267
|
+
/>
|
|
268
|
+
</Form.Item>
|
|
251
269
|
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
252
270
|
<Switch />
|
|
253
271
|
</Form.Item>
|
package/src/locale/en-US.json
CHANGED
|
@@ -53,6 +53,9 @@
|
|
|
53
53
|
"Missing usage behavior": "Missing usage behavior",
|
|
54
54
|
"Use reserved estimate": "Use reserved estimate",
|
|
55
55
|
"Allow without token charge": "Allow without token charge",
|
|
56
|
+
"Context overflow behavior": "Context overflow behavior",
|
|
57
|
+
"Reject request": "Reject request",
|
|
58
|
+
"Truncate oldest conversation turns": "Truncate oldest conversation turns",
|
|
56
59
|
"Started at": "Started at",
|
|
57
60
|
"Requested model": "Requested model",
|
|
58
61
|
"Resolved service": "Resolved service",
|
package/src/locale/vi-VN.json
CHANGED
|
@@ -53,6 +53,9 @@
|
|
|
53
53
|
"Missing usage behavior": "Xử lý khi thiếu token usage",
|
|
54
54
|
"Use reserved estimate": "Dùng số liệu giữ chỗ để ước tính",
|
|
55
55
|
"Allow without token charge": "Cho phép và không tính token",
|
|
56
|
+
"Context overflow behavior": "Xử lý khi vượt context",
|
|
57
|
+
"Reject request": "Từ chối request",
|
|
58
|
+
"Truncate oldest conversation turns": "Cắt các lượt hội thoại cũ nhất",
|
|
56
59
|
"Started at": "Bắt đầu lúc",
|
|
57
60
|
"Requested model": "Model được yêu cầu",
|
|
58
61
|
"Resolved service": "Service đã resolve",
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -53,6 +53,9 @@
|
|
|
53
53
|
"Missing usage behavior": "缺少用量时的行为",
|
|
54
54
|
"Use reserved estimate": "使用预留估算",
|
|
55
55
|
"Allow without token charge": "允许且不计令牌",
|
|
56
|
+
"Context overflow behavior": "上下文超限处理",
|
|
57
|
+
"Reject request": "拒绝请求",
|
|
58
|
+
"Truncate oldest conversation turns": "截断最早的对话轮次",
|
|
56
59
|
"Started at": "开始时间",
|
|
57
60
|
"Requested model": "请求模型",
|
|
58
61
|
"Resolved service": "解析后的服务",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
4
|
+
|
|
5
|
+
function context({ behavior = 'reject', metadata = { contextWindow: 120, maxCompletionTokens: 40 } } = {}): Context {
|
|
6
|
+
return {
|
|
7
|
+
state: { currentUser: { id: 1 } },
|
|
8
|
+
db: {
|
|
9
|
+
getRepository: vi.fn((name: string) => {
|
|
10
|
+
if (name === 'aiApiModelMetadata') {
|
|
11
|
+
return {
|
|
12
|
+
findOne: vi.fn().mockResolvedValue({ get: (key: string) => metadata[key as keyof typeof metadata] }),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (name === 'aiApiUserQuotaPolicies') {
|
|
16
|
+
return {
|
|
17
|
+
findOne: vi
|
|
18
|
+
.fn()
|
|
19
|
+
.mockResolvedValue({ get: (key: string) => (key === 'contextOverflowBehavior' ? behavior : undefined) }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { findOne: vi.fn() };
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
} as unknown as Context;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function request(messages: OpenAIMessage[], tools?: unknown) {
|
|
29
|
+
return {
|
|
30
|
+
serviceName: 'test-service',
|
|
31
|
+
modelId: 'test-model',
|
|
32
|
+
messages,
|
|
33
|
+
tools,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('direct LLM context preparation', () => {
|
|
38
|
+
it('uses reject when the user has no enabled policy', async () => {
|
|
39
|
+
const ctx = context();
|
|
40
|
+
vi.mocked(ctx.db.getRepository).mockImplementation((name: string) => {
|
|
41
|
+
if (name === 'aiApiModelMetadata') {
|
|
42
|
+
return {
|
|
43
|
+
findOne: vi.fn().mockResolvedValue({ get: (key: string) => (key === 'contextWindow' ? 120 : 40) }),
|
|
44
|
+
} as never;
|
|
45
|
+
}
|
|
46
|
+
return { findOne: vi.fn().mockResolvedValue(null) } as never;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await expect(
|
|
50
|
+
prepareDirectLlmContext(ctx, request([{ role: 'user', content: 'x'.repeat(400) }])),
|
|
51
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps a request that fits the model input budget', async () => {
|
|
55
|
+
const messages = [{ role: 'user', content: 'hello' }];
|
|
56
|
+
const prepared = await prepareDirectLlmContext(context(), request(messages));
|
|
57
|
+
|
|
58
|
+
expect(prepared.messages).toBe(messages);
|
|
59
|
+
expect(prepared.truncated).toBe(false);
|
|
60
|
+
expect(prepared.inputTokenBudget).toBe(80);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects a requested output limit above model metadata', async () => {
|
|
64
|
+
await expect(
|
|
65
|
+
prepareDirectLlmContext(context(), { ...request([{ role: 'user', content: 'hello' }]), maxCompletionTokens: 41 }),
|
|
66
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'max_completion_tokens_exceeds_model_limit' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('truncates oldest complete turns while preserving instructions and newest turn', async () => {
|
|
70
|
+
const messages: OpenAIMessage[] = [
|
|
71
|
+
{ role: 'system', content: 'Always answer safely.' },
|
|
72
|
+
{ role: 'user', content: 'first '.repeat(30) },
|
|
73
|
+
{ role: 'assistant', content: 'first answer '.repeat(20) },
|
|
74
|
+
{ role: 'user', content: 'latest question' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
|
|
78
|
+
|
|
79
|
+
expect(prepared.truncated).toBe(true);
|
|
80
|
+
expect(prepared.messages).toEqual([messages[0], messages[3]]);
|
|
81
|
+
expect(messages).toHaveLength(4);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps assistant tool calls and their tool responses in the same turn', async () => {
|
|
85
|
+
const messages: OpenAIMessage[] = [
|
|
86
|
+
{ role: 'user', content: 'old question '.repeat(30) },
|
|
87
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'call-old', type: 'function' }] },
|
|
88
|
+
{ role: 'tool', tool_call_id: 'call-old', content: 'old result '.repeat(20) },
|
|
89
|
+
{ role: 'user', content: 'latest question' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
|
|
93
|
+
|
|
94
|
+
expect(prepared.messages).toEqual([messages[3]]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects when the newest turn cannot fit without cutting content', async () => {
|
|
98
|
+
await expect(
|
|
99
|
+
prepareDirectLlmContext(context({ behavior: 'truncate' }), request([{ role: 'user', content: 'x'.repeat(400) }])),
|
|
100
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects image content until a model-specific estimator is available', async () => {
|
|
104
|
+
await expect(
|
|
105
|
+
prepareDirectLlmContext(
|
|
106
|
+
context(),
|
|
107
|
+
request([
|
|
108
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.test/image.png' } }] },
|
|
109
|
+
]),
|
|
110
|
+
),
|
|
111
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_estimation_unsupported' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('counts tool definitions as fixed input overhead', async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
prepareDirectLlmContext(
|
|
117
|
+
context(),
|
|
118
|
+
request(
|
|
119
|
+
[{ role: 'user', content: 'hello' }],
|
|
120
|
+
[{ type: 'function', function: { name: 'large', parameters: { text: 'x'.repeat(400) } } }],
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -15,18 +15,20 @@ interface ModelResult {
|
|
|
15
15
|
usage_metadata?: Record<string, unknown>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function createContext(result: ModelResult)
|
|
18
|
+
function createContext(result: ModelResult) {
|
|
19
19
|
const model = {
|
|
20
20
|
invoke: vi.fn().mockResolvedValue(result),
|
|
21
21
|
modelKwargs: {},
|
|
22
22
|
};
|
|
23
|
+
let providerCreateCount = 0;
|
|
23
24
|
class TestProvider {
|
|
24
25
|
createModel() {
|
|
26
|
+
providerCreateCount += 1;
|
|
25
27
|
return model;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
const ctx = {
|
|
30
32
|
app: {
|
|
31
33
|
pm: {
|
|
32
34
|
get: vi.fn().mockReturnValue({
|
|
@@ -37,7 +39,16 @@ function createContext(result: ModelResult): Context {
|
|
|
37
39
|
},
|
|
38
40
|
},
|
|
39
41
|
db: {
|
|
40
|
-
getRepository: vi.fn(
|
|
42
|
+
getRepository: vi.fn((name: string) => {
|
|
43
|
+
if (name === 'aiApiModelMetadata') {
|
|
44
|
+
return {
|
|
45
|
+
findOne: vi.fn().mockResolvedValue({
|
|
46
|
+
get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { findOne: vi.fn().mockResolvedValue(null) };
|
|
51
|
+
}),
|
|
41
52
|
},
|
|
42
53
|
log: { error: vi.fn() },
|
|
43
54
|
request: {
|
|
@@ -50,6 +61,8 @@ function createContext(result: ModelResult): Context {
|
|
|
50
61
|
state: {},
|
|
51
62
|
set: vi.fn(),
|
|
52
63
|
} as unknown as Context;
|
|
64
|
+
|
|
65
|
+
return { ctx, model, getProviderCreateCount: () => providerCreateCount };
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
class ListenerTarget {
|
|
@@ -139,7 +152,16 @@ function createStreamingContext(
|
|
|
139
152
|
},
|
|
140
153
|
},
|
|
141
154
|
db: {
|
|
142
|
-
getRepository: vi.fn(
|
|
155
|
+
getRepository: vi.fn((name: string) => {
|
|
156
|
+
if (name === 'aiApiModelMetadata') {
|
|
157
|
+
return {
|
|
158
|
+
findOne: vi.fn().mockResolvedValue({
|
|
159
|
+
get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { findOne: vi.fn().mockResolvedValue(null) };
|
|
164
|
+
}),
|
|
143
165
|
},
|
|
144
166
|
log: { error: vi.fn() },
|
|
145
167
|
req,
|
|
@@ -174,7 +196,7 @@ describe('AI API chat usage collection', () => {
|
|
|
174
196
|
});
|
|
175
197
|
|
|
176
198
|
it('keeps the public zero fallback but marks missing provider usage as unavailable internally', async () => {
|
|
177
|
-
const ctx = createContext({ content: 'Hello back' });
|
|
199
|
+
const { ctx } = createContext({ content: 'Hello back' });
|
|
178
200
|
|
|
179
201
|
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
180
202
|
|
|
@@ -195,7 +217,7 @@ describe('AI API chat usage collection', () => {
|
|
|
195
217
|
});
|
|
196
218
|
|
|
197
219
|
it('stores provider usage and provider request ID separately from the gateway response ID', async () => {
|
|
198
|
-
const ctx = createContext({
|
|
220
|
+
const { ctx } = createContext({
|
|
199
221
|
content: 'Hello back',
|
|
200
222
|
response_metadata: { request_id: 'provider-request-1' },
|
|
201
223
|
usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
|
|
@@ -303,6 +325,31 @@ describe('AI API chat usage collection', () => {
|
|
|
303
325
|
);
|
|
304
326
|
});
|
|
305
327
|
|
|
328
|
+
it('rejects context overflow before creating a provider or reserving quota', async () => {
|
|
329
|
+
const { ctx, getProviderCreateCount } = createContext({ content: 'unused' });
|
|
330
|
+
(ctx.request.body as Record<string, unknown>).messages = [{ role: 'user', content: 'x'.repeat(600_000) }];
|
|
331
|
+
|
|
332
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
333
|
+
|
|
334
|
+
expect(ctx.status).toBe(400);
|
|
335
|
+
expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
|
|
336
|
+
expect(ctx.state.aiApiLlmBilling).toBeUndefined();
|
|
337
|
+
expect(getProviderCreateCount()).toBe(0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('rejects oversized legacy prompts before creating a provider or reserving quota', async () => {
|
|
341
|
+
const { ctx, model, getProviderCreateCount } = createContext({ content: 'unused' });
|
|
342
|
+
(ctx.request.body as Record<string, unknown>).prompt = 'x'.repeat(600_000);
|
|
343
|
+
|
|
344
|
+
await handleCompletions(ctx, {} as PluginAiApiServer);
|
|
345
|
+
|
|
346
|
+
expect(ctx.status).toBe(400);
|
|
347
|
+
expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
|
|
348
|
+
expect(ctx.state.aiApiLlmBilling).toBeUndefined();
|
|
349
|
+
expect(getProviderCreateCount()).toBe(0);
|
|
350
|
+
expect(model.invoke).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
306
353
|
it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
|
|
307
354
|
const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
|
|
308
355
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Model } from '@nocobase/database';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('dayjs', () => ({
|
|
5
|
+
default: () => ({ tz: vi.fn() }),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { validateQuotaPolicy } from '../validation';
|
|
9
|
+
|
|
10
|
+
function quotaPolicy(values: Record<string, unknown>): Model {
|
|
11
|
+
return {
|
|
12
|
+
get: (key: string) => values[key],
|
|
13
|
+
} as Model;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const requiredPolicy = {
|
|
17
|
+
periodType: 'monthly',
|
|
18
|
+
missingUsageBehavior: 'use_reserved',
|
|
19
|
+
timezone: 'UTC',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('AI API quota policy validation', () => {
|
|
23
|
+
it('uses reject when context overflow behavior is absent', () => {
|
|
24
|
+
expect(() => validateQuotaPolicy(quotaPolicy(requiredPolicy))).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it.each(['reject', 'truncate'] as const)('accepts %s context overflow behavior', (contextOverflowBehavior) => {
|
|
28
|
+
expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior }))).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects an unsupported context overflow behavior', () => {
|
|
32
|
+
expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior: 'compact' }))).toThrow(
|
|
33
|
+
'contextOverflowBehavior must be reject or truncate.',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -22,6 +22,7 @@ export default defineCollection({
|
|
|
22
22
|
{ name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
|
|
23
23
|
{ name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
|
|
24
24
|
{ name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
|
|
25
|
+
{ name: 'contextOverflowBehavior', type: 'string', allowNull: false, defaultValue: 'reject' },
|
|
25
26
|
],
|
|
26
27
|
indexes: [
|
|
27
28
|
{
|
|
@@ -31,6 +31,7 @@ import { enforceModelAccess } from '../utils/user-permissions';
|
|
|
31
31
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
32
32
|
import type PluginAiApiServer from '../plugin';
|
|
33
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
34
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
34
35
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -142,8 +143,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
142
143
|
return;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
await prepareLlmBilling(ctx, resolved);
|
|
146
|
-
|
|
147
146
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
148
147
|
if (stream) {
|
|
149
148
|
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
@@ -166,13 +165,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
166
165
|
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
167
166
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
168
167
|
|
|
169
|
-
const Provider = providerMeta.provider;
|
|
170
|
-
const provider = new Provider({
|
|
171
|
-
app: ctx.app,
|
|
172
|
-
serviceOptions: service.options,
|
|
173
|
-
modelOptions,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
168
|
// ─── Build system prompt from AI Employee ───
|
|
177
169
|
let systemPrompt = '';
|
|
178
170
|
if (config?.defaultAiEmployee) {
|
|
@@ -197,12 +189,31 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
197
189
|
}
|
|
198
190
|
|
|
199
191
|
// ─── Build messages (inject system prompt if not provided by client) ───
|
|
200
|
-
|
|
192
|
+
let messages: OpenAIMessage[] = [...body.messages];
|
|
201
193
|
const hasSystemMessage = messages.some((m: any) => m.role === 'system');
|
|
202
194
|
if (systemPrompt && !hasSystemMessage) {
|
|
203
195
|
messages.unshift({ role: 'system', content: systemPrompt });
|
|
204
196
|
}
|
|
205
197
|
|
|
198
|
+
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
199
|
+
serviceName: service.name,
|
|
200
|
+
modelId,
|
|
201
|
+
messages,
|
|
202
|
+
tools: body.tools,
|
|
203
|
+
maxCompletionTokens: body.max_completion_tokens,
|
|
204
|
+
maxTokens: body.max_tokens,
|
|
205
|
+
});
|
|
206
|
+
messages = preparedContext.messages;
|
|
207
|
+
|
|
208
|
+
await prepareLlmBilling(ctx, resolved);
|
|
209
|
+
|
|
210
|
+
const Provider = providerMeta.provider;
|
|
211
|
+
const provider = new Provider({
|
|
212
|
+
app: ctx.app,
|
|
213
|
+
serviceOptions: service.options,
|
|
214
|
+
modelOptions,
|
|
215
|
+
});
|
|
216
|
+
|
|
206
217
|
// ─── Build message tuples for LangChain model ───
|
|
207
218
|
// LangChain chat models accept [role, content] tuples or BaseMessage objects.
|
|
208
219
|
// We use tuples to avoid importing @langchain/core directly.
|
|
@@ -257,15 +268,16 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
257
268
|
}
|
|
258
269
|
} catch (err) {
|
|
259
270
|
ctx.log.error('AI API chat completions error:', err);
|
|
260
|
-
if (!ctx.res
|
|
271
|
+
if (!ctx.res?.headersSent) {
|
|
261
272
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
262
|
-
|
|
273
|
+
const isContextError = err instanceof DirectLlmContextError;
|
|
274
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
263
275
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
264
276
|
ctx.body = toOpenAIError(
|
|
265
277
|
ctx.status,
|
|
266
278
|
getErrorMessage(err, 'Internal server error'),
|
|
267
|
-
isQuotaError ? 'quota_error' : 'server_error',
|
|
268
|
-
isQuotaError ? err.code : undefined,
|
|
279
|
+
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
280
|
+
isQuotaError || isContextError ? err.code : undefined,
|
|
269
281
|
);
|
|
270
282
|
}
|
|
271
283
|
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
27
27
|
import type PluginAiApiServer from '../plugin';
|
|
28
28
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
29
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
29
30
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -114,8 +115,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
await prepareLlmBilling(ctx, resolved);
|
|
118
|
-
|
|
119
118
|
const modelOptions: Record<string, any> = {
|
|
120
119
|
model: modelId,
|
|
121
120
|
llmService: service.name,
|
|
@@ -126,13 +125,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
126
125
|
if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
127
126
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
128
127
|
|
|
129
|
-
const Provider = providerMeta.provider;
|
|
130
|
-
const provider = new Provider({
|
|
131
|
-
app: ctx.app,
|
|
132
|
-
serviceOptions: service.options,
|
|
133
|
-
modelOptions,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
128
|
// ─── Convert prompt to message tuple ───
|
|
137
129
|
const prompt =
|
|
138
130
|
typeof body.prompt === 'string'
|
|
@@ -142,7 +134,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
142
134
|
: String(body.prompt);
|
|
143
135
|
|
|
144
136
|
// Inject system prompt from AI Employee if configured
|
|
145
|
-
const
|
|
137
|
+
const messages: OpenAIMessage[] = [];
|
|
146
138
|
if (config?.defaultAiEmployee) {
|
|
147
139
|
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
148
140
|
filter: { username: config.defaultAiEmployee },
|
|
@@ -150,11 +142,30 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
150
142
|
if (employee) {
|
|
151
143
|
const systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
152
144
|
if (systemPrompt) {
|
|
153
|
-
|
|
145
|
+
messages.push({ role: 'system', content: systemPrompt });
|
|
154
146
|
}
|
|
155
147
|
}
|
|
156
148
|
}
|
|
157
|
-
|
|
149
|
+
messages.push({ role: 'user', content: prompt });
|
|
150
|
+
|
|
151
|
+
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
152
|
+
serviceName: service.name,
|
|
153
|
+
modelId,
|
|
154
|
+
messages,
|
|
155
|
+
maxTokens: body.max_tokens,
|
|
156
|
+
});
|
|
157
|
+
await prepareLlmBilling(ctx, resolved);
|
|
158
|
+
|
|
159
|
+
const Provider = providerMeta.provider;
|
|
160
|
+
const provider = new Provider({
|
|
161
|
+
app: ctx.app,
|
|
162
|
+
serviceOptions: service.options,
|
|
163
|
+
modelOptions,
|
|
164
|
+
});
|
|
165
|
+
const langchainMessages = preparedContext.messages.map((message): [string, string] => [
|
|
166
|
+
message.role === 'user' ? 'human' : message.role,
|
|
167
|
+
String(message.content ?? ''),
|
|
168
|
+
]);
|
|
158
169
|
|
|
159
170
|
const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
|
|
160
171
|
const chatModel = provider.createModel();
|
|
@@ -174,15 +185,16 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
174
185
|
}
|
|
175
186
|
} catch (err) {
|
|
176
187
|
ctx.log.error('AI API completions error:', err);
|
|
177
|
-
if (!ctx.res
|
|
188
|
+
if (!ctx.res?.headersSent) {
|
|
178
189
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
179
|
-
|
|
190
|
+
const isContextError = err instanceof DirectLlmContextError;
|
|
191
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
180
192
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
181
193
|
ctx.body = toOpenAIError(
|
|
182
194
|
ctx.status,
|
|
183
195
|
getErrorMessage(err, 'Internal server error'),
|
|
184
|
-
isQuotaError ? 'quota_error' : 'server_error',
|
|
185
|
-
isQuotaError ? err.code : undefined,
|
|
196
|
+
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
197
|
+
isQuotaError || isContextError ? err.code : undefined,
|
|
186
198
|
);
|
|
187
199
|
}
|
|
188
200
|
}
|