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
|
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
|
|
|
11
11
|
import { toOpenAIError } from '../utils/openai-format';
|
|
12
12
|
import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
|
|
13
13
|
import { getAiApiConfig } from '../utils/request-cache';
|
|
14
|
+
import { listAccessibleVirtualModels, type VirtualModel } from '../utils/virtual-models';
|
|
14
15
|
import type PluginAiApiServer from '../plugin';
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -75,6 +76,17 @@ export async function handleListModels(ctx: Context, plugin: PluginAiApiServer)
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Expose enabled virtual aliases (e.g. "auto") so clients can discover and use them.
|
|
80
|
+
// They carry virtual:true and a description, but no capability overrides of their own.
|
|
81
|
+
try {
|
|
82
|
+
const virtualModels = await listAccessibleVirtualModels(ctx, scope, config?.enabledLlmServices);
|
|
83
|
+
for (const virtualModel of virtualModels) {
|
|
84
|
+
models.push(buildVirtualModelObject(virtualModel, now));
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Virtual models table may not exist yet during a rolling upgrade — skip silently.
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
ctx.status = 200;
|
|
79
91
|
ctx.body = {
|
|
80
92
|
object: 'list',
|
|
@@ -137,6 +149,16 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
137
149
|
if (found) break;
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
if (!found) {
|
|
153
|
+
try {
|
|
154
|
+
const virtualModels = await listAccessibleVirtualModels(ctx, scope, config?.enabledLlmServices);
|
|
155
|
+
const virtualModel = virtualModels.find((candidate) => candidate.name === modelId);
|
|
156
|
+
if (virtualModel) found = buildVirtualModelObject(virtualModel, now);
|
|
157
|
+
} catch {
|
|
158
|
+
// Virtual models table may not exist yet during a rolling upgrade.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
140
162
|
if (!found) {
|
|
141
163
|
ctx.status = 404;
|
|
142
164
|
ctx.body = toOpenAIError(404, `Model '${modelId}' not found`, 'invalid_request_error', 'model_not_found');
|
|
@@ -246,6 +268,18 @@ export function buildModelObject(
|
|
|
246
268
|
return model;
|
|
247
269
|
}
|
|
248
270
|
|
|
271
|
+
export function buildVirtualModelObject(virtualModel: VirtualModel, created: number): Record<string, unknown> {
|
|
272
|
+
return {
|
|
273
|
+
id: virtualModel.name,
|
|
274
|
+
object: 'model',
|
|
275
|
+
created,
|
|
276
|
+
owned_by: 'ai-api-gateway',
|
|
277
|
+
virtual: true,
|
|
278
|
+
mode: virtualModel.mode,
|
|
279
|
+
description: `Virtual ${virtualModel.mode} alias routed to a concrete model based on the request shape.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
249
283
|
function toPositiveInt(value: unknown): number | null {
|
|
250
284
|
const n = Number(value);
|
|
251
285
|
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
@@ -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
|
+
}
|