plugin-ai-api 1.0.20 → 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.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
- package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
- package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
- 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.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
- package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
- 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 +28 -9
- package/dist/locale/vi-VN.json +20 -1
- package/dist/locale/zh-CN.json +20 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/plugin.js +13 -1
- package/dist/server/resource/ai-api-config.js +17 -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 +145 -4
- package/dist/server/routes/completions.js +8 -1
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +94 -22
- package/dist/server/usage.js +2 -0
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +18 -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 +17 -8
- 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 -1
- package/src/client-v2/pages/GeneralPage.tsx +13 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/plugin.tsx +41 -1
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +28 -9
- package/src/locale/vi-VN.json +20 -1
- package/src/locale/zh-CN.json +20 -1
- package/src/server/__tests__/app-observability.test.ts +98 -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/collections/ai-api-config.ts +6 -0
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/plugin.ts +24 -4
- package/src/server/resource/ai-api-config.ts +23 -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 +262 -4
- package/src/server/routes/completions.ts +14 -2
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +136 -26
- package/src/server/usage.ts +2 -0
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +38 -1
- package/dist/client/302.25edd5d75460acbf.js +0 -10
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
package/src/locale/vi-VN.json
CHANGED
|
@@ -82,5 +82,24 @@
|
|
|
82
82
|
"Reset": "Đặt lại",
|
|
83
83
|
"Requests": "Số request",
|
|
84
84
|
"Total cost": "Tổng chi phí",
|
|
85
|
-
"Usage records": "Chi tiết usage"
|
|
85
|
+
"Usage records": "Chi tiết usage",
|
|
86
|
+
"Model metadata": "Metadata model",
|
|
87
|
+
"Add override": "Thêm override",
|
|
88
|
+
"Edit override": "Sửa override",
|
|
89
|
+
"Delete this override?": "Xóa override này?",
|
|
90
|
+
"Context window": "Context window",
|
|
91
|
+
"Max completion tokens": "Max completion tokens",
|
|
92
|
+
"Owned by": "Nhà cung cấp",
|
|
93
|
+
"Display name": "Tên hiển thị",
|
|
94
|
+
"Description": "Mô tả",
|
|
95
|
+
"Leave empty to not override": "Để trống nếu không override",
|
|
96
|
+
"Total input + output token capacity reported to clients.": "Tổng dung lượng token (input + output) trả về cho client.",
|
|
97
|
+
"Maximum output tokens reported to clients.": "Số token output tối đa trả về cho client.",
|
|
98
|
+
"AI API": "AI API",
|
|
99
|
+
"Allow this role to use the AI API": "Cho phép vai trò này sử dụng AI API",
|
|
100
|
+
"Allow all AI Employees": "Cho phép tất cả AI Employee",
|
|
101
|
+
"Select which AI Employees this role may use:": "Chọn AI Employee mà vai trò này được dùng:",
|
|
102
|
+
"Select allowed AI Employees": "Chọn AI Employee được phép",
|
|
103
|
+
"Max request body size (MB)": "Giới hạn kích thước request body (MB)",
|
|
104
|
+
"Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "Tăng giá trị này để nhận ảnh base64 gửi trực tiếp. Base64 làm tăng khoảng 33% so với kích thước tệp gốc."
|
|
86
105
|
}
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -82,5 +82,24 @@
|
|
|
82
82
|
"Reset": "重置",
|
|
83
83
|
"Requests": "请求数",
|
|
84
84
|
"Total cost": "总费用",
|
|
85
|
-
"Usage records": "用量记录"
|
|
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%。"
|
|
86
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,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
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import type { AddressInfo } from 'net';
|
|
3
|
+
import { MAX_REQUEST_BODY_MB_LIMIT, getRawBody, normalizeMaxRequestBodyMb } from '../routes/router';
|
|
4
|
+
import { findContentBlockProblem, findMessageProblem, normalizeMessageContent } from '../routes/chat-completions';
|
|
5
|
+
|
|
6
|
+
describe('AI API max request body configuration', () => {
|
|
7
|
+
it('falls back to 10 MB when the configured value is missing or unusable', () => {
|
|
8
|
+
expect(normalizeMaxRequestBodyMb(undefined)).toBe(10);
|
|
9
|
+
expect(normalizeMaxRequestBodyMb(null)).toBe(10);
|
|
10
|
+
expect(normalizeMaxRequestBodyMb('not a number')).toBe(10);
|
|
11
|
+
expect(normalizeMaxRequestBodyMb(0)).toBe(10);
|
|
12
|
+
expect(normalizeMaxRequestBodyMb(-5)).toBe(10);
|
|
13
|
+
expect(normalizeMaxRequestBodyMb(2.5)).toBe(10);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('accepts a raised limit and clamps anything above the ceiling', () => {
|
|
17
|
+
expect(normalizeMaxRequestBodyMb(25)).toBe(25);
|
|
18
|
+
expect(normalizeMaxRequestBodyMb('25')).toBe(25);
|
|
19
|
+
expect(normalizeMaxRequestBodyMb(MAX_REQUEST_BODY_MB_LIMIT)).toBe(MAX_REQUEST_BODY_MB_LIMIT);
|
|
20
|
+
expect(normalizeMaxRequestBodyMb(MAX_REQUEST_BODY_MB_LIMIT + 1)).toBe(MAX_REQUEST_BODY_MB_LIMIT);
|
|
21
|
+
expect(normalizeMaxRequestBodyMb(Number.MAX_SAFE_INTEGER)).toBe(MAX_REQUEST_BODY_MB_LIMIT);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Drives the real getRawBody over a live socket.
|
|
27
|
+
*
|
|
28
|
+
* The point is the transport, not the arithmetic: ctx.req and the response share
|
|
29
|
+
* one TCP connection, so destroying the request also destroys the reply and the
|
|
30
|
+
* client sees ECONNRESET instead of our 413 JSON. Only an end-to-end socket test
|
|
31
|
+
* can catch that regression, so this must call the production helper rather than
|
|
32
|
+
* a copy of it.
|
|
33
|
+
*/
|
|
34
|
+
describe('AI API oversized request handling', () => {
|
|
35
|
+
const MAX_BYTES = 1024;
|
|
36
|
+
|
|
37
|
+
interface Outcome {
|
|
38
|
+
status?: number;
|
|
39
|
+
body: string;
|
|
40
|
+
clientError?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function reply(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
44
|
+
res.setHeader('Content-Type', 'application/json');
|
|
45
|
+
try {
|
|
46
|
+
const raw = await getRawBody({ req } as Parameters<typeof getRawBody>[0], MAX_BYTES);
|
|
47
|
+
res.statusCode = 200;
|
|
48
|
+
res.end(JSON.stringify({ received: raw.length }));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const { message, statusCode } = err as Error & { statusCode?: number };
|
|
51
|
+
res.statusCode = statusCode === 413 ? 413 : 400;
|
|
52
|
+
res.end(JSON.stringify({ error: { message, type: 'invalid_request_error' } }));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function post(payloadBytes: number, options: { declareLength?: boolean } = {}): Promise<Outcome> {
|
|
57
|
+
const server = http.createServer(reply);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
61
|
+
const { port } = server.address() as AddressInfo;
|
|
62
|
+
|
|
63
|
+
return await new Promise<Outcome>((resolve) => {
|
|
64
|
+
const payload = Buffer.alloc(payloadBytes, 'x');
|
|
65
|
+
const req = http.request(
|
|
66
|
+
{
|
|
67
|
+
port,
|
|
68
|
+
method: 'POST',
|
|
69
|
+
path: '/api/ai-llm/v1/chat/completions',
|
|
70
|
+
headers: options.declareLength === false ? {} : { 'Content-Length': String(payload.length) },
|
|
71
|
+
},
|
|
72
|
+
(res) => {
|
|
73
|
+
let body = '';
|
|
74
|
+
res.on('data', (chunk) => (body += chunk));
|
|
75
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
req.on('error', (err: NodeJS.ErrnoException) => resolve({ body: '', clientError: err.code }));
|
|
79
|
+
req.end(payload);
|
|
80
|
+
});
|
|
81
|
+
} finally {
|
|
82
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it('answers an oversized body with a readable 413 rather than resetting the connection', async () => {
|
|
87
|
+
const response = await post(200 * 1024);
|
|
88
|
+
|
|
89
|
+
expect(response.clientError).toBeUndefined();
|
|
90
|
+
expect(response.status).toBe(413);
|
|
91
|
+
expect(JSON.parse(response.body).error.message).toContain('too large');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports 413 for a chunked upload that only exceeds the cap mid-stream', async () => {
|
|
95
|
+
const response = await post(64 * 1024, { declareLength: false });
|
|
96
|
+
|
|
97
|
+
expect(response.clientError).toBeUndefined();
|
|
98
|
+
expect(response.status).toBe(413);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns the exact bytes for a body within the limit', async () => {
|
|
102
|
+
const response = await post(512);
|
|
103
|
+
|
|
104
|
+
expect(response.clientError).toBeUndefined();
|
|
105
|
+
expect(response.status).toBe(200);
|
|
106
|
+
expect(JSON.parse(response.body).received).toBe(512);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('accepts a body sitting exactly on the cap', async () => {
|
|
110
|
+
const response = await post(MAX_BYTES);
|
|
111
|
+
|
|
112
|
+
expect(response.status).toBe(200);
|
|
113
|
+
expect(JSON.parse(response.body).received).toBe(MAX_BYTES);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('AI API multimodal content block validation', () => {
|
|
118
|
+
const wrap = (content: unknown) => [{ role: 'user', content }];
|
|
119
|
+
|
|
120
|
+
it('accepts plain string content and well-formed text/image_url blocks', () => {
|
|
121
|
+
expect(findContentBlockProblem(wrap('Hello'))).toBeUndefined();
|
|
122
|
+
expect(
|
|
123
|
+
findContentBlockProblem(
|
|
124
|
+
wrap([
|
|
125
|
+
{ type: 'text', text: 'What is in this picture?' },
|
|
126
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } },
|
|
127
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/cat.jpg' } },
|
|
128
|
+
]),
|
|
129
|
+
),
|
|
130
|
+
).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('accepts a bare string image_url but rewrites it to the object form every provider converts', () => {
|
|
134
|
+
const block = { type: 'image_url', image_url: 'https://example.com/a.png' };
|
|
135
|
+
|
|
136
|
+
expect(findContentBlockProblem(wrap([block]))).toBeUndefined();
|
|
137
|
+
// `isOpenAIDataBlock` requires `image_url` to be an object, so the string
|
|
138
|
+
// form would otherwise reach the provider unconverted.
|
|
139
|
+
expect(normalizeMessageContent([block])).toEqual([
|
|
140
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('leaves the object form and plain text blocks untouched', () => {
|
|
145
|
+
const blocks = [
|
|
146
|
+
{ type: 'text', text: 'hi' },
|
|
147
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
expect(normalizeMessageContent(blocks)).toEqual(blocks);
|
|
151
|
+
expect(normalizeMessageContent('plain')).toBe('plain');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects base64 payloads that match the grammar but cannot be decoded', () => {
|
|
155
|
+
// Each of these passes LangChain's regex and then throws inside `atob`,
|
|
156
|
+
// which used to surface as an HTTP 500.
|
|
157
|
+
for (const payload of ['A===', 'A=', 'AAAAA', 'AAAA=']) {
|
|
158
|
+
const problem = findContentBlockProblem(
|
|
159
|
+
wrap([{ type: 'image_url', image_url: { url: `data:image/png;base64,${payload}` } }]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(problem, payload).toBeDefined();
|
|
163
|
+
expect(problem?.reason, payload).toContain('not decodable');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('still accepts correctly padded base64 payloads', () => {
|
|
168
|
+
for (const payload of ['QUJD', 'QQ==', 'QUI=', 'iVBORw0KGgo=']) {
|
|
169
|
+
expect(
|
|
170
|
+
findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: `data:image/png;base64,${payload}` } }])),
|
|
171
|
+
payload,
|
|
172
|
+
).toBeUndefined();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects an unsupported block type and names it', () => {
|
|
177
|
+
const problem = findContentBlockProblem([
|
|
178
|
+
{ role: 'system', content: 'You are helpful.' },
|
|
179
|
+
{ role: 'user', content: [{ type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } }] },
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
expect(problem?.index).toBe(1);
|
|
183
|
+
expect(problem?.reason).toContain("'file' is not supported");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejects a text block with no text payload', () => {
|
|
187
|
+
expect(findContentBlockProblem(wrap([{ type: 'text' }]))?.reason).toContain("requires a string 'text' field");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects an image_url block with no url', () => {
|
|
191
|
+
expect(findContentBlockProblem(wrap([{ type: 'image_url' }]))?.reason).toContain(
|
|
192
|
+
"requires a non-empty 'image_url.url'",
|
|
193
|
+
);
|
|
194
|
+
expect(findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: '' } }]))?.reason).toContain(
|
|
195
|
+
"requires a non-empty 'image_url.url'",
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('rejects a non-image data URL that the provider would treat as an image', () => {
|
|
200
|
+
const problem = findContentBlockProblem(
|
|
201
|
+
wrap([{ type: 'image_url', image_url: { url: 'data:application/pdf;base64,JVBERi0=' } }]),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(problem?.reason).toContain('application/pdf');
|
|
205
|
+
expect(problem?.reason).toContain('not an image');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('rejects a malformed base64 data URL instead of letting the adapter throw a 500', () => {
|
|
209
|
+
for (const url of [
|
|
210
|
+
'data:image/png;base64,iVBORw0 KGgo=',
|
|
211
|
+
'data:image/png;base64,iVBORw0-KGgo=',
|
|
212
|
+
'data:image/png,notbase64',
|
|
213
|
+
'data:image/svg+xml;base64,PHN2Zz4=',
|
|
214
|
+
]) {
|
|
215
|
+
const problem = findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url } }]));
|
|
216
|
+
expect(problem, url).toBeDefined();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('rejects a non-http(s) URL protocol', () => {
|
|
221
|
+
expect(
|
|
222
|
+
findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: 'ftp://example.com/a.png' } }]))?.reason,
|
|
223
|
+
).toContain("protocol 'ftp:'");
|
|
224
|
+
expect(
|
|
225
|
+
findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: 'file:///etc/passwd' } }]))?.reason,
|
|
226
|
+
).toContain("protocol 'file:'");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('rejects a garbage url string', () => {
|
|
230
|
+
expect(findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: 'not a url' } }]))?.reason).toContain(
|
|
231
|
+
'not a valid URL',
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('rejects a block that is not an object or has no type', () => {
|
|
236
|
+
expect(findContentBlockProblem(wrap([42]))?.reason).toContain('must be an object');
|
|
237
|
+
expect(findContentBlockProblem(wrap([{ text: 'no type field' }]))?.reason).toContain("requires a 'type' field");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('ignores messages whose content is not an array', () => {
|
|
241
|
+
expect(findContentBlockProblem([{ role: 'assistant', content: null }, { role: 'tool' }])).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `findContentBlockProblem` only inspects array content, so these malformed
|
|
247
|
+
* messages used to reach `messages.some((m) => m.role === ...)` — a TypeError
|
|
248
|
+
* reported as HTTP 500 — or died inside LangChain with MESSAGE_COERCION_FAILURE.
|
|
249
|
+
*/
|
|
250
|
+
describe('AI API message schema validation', () => {
|
|
251
|
+
it('rejects a non-object message instead of throwing on m.role', () => {
|
|
252
|
+
expect(findMessageProblem([null])?.reason).toContain('must be an object');
|
|
253
|
+
expect(findMessageProblem([null])?.index).toBe(0);
|
|
254
|
+
expect(findMessageProblem(['hello'])?.reason).toContain('must be an object');
|
|
255
|
+
expect(findMessageProblem([42])?.reason).toContain('must be an object');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('names the offending index', () => {
|
|
259
|
+
const problem = findMessageProblem([{ role: 'user', content: 'ok' }, null]);
|
|
260
|
+
|
|
261
|
+
expect(problem?.index).toBe(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('rejects a missing or unsupported role before LangChain coerces it', () => {
|
|
265
|
+
expect(findMessageProblem([{ content: 'no role' }])?.reason).toContain("requires a string 'role'");
|
|
266
|
+
expect(findMessageProblem([{ role: 'function', content: 'x' }])?.reason).toContain(
|
|
267
|
+
"role 'function' is not supported",
|
|
268
|
+
);
|
|
269
|
+
expect(findMessageProblem([{ role: 'moderator', content: 'x' }])?.reason).toContain(
|
|
270
|
+
"role 'moderator' is not supported",
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('accepts every role LangChain can coerce', () => {
|
|
275
|
+
for (const role of ['system', 'developer', 'user', 'human', 'assistant', 'ai']) {
|
|
276
|
+
expect(findMessageProblem([{ role, content: 'hi' }]), role).toBeUndefined();
|
|
277
|
+
}
|
|
278
|
+
expect(findMessageProblem([{ role: 'tool', content: 'out', tool_call_id: 'call_1' }])).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('requires tool_call_id on a tool message', () => {
|
|
282
|
+
expect(findMessageProblem([{ role: 'tool', content: 'out' }])?.reason).toContain(
|
|
283
|
+
"requires a string 'tool_call_id'",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('requires content unless an assistant turn only carries tool_calls', () => {
|
|
288
|
+
expect(findMessageProblem([{ role: 'user' }])?.reason).toContain("requires a 'content' field");
|
|
289
|
+
expect(findMessageProblem([{ role: 'assistant', content: null }])?.reason).toContain("requires a 'content' field");
|
|
290
|
+
expect(
|
|
291
|
+
findMessageProblem([{ role: 'assistant', tool_calls: [{ id: 'call_1', type: 'function', function: {} }] }]),
|
|
292
|
+
).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('rejects a content value that is neither string nor array', () => {
|
|
296
|
+
expect(findMessageProblem([{ role: 'user', content: 42 }])?.reason).toContain('must be a string or an array');
|
|
297
|
+
expect(findMessageProblem([{ role: 'user', content: { text: 'x' } }])?.reason).toContain(
|
|
298
|
+
'must be a string or an array',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('accepts a well-formed multimodal conversation', () => {
|
|
303
|
+
expect(
|
|
304
|
+
findMessageProblem([
|
|
305
|
+
{ role: 'system', content: 'You are helpful.' },
|
|
306
|
+
{ role: 'user', content: [{ type: 'text', text: 'What is this?' }] },
|
|
307
|
+
]),
|
|
308
|
+
).toBeUndefined();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import { AiApiClientDisconnectedError, createRequestAbortController, isClientDisconnected } from '../utils/streaming';
|
|
3
|
+
|
|
4
|
+
class ListenerTarget {
|
|
5
|
+
private listeners = new Map<string, Set<() => void>>();
|
|
6
|
+
aborted = false;
|
|
7
|
+
writableEnded = false;
|
|
8
|
+
|
|
9
|
+
once(event: string, listener: () => void) {
|
|
10
|
+
const wrapped = () => {
|
|
11
|
+
this.off(event, wrapped);
|
|
12
|
+
listener();
|
|
13
|
+
};
|
|
14
|
+
const group = this.listeners.get(event) ?? new Set();
|
|
15
|
+
group.add(wrapped);
|
|
16
|
+
this.listeners.set(event, group);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
off(event: string, listener: () => void) {
|
|
20
|
+
this.listeners.get(event)?.delete(listener);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
emit(event: string) {
|
|
24
|
+
for (const listener of [...(this.listeners.get(event) ?? [])]) listener();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('AI API streaming cancellation classification', () => {
|
|
29
|
+
it('uses a recognizable client-disconnect abort reason', () => {
|
|
30
|
+
const req = new ListenerTarget();
|
|
31
|
+
const res = new ListenerTarget();
|
|
32
|
+
const ctx = { req, res } as unknown as Context;
|
|
33
|
+
const controller = createRequestAbortController(ctx);
|
|
34
|
+
|
|
35
|
+
req.aborted = true;
|
|
36
|
+
req.emit('aborted');
|
|
37
|
+
|
|
38
|
+
expect(controller.signal.aborted).toBe(true);
|
|
39
|
+
expect(controller.signal.reason).toBeInstanceOf(AiApiClientDisconnectedError);
|
|
40
|
+
expect(isClientDisconnected(ctx, controller.signal.reason)).toBe(true);
|
|
41
|
+
controller.dispose();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not treat an unrelated provider abort as client cancellation', () => {
|
|
45
|
+
const req = new ListenerTarget();
|
|
46
|
+
const res = new ListenerTarget();
|
|
47
|
+
const ctx = { req, res } as unknown as Context;
|
|
48
|
+
|
|
49
|
+
expect(isClientDisconnected(ctx, new DOMException('Provider timeout', 'AbortError'))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -41,6 +41,12 @@ export default defineCollection({
|
|
|
41
41
|
defaultValue: 60,
|
|
42
42
|
comment: 'Max requests per user per minute',
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: 'maxRequestBodyMb',
|
|
46
|
+
type: 'integer',
|
|
47
|
+
defaultValue: 10,
|
|
48
|
+
comment: 'Max request body size in MB. Raise this to accept inline base64 images in vision requests.',
|
|
49
|
+
},
|
|
44
50
|
{
|
|
45
51
|
name: 'quotaEnabled',
|
|
46
52
|
type: 'boolean',
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-model metadata overrides surfaced through the OpenAI-compatible GET /v1/models
|
|
14
|
+
* endpoint. Lets an admin correct or supply values that the upstream provider either
|
|
15
|
+
* reports incorrectly (e.g. an inflated context window) or omits entirely.
|
|
16
|
+
*
|
|
17
|
+
* A row is keyed by (llmService, model). All override fields are nullable — a null
|
|
18
|
+
* value means "do not override this attribute", so an admin can override just the
|
|
19
|
+
* context window without touching anything else.
|
|
20
|
+
*/
|
|
21
|
+
export default defineCollection({
|
|
22
|
+
name: 'aiApiModelMetadata',
|
|
23
|
+
autoGenId: true,
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: 'llmService', type: 'string', allowNull: false, index: true },
|
|
26
|
+
{ name: 'model', type: 'string', allowNull: false, index: true },
|
|
27
|
+
{
|
|
28
|
+
name: 'contextWindow',
|
|
29
|
+
type: 'integer',
|
|
30
|
+
allowNull: true,
|
|
31
|
+
comment:
|
|
32
|
+
'Override for the model context window (input+output token capacity). Returned as context_window / context_length.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'maxCompletionTokens',
|
|
36
|
+
type: 'integer',
|
|
37
|
+
allowNull: true,
|
|
38
|
+
comment: 'Override for the maximum output tokens. Returned as max_completion_tokens.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'ownedByOverride',
|
|
42
|
+
type: 'string',
|
|
43
|
+
allowNull: true,
|
|
44
|
+
comment: 'Override for the owned_by field in the OpenAI model object.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'displayName',
|
|
48
|
+
type: 'string',
|
|
49
|
+
allowNull: true,
|
|
50
|
+
comment: 'Friendly display name returned as display_name / name in the model object.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'description',
|
|
54
|
+
type: 'text',
|
|
55
|
+
allowNull: true,
|
|
56
|
+
comment: 'Human-readable description returned as description in the model object.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'enabled',
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
defaultValue: true,
|
|
62
|
+
index: true,
|
|
63
|
+
comment: 'When false, the model is hidden from /v1/models and reported as active:false.',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
indexes: [
|
|
67
|
+
{
|
|
68
|
+
fields: ['llmService', 'model'],
|
|
69
|
+
unique: true,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|