plugin-ai-api 1.1.1 → 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/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/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -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/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/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- 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 +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- 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 +44 -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/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/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- 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 +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -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/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/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- 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 +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- 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 +49 -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-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.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,640 @@
|
|
|
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 PluginAiApiServer from '../plugin';
|
|
12
|
+
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
13
|
+
import { FileProcessorError } from '../services/file-processor';
|
|
14
|
+
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
15
|
+
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
16
|
+
import {
|
|
17
|
+
DirectLlmContextError,
|
|
18
|
+
prepareDirectLlmContext,
|
|
19
|
+
type ContextOverflowBehavior,
|
|
20
|
+
type OpenAIMessage,
|
|
21
|
+
} from '../utils/direct-llm-context';
|
|
22
|
+
import { formatSSE, formatSSEDone, toOpenAIError } from '../utils/openai-format';
|
|
23
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
24
|
+
import {
|
|
25
|
+
deleteResponseRecord,
|
|
26
|
+
getResponseRecord,
|
|
27
|
+
loadConversationChain,
|
|
28
|
+
storeResponseRecord,
|
|
29
|
+
} from '../utils/response-store';
|
|
30
|
+
import {
|
|
31
|
+
chatResultToResponse,
|
|
32
|
+
extractReasoningText,
|
|
33
|
+
extractResponseIncompleteReason,
|
|
34
|
+
extractResponseOutputText,
|
|
35
|
+
extractResponseServiceTier,
|
|
36
|
+
findUnsupportedResponseInput,
|
|
37
|
+
findUnsupportedResponseParameter,
|
|
38
|
+
findResponseInputProblem,
|
|
39
|
+
findResponseParameterProblem,
|
|
40
|
+
findResponseToolChoiceProblem,
|
|
41
|
+
findResponseToolProblem,
|
|
42
|
+
generateResponseId,
|
|
43
|
+
isRecord,
|
|
44
|
+
isResponseServiceTier,
|
|
45
|
+
responsesBodyToChatBody,
|
|
46
|
+
type ResponseObject,
|
|
47
|
+
} from '../utils/responses-format';
|
|
48
|
+
import {
|
|
49
|
+
appendResponseStreamChunk,
|
|
50
|
+
createResponseErrorEvent,
|
|
51
|
+
createResponseFailedEvent,
|
|
52
|
+
createResponseStartEvents,
|
|
53
|
+
createResponseStreamState,
|
|
54
|
+
finalizeResponseStream,
|
|
55
|
+
setResponseStreamUsage,
|
|
56
|
+
} from '../utils/responses-stream';
|
|
57
|
+
import { resolveModelString } from '../utils/resolve-service';
|
|
58
|
+
import { createRequestAbortController, isClientDisconnected, writeResponse } from '../utils/streaming';
|
|
59
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
60
|
+
import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
|
|
61
|
+
import {
|
|
62
|
+
applyProviderRequestParameters,
|
|
63
|
+
bindRequestTools,
|
|
64
|
+
extractFinishReason,
|
|
65
|
+
findContentBlockProblem,
|
|
66
|
+
findMessageProblem,
|
|
67
|
+
getProviderRequestParameters,
|
|
68
|
+
normalizeMessageContent,
|
|
69
|
+
normalizeToolCalls,
|
|
70
|
+
processMessageContentFileBlocks,
|
|
71
|
+
type MessageContent,
|
|
72
|
+
} from './chat-completions';
|
|
73
|
+
|
|
74
|
+
interface ProviderResult {
|
|
75
|
+
content?: unknown;
|
|
76
|
+
usage_metadata?: unknown;
|
|
77
|
+
response_metadata?: unknown;
|
|
78
|
+
tool_calls?: unknown;
|
|
79
|
+
additional_kwargs?: unknown;
|
|
80
|
+
reasoning_content?: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RunnableChatModel {
|
|
84
|
+
invoke(messages: unknown[], params: Record<string, unknown>): Promise<ProviderResult>;
|
|
85
|
+
stream(messages: unknown[], params: Record<string, unknown>): Promise<AsyncIterable<ProviderResult>>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface LlmProviderInstance {
|
|
89
|
+
createModel(): unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface LlmProviderConstructor {
|
|
93
|
+
new (options: { app: unknown; serviceOptions: unknown; modelOptions: Record<string, unknown> }): LlmProviderInstance;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface LlmProviderMetadata {
|
|
97
|
+
provider: LlmProviderConstructor;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Provider keys registered in plugin-ai whose chat model speaks the OpenAI Responses protocol.
|
|
101
|
+
// Only these providers accept the `store` field in the upstream request payload.
|
|
102
|
+
export const RESPONSES_CAPABLE_PROVIDERS = new Set(['openai']);
|
|
103
|
+
|
|
104
|
+
function isResponsesCapableProvider(providerName: string): boolean {
|
|
105
|
+
return RESPONSES_CAPABLE_PROVIDERS.has(providerName);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getErrorMessage(error: unknown, fallback: string): string {
|
|
109
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getCurrentUserId(ctx: Context): string | number | bigint | undefined {
|
|
113
|
+
return ctx.state.currentUser?.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateRequest(ctx: Context, body: Record<string, unknown>): boolean {
|
|
117
|
+
if (typeof body.model !== 'string' || !body.model) {
|
|
118
|
+
ctx.status = 400;
|
|
119
|
+
ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (body.input === undefined || body.input === null) {
|
|
123
|
+
ctx.status = 400;
|
|
124
|
+
ctx.body = toOpenAIError(400, "'input' is required", 'invalid_request_error', 'missing_input');
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (body.model.trim().length === 0) {
|
|
128
|
+
ctx.status = 400;
|
|
129
|
+
ctx.body = toOpenAIError(400, "'model' must not be blank", 'invalid_request_error', 'missing_model');
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const parameterProblem = findResponseParameterProblem(body);
|
|
133
|
+
if (parameterProblem) {
|
|
134
|
+
ctx.status = 400;
|
|
135
|
+
ctx.body = toOpenAIError(400, parameterProblem, 'invalid_request_error', 'invalid_parameter');
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const inputProblem = findResponseInputProblem(body.input);
|
|
139
|
+
if (inputProblem) {
|
|
140
|
+
ctx.status = 400;
|
|
141
|
+
ctx.body = toOpenAIError(400, inputProblem, 'invalid_request_error', 'invalid_input');
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const unsupportedParameter = findUnsupportedResponseParameter(body);
|
|
145
|
+
if (unsupportedParameter) {
|
|
146
|
+
ctx.status = 400;
|
|
147
|
+
ctx.body = toOpenAIError(
|
|
148
|
+
400,
|
|
149
|
+
`Responses API parameter '${unsupportedParameter}' is not supported by this gateway`,
|
|
150
|
+
'invalid_request_error',
|
|
151
|
+
'unsupported_parameter',
|
|
152
|
+
);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
body.truncation !== undefined &&
|
|
157
|
+
body.truncation !== null &&
|
|
158
|
+
body.truncation !== 'auto' &&
|
|
159
|
+
body.truncation !== 'disabled'
|
|
160
|
+
) {
|
|
161
|
+
ctx.status = 400;
|
|
162
|
+
ctx.body = toOpenAIError(
|
|
163
|
+
400,
|
|
164
|
+
"'truncation' must be 'auto' or 'disabled'",
|
|
165
|
+
'invalid_request_error',
|
|
166
|
+
'invalid_truncation',
|
|
167
|
+
);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (body.service_tier !== undefined && body.service_tier !== null && !isResponseServiceTier(body.service_tier)) {
|
|
171
|
+
ctx.status = 400;
|
|
172
|
+
ctx.body = toOpenAIError(
|
|
173
|
+
400,
|
|
174
|
+
"'service_tier' must be one of auto, default, flex, scale, or priority",
|
|
175
|
+
'invalid_request_error',
|
|
176
|
+
'invalid_service_tier',
|
|
177
|
+
);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const unsupportedInput = findUnsupportedResponseInput(body.input);
|
|
181
|
+
if (unsupportedInput) {
|
|
182
|
+
ctx.status = 400;
|
|
183
|
+
ctx.body = toOpenAIError(
|
|
184
|
+
400,
|
|
185
|
+
`Responses API input '${unsupportedInput}' is not supported because this gateway has no OpenAI file store. Use image_url, file_url, or file_data instead.`,
|
|
186
|
+
'invalid_request_error',
|
|
187
|
+
'unsupported_input',
|
|
188
|
+
);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const toolProblem = findResponseToolProblem(body.tools);
|
|
192
|
+
if (toolProblem) {
|
|
193
|
+
ctx.status = 400;
|
|
194
|
+
ctx.body = toOpenAIError(400, toolProblem, 'invalid_request_error', 'unsupported_tool');
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const toolChoiceProblem = findResponseToolChoiceProblem(body.tool_choice);
|
|
198
|
+
if (toolChoiceProblem) {
|
|
199
|
+
ctx.status = 400;
|
|
200
|
+
ctx.body = toOpenAIError(400, toolChoiceProblem, 'invalid_request_error', 'invalid_tool_choice');
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (body.metadata !== undefined && body.metadata !== null) {
|
|
204
|
+
const entries = isRecord(body.metadata) ? Object.entries(body.metadata) : [];
|
|
205
|
+
if (
|
|
206
|
+
!isRecord(body.metadata) ||
|
|
207
|
+
entries.length > 16 ||
|
|
208
|
+
entries.some(([key, value]) => key.length > 64 || typeof value !== 'string' || value.length > 512)
|
|
209
|
+
) {
|
|
210
|
+
ctx.status = 400;
|
|
211
|
+
ctx.body = toOpenAIError(
|
|
212
|
+
400,
|
|
213
|
+
"'metadata' must contain at most 16 string values (keys <= 64 chars, values <= 512 chars)",
|
|
214
|
+
'invalid_request_error',
|
|
215
|
+
'invalid_metadata',
|
|
216
|
+
);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toLangChainMessages(messages: OpenAIMessage[]): Array<unknown> {
|
|
224
|
+
return messages.map((message) => {
|
|
225
|
+
const role = message.role === 'assistant' ? 'ai' : message.role;
|
|
226
|
+
const content = normalizeMessageContent(message.content);
|
|
227
|
+
if (message.role === 'assistant' && message.tool_calls) {
|
|
228
|
+
return {
|
|
229
|
+
role,
|
|
230
|
+
content,
|
|
231
|
+
tool_calls: message.tool_calls,
|
|
232
|
+
additional_kwargs: {
|
|
233
|
+
...(isRecord(message.additional_kwargs) ? message.additional_kwargs : {}),
|
|
234
|
+
tool_calls: message.tool_calls,
|
|
235
|
+
},
|
|
236
|
+
...(isRecord(message.response_metadata) ? { response_metadata: message.response_metadata } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (message.role === 'assistant' && message.additional_kwargs) {
|
|
240
|
+
return {
|
|
241
|
+
role,
|
|
242
|
+
content,
|
|
243
|
+
additional_kwargs: message.additional_kwargs,
|
|
244
|
+
...(isRecord(message.response_metadata) ? { response_metadata: message.response_metadata } : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (message.role === 'tool') {
|
|
248
|
+
return { role: 'tool', content, tool_call_id: message.tool_call_id, name: message.name };
|
|
249
|
+
}
|
|
250
|
+
return [role, content] as [string, MessageContent];
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function responseOverflowBehavior(body: Record<string, unknown>): ContextOverflowBehavior {
|
|
255
|
+
return body.truncation === 'auto' ? 'truncate' : 'reject';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const RESPONSE_INVOCATION_PARAMETERS = new Set([
|
|
259
|
+
'max_completion_tokens',
|
|
260
|
+
'parallel_tool_calls',
|
|
261
|
+
'promptCacheKey',
|
|
262
|
+
'promptCacheRetention',
|
|
263
|
+
'reasoning',
|
|
264
|
+
'response_format',
|
|
265
|
+
'service_tier',
|
|
266
|
+
'temperature',
|
|
267
|
+
'top_p',
|
|
268
|
+
'user',
|
|
269
|
+
'verbosity',
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
function responseModelKwargs(parameters: Record<string, unknown>): Record<string, unknown> {
|
|
273
|
+
return Object.fromEntries(Object.entries(parameters).filter(([name]) => !RESPONSE_INVOCATION_PARAMETERS.has(name)));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function findCompletedResponse(events: Array<Record<string, unknown>>): ResponseObject | undefined {
|
|
277
|
+
const terminal = events.find((event) => event.type === 'response.completed' || event.type === 'response.incomplete');
|
|
278
|
+
return terminal && isRecord(terminal.response) ? (terminal.response as unknown as ResponseObject) : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function findUnsupportedRetrieveParameter(ctx: Context): string | undefined {
|
|
282
|
+
const query = isRecord(ctx.query) ? ctx.query : {};
|
|
283
|
+
for (const name of ['include', 'include[]', 'include_obfuscation', 'starting_after']) {
|
|
284
|
+
if (Object.hasOwn(query, name)) return name === 'include[]' ? 'include' : name;
|
|
285
|
+
}
|
|
286
|
+
if (Object.hasOwn(query, 'stream') && query.stream !== false && query.stream !== 'false') return 'stream';
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function handleGetResponse(ctx: Context, responseId: string): Promise<void> {
|
|
291
|
+
const unsupportedParameter = findUnsupportedRetrieveParameter(ctx);
|
|
292
|
+
if (unsupportedParameter) {
|
|
293
|
+
ctx.status = 400;
|
|
294
|
+
ctx.body = toOpenAIError(
|
|
295
|
+
400,
|
|
296
|
+
`Responses API retrieve parameter '${unsupportedParameter}' is not supported by this gateway`,
|
|
297
|
+
'invalid_request_error',
|
|
298
|
+
'unsupported_parameter',
|
|
299
|
+
);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const userId = getCurrentUserId(ctx);
|
|
303
|
+
const record = userId === undefined ? null : await getResponseRecord(ctx, responseId, userId);
|
|
304
|
+
if (!record) {
|
|
305
|
+
ctx.status = 404;
|
|
306
|
+
ctx.body = toOpenAIError(
|
|
307
|
+
404,
|
|
308
|
+
`Response '${responseId}' was not found`,
|
|
309
|
+
'invalid_request_error',
|
|
310
|
+
'response_not_found',
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
ctx.status = 200;
|
|
315
|
+
ctx.body = record.output;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function handleDeleteResponse(ctx: Context, responseId: string): Promise<void> {
|
|
319
|
+
const userId = getCurrentUserId(ctx);
|
|
320
|
+
const deleted = userId === undefined ? false : await deleteResponseRecord(ctx, responseId, userId);
|
|
321
|
+
if (!deleted) {
|
|
322
|
+
ctx.status = 404;
|
|
323
|
+
ctx.body = toOpenAIError(
|
|
324
|
+
404,
|
|
325
|
+
`Response '${responseId}' was not found`,
|
|
326
|
+
'invalid_request_error',
|
|
327
|
+
'response_not_found',
|
|
328
|
+
);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
ctx.status = 204;
|
|
332
|
+
ctx.body = undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** POST /api/ai-llm/v1/responses */
|
|
336
|
+
export async function handleResponses(ctx: Context, plugin: PluginAiApiServer): Promise<void> {
|
|
337
|
+
const body = (ctx.request.body ?? {}) as Record<string, unknown>;
|
|
338
|
+
if (!validateRequest(ctx, body)) return;
|
|
339
|
+
|
|
340
|
+
const chatBody = responsesBodyToChatBody(body);
|
|
341
|
+
let messages = Array.isArray(chatBody.messages) ? (chatBody.messages as OpenAIMessage[]) : [];
|
|
342
|
+
if (messages.length === 0) {
|
|
343
|
+
ctx.status = 400;
|
|
344
|
+
ctx.body = toOpenAIError(
|
|
345
|
+
400,
|
|
346
|
+
"'input' must contain at least one supported message or function call item",
|
|
347
|
+
'invalid_request_error',
|
|
348
|
+
'invalid_input',
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const messageProblem = findMessageProblem(messages);
|
|
354
|
+
if (messageProblem) {
|
|
355
|
+
ctx.status = 400;
|
|
356
|
+
ctx.body = toOpenAIError(
|
|
357
|
+
400,
|
|
358
|
+
`Invalid input[${messageProblem.index}]: ${messageProblem.reason}.`,
|
|
359
|
+
'invalid_request_error',
|
|
360
|
+
'invalid_input',
|
|
361
|
+
);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const blockProblem = findContentBlockProblem(messages);
|
|
365
|
+
if (blockProblem) {
|
|
366
|
+
ctx.status = 400;
|
|
367
|
+
ctx.body = toOpenAIError(
|
|
368
|
+
400,
|
|
369
|
+
`Invalid content block in input[${blockProblem.index}]: ${blockProblem.reason}.`,
|
|
370
|
+
'invalid_request_error',
|
|
371
|
+
'invalid_content_block',
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const userId = getCurrentUserId(ctx);
|
|
377
|
+
if (typeof body.previous_response_id === 'string') {
|
|
378
|
+
if (userId === undefined) {
|
|
379
|
+
ctx.status = 401;
|
|
380
|
+
ctx.body = toOpenAIError(401, 'Authentication is required', 'authentication_error', 'invalid_api_key');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const previousMessages = await loadConversationChain(ctx, body.previous_response_id, userId);
|
|
384
|
+
if (!previousMessages) {
|
|
385
|
+
ctx.status = 404;
|
|
386
|
+
ctx.body = toOpenAIError(
|
|
387
|
+
404,
|
|
388
|
+
`Previous response '${body.previous_response_id}' was not found`,
|
|
389
|
+
'invalid_request_error',
|
|
390
|
+
'previous_response_not_found',
|
|
391
|
+
);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
messages = [...previousMessages, ...messages];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const virtual = await resolveVirtualModel(ctx, body.model as string, chatBody, 'chat');
|
|
398
|
+
if (virtual?.status === 'unavailable') {
|
|
399
|
+
respondVirtualModelUnavailable(ctx, virtual);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (virtual?.status === 'resolved') {
|
|
403
|
+
ctx.state.aiApiVirtualModel = virtual.virtualModel;
|
|
404
|
+
ctx.state.aiApiRoutingReason = virtual.reason;
|
|
405
|
+
}
|
|
406
|
+
const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model as string));
|
|
407
|
+
if (!resolved) {
|
|
408
|
+
ctx.status = 404;
|
|
409
|
+
ctx.body = toOpenAIError(
|
|
410
|
+
404,
|
|
411
|
+
`Could not resolve model '${body.model}'. Format: 'serviceName/modelId'. Use GET /v1/models to see available models.`,
|
|
412
|
+
'invalid_request_error',
|
|
413
|
+
'model_not_found',
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const { service, modelId } = resolved;
|
|
419
|
+
try {
|
|
420
|
+
const aiPlugin = ctx.app.pm.get('ai') as
|
|
421
|
+
| { aiManager?: { llmProviders?: Map<string, LlmProviderMetadata> } }
|
|
422
|
+
| undefined;
|
|
423
|
+
if (!aiPlugin?.aiManager?.llmProviders) {
|
|
424
|
+
ctx.status = 500;
|
|
425
|
+
ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (service.get('enabled') === false) {
|
|
429
|
+
ctx.status = 404;
|
|
430
|
+
ctx.body = toOpenAIError(404, 'LLM service is disabled', 'invalid_request_error', 'model_not_found');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const config = await getAiApiConfig(ctx);
|
|
434
|
+
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) return;
|
|
435
|
+
|
|
436
|
+
const providerName = service.get('provider') as string;
|
|
437
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(providerName);
|
|
438
|
+
if (!providerMeta) {
|
|
439
|
+
ctx.status = 500;
|
|
440
|
+
ctx.body = toOpenAIError(500, `Provider '${providerName}' not registered`, 'server_error');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
messages = await Promise.all(
|
|
445
|
+
messages.map(async (message) => ({
|
|
446
|
+
...message,
|
|
447
|
+
content: await processMessageContentFileBlocks(message.content, ctx, plugin),
|
|
448
|
+
})),
|
|
449
|
+
);
|
|
450
|
+
const prepared = await prepareDirectLlmContext(ctx, {
|
|
451
|
+
serviceName: service.get('name') as string,
|
|
452
|
+
modelId,
|
|
453
|
+
messages,
|
|
454
|
+
tools: chatBody.tools,
|
|
455
|
+
maxCompletionTokens: chatBody.max_completion_tokens,
|
|
456
|
+
overflowBehavior: responseOverflowBehavior(body),
|
|
457
|
+
});
|
|
458
|
+
await prepareLlmBilling(ctx, resolved);
|
|
459
|
+
|
|
460
|
+
const modelOptions: Record<string, unknown> = {
|
|
461
|
+
model: modelId,
|
|
462
|
+
llmService: service.get('name'),
|
|
463
|
+
};
|
|
464
|
+
if (chatBody.temperature !== undefined) modelOptions.temperature = chatBody.temperature;
|
|
465
|
+
if (chatBody.top_p !== undefined) modelOptions.topP = chatBody.top_p;
|
|
466
|
+
if (chatBody.max_completion_tokens !== undefined) modelOptions.maxTokens = chatBody.max_completion_tokens;
|
|
467
|
+
if (chatBody.reasoning !== undefined) modelOptions.reasoning = chatBody.reasoning;
|
|
468
|
+
if (chatBody.service_tier !== undefined) modelOptions.service_tier = chatBody.service_tier;
|
|
469
|
+
if (chatBody.promptCacheKey !== undefined) modelOptions.promptCacheKey = chatBody.promptCacheKey;
|
|
470
|
+
if (chatBody.promptCacheRetention !== undefined) modelOptions.promptCacheRetention = chatBody.promptCacheRetention;
|
|
471
|
+
if (chatBody.verbosity !== undefined) modelOptions.verbosity = chatBody.verbosity;
|
|
472
|
+
if (chatBody.user !== undefined) modelOptions.user = chatBody.user;
|
|
473
|
+
|
|
474
|
+
const provider = new providerMeta.provider({
|
|
475
|
+
app: ctx.app,
|
|
476
|
+
serviceOptions: service.get('options'),
|
|
477
|
+
modelOptions,
|
|
478
|
+
});
|
|
479
|
+
const providerParameters = getProviderRequestParameters(chatBody);
|
|
480
|
+
const baseModel = provider.createModel();
|
|
481
|
+
applyProviderRequestParameters(baseModel, responseModelKwargs(providerParameters));
|
|
482
|
+
// Forward store=false only to providers that speak the OpenAI Responses protocol, so they do not
|
|
483
|
+
// persist the response on the provider side. Chat Completions backends (e.g. openai-completions,
|
|
484
|
+
// dashscope, kimi) receive modelKwargs verbatim in the /chat/completions payload and would reject
|
|
485
|
+
// the unknown `store` field, so they must not get it.
|
|
486
|
+
if (
|
|
487
|
+
body.store === false &&
|
|
488
|
+
isResponsesCapableProvider(providerName) &&
|
|
489
|
+
baseModel &&
|
|
490
|
+
typeof baseModel === 'object'
|
|
491
|
+
) {
|
|
492
|
+
(baseModel as { modelKwargs?: Record<string, unknown> }).modelKwargs = {
|
|
493
|
+
...((baseModel as { modelKwargs?: Record<string, unknown> }).modelKwargs ?? {}),
|
|
494
|
+
store: false,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const chatModel = bindRequestTools(
|
|
498
|
+
baseModel,
|
|
499
|
+
chatBody.tools,
|
|
500
|
+
chatBody.tool_choice,
|
|
501
|
+
providerParameters,
|
|
502
|
+
) as RunnableChatModel;
|
|
503
|
+
markLlmProviderAttempted(ctx);
|
|
504
|
+
|
|
505
|
+
const modelName = `${service.get('name')}/${modelId}`;
|
|
506
|
+
const langchainMessages = toLangChainMessages(prepared.messages);
|
|
507
|
+
if (body.stream === true) {
|
|
508
|
+
await handleStreamingResponse(ctx, chatModel, langchainMessages, body, modelName, providerParameters, userId);
|
|
509
|
+
} else {
|
|
510
|
+
await handleNonStreamingResponse(ctx, chatModel, langchainMessages, body, modelName, providerParameters, userId);
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
ctx.log.error('AI API responses error:', error);
|
|
514
|
+
if (!ctx.res?.headersSent) {
|
|
515
|
+
const quotaError = error instanceof AiApiQuotaError;
|
|
516
|
+
const requestError = error instanceof DirectLlmContextError || error instanceof FileProcessorError;
|
|
517
|
+
ctx.status = quotaError ? 429 : requestError ? 400 : 500;
|
|
518
|
+
if (quotaError) ctx.set('X-RateLimit-Reason', error.code);
|
|
519
|
+
ctx.body = toOpenAIError(
|
|
520
|
+
ctx.status,
|
|
521
|
+
getErrorMessage(error, 'Internal server error'),
|
|
522
|
+
quotaError ? 'quota_error' : requestError ? 'invalid_request_error' : 'server_error',
|
|
523
|
+
quotaError || requestError ? error.code : undefined,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function handleNonStreamingResponse(
|
|
530
|
+
ctx: Context,
|
|
531
|
+
chatModel: RunnableChatModel,
|
|
532
|
+
messages: unknown[],
|
|
533
|
+
body: Record<string, unknown>,
|
|
534
|
+
modelName: string,
|
|
535
|
+
providerParameters: Record<string, unknown>,
|
|
536
|
+
userId: string | number | bigint | undefined,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
const result = await chatModel.invoke(messages, providerParameters);
|
|
539
|
+
const content = extractResponseOutputText(result);
|
|
540
|
+
const responseId = generateResponseId();
|
|
541
|
+
const usage = setAiApiUsageResult(
|
|
542
|
+
ctx,
|
|
543
|
+
result.usage_metadata,
|
|
544
|
+
{ gatewayResponseId: responseId, providerRequestId: extractProviderRequestId(result) },
|
|
545
|
+
result.response_metadata,
|
|
546
|
+
);
|
|
547
|
+
const response = chatResultToResponse({
|
|
548
|
+
id: responseId,
|
|
549
|
+
model: modelName,
|
|
550
|
+
content,
|
|
551
|
+
reasoningText: extractReasoningText(result),
|
|
552
|
+
usage,
|
|
553
|
+
toolCalls: normalizeToolCalls(result.tool_calls),
|
|
554
|
+
finishReason: extractFinishReason(result) ?? extractResponseIncompleteReason(result),
|
|
555
|
+
serviceTier: extractResponseServiceTier(result),
|
|
556
|
+
requestBody: body,
|
|
557
|
+
});
|
|
558
|
+
if (body.store !== false && userId !== undefined) await storeResponseRecord(ctx, response, body, userId);
|
|
559
|
+
ctx.status = 200;
|
|
560
|
+
ctx.body = response;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function handleStreamingResponse(
|
|
564
|
+
ctx: Context,
|
|
565
|
+
chatModel: RunnableChatModel,
|
|
566
|
+
messages: unknown[],
|
|
567
|
+
body: Record<string, unknown>,
|
|
568
|
+
modelName: string,
|
|
569
|
+
providerParameters: Record<string, unknown>,
|
|
570
|
+
userId: string | number | bigint | undefined,
|
|
571
|
+
): Promise<void> {
|
|
572
|
+
const responseId = generateResponseId();
|
|
573
|
+
const state = createResponseStreamState(responseId, modelName, body);
|
|
574
|
+
ctx.set({
|
|
575
|
+
'Content-Type': 'text/event-stream',
|
|
576
|
+
'Cache-Control': 'no-cache',
|
|
577
|
+
Connection: 'keep-alive',
|
|
578
|
+
'X-Accel-Buffering': 'no',
|
|
579
|
+
});
|
|
580
|
+
ctx.status = 200;
|
|
581
|
+
for (const event of createResponseStartEvents(state)) await writeResponse(ctx, formatSSE(event));
|
|
582
|
+
|
|
583
|
+
const requestAbort = createRequestAbortController(ctx);
|
|
584
|
+
let usage: Usage | undefined;
|
|
585
|
+
let usageMetadata: unknown;
|
|
586
|
+
let providerRequestId: string | undefined;
|
|
587
|
+
try {
|
|
588
|
+
const stream = await chatModel.stream(messages, { ...providerParameters, signal: requestAbort.signal });
|
|
589
|
+
for await (const chunk of stream) {
|
|
590
|
+
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
591
|
+
const events = appendResponseStreamChunk(state, chunk);
|
|
592
|
+
if (events.length) markAiApiFirstProviderOutput(ctx);
|
|
593
|
+
for (const event of events) await writeResponse(ctx, formatSSE(event));
|
|
594
|
+
const normalized = normalizeUsage(chunk.usage_metadata);
|
|
595
|
+
if (normalized) {
|
|
596
|
+
usage = normalized;
|
|
597
|
+
usageMetadata = chunk.response_metadata;
|
|
598
|
+
setResponseStreamUsage(state, normalized);
|
|
599
|
+
}
|
|
600
|
+
providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const finalEvents = finalizeResponseStream(state) as Array<Record<string, unknown>>;
|
|
604
|
+
const response = findCompletedResponse(finalEvents);
|
|
605
|
+
if (response && body.store !== false && userId !== undefined) {
|
|
606
|
+
try {
|
|
607
|
+
await storeResponseRecord(ctx, response, body, userId);
|
|
608
|
+
} catch (storeError) {
|
|
609
|
+
// The provider already produced the full result and the client is mid-SSE. We cannot
|
|
610
|
+
// meaningfully turn this into an error response, so deliver the result and log the
|
|
611
|
+
// persistence failure instead of corrupting the terminal stream.
|
|
612
|
+
ctx.log.error('[ai-api] Failed to persist streamed response record:', storeError);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const event of finalEvents) await writeResponse(ctx, formatSSE(event));
|
|
616
|
+
await writeResponse(ctx, formatSSEDone());
|
|
617
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: responseId, providerRequestId }, usageMetadata);
|
|
618
|
+
ctx.state.aiApiStreamResult = { succeeded: true, id: responseId };
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const cancelled = isClientDisconnected(ctx, error);
|
|
621
|
+
ctx.log.error('AI API Responses streaming error:', error);
|
|
622
|
+
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
623
|
+
const streamError = {
|
|
624
|
+
message: getErrorMessage(error, 'Streaming error'),
|
|
625
|
+
code: cancelled ? 'client_disconnected' : 'server_error',
|
|
626
|
+
};
|
|
627
|
+
await writeResponse(ctx, formatSSE(createResponseErrorEvent(state, streamError)));
|
|
628
|
+
await writeResponse(ctx, formatSSE(createResponseFailedEvent(state, streamError)));
|
|
629
|
+
}
|
|
630
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: responseId, providerRequestId }, usageMetadata);
|
|
631
|
+
ctx.state.aiApiStreamResult = {
|
|
632
|
+
succeeded: false,
|
|
633
|
+
id: responseId,
|
|
634
|
+
errorCode: cancelled ? 'client_disconnected' : 'stream_error',
|
|
635
|
+
};
|
|
636
|
+
} finally {
|
|
637
|
+
requestAbort.dispose();
|
|
638
|
+
if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
|
|
639
|
+
}
|
|
640
|
+
}
|