plugin-ai-api 1.1.0 → 1.1.2
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/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- 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 +38 -1
- package/dist/locale/vi-VN.json +177 -138
- package/dist/locale/zh-CN.json +177 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +34 -5
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +26 -4
- package/dist/server/utils/direct-llm-context.js +10 -9
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +45 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +38 -1
- package/src/locale/vi-VN.json +177 -138
- package/src/locale/zh-CN.json +177 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +15 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +42 -6
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +30 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +23 -12
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +50 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -0,0 +1,384 @@
|
|
|
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 type { OpenAIUsage } from './openai-format';
|
|
11
|
+
import {
|
|
12
|
+
chatResultToResponse,
|
|
13
|
+
createResponseOutputMessage,
|
|
14
|
+
createResponseReasoningItem,
|
|
15
|
+
extractReasoningText,
|
|
16
|
+
extractResponseIncompleteReason,
|
|
17
|
+
extractResponseOutputText,
|
|
18
|
+
extractResponseServiceTier,
|
|
19
|
+
generateResponseItemId,
|
|
20
|
+
responseIncompleteReason,
|
|
21
|
+
responseUsageFromOpenAIUsage,
|
|
22
|
+
type ResponseFunctionToolCall,
|
|
23
|
+
type ResponseObject,
|
|
24
|
+
type ResponseOutputMessage,
|
|
25
|
+
type ResponseOutputText,
|
|
26
|
+
type ResponseReasoningItem,
|
|
27
|
+
} from './responses-format';
|
|
28
|
+
|
|
29
|
+
export interface ResponseStreamEvent {
|
|
30
|
+
type: string;
|
|
31
|
+
sequence_number: number;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ToolCallState {
|
|
36
|
+
outputIndex: number;
|
|
37
|
+
item: ResponseFunctionToolCall;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResponseStreamState {
|
|
41
|
+
id: string;
|
|
42
|
+
model: string;
|
|
43
|
+
requestBody: Record<string, unknown>;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
sequenceNumber: number;
|
|
46
|
+
nextOutputIndex: number;
|
|
47
|
+
textItem?: { outputIndex: number; item: ResponseOutputMessage; text: string };
|
|
48
|
+
reasoningItem?: { outputIndex: number; item: ResponseReasoningItem; text: string };
|
|
49
|
+
toolCalls: Map<number, ToolCallState>;
|
|
50
|
+
usage?: OpenAIUsage;
|
|
51
|
+
finishReason?: string;
|
|
52
|
+
serviceTier?: ResponseObject['service_tier'];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function event(state: ResponseStreamState, value: Record<string, unknown> & { type: string }): ResponseStreamEvent {
|
|
56
|
+
return { ...value, sequence_number: state.sequenceNumber++ };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createResponseStreamState(
|
|
60
|
+
id: string,
|
|
61
|
+
model: string,
|
|
62
|
+
requestBody: Record<string, unknown>,
|
|
63
|
+
): ResponseStreamState {
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
model,
|
|
67
|
+
requestBody,
|
|
68
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
69
|
+
sequenceNumber: 0,
|
|
70
|
+
nextOutputIndex: 0,
|
|
71
|
+
toolCalls: new Map(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function responseSnapshot(state: ResponseStreamState, status: ResponseObject['status']): ResponseObject {
|
|
76
|
+
const content = state.textItem?.text ?? '';
|
|
77
|
+
const reasoningText = state.reasoningItem?.text;
|
|
78
|
+
const toolCalls = [...state.toolCalls.values()].map(({ item }) => ({
|
|
79
|
+
id: item.call_id,
|
|
80
|
+
type: 'function' as const,
|
|
81
|
+
function: { name: item.name, arguments: item.arguments },
|
|
82
|
+
}));
|
|
83
|
+
const response = chatResultToResponse({
|
|
84
|
+
id: state.id,
|
|
85
|
+
model: state.model,
|
|
86
|
+
content,
|
|
87
|
+
reasoningText,
|
|
88
|
+
usage: state.usage,
|
|
89
|
+
toolCalls,
|
|
90
|
+
finishReason: state.finishReason,
|
|
91
|
+
serviceTier: state.serviceTier,
|
|
92
|
+
requestBody: state.requestBody,
|
|
93
|
+
status,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Preserve the stable item IDs and the provider emission order clients saw in earlier events.
|
|
97
|
+
const indexedOutput: Array<{ index: number; item: ResponseObject['output'][number] }> = [];
|
|
98
|
+
if (state.reasoningItem) {
|
|
99
|
+
indexedOutput.push({
|
|
100
|
+
index: state.reasoningItem.outputIndex,
|
|
101
|
+
item: {
|
|
102
|
+
...state.reasoningItem.item,
|
|
103
|
+
status: status === 'in_progress' ? 'in_progress' : status === 'failed' ? 'incomplete' : 'completed',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (state.textItem) {
|
|
108
|
+
indexedOutput.push({
|
|
109
|
+
index: state.textItem.outputIndex,
|
|
110
|
+
item: {
|
|
111
|
+
...state.textItem.item,
|
|
112
|
+
status:
|
|
113
|
+
status === 'in_progress'
|
|
114
|
+
? 'in_progress'
|
|
115
|
+
: status === 'failed' || responseIncompleteReason(state.finishReason)
|
|
116
|
+
? 'incomplete'
|
|
117
|
+
: 'completed',
|
|
118
|
+
content: state.textItem.text ? [{ type: 'output_text', text: state.textItem.text, annotations: [] }] : [],
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
for (const { item, outputIndex } of state.toolCalls.values()) {
|
|
123
|
+
indexedOutput.push({
|
|
124
|
+
index: outputIndex,
|
|
125
|
+
item: {
|
|
126
|
+
...item,
|
|
127
|
+
status: status === 'in_progress' ? 'in_progress' : status === 'failed' ? 'incomplete' : 'completed',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const output = indexedOutput.sort((left, right) => left.index - right.index).map(({ item }) => item);
|
|
132
|
+
response.created_at = state.createdAt;
|
|
133
|
+
response.output = output;
|
|
134
|
+
response.output_text = content;
|
|
135
|
+
response.usage = responseUsageFromOpenAIUsage(state.usage);
|
|
136
|
+
return response;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createResponseStartEvents(state: ResponseStreamState): ResponseStreamEvent[] {
|
|
140
|
+
return [
|
|
141
|
+
event(state, { type: 'response.created', response: responseSnapshot(state, 'in_progress') }),
|
|
142
|
+
event(state, { type: 'response.in_progress', response: responseSnapshot(state, 'in_progress') }),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ensureTextItem(state: ResponseStreamState): ResponseStreamEvent[] {
|
|
147
|
+
if (state.textItem) return [];
|
|
148
|
+
const outputIndex = state.nextOutputIndex++;
|
|
149
|
+
const item = createResponseOutputMessage('', 'in_progress', generateResponseItemId('msg'));
|
|
150
|
+
state.textItem = { outputIndex, item, text: '' };
|
|
151
|
+
const part: ResponseOutputText = { type: 'output_text', text: '', annotations: [] };
|
|
152
|
+
return [
|
|
153
|
+
event(state, { type: 'response.output_item.added', output_index: outputIndex, item }),
|
|
154
|
+
event(state, {
|
|
155
|
+
type: 'response.content_part.added',
|
|
156
|
+
item_id: item.id,
|
|
157
|
+
output_index: outputIndex,
|
|
158
|
+
content_index: 0,
|
|
159
|
+
part,
|
|
160
|
+
}),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ensureReasoningItem(state: ResponseStreamState): ResponseStreamEvent[] {
|
|
165
|
+
if (state.reasoningItem) return [];
|
|
166
|
+
const outputIndex = state.nextOutputIndex++;
|
|
167
|
+
const item = createResponseReasoningItem('');
|
|
168
|
+
item.status = 'in_progress';
|
|
169
|
+
state.reasoningItem = { outputIndex, item, text: '' };
|
|
170
|
+
return [
|
|
171
|
+
event(state, { type: 'response.output_item.added', output_index: outputIndex, item }),
|
|
172
|
+
event(state, {
|
|
173
|
+
type: 'response.content_part.added',
|
|
174
|
+
item_id: item.id,
|
|
175
|
+
output_index: outputIndex,
|
|
176
|
+
content_index: 0,
|
|
177
|
+
part: { type: 'reasoning_text', text: '' },
|
|
178
|
+
}),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
183
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function appendToolCallChunks(state: ResponseStreamState, value: unknown): ResponseStreamEvent[] {
|
|
187
|
+
if (!Array.isArray(value)) return [];
|
|
188
|
+
const events: ResponseStreamEvent[] = [];
|
|
189
|
+
for (const [fallbackIndex, raw] of value.entries()) {
|
|
190
|
+
if (!isRecord(raw)) continue;
|
|
191
|
+
const index = typeof raw.index === 'number' ? raw.index : fallbackIndex;
|
|
192
|
+
const functionValue = isRecord(raw.function) ? raw.function : undefined;
|
|
193
|
+
const id = typeof raw.id === 'string' ? raw.id : undefined;
|
|
194
|
+
const name =
|
|
195
|
+
typeof raw.name === 'string'
|
|
196
|
+
? raw.name
|
|
197
|
+
: typeof functionValue?.name === 'string'
|
|
198
|
+
? functionValue.name
|
|
199
|
+
: undefined;
|
|
200
|
+
const argumentsDelta =
|
|
201
|
+
typeof raw.args === 'string'
|
|
202
|
+
? raw.args
|
|
203
|
+
: typeof functionValue?.arguments === 'string'
|
|
204
|
+
? functionValue.arguments
|
|
205
|
+
: raw.args === undefined
|
|
206
|
+
? ''
|
|
207
|
+
: JSON.stringify(raw.args);
|
|
208
|
+
|
|
209
|
+
let call = state.toolCalls.get(index);
|
|
210
|
+
if (!call) {
|
|
211
|
+
const callId = id ?? generateResponseItemId('fc');
|
|
212
|
+
const item: ResponseFunctionToolCall = {
|
|
213
|
+
id: generateResponseItemId('fc'),
|
|
214
|
+
type: 'function_call',
|
|
215
|
+
status: 'in_progress',
|
|
216
|
+
call_id: callId,
|
|
217
|
+
name: name ?? '',
|
|
218
|
+
arguments: '',
|
|
219
|
+
};
|
|
220
|
+
call = { outputIndex: state.nextOutputIndex++, item };
|
|
221
|
+
state.toolCalls.set(index, call);
|
|
222
|
+
events.push(event(state, { type: 'response.output_item.added', output_index: call.outputIndex, item }));
|
|
223
|
+
}
|
|
224
|
+
if (name) call.item.name = name;
|
|
225
|
+
// Provider chunks may carry the real call id only in a later delta — keep it in sync so
|
|
226
|
+
// tool results submitted against the streamed id match the stored item.
|
|
227
|
+
if (id) call.item.call_id = id;
|
|
228
|
+
if (argumentsDelta) {
|
|
229
|
+
call.item.arguments += argumentsDelta;
|
|
230
|
+
events.push(
|
|
231
|
+
event(state, {
|
|
232
|
+
type: 'response.function_call_arguments.delta',
|
|
233
|
+
item_id: call.item.id,
|
|
234
|
+
output_index: call.outputIndex,
|
|
235
|
+
delta: argumentsDelta,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return events;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function appendResponseStreamChunk(state: ResponseStreamState, chunk: unknown): ResponseStreamEvent[] {
|
|
244
|
+
if (!isRecord(chunk)) return [];
|
|
245
|
+
const events: ResponseStreamEvent[] = [];
|
|
246
|
+
const reasoningDelta = extractReasoningText(chunk);
|
|
247
|
+
if (reasoningDelta) {
|
|
248
|
+
events.push(...ensureReasoningItem(state));
|
|
249
|
+
const reasoning = state.reasoningItem as NonNullable<ResponseStreamState['reasoningItem']>;
|
|
250
|
+
reasoning.text += reasoningDelta;
|
|
251
|
+
reasoning.item.content = [{ type: 'reasoning_text', text: reasoning.text }];
|
|
252
|
+
events.push(
|
|
253
|
+
event(state, {
|
|
254
|
+
type: 'response.reasoning_text.delta',
|
|
255
|
+
item_id: reasoning.item.id,
|
|
256
|
+
output_index: reasoning.outputIndex,
|
|
257
|
+
content_index: 0,
|
|
258
|
+
delta: reasoningDelta,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const content = extractResponseOutputText(chunk);
|
|
264
|
+
if (content) {
|
|
265
|
+
events.push(...ensureTextItem(state));
|
|
266
|
+
const text = state.textItem as NonNullable<ResponseStreamState['textItem']>;
|
|
267
|
+
text.text += content;
|
|
268
|
+
text.item.content = [{ type: 'output_text', text: text.text, annotations: [] }];
|
|
269
|
+
events.push(
|
|
270
|
+
event(state, {
|
|
271
|
+
type: 'response.output_text.delta',
|
|
272
|
+
item_id: text.item.id,
|
|
273
|
+
output_index: text.outputIndex,
|
|
274
|
+
content_index: 0,
|
|
275
|
+
delta: content,
|
|
276
|
+
logprobs: [],
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
events.push(...appendToolCallChunks(state, chunk.tool_call_chunks));
|
|
282
|
+
const finishReason = [chunk.response_metadata, chunk.additional_kwargs]
|
|
283
|
+
.filter(isRecord)
|
|
284
|
+
.map((metadata) => metadata.finish_reason)
|
|
285
|
+
.find((candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0);
|
|
286
|
+
const incompleteReason = extractResponseIncompleteReason(chunk);
|
|
287
|
+
if (finishReason || incompleteReason) state.finishReason = finishReason ?? incompleteReason;
|
|
288
|
+
state.serviceTier = state.serviceTier ?? extractResponseServiceTier(chunk);
|
|
289
|
+
return events;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function setResponseStreamUsage(state: ResponseStreamState, usage: OpenAIUsage | undefined): void {
|
|
293
|
+
if (usage) state.usage = usage;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function finalizeResponseStream(state: ResponseStreamState): ResponseStreamEvent[] {
|
|
297
|
+
const events: ResponseStreamEvent[] = [];
|
|
298
|
+
const incompleteReason = responseIncompleteReason(state.finishReason);
|
|
299
|
+
if (state.reasoningItem) {
|
|
300
|
+
const { item, outputIndex, text } = state.reasoningItem;
|
|
301
|
+
item.status = 'completed';
|
|
302
|
+
events.push(
|
|
303
|
+
event(state, {
|
|
304
|
+
type: 'response.reasoning_text.done',
|
|
305
|
+
item_id: item.id,
|
|
306
|
+
output_index: outputIndex,
|
|
307
|
+
content_index: 0,
|
|
308
|
+
text,
|
|
309
|
+
}),
|
|
310
|
+
event(state, {
|
|
311
|
+
type: 'response.content_part.done',
|
|
312
|
+
item_id: item.id,
|
|
313
|
+
output_index: outputIndex,
|
|
314
|
+
content_index: 0,
|
|
315
|
+
part: { type: 'reasoning_text', text },
|
|
316
|
+
}),
|
|
317
|
+
event(state, { type: 'response.output_item.done', output_index: outputIndex, item }),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (state.textItem) {
|
|
321
|
+
const { item, outputIndex, text } = state.textItem;
|
|
322
|
+
item.status = incompleteReason ? 'incomplete' : 'completed';
|
|
323
|
+
const part: ResponseOutputText = { type: 'output_text', text, annotations: [] };
|
|
324
|
+
item.content = [part];
|
|
325
|
+
events.push(
|
|
326
|
+
event(state, {
|
|
327
|
+
type: 'response.output_text.done',
|
|
328
|
+
item_id: item.id,
|
|
329
|
+
output_index: outputIndex,
|
|
330
|
+
content_index: 0,
|
|
331
|
+
text,
|
|
332
|
+
logprobs: [],
|
|
333
|
+
}),
|
|
334
|
+
event(state, {
|
|
335
|
+
type: 'response.content_part.done',
|
|
336
|
+
item_id: item.id,
|
|
337
|
+
output_index: outputIndex,
|
|
338
|
+
content_index: 0,
|
|
339
|
+
part,
|
|
340
|
+
}),
|
|
341
|
+
event(state, { type: 'response.output_item.done', output_index: outputIndex, item }),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
for (const { item, outputIndex } of state.toolCalls.values()) {
|
|
345
|
+
item.status = 'completed';
|
|
346
|
+
events.push(
|
|
347
|
+
event(state, {
|
|
348
|
+
type: 'response.function_call_arguments.done',
|
|
349
|
+
item_id: item.id,
|
|
350
|
+
output_index: outputIndex,
|
|
351
|
+
name: item.name,
|
|
352
|
+
arguments: item.arguments,
|
|
353
|
+
}),
|
|
354
|
+
event(state, { type: 'response.output_item.done', output_index: outputIndex, item }),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const response = responseSnapshot(state, incompleteReason ? 'incomplete' : 'completed');
|
|
358
|
+
events.push(
|
|
359
|
+
event(state, { type: response.status === 'incomplete' ? 'response.incomplete' : 'response.completed', response }),
|
|
360
|
+
);
|
|
361
|
+
return events;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function createResponseErrorEvent(
|
|
365
|
+
state: ResponseStreamState,
|
|
366
|
+
error: { message: string; code?: string },
|
|
367
|
+
): ResponseStreamEvent {
|
|
368
|
+
return event(state, {
|
|
369
|
+
type: 'error',
|
|
370
|
+
code: error.code ?? 'server_error',
|
|
371
|
+
message: error.message,
|
|
372
|
+
param: null,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function createResponseFailedEvent(
|
|
377
|
+
state: ResponseStreamState,
|
|
378
|
+
error: { message: string; code?: string },
|
|
379
|
+
): ResponseStreamEvent {
|
|
380
|
+
const response = responseSnapshot(state, 'failed');
|
|
381
|
+
response.error = { code: error.code ?? 'server_error', message: error.message };
|
|
382
|
+
response.completed_at = null;
|
|
383
|
+
return event(state, { type: 'response.failed', response });
|
|
384
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
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 { Context } from '@nocobase/actions';
|
|
11
|
+
import type { Model } from '@nocobase/database';
|
|
12
|
+
import { toOpenAIError } from './openai-format';
|
|
13
|
+
import { resolveModelReference } from './resolve-service';
|
|
14
|
+
import { AiApiAccessScope, isModelAllowed, isServiceAllowed, resolveUserAccessScope } from './user-permissions';
|
|
15
|
+
import { getAiApiConfig } from './request-cache';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Virtual-model routing ("auto" and admin-defined aliases).
|
|
19
|
+
*
|
|
20
|
+
* A virtual alias resolves to a concrete `service/modelId` purely from the
|
|
21
|
+
* request's structural shape — never from prompt semantics (that is the
|
|
22
|
+
* deliberately deferred LLM-classifier path). The resolver walks an ordered
|
|
23
|
+
* bucket of candidates and picks the FIRST model the caller is permitted to
|
|
24
|
+
* use; when none is usable it falls back to the configured `fallbackModel`.
|
|
25
|
+
*
|
|
26
|
+
* Security invariant: a candidate is only returned when it passes the caller's
|
|
27
|
+
* usage-group scope (isServiceAllowed + isModelAllowed), so an alias can never
|
|
28
|
+
* widen access beyond what the user could call directly.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface VirtualModel {
|
|
32
|
+
name: string;
|
|
33
|
+
mode: VirtualModelMode;
|
|
34
|
+
fallbackModel: string;
|
|
35
|
+
visionModels?: string[];
|
|
36
|
+
toolModels?: string[];
|
|
37
|
+
reasoningModels?: string[];
|
|
38
|
+
cheapModels?: string[];
|
|
39
|
+
generalModels?: string[];
|
|
40
|
+
enabled?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type VirtualModelMode = 'chat' | 'embedding';
|
|
44
|
+
|
|
45
|
+
export interface VirtualResolution {
|
|
46
|
+
status: 'resolved';
|
|
47
|
+
/** The alias the client asked for, e.g. "auto". */
|
|
48
|
+
virtualModel: string;
|
|
49
|
+
/** Why this bucket was chosen — recorded for audit. */
|
|
50
|
+
reason: 'vision' | 'tools' | 'structured_output' | 'reasoning' | 'cheap' | 'general' | 'fallback';
|
|
51
|
+
/** The resolved concrete service + modelId, ready for the existing pipeline. */
|
|
52
|
+
resolved: { service: Model; modelId: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UnavailableVirtualModel {
|
|
56
|
+
status: 'unavailable';
|
|
57
|
+
virtualModel: string;
|
|
58
|
+
reason: 'mode_mismatch' | 'no_permitted_model' | 'permission_check_failed';
|
|
59
|
+
configuredMode: VirtualModelMode;
|
|
60
|
+
requestedMode: VirtualModelMode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type VirtualModelLookup = VirtualResolution | UnavailableVirtualModel;
|
|
64
|
+
|
|
65
|
+
interface RequestSignals {
|
|
66
|
+
hasImage: boolean;
|
|
67
|
+
hasTools: boolean;
|
|
68
|
+
wantsStructuredOutput: boolean;
|
|
69
|
+
wantsReasoning: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return typeof value === 'object' && value !== null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function contentHasImage(content: unknown): boolean {
|
|
77
|
+
if (!Array.isArray(content)) return false;
|
|
78
|
+
return content.some((block) => {
|
|
79
|
+
if (!isRecord(block)) return false;
|
|
80
|
+
if (block.type === 'image_url') return true;
|
|
81
|
+
if (block.type === 'file') return true;
|
|
82
|
+
if (block.type === 'file_url') return true;
|
|
83
|
+
return false;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Extract the structural signals that decide the routing bucket. */
|
|
88
|
+
export function detectRequestSignals(body: Record<string, unknown>): RequestSignals {
|
|
89
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
90
|
+
const hasImage = messages.some((msg) => isRecord(msg) && contentHasImage(msg.content));
|
|
91
|
+
const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
|
|
92
|
+
const rf = body.response_format;
|
|
93
|
+
const wantsStructuredOutput = isRecord(rf) && (rf.type === 'json_object' || rf.type === 'json_schema');
|
|
94
|
+
const wantsReasoning =
|
|
95
|
+
(Object.hasOwn(body, 'reasoning') && body.reasoning !== undefined && body.reasoning !== null) ||
|
|
96
|
+
(Object.hasOwn(body, 'reasoning_effort') && body.reasoning_effort !== undefined && body.reasoning_effort !== null);
|
|
97
|
+
return { hasImage, hasTools, wantsStructuredOutput, wantsReasoning };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function valueOf<T>(model: unknown, key: string): T | undefined {
|
|
101
|
+
if (!model) return undefined;
|
|
102
|
+
if (typeof (model as { get?: unknown }).get === 'function') {
|
|
103
|
+
return (model as { get: (k: string) => unknown }).get(key) as T | undefined;
|
|
104
|
+
}
|
|
105
|
+
return (model as Record<string, unknown>)[key] as T | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stringList(value: unknown): string[] {
|
|
109
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.length > 0) : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toVirtualModel(row: unknown, fallbackName = ''): VirtualModel {
|
|
113
|
+
return {
|
|
114
|
+
name: String(valueOf(row, 'name') ?? fallbackName),
|
|
115
|
+
mode: valueOf<string>(row, 'mode') === 'embedding' ? 'embedding' : 'chat',
|
|
116
|
+
fallbackModel: String(valueOf(row, 'fallbackModel') ?? ''),
|
|
117
|
+
visionModels: stringList(valueOf(row, 'visionModels')),
|
|
118
|
+
toolModels: stringList(valueOf(row, 'toolModels')),
|
|
119
|
+
reasoningModels: stringList(valueOf(row, 'reasoningModels')),
|
|
120
|
+
cheapModels: stringList(valueOf(row, 'cheapModels')),
|
|
121
|
+
generalModels: stringList(valueOf(row, 'generalModels')),
|
|
122
|
+
enabled: valueOf<boolean>(row, 'enabled'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadVirtualModel(ctx: Context, name: string): Promise<VirtualModel | null> {
|
|
127
|
+
const row = await ctx.db.getRepository('aiApiVirtualModels').findOne({ filter: { name, enabled: true } });
|
|
128
|
+
return row ? toVirtualModel(row, name) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Derive an ordered bucket from aiApiModelMetadata when the admin left the
|
|
133
|
+
* explicit list empty: enabled rows matching the capability, ascending sortOrder.
|
|
134
|
+
*/
|
|
135
|
+
async function deriveBucket(ctx: Context, capability: 'vision' | 'tool' | 'reasoning' | 'general'): Promise<string[]> {
|
|
136
|
+
const repo = ctx.db.getRepository('aiApiModelMetadata');
|
|
137
|
+
const filter: Record<string, unknown> = { enabled: true };
|
|
138
|
+
if (capability === 'vision') filter.supportsVision = true;
|
|
139
|
+
else if (capability === 'tool') filter.supportsToolCalling = true;
|
|
140
|
+
else if (capability === 'reasoning') filter.reasoningTier = 'reasoning';
|
|
141
|
+
// The general bucket is a catch-all for requests with no capability signal. It intentionally
|
|
142
|
+
// includes every enabled model (any reasoningTier) so a general request can be served by
|
|
143
|
+
// whatever the admin ranked first via sortOrder.
|
|
144
|
+
else filter.reasoningTier = { $in: ['general', 'cheap', 'reasoning'] };
|
|
145
|
+
|
|
146
|
+
const rows = await repo.find({ filter, sort: 'sortOrder', pageSize: 200 });
|
|
147
|
+
const list = (rows as unknown[]).map((row) => {
|
|
148
|
+
const service = valueOf<string>(row, 'llmService');
|
|
149
|
+
const model = valueOf<string>(row, 'model');
|
|
150
|
+
return service && model ? `${service}/${model}` : '';
|
|
151
|
+
});
|
|
152
|
+
return list.filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function bucketFor(
|
|
156
|
+
ctx: Context,
|
|
157
|
+
vm: VirtualModel,
|
|
158
|
+
signals: RequestSignals,
|
|
159
|
+
): Promise<{ reason: VirtualResolution['reason']; candidates: string[] }> {
|
|
160
|
+
const explicit = (list: string[] | undefined) => (list && list.length ? list : null);
|
|
161
|
+
if (signals.hasImage) {
|
|
162
|
+
return { reason: 'vision', candidates: explicit(vm.visionModels) ?? (await deriveBucket(ctx, 'vision')) };
|
|
163
|
+
}
|
|
164
|
+
if (signals.hasTools) {
|
|
165
|
+
return { reason: 'tools', candidates: explicit(vm.toolModels) ?? (await deriveBucket(ctx, 'tool')) };
|
|
166
|
+
}
|
|
167
|
+
if (signals.wantsReasoning) {
|
|
168
|
+
return { reason: 'reasoning', candidates: explicit(vm.reasoningModels) ?? (await deriveBucket(ctx, 'reasoning')) };
|
|
169
|
+
}
|
|
170
|
+
if (signals.wantsStructuredOutput) {
|
|
171
|
+
return {
|
|
172
|
+
reason: 'structured_output',
|
|
173
|
+
candidates: explicit(vm.generalModels) ?? (await deriveBucket(ctx, 'general')),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Default to the cheapest bucket the admin configured; fall back to general.
|
|
177
|
+
const cheap = explicit(vm.cheapModels);
|
|
178
|
+
if (cheap) return { reason: 'cheap', candidates: cheap };
|
|
179
|
+
return { reason: 'general', candidates: explicit(vm.generalModels) ?? (await deriveBucket(ctx, 'general')) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve a virtual alias to a concrete model for the current caller.
|
|
184
|
+
* Returns null when the alias does not exist or is disabled (caller then falls
|
|
185
|
+
* through to the normal model resolution).
|
|
186
|
+
*/
|
|
187
|
+
export async function resolveVirtualModel(
|
|
188
|
+
ctx: Context,
|
|
189
|
+
alias: string,
|
|
190
|
+
body: Record<string, unknown>,
|
|
191
|
+
requestedMode: VirtualModelMode,
|
|
192
|
+
): Promise<VirtualModelLookup | null> {
|
|
193
|
+
const vm = await loadVirtualModel(ctx, alias);
|
|
194
|
+
if (!vm) return null;
|
|
195
|
+
if (vm.mode !== requestedMode) {
|
|
196
|
+
return {
|
|
197
|
+
status: 'unavailable',
|
|
198
|
+
virtualModel: alias,
|
|
199
|
+
reason: 'mode_mismatch',
|
|
200
|
+
configuredMode: vm.mode,
|
|
201
|
+
requestedMode,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const config = await getAiApiConfig(ctx);
|
|
206
|
+
const globalServices = valueOf<unknown>(config, 'enabledLlmServices') ?? [];
|
|
207
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
208
|
+
if (scope.lookupFailed) {
|
|
209
|
+
return {
|
|
210
|
+
status: 'unavailable',
|
|
211
|
+
virtualModel: alias,
|
|
212
|
+
reason: 'permission_check_failed',
|
|
213
|
+
configuredMode: vm.mode,
|
|
214
|
+
requestedMode,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const usable = (service: Model, modelId: string) =>
|
|
218
|
+
valueOf<boolean>(service, 'enabled') !== false &&
|
|
219
|
+
isServiceAllowed(scope, globalServices, {
|
|
220
|
+
name: valueOf<string>(service, 'name'),
|
|
221
|
+
title: valueOf<string>(service, 'title'),
|
|
222
|
+
}) &&
|
|
223
|
+
isModelAllowed(scope, `${valueOf<string>(service, 'name')}/${modelId}`);
|
|
224
|
+
|
|
225
|
+
// Embedding requests have none of the chat capability signals below. Their
|
|
226
|
+
// alias is therefore a stable endpoint-family name for the configured fallback.
|
|
227
|
+
if (requestedMode === 'embedding') {
|
|
228
|
+
const fallback = await resolveModelReference(ctx, vm.fallbackModel);
|
|
229
|
+
if (fallback && usable(fallback.service, fallback.modelId)) {
|
|
230
|
+
return { status: 'resolved', virtualModel: alias, reason: 'fallback', resolved: fallback };
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
status: 'unavailable',
|
|
234
|
+
virtualModel: alias,
|
|
235
|
+
reason: 'no_permitted_model',
|
|
236
|
+
configuredMode: vm.mode,
|
|
237
|
+
requestedMode,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const signals = detectRequestSignals(body);
|
|
241
|
+
const { reason, candidates } = await bucketFor(ctx, vm, signals);
|
|
242
|
+
|
|
243
|
+
for (const candidate of candidates) {
|
|
244
|
+
const resolved = await resolveModelReference(ctx, candidate);
|
|
245
|
+
if (resolved && usable(resolved.service, resolved.modelId)) {
|
|
246
|
+
return { status: 'resolved', virtualModel: alias, reason, resolved };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const fallback = await resolveModelReference(ctx, vm.fallbackModel);
|
|
251
|
+
if (fallback && usable(fallback.service, fallback.modelId)) {
|
|
252
|
+
return { status: 'resolved', virtualModel: alias, reason: 'fallback', resolved: fallback };
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
status: 'unavailable',
|
|
256
|
+
virtualModel: alias,
|
|
257
|
+
reason: 'no_permitted_model',
|
|
258
|
+
configuredMode: vm.mode,
|
|
259
|
+
requestedMode,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Return aliases whose required fallback model is accessible to the caller. */
|
|
264
|
+
export async function listAccessibleVirtualModels(
|
|
265
|
+
ctx: Context,
|
|
266
|
+
scope: AiApiAccessScope,
|
|
267
|
+
globalServices: unknown,
|
|
268
|
+
): Promise<VirtualModel[]> {
|
|
269
|
+
const rows = await ctx.db.getRepository('aiApiVirtualModels').find({ filter: { enabled: true } });
|
|
270
|
+
const accessible: VirtualModel[] = [];
|
|
271
|
+
|
|
272
|
+
for (const row of rows as unknown[]) {
|
|
273
|
+
const vm = toVirtualModel(row);
|
|
274
|
+
if (!vm.fallbackModel) continue;
|
|
275
|
+
const resolved = await resolveModelReference(ctx, vm.fallbackModel);
|
|
276
|
+
if (!resolved || valueOf<boolean>(resolved.service, 'enabled') === false) continue;
|
|
277
|
+
const service = {
|
|
278
|
+
name: valueOf<string>(resolved.service, 'name'),
|
|
279
|
+
title: valueOf<string>(resolved.service, 'title'),
|
|
280
|
+
};
|
|
281
|
+
if (
|
|
282
|
+
isServiceAllowed(scope, globalServices, service) &&
|
|
283
|
+
isModelAllowed(scope, `${service.name}/${resolved.modelId}`)
|
|
284
|
+
) {
|
|
285
|
+
accessible.push(vm);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return accessible;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function respondVirtualModelUnavailable(ctx: Context, result: UnavailableVirtualModel): void {
|
|
293
|
+
if (result.reason === 'permission_check_failed') {
|
|
294
|
+
ctx.status = 503;
|
|
295
|
+
ctx.body = toOpenAIError(
|
|
296
|
+
503,
|
|
297
|
+
'Unable to verify LLM permissions for this user. Please retry shortly.',
|
|
298
|
+
'service_unavailable',
|
|
299
|
+
'permission_check_failed',
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (result.reason === 'mode_mismatch') {
|
|
304
|
+
ctx.status = 404;
|
|
305
|
+
ctx.body = toOpenAIError(
|
|
306
|
+
404,
|
|
307
|
+
`Virtual model '${result.virtualModel}' serves ${result.configuredMode} requests and cannot be used for ${result.requestedMode} requests.`,
|
|
308
|
+
'invalid_request_error',
|
|
309
|
+
'model_not_found',
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
ctx.status = 403;
|
|
314
|
+
ctx.body = toOpenAIError(
|
|
315
|
+
403,
|
|
316
|
+
`No model behind virtual alias '${result.virtualModel}' is available to this user. Use GET /v1/models to see available models.`,
|
|
317
|
+
'permission_denied',
|
|
318
|
+
'model_not_available',
|
|
319
|
+
);
|
|
320
|
+
}
|