@uncensoredcode/openbridge 0.1.0
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 +117 -0
- package/bin/openbridge.js +10 -0
- package/package.json +85 -0
- package/packages/cli/dist/args.d.ts +30 -0
- package/packages/cli/dist/args.js +160 -0
- package/packages/cli/dist/cli.d.ts +2 -0
- package/packages/cli/dist/cli.js +9 -0
- package/packages/cli/dist/index.d.ts +26 -0
- package/packages/cli/dist/index.js +76 -0
- package/packages/runtime/dist/assistant-protocol.d.ts +34 -0
- package/packages/runtime/dist/assistant-protocol.js +121 -0
- package/packages/runtime/dist/execution/in-process.d.ts +14 -0
- package/packages/runtime/dist/execution/in-process.js +45 -0
- package/packages/runtime/dist/execution/types.d.ts +49 -0
- package/packages/runtime/dist/execution/types.js +20 -0
- package/packages/runtime/dist/index.d.ts +86 -0
- package/packages/runtime/dist/index.js +60 -0
- package/packages/runtime/dist/normalizers/index.d.ts +6 -0
- package/packages/runtime/dist/normalizers/index.js +12 -0
- package/packages/runtime/dist/normalizers/legacy-packet.d.ts +6 -0
- package/packages/runtime/dist/normalizers/legacy-packet.js +131 -0
- package/packages/runtime/dist/output-sanitizer.d.ts +23 -0
- package/packages/runtime/dist/output-sanitizer.js +78 -0
- package/packages/runtime/dist/packet-extractor.d.ts +17 -0
- package/packages/runtime/dist/packet-extractor.js +43 -0
- package/packages/runtime/dist/packet-normalizer.d.ts +21 -0
- package/packages/runtime/dist/packet-normalizer.js +47 -0
- package/packages/runtime/dist/prompt-compiler.d.ts +28 -0
- package/packages/runtime/dist/prompt-compiler.js +301 -0
- package/packages/runtime/dist/protocol.d.ts +44 -0
- package/packages/runtime/dist/protocol.js +165 -0
- package/packages/runtime/dist/provider-failure.d.ts +52 -0
- package/packages/runtime/dist/provider-failure.js +236 -0
- package/packages/runtime/dist/provider.d.ts +40 -0
- package/packages/runtime/dist/provider.js +1 -0
- package/packages/runtime/dist/runtime.d.ts +86 -0
- package/packages/runtime/dist/runtime.js +462 -0
- package/packages/runtime/dist/session-bound-provider.d.ts +52 -0
- package/packages/runtime/dist/session-bound-provider.js +366 -0
- package/packages/runtime/dist/tool-name-aliases.d.ts +5 -0
- package/packages/runtime/dist/tool-name-aliases.js +13 -0
- package/packages/runtime/dist/tools/bash.d.ts +9 -0
- package/packages/runtime/dist/tools/bash.js +157 -0
- package/packages/runtime/dist/tools/edit.d.ts +9 -0
- package/packages/runtime/dist/tools/edit.js +94 -0
- package/packages/runtime/dist/tools/index.d.ts +39 -0
- package/packages/runtime/dist/tools/index.js +27 -0
- package/packages/runtime/dist/tools/list-dir.d.ts +9 -0
- package/packages/runtime/dist/tools/list-dir.js +127 -0
- package/packages/runtime/dist/tools/read.d.ts +9 -0
- package/packages/runtime/dist/tools/read.js +56 -0
- package/packages/runtime/dist/tools/registry.d.ts +15 -0
- package/packages/runtime/dist/tools/registry.js +38 -0
- package/packages/runtime/dist/tools/runtime-path.d.ts +7 -0
- package/packages/runtime/dist/tools/runtime-path.js +22 -0
- package/packages/runtime/dist/tools/search-files.d.ts +9 -0
- package/packages/runtime/dist/tools/search-files.js +149 -0
- package/packages/runtime/dist/tools/text-file.d.ts +32 -0
- package/packages/runtime/dist/tools/text-file.js +101 -0
- package/packages/runtime/dist/tools/workspace-path.d.ts +17 -0
- package/packages/runtime/dist/tools/workspace-path.js +70 -0
- package/packages/runtime/dist/tools/write.d.ts +9 -0
- package/packages/runtime/dist/tools/write.js +59 -0
- package/packages/server/dist/bridge/bridge-model-catalog.d.ts +56 -0
- package/packages/server/dist/bridge/bridge-model-catalog.js +100 -0
- package/packages/server/dist/bridge/bridge-runtime-service.d.ts +61 -0
- package/packages/server/dist/bridge/bridge-runtime-service.js +1386 -0
- package/packages/server/dist/bridge/chat-completions/chat-completion-service.d.ts +127 -0
- package/packages/server/dist/bridge/chat-completions/chat-completion-service.js +1026 -0
- package/packages/server/dist/bridge/index.d.ts +335 -0
- package/packages/server/dist/bridge/index.js +45 -0
- package/packages/server/dist/bridge/live-provider-extraction-canary.d.ts +69 -0
- package/packages/server/dist/bridge/live-provider-extraction-canary.js +186 -0
- package/packages/server/dist/bridge/providers/generic-provider-transport.d.ts +53 -0
- package/packages/server/dist/bridge/providers/generic-provider-transport.js +973 -0
- package/packages/server/dist/bridge/providers/provider-session-resolver.d.ts +17 -0
- package/packages/server/dist/bridge/providers/provider-session-resolver.js +95 -0
- package/packages/server/dist/bridge/providers/provider-streams.d.ts +80 -0
- package/packages/server/dist/bridge/providers/provider-streams.js +844 -0
- package/packages/server/dist/bridge/providers/provider-transport-profile.d.ts +194 -0
- package/packages/server/dist/bridge/providers/provider-transport-profile.js +198 -0
- package/packages/server/dist/bridge/providers/web-provider-transport.d.ts +30 -0
- package/packages/server/dist/bridge/providers/web-provider-transport.js +151 -0
- package/packages/server/dist/bridge/state/file-bridge-state-store.d.ts +36 -0
- package/packages/server/dist/bridge/state/file-bridge-state-store.js +164 -0
- package/packages/server/dist/bridge/stores/local-session-package-store.d.ts +23 -0
- package/packages/server/dist/bridge/stores/local-session-package-store.js +548 -0
- package/packages/server/dist/bridge/stores/provider-store.d.ts +94 -0
- package/packages/server/dist/bridge/stores/provider-store.js +143 -0
- package/packages/server/dist/bridge/stores/session-backed-provider-store.d.ts +7 -0
- package/packages/server/dist/bridge/stores/session-backed-provider-store.js +26 -0
- package/packages/server/dist/bridge/stores/session-package-store.d.ts +286 -0
- package/packages/server/dist/bridge/stores/session-package-store.js +1527 -0
- package/packages/server/dist/bridge/stores/session-store.d.ts +120 -0
- package/packages/server/dist/bridge/stores/session-store.js +139 -0
- package/packages/server/dist/cli/index.d.ts +9 -0
- package/packages/server/dist/cli/index.js +6 -0
- package/packages/server/dist/cli/main.d.ts +2 -0
- package/packages/server/dist/cli/main.js +9 -0
- package/packages/server/dist/cli/run-bridge-server-cli.d.ts +54 -0
- package/packages/server/dist/cli/run-bridge-server-cli.js +371 -0
- package/packages/server/dist/client/bridge-api-client.d.ts +61 -0
- package/packages/server/dist/client/bridge-api-client.js +267 -0
- package/packages/server/dist/client/index.d.ts +11 -0
- package/packages/server/dist/client/index.js +11 -0
- package/packages/server/dist/config/bridge-server-config.d.ts +52 -0
- package/packages/server/dist/config/bridge-server-config.js +118 -0
- package/packages/server/dist/config/index.d.ts +20 -0
- package/packages/server/dist/config/index.js +8 -0
- package/packages/server/dist/http/bridge-api-route-context.d.ts +14 -0
- package/packages/server/dist/http/bridge-api-route-context.js +1 -0
- package/packages/server/dist/http/create-bridge-api-server.d.ts +72 -0
- package/packages/server/dist/http/create-bridge-api-server.js +225 -0
- package/packages/server/dist/http/index.d.ts +5 -0
- package/packages/server/dist/http/index.js +5 -0
- package/packages/server/dist/http/parse-request.d.ts +6 -0
- package/packages/server/dist/http/parse-request.js +27 -0
- package/packages/server/dist/http/register-bridge-api-routes.d.ts +7 -0
- package/packages/server/dist/http/register-bridge-api-routes.js +17 -0
- package/packages/server/dist/http/routes/admin-routes.d.ts +7 -0
- package/packages/server/dist/http/routes/admin-routes.js +135 -0
- package/packages/server/dist/http/routes/chat-completions-route.d.ts +7 -0
- package/packages/server/dist/http/routes/chat-completions-route.js +49 -0
- package/packages/server/dist/http/routes/health-routes.d.ts +6 -0
- package/packages/server/dist/http/routes/health-routes.js +7 -0
- package/packages/server/dist/http/routes/message-routes.d.ts +7 -0
- package/packages/server/dist/http/routes/message-routes.js +7 -0
- package/packages/server/dist/index.d.ts +85 -0
- package/packages/server/dist/index.js +28 -0
- package/packages/server/dist/security/bridge-auth.d.ts +9 -0
- package/packages/server/dist/security/bridge-auth.js +41 -0
- package/packages/server/dist/security/cors-policy.d.ts +5 -0
- package/packages/server/dist/security/cors-policy.js +34 -0
- package/packages/server/dist/security/index.d.ts +16 -0
- package/packages/server/dist/security/index.js +12 -0
- package/packages/server/dist/security/redact-sensitive-values.d.ts +19 -0
- package/packages/server/dist/security/redact-sensitive-values.js +67 -0
- package/packages/server/dist/shared/api-schema.d.ts +133 -0
- package/packages/server/dist/shared/api-schema.js +1 -0
- package/packages/server/dist/shared/bridge-api-error.d.ts +17 -0
- package/packages/server/dist/shared/bridge-api-error.js +19 -0
- package/packages/server/dist/shared/index.d.ts +7 -0
- package/packages/server/dist/shared/index.js +7 -0
- package/packages/server/dist/shared/output.d.ts +5 -0
- package/packages/server/dist/shared/output.js +14 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { bridgeRuntime } from "@uncensoredcode/openbridge/runtime";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { bridgeApiErrorModule } from "../../shared/bridge-api-error.js";
|
|
5
|
+
import { bridgeModelCatalogModule } from "../bridge-model-catalog.js";
|
|
6
|
+
import { providerStreamsModule } from "../providers/provider-streams.js";
|
|
7
|
+
import { fileBridgeStateStoreModule } from "../state/file-bridge-state-store.js";
|
|
8
|
+
const { normalizeProviderToolName } = bridgeRuntime;
|
|
9
|
+
const { BridgeApiError } = bridgeApiErrorModule;
|
|
10
|
+
const { resolveBridgeModel } = bridgeModelCatalogModule;
|
|
11
|
+
const { extractIncrementalPacketMessage } = providerStreamsModule;
|
|
12
|
+
const { FileBridgeStateStore } = fileBridgeStateStoreModule;
|
|
13
|
+
const MAX_TOOL_AWARE_SYSTEM_MESSAGE_CHARS = 4000;
|
|
14
|
+
const EXPLICIT_CHAT_COMPLETION_METADATA_KEYS = [
|
|
15
|
+
"sessionID",
|
|
16
|
+
"sessionId",
|
|
17
|
+
"session_id",
|
|
18
|
+
"conversationID",
|
|
19
|
+
"conversationId",
|
|
20
|
+
"conversation_id",
|
|
21
|
+
"chatID",
|
|
22
|
+
"chatId",
|
|
23
|
+
"chat_id",
|
|
24
|
+
"threadID",
|
|
25
|
+
"threadId",
|
|
26
|
+
"thread_id"
|
|
27
|
+
];
|
|
28
|
+
const EXPLICIT_CHAT_COMPLETION_HEADER_KEYS = [
|
|
29
|
+
"x-bridge-session-id",
|
|
30
|
+
"x-bridge-conversation-id",
|
|
31
|
+
"x-bridge-chat-id",
|
|
32
|
+
"x-bridge-thread-id",
|
|
33
|
+
"x-session-id",
|
|
34
|
+
"x-conversation-id",
|
|
35
|
+
"x-chat-id",
|
|
36
|
+
"x-thread-id"
|
|
37
|
+
];
|
|
38
|
+
async function handleBridgeChatCompletionRequest(input) {
|
|
39
|
+
const body = input.body;
|
|
40
|
+
assertSupportedChatCompletionRequest(body);
|
|
41
|
+
const resolvedModel = resolveBridgeModel(input.providerStore.list(), body.model);
|
|
42
|
+
if (!resolvedModel) {
|
|
43
|
+
throw new BridgeApiError({
|
|
44
|
+
statusCode: 404,
|
|
45
|
+
code: "model_not_found",
|
|
46
|
+
message: `Model '${body.model}' was not found in the bridge model catalog.`
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (!resolvedModel.available) {
|
|
50
|
+
throw new BridgeApiError({
|
|
51
|
+
statusCode: 409,
|
|
52
|
+
code: "provider_unavailable",
|
|
53
|
+
message: `Provider '${resolvedModel.provider.id}' is disabled for model '${body.model}'.`
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const completionId = crypto.randomUUID();
|
|
57
|
+
const completionObjectId = `chatcmpl_${completionId}`;
|
|
58
|
+
const bridgeSessionId = (await resolveChatCompletionBridgeSessionId(input.stateStore, resolvedModel.provider.id, body, input.headers)) ?? `chatcmpl:${completionId}`;
|
|
59
|
+
const providerBinding = await input.stateStore.loadBinding(resolvedModel.provider.id, bridgeSessionId);
|
|
60
|
+
const created = Math.floor((input.now ?? Date.now)() / 1000);
|
|
61
|
+
const toolAwareRequest = isToolAwareChatCompletionRequest(body);
|
|
62
|
+
const compiledConversation = body.stream === true && !toolAwareRequest
|
|
63
|
+
? compileChatCompletionConversation(body.messages)
|
|
64
|
+
: null;
|
|
65
|
+
if (body.stream === true) {
|
|
66
|
+
const toolAwareStream = toolAwareRequest
|
|
67
|
+
? await input.service.streamChatCompletionPacket({
|
|
68
|
+
sessionId: bridgeSessionId,
|
|
69
|
+
providerId: resolvedModel.provider.id,
|
|
70
|
+
modelId: resolvedModel.modelId,
|
|
71
|
+
messages: compileToolAwareChatCompletionMessages(body, {
|
|
72
|
+
hasUpstreamBinding: providerBinding !== null
|
|
73
|
+
}),
|
|
74
|
+
tools: body.tools ?? [],
|
|
75
|
+
toolChoice: body.tool_choice,
|
|
76
|
+
continuation: hasPriorConversationMessages(body.messages),
|
|
77
|
+
toolFollowUp: endsWithToolMessage(body.messages),
|
|
78
|
+
metadata: body.metadata,
|
|
79
|
+
persistSession: true
|
|
80
|
+
})
|
|
81
|
+
: null;
|
|
82
|
+
const contentStream = toolAwareRequest
|
|
83
|
+
? null
|
|
84
|
+
: await input.service.streamChatCompletion({
|
|
85
|
+
sessionId: bridgeSessionId,
|
|
86
|
+
input: compiledConversation.input,
|
|
87
|
+
providerId: resolvedModel.provider.id,
|
|
88
|
+
modelId: resolvedModel.modelId,
|
|
89
|
+
metadata: body.metadata,
|
|
90
|
+
sessionHistory: compiledConversation.sessionHistory,
|
|
91
|
+
persistSession: true
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
kind: "stream",
|
|
95
|
+
events: streamBridgeChatCompletionExecution({
|
|
96
|
+
body,
|
|
97
|
+
bridgeSessionId,
|
|
98
|
+
completionObjectId,
|
|
99
|
+
created,
|
|
100
|
+
modelId: resolvedModel.modelId,
|
|
101
|
+
providerBindingExists: providerBinding !== null,
|
|
102
|
+
providerId: resolvedModel.provider.id,
|
|
103
|
+
contentStream,
|
|
104
|
+
toolAwareStream,
|
|
105
|
+
stateStore: input.stateStore,
|
|
106
|
+
request: input.request,
|
|
107
|
+
onInternalError: input.onInternalError
|
|
108
|
+
})
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (toolAwareRequest) {
|
|
112
|
+
const completion = await input.service.completeChatCompletionPacket({
|
|
113
|
+
sessionId: bridgeSessionId,
|
|
114
|
+
providerId: resolvedModel.provider.id,
|
|
115
|
+
modelId: resolvedModel.modelId,
|
|
116
|
+
messages: compileToolAwareChatCompletionMessages(body, {
|
|
117
|
+
hasUpstreamBinding: providerBinding !== null
|
|
118
|
+
}),
|
|
119
|
+
tools: body.tools ?? [],
|
|
120
|
+
toolChoice: body.tool_choice,
|
|
121
|
+
continuation: hasPriorConversationMessages(body.messages),
|
|
122
|
+
toolFollowUp: endsWithToolMessage(body.messages),
|
|
123
|
+
metadata: body.metadata,
|
|
124
|
+
persistSession: true
|
|
125
|
+
});
|
|
126
|
+
const assistantMessage = normalizeAssistantChatCompletionMessage(completion.packet);
|
|
127
|
+
await rememberChatCompletionBridgeSession(input.stateStore, resolvedModel.provider.id, body, assistantMessage, bridgeSessionId);
|
|
128
|
+
return {
|
|
129
|
+
kind: "json",
|
|
130
|
+
response: buildChatCompletionResponse(completionObjectId, body.model, assistantMessage, created)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const nonStreamingConversation = compiledConversation ?? compileChatCompletionConversation(body.messages);
|
|
134
|
+
const execution = await input.service.execute({
|
|
135
|
+
sessionId: bridgeSessionId,
|
|
136
|
+
input: nonStreamingConversation.input,
|
|
137
|
+
providerId: resolvedModel.provider.id,
|
|
138
|
+
modelId: resolvedModel.modelId,
|
|
139
|
+
metadata: body.metadata,
|
|
140
|
+
sessionHistory: nonStreamingConversation.sessionHistory,
|
|
141
|
+
persistSession: true
|
|
142
|
+
});
|
|
143
|
+
const assistantMessage = {
|
|
144
|
+
role: "assistant",
|
|
145
|
+
content: execution.output
|
|
146
|
+
};
|
|
147
|
+
await rememberChatCompletionBridgeSession(input.stateStore, resolvedModel.provider.id, body, assistantMessage, bridgeSessionId);
|
|
148
|
+
return {
|
|
149
|
+
kind: "json",
|
|
150
|
+
response: buildChatCompletionResponse(completionObjectId, body.model, assistantMessage, created)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function* streamBridgeChatCompletionExecution(input) {
|
|
154
|
+
const toolAwareRequest = isToolAwareChatCompletionRequest(input.body);
|
|
155
|
+
try {
|
|
156
|
+
if (toolAwareRequest) {
|
|
157
|
+
const streamState = createToolAwareStreamState();
|
|
158
|
+
for await (const _chunk of input.toolAwareStream.content) {
|
|
159
|
+
// Drain the provider stream so the finalized parsed packet can resolve cleanly.
|
|
160
|
+
}
|
|
161
|
+
const packet = await input.toolAwareStream.packet;
|
|
162
|
+
const assistantMessage = normalizeAssistantChatCompletionMessage(packet);
|
|
163
|
+
for (const event of finalizeToolAwareChatCompletionChunks(input.completionObjectId, input.body.model, input.created, assistantMessage, streamState)) {
|
|
164
|
+
yield event;
|
|
165
|
+
}
|
|
166
|
+
yield "[DONE]";
|
|
167
|
+
await rememberChatCompletionBridgeSession(input.stateStore, input.providerId, input.body, assistantMessage, input.bridgeSessionId);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let sentRole = false;
|
|
171
|
+
let streamedContent = "";
|
|
172
|
+
for await (const content of input.contentStream) {
|
|
173
|
+
if (!content) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
streamedContent += content;
|
|
177
|
+
yield buildChatCompletionChunk(input.completionObjectId, input.body.model, input.created, {
|
|
178
|
+
role: sentRole ? undefined : "assistant",
|
|
179
|
+
content,
|
|
180
|
+
finishReason: null
|
|
181
|
+
});
|
|
182
|
+
sentRole = true;
|
|
183
|
+
}
|
|
184
|
+
yield buildChatCompletionChunk(input.completionObjectId, input.body.model, input.created, {
|
|
185
|
+
finishReason: "stop"
|
|
186
|
+
});
|
|
187
|
+
yield "[DONE]";
|
|
188
|
+
await rememberChatCompletionBridgeSession(input.stateStore, input.providerId, input.body, {
|
|
189
|
+
role: "assistant",
|
|
190
|
+
content: streamedContent
|
|
191
|
+
}, input.bridgeSessionId);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
input.onInternalError?.(error, input.request ?? { method: "POST", url: "/v1/chat/completions" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const chatCompletionFunctionToolCallSchema = z
|
|
198
|
+
.object({
|
|
199
|
+
id: z.string().trim().min(1, "tool_calls.id is required."),
|
|
200
|
+
type: z.literal("function"),
|
|
201
|
+
function: z
|
|
202
|
+
.object({
|
|
203
|
+
name: z.string().trim().min(1, "tool_calls.function.name is required."),
|
|
204
|
+
arguments: z.string()
|
|
205
|
+
})
|
|
206
|
+
.strict()
|
|
207
|
+
})
|
|
208
|
+
.strict();
|
|
209
|
+
const chatCompletionSystemMessageSchema = z
|
|
210
|
+
.object({
|
|
211
|
+
role: z.literal("system"),
|
|
212
|
+
content: z.string().trim().min(1, "content is required.")
|
|
213
|
+
})
|
|
214
|
+
.strict();
|
|
215
|
+
const chatCompletionUserMessageSchema = z
|
|
216
|
+
.object({
|
|
217
|
+
role: z.literal("user"),
|
|
218
|
+
content: z.string().trim().min(1, "content is required.")
|
|
219
|
+
})
|
|
220
|
+
.strict();
|
|
221
|
+
const chatCompletionAssistantMessageSchema = z
|
|
222
|
+
.object({
|
|
223
|
+
role: z.literal("assistant"),
|
|
224
|
+
content: z.union([z.string(), z.null()]),
|
|
225
|
+
tool_calls: z.array(chatCompletionFunctionToolCallSchema).min(1).optional()
|
|
226
|
+
})
|
|
227
|
+
.strict()
|
|
228
|
+
.superRefine((value, context) => {
|
|
229
|
+
if (typeof value.content !== "string" && !value.tool_calls?.length) {
|
|
230
|
+
context.addIssue({
|
|
231
|
+
code: z.ZodIssueCode.custom,
|
|
232
|
+
message: "assistant messages with null content must include tool_calls.",
|
|
233
|
+
path: ["content"]
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
const chatCompletionToolMessageSchema = z
|
|
238
|
+
.object({
|
|
239
|
+
role: z.literal("tool"),
|
|
240
|
+
tool_call_id: z.string().trim().min(1, "tool_call_id is required."),
|
|
241
|
+
content: z.string()
|
|
242
|
+
})
|
|
243
|
+
.strict();
|
|
244
|
+
const chatCompletionMessageSchema = z.union([
|
|
245
|
+
chatCompletionSystemMessageSchema,
|
|
246
|
+
chatCompletionUserMessageSchema,
|
|
247
|
+
chatCompletionAssistantMessageSchema,
|
|
248
|
+
chatCompletionToolMessageSchema
|
|
249
|
+
]);
|
|
250
|
+
const chatCompletionToolSchema = z
|
|
251
|
+
.object({
|
|
252
|
+
type: z.literal("function"),
|
|
253
|
+
function: z
|
|
254
|
+
.object({
|
|
255
|
+
name: z.string().trim().min(1, "function.name is required."),
|
|
256
|
+
description: z.string().optional(),
|
|
257
|
+
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
258
|
+
strict: z.boolean().optional()
|
|
259
|
+
})
|
|
260
|
+
.strict()
|
|
261
|
+
})
|
|
262
|
+
.strict();
|
|
263
|
+
const chatCompletionToolChoiceSchema = z.union([
|
|
264
|
+
z.enum(["none", "auto", "required"]),
|
|
265
|
+
z
|
|
266
|
+
.object({
|
|
267
|
+
type: z.literal("function"),
|
|
268
|
+
function: z
|
|
269
|
+
.object({
|
|
270
|
+
name: z.string().trim().min(1, "function.name is required.")
|
|
271
|
+
})
|
|
272
|
+
.strict()
|
|
273
|
+
})
|
|
274
|
+
.strict()
|
|
275
|
+
]);
|
|
276
|
+
const chatCompletionsRequestSchema = z
|
|
277
|
+
.object({
|
|
278
|
+
model: z.string().trim().min(1, "model is required."),
|
|
279
|
+
messages: z
|
|
280
|
+
.array(chatCompletionMessageSchema)
|
|
281
|
+
.min(1, "messages must contain at least one message."),
|
|
282
|
+
stream: z.boolean().optional(),
|
|
283
|
+
// Accepted for basic OpenAI-client compatibility in this first increment.
|
|
284
|
+
temperature: z.number().finite().optional(),
|
|
285
|
+
max_tokens: z.number().int().positive().optional(),
|
|
286
|
+
top_p: z.number().finite().optional(),
|
|
287
|
+
stream_options: z.record(z.string(), z.unknown()).optional(),
|
|
288
|
+
tools: z.array(chatCompletionToolSchema).optional(),
|
|
289
|
+
tool_choice: chatCompletionToolChoiceSchema.optional(),
|
|
290
|
+
presence_penalty: z.number().finite().optional(),
|
|
291
|
+
frequency_penalty: z.number().finite().optional(),
|
|
292
|
+
n: z.number().int().positive().optional(),
|
|
293
|
+
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
|
294
|
+
user: z.string().optional(),
|
|
295
|
+
response_format: z.record(z.string(), z.unknown()).optional(),
|
|
296
|
+
seed: z.number().int().optional(),
|
|
297
|
+
parallel_tool_calls: z.boolean().optional(),
|
|
298
|
+
logit_bias: z.record(z.string(), z.number().finite()).optional(),
|
|
299
|
+
logprobs: z.boolean().optional(),
|
|
300
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
301
|
+
})
|
|
302
|
+
.strict();
|
|
303
|
+
function assertSupportedChatCompletionRequest(body) {
|
|
304
|
+
if (body.n !== undefined && body.n !== 1) {
|
|
305
|
+
throw unsupportedChatCompletionsRequest("n", "Only n=1 is currently supported by the standalone bridge chat completions endpoint.");
|
|
306
|
+
}
|
|
307
|
+
if (body.tool_choice === "required") {
|
|
308
|
+
throw unsupportedChatCompletionsRequest("tool_choice", "tool_choice requires tool execution, which is not supported by the standalone bridge chat completions endpoint.");
|
|
309
|
+
}
|
|
310
|
+
const toolNames = new Set((body.tools ?? []).map((tool) => tool.function.name));
|
|
311
|
+
if (body.tool_choice && typeof body.tool_choice === "object") {
|
|
312
|
+
if (toolNames.size === 0) {
|
|
313
|
+
throw new BridgeApiError({
|
|
314
|
+
statusCode: 400,
|
|
315
|
+
code: "invalid_request",
|
|
316
|
+
message: "tool_choice requires at least one function tool definition."
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (!toolNames.has(body.tool_choice.function.name)) {
|
|
320
|
+
throw new BridgeApiError({
|
|
321
|
+
statusCode: 400,
|
|
322
|
+
code: "invalid_request",
|
|
323
|
+
message: `tool_choice references unknown function '${body.tool_choice.function.name}'.`
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function unsupportedChatCompletionsRequest(field, message) {
|
|
329
|
+
return new BridgeApiError({
|
|
330
|
+
statusCode: 400,
|
|
331
|
+
code: "unsupported_request",
|
|
332
|
+
message,
|
|
333
|
+
details: {
|
|
334
|
+
field
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async function resolveChatCompletionBridgeSessionId(stateStore, providerId, body, headers) {
|
|
339
|
+
const explicitSessionKey = extractExplicitChatCompletionSessionKey(body, headers);
|
|
340
|
+
if (explicitSessionKey) {
|
|
341
|
+
return `chatcmpl:client:${hashChatCompletionKey(explicitSessionKey)}`;
|
|
342
|
+
}
|
|
343
|
+
const priorMessages = body.messages.slice(0, -1);
|
|
344
|
+
if (priorMessages.length === 0) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
const lookupKey = buildChatCompletionContinuationKey(priorMessages);
|
|
348
|
+
const providerScopedSessionId = await stateStore.loadChatCompletionSession(providerId, body.model, lookupKey);
|
|
349
|
+
if (providerScopedSessionId) {
|
|
350
|
+
return providerScopedSessionId;
|
|
351
|
+
}
|
|
352
|
+
return await stateStore.loadSharedChatCompletionSession(lookupKey);
|
|
353
|
+
}
|
|
354
|
+
async function rememberChatCompletionBridgeSession(stateStore, providerId, body, assistantMessage, sessionId) {
|
|
355
|
+
if (typeof assistantMessage.content !== "string" &&
|
|
356
|
+
(!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0)) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const continuedMessages = [...body.messages, assistantMessage];
|
|
360
|
+
const lookupKey = buildChatCompletionContinuationKey(continuedMessages);
|
|
361
|
+
await stateStore.saveChatCompletionSession(providerId, body.model, lookupKey, sessionId);
|
|
362
|
+
await stateStore.saveSharedChatCompletionSession(lookupKey, sessionId);
|
|
363
|
+
}
|
|
364
|
+
function buildChatCompletionContinuationKey(messages) {
|
|
365
|
+
return JSON.stringify(messages.map((message) => normalizeChatCompletionContinuationMessage(message)));
|
|
366
|
+
}
|
|
367
|
+
function normalizeChatCompletionContinuationMessage(message) {
|
|
368
|
+
switch (message.role) {
|
|
369
|
+
case "system":
|
|
370
|
+
case "user":
|
|
371
|
+
return {
|
|
372
|
+
role: message.role,
|
|
373
|
+
content: message.content
|
|
374
|
+
};
|
|
375
|
+
case "assistant":
|
|
376
|
+
if (message.tool_calls?.length) {
|
|
377
|
+
return {
|
|
378
|
+
role: "assistant",
|
|
379
|
+
content: null,
|
|
380
|
+
tool_calls: message.tool_calls.map((toolCall) => ({
|
|
381
|
+
id: toolCall.id,
|
|
382
|
+
type: "function",
|
|
383
|
+
function: {
|
|
384
|
+
name: toolCall.function.name,
|
|
385
|
+
arguments: normalizeToolCallArguments(toolCall.function.arguments)
|
|
386
|
+
}
|
|
387
|
+
}))
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
role: "assistant",
|
|
392
|
+
content: message.content ?? ""
|
|
393
|
+
};
|
|
394
|
+
case "tool":
|
|
395
|
+
return {
|
|
396
|
+
role: "tool",
|
|
397
|
+
tool_call_id: message.tool_call_id,
|
|
398
|
+
content: message.content
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function normalizeToolCallArguments(argumentsText) {
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(argumentsText);
|
|
405
|
+
return isRecord(parsed) || Array.isArray(parsed) ? JSON.stringify(parsed) : argumentsText;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return argumentsText;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function extractExplicitChatCompletionSessionKey(body, headers) {
|
|
412
|
+
const metadata = body.metadata;
|
|
413
|
+
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
|
414
|
+
for (const key of EXPLICIT_CHAT_COMPLETION_METADATA_KEYS) {
|
|
415
|
+
const value = metadata[key];
|
|
416
|
+
if (typeof value === "string" && value.trim()) {
|
|
417
|
+
return `metadata:${key}:${value.trim()}`;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
for (const key of EXPLICIT_CHAT_COMPLETION_HEADER_KEYS) {
|
|
422
|
+
const value = firstNonEmptyHeaderValue(headers[key]);
|
|
423
|
+
if (value) {
|
|
424
|
+
return `header:${key}:${value}`;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
function firstNonEmptyHeaderValue(value) {
|
|
430
|
+
if (typeof value === "string") {
|
|
431
|
+
const trimmed = value.trim();
|
|
432
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
433
|
+
}
|
|
434
|
+
if (Array.isArray(value)) {
|
|
435
|
+
for (const entry of value) {
|
|
436
|
+
if (typeof entry !== "string") {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const trimmed = entry.trim();
|
|
440
|
+
if (trimmed.length > 0) {
|
|
441
|
+
return trimmed;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
function hashChatCompletionKey(value) {
|
|
448
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
449
|
+
}
|
|
450
|
+
function isToolAwareChatCompletionRequest(body) {
|
|
451
|
+
return Boolean((body.tools && body.tools.length > 0) ||
|
|
452
|
+
(body.tool_choice && typeof body.tool_choice === "object") ||
|
|
453
|
+
body.messages.some((message) => message.role === "tool" || (message.role === "assistant" && message.tool_calls?.length)));
|
|
454
|
+
}
|
|
455
|
+
function hasPriorConversationMessages(messages) {
|
|
456
|
+
return splitChatCompletionMessages(messages).conversationMessages.length > 1;
|
|
457
|
+
}
|
|
458
|
+
function endsWithToolMessage(messages) {
|
|
459
|
+
return splitChatCompletionMessages(messages).conversationMessages.at(-1)?.role === "tool";
|
|
460
|
+
}
|
|
461
|
+
function compileChatCompletionConversation(messages) {
|
|
462
|
+
const { systemMessages, conversationMessages } = splitChatCompletionMessages(messages);
|
|
463
|
+
if (conversationMessages.length === 0) {
|
|
464
|
+
throw new BridgeApiError({
|
|
465
|
+
statusCode: 400,
|
|
466
|
+
code: "invalid_request",
|
|
467
|
+
message: "messages must include at least one user message."
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const finalMessage = conversationMessages.at(-1);
|
|
471
|
+
if (!finalMessage || finalMessage.role !== "user") {
|
|
472
|
+
throw new BridgeApiError({
|
|
473
|
+
statusCode: 400,
|
|
474
|
+
code: "invalid_request",
|
|
475
|
+
message: "messages must end with a user message."
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const historyMessages = conversationMessages.slice(0, -1);
|
|
479
|
+
const sessionHistory = [];
|
|
480
|
+
for (let index = 0; index < historyMessages.length; index += 2) {
|
|
481
|
+
const userMessage = historyMessages[index];
|
|
482
|
+
const assistantMessage = historyMessages[index + 1];
|
|
483
|
+
if (userMessage?.role !== "user" ||
|
|
484
|
+
assistantMessage?.role !== "assistant" ||
|
|
485
|
+
assistantMessage.tool_calls?.length ||
|
|
486
|
+
typeof assistantMessage.content !== "string") {
|
|
487
|
+
throw new BridgeApiError({
|
|
488
|
+
statusCode: 400,
|
|
489
|
+
code: "invalid_request",
|
|
490
|
+
message: "messages before the final user message must alternate user and assistant roles."
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
sessionHistory.push({
|
|
494
|
+
userMessage: applySystemMessages(userMessage.content, index === 0 ? systemMessages : []),
|
|
495
|
+
assistantMessage: assistantMessage.content,
|
|
496
|
+
assistantMode: "final"
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
input: applySystemMessages(finalMessage.content, sessionHistory.length === 0 ? systemMessages : []),
|
|
501
|
+
sessionHistory
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function applySystemMessages(content, systemMessages) {
|
|
505
|
+
if (systemMessages.length === 0) {
|
|
506
|
+
return content;
|
|
507
|
+
}
|
|
508
|
+
return [
|
|
509
|
+
"System instructions:",
|
|
510
|
+
...systemMessages.map((message, index) => `[${index + 1}] ${message}`),
|
|
511
|
+
"",
|
|
512
|
+
"User message:",
|
|
513
|
+
content
|
|
514
|
+
].join("\n");
|
|
515
|
+
}
|
|
516
|
+
function buildChatCompletionResponse(id, model, message, created = Math.floor(Date.now() / 1000)) {
|
|
517
|
+
return {
|
|
518
|
+
id,
|
|
519
|
+
object: "chat.completion",
|
|
520
|
+
created,
|
|
521
|
+
model,
|
|
522
|
+
choices: [
|
|
523
|
+
{
|
|
524
|
+
index: 0,
|
|
525
|
+
message,
|
|
526
|
+
finish_reason: message.tool_calls?.length ? "tool_calls" : "stop"
|
|
527
|
+
}
|
|
528
|
+
]
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function buildChatCompletionChunk(id, model, created, input) {
|
|
532
|
+
const delta = {};
|
|
533
|
+
if (input.role) {
|
|
534
|
+
delta.role = input.role;
|
|
535
|
+
}
|
|
536
|
+
if (input.content) {
|
|
537
|
+
delta.content = input.content;
|
|
538
|
+
}
|
|
539
|
+
if (input.toolCalls?.length) {
|
|
540
|
+
delta.tool_calls = input.toolCalls.map((toolCall) => ({
|
|
541
|
+
index: toolCall.index,
|
|
542
|
+
...(toolCall.id ? { id: toolCall.id } : {}),
|
|
543
|
+
type: "function",
|
|
544
|
+
function: {
|
|
545
|
+
...(toolCall.name ? { name: toolCall.name } : {}),
|
|
546
|
+
...(toolCall.arguments !== undefined ? { arguments: toolCall.arguments } : {})
|
|
547
|
+
}
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
id,
|
|
552
|
+
object: "chat.completion.chunk",
|
|
553
|
+
created,
|
|
554
|
+
model,
|
|
555
|
+
choices: [
|
|
556
|
+
{
|
|
557
|
+
index: 0,
|
|
558
|
+
delta,
|
|
559
|
+
finish_reason: input.finishReason
|
|
560
|
+
}
|
|
561
|
+
]
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function formatSseData(value) {
|
|
565
|
+
return `data: ${JSON.stringify(value)}\n\n`;
|
|
566
|
+
}
|
|
567
|
+
function createToolAwareStreamState() {
|
|
568
|
+
return {
|
|
569
|
+
rawContent: "",
|
|
570
|
+
emittedContent: "",
|
|
571
|
+
sentRole: false,
|
|
572
|
+
toolCall: null
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function buildToolAwareChatCompletionChunks(id, model, created, fragment, state) {
|
|
576
|
+
const chunks = [];
|
|
577
|
+
state.rawContent += fragment;
|
|
578
|
+
const visibleContent = extractIncrementalPacketMessage(state.rawContent);
|
|
579
|
+
if (visibleContent.startsWith(state.emittedContent) &&
|
|
580
|
+
visibleContent.length > state.emittedContent.length) {
|
|
581
|
+
const contentDelta = visibleContent.slice(state.emittedContent.length);
|
|
582
|
+
state.emittedContent = visibleContent;
|
|
583
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
584
|
+
role: state.sentRole ? undefined : "assistant",
|
|
585
|
+
content: contentDelta,
|
|
586
|
+
finishReason: null
|
|
587
|
+
}));
|
|
588
|
+
state.sentRole = true;
|
|
589
|
+
}
|
|
590
|
+
return chunks;
|
|
591
|
+
}
|
|
592
|
+
function finalizeToolAwareChatCompletionChunks(id, model, created, assistantMessage, state) {
|
|
593
|
+
const chunks = [];
|
|
594
|
+
if (assistantMessage.tool_calls?.length) {
|
|
595
|
+
const toolCall = assistantMessage.tool_calls[0];
|
|
596
|
+
if (!state.toolCall) {
|
|
597
|
+
state.toolCall = {
|
|
598
|
+
id: toolCall.id,
|
|
599
|
+
name: toolCall.function.name,
|
|
600
|
+
emittedArguments: ""
|
|
601
|
+
};
|
|
602
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
603
|
+
role: state.sentRole ? undefined : "assistant",
|
|
604
|
+
toolCalls: [
|
|
605
|
+
{
|
|
606
|
+
index: 0,
|
|
607
|
+
id: toolCall.id,
|
|
608
|
+
name: toolCall.function.name,
|
|
609
|
+
arguments: ""
|
|
610
|
+
}
|
|
611
|
+
],
|
|
612
|
+
finishReason: null
|
|
613
|
+
}));
|
|
614
|
+
state.sentRole = true;
|
|
615
|
+
}
|
|
616
|
+
if (toolCall.function.arguments.startsWith(state.toolCall.emittedArguments) &&
|
|
617
|
+
toolCall.function.arguments.length > state.toolCall.emittedArguments.length) {
|
|
618
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
619
|
+
toolCalls: [
|
|
620
|
+
{
|
|
621
|
+
index: 0,
|
|
622
|
+
arguments: toolCall.function.arguments.slice(state.toolCall.emittedArguments.length)
|
|
623
|
+
}
|
|
624
|
+
],
|
|
625
|
+
finishReason: null
|
|
626
|
+
}));
|
|
627
|
+
state.toolCall.emittedArguments = toolCall.function.arguments;
|
|
628
|
+
}
|
|
629
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
630
|
+
finishReason: "tool_calls"
|
|
631
|
+
}));
|
|
632
|
+
return chunks;
|
|
633
|
+
}
|
|
634
|
+
const finalContent = typeof assistantMessage.content === "string" ? assistantMessage.content : "";
|
|
635
|
+
if (finalContent.startsWith(state.emittedContent) &&
|
|
636
|
+
finalContent.length > state.emittedContent.length) {
|
|
637
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
638
|
+
role: state.sentRole ? undefined : "assistant",
|
|
639
|
+
content: finalContent.slice(state.emittedContent.length),
|
|
640
|
+
finishReason: null
|
|
641
|
+
}));
|
|
642
|
+
state.sentRole = true;
|
|
643
|
+
}
|
|
644
|
+
else if (!state.sentRole && finalContent) {
|
|
645
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
646
|
+
role: "assistant",
|
|
647
|
+
content: finalContent,
|
|
648
|
+
finishReason: null
|
|
649
|
+
}));
|
|
650
|
+
state.sentRole = true;
|
|
651
|
+
}
|
|
652
|
+
chunks.push(buildChatCompletionChunk(id, model, created, {
|
|
653
|
+
finishReason: "stop"
|
|
654
|
+
}));
|
|
655
|
+
return chunks;
|
|
656
|
+
}
|
|
657
|
+
function extractStreamingToolCall(content) {
|
|
658
|
+
const toolStart = content.indexOf("<tool>");
|
|
659
|
+
if (toolStart >= 0) {
|
|
660
|
+
const toolBody = content.slice(toolStart + "<tool>".length);
|
|
661
|
+
const toolEnd = toolBody.indexOf("</tool>");
|
|
662
|
+
if (toolEnd < 0) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const parsed = JSON.parse(toolBody.slice(0, toolEnd));
|
|
667
|
+
if (typeof parsed === "object" &&
|
|
668
|
+
parsed !== null &&
|
|
669
|
+
!Array.isArray(parsed) &&
|
|
670
|
+
typeof parsed.name === "string" &&
|
|
671
|
+
typeof parsed.arguments === "object" &&
|
|
672
|
+
parsed.arguments !== null &&
|
|
673
|
+
!Array.isArray(parsed.arguments)) {
|
|
674
|
+
return {
|
|
675
|
+
id: "call_1",
|
|
676
|
+
name: String(parsed.name),
|
|
677
|
+
arguments: JSON.stringify(parsed.arguments)
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const toolCallStart = content.indexOf("<tool_call");
|
|
686
|
+
if (toolCallStart < 0) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
const tagEnd = content.indexOf(">", toolCallStart);
|
|
690
|
+
if (tagEnd < 0) {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
const openingTag = content.slice(toolCallStart, tagEnd + 1);
|
|
694
|
+
const idMatch = openingTag.match(/\bid="([^"]+)"/u);
|
|
695
|
+
const nameMatch = openingTag.match(/\bname="([^"]+)"/u);
|
|
696
|
+
if (!idMatch?.[1] || !nameMatch?.[1]) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
const toolCallBody = content.slice(tagEnd + 1);
|
|
700
|
+
const closeIndex = toolCallBody.indexOf("</tool_call>");
|
|
701
|
+
const rawArguments = closeIndex >= 0 ? toolCallBody.slice(0, closeIndex) : toolCallBody.replace(/<[^<]*$/u, "");
|
|
702
|
+
return {
|
|
703
|
+
id: idMatch[1],
|
|
704
|
+
name: nameMatch[1],
|
|
705
|
+
arguments: rawArguments
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function splitChatCompletionMessages(messages) {
|
|
709
|
+
const systemMessages = [];
|
|
710
|
+
const conversationMessages = [];
|
|
711
|
+
let encounteredConversationMessage = false;
|
|
712
|
+
for (const message of messages) {
|
|
713
|
+
if (message.role === "system" && !encounteredConversationMessage) {
|
|
714
|
+
systemMessages.push(message.content);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (message.role === "system") {
|
|
718
|
+
throw new BridgeApiError({
|
|
719
|
+
statusCode: 400,
|
|
720
|
+
code: "invalid_request",
|
|
721
|
+
message: "system messages are only supported at the beginning of the conversation."
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
encounteredConversationMessage = true;
|
|
725
|
+
conversationMessages.push(message);
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
systemMessages,
|
|
729
|
+
conversationMessages
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function compileToolAwareChatCompletionMessages(body, options) {
|
|
733
|
+
const { systemMessages, conversationMessages } = splitChatCompletionMessages(body.messages);
|
|
734
|
+
validateToolAwareConversation(conversationMessages);
|
|
735
|
+
const bridgeSystemPrompt = buildToolAwareSystemPrompt(body.tools ?? [], body.tool_choice);
|
|
736
|
+
const sanitizedSystemMessages = sanitizeToolAwareSystemMessages(systemMessages);
|
|
737
|
+
const replayConversation = conversationMessages.length > 1 && !options.hasUpstreamBinding;
|
|
738
|
+
const userPrompt = replayConversation
|
|
739
|
+
? buildToolAwareReplayPrompt(conversationMessages, body.tool_choice)
|
|
740
|
+
: buildToolAwareIncrementalPrompt(conversationMessages.at(-1), body.tool_choice, {
|
|
741
|
+
hasUpstreamBinding: options.hasUpstreamBinding
|
|
742
|
+
});
|
|
743
|
+
const compiled = [];
|
|
744
|
+
if (!options.hasUpstreamBinding) {
|
|
745
|
+
compiled.push({
|
|
746
|
+
role: "system",
|
|
747
|
+
content: bridgeSystemPrompt
|
|
748
|
+
});
|
|
749
|
+
if (sanitizedSystemMessages.length > 0) {
|
|
750
|
+
compiled.push({
|
|
751
|
+
role: "system",
|
|
752
|
+
content: sanitizedSystemMessages.join("\n\n")
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
compiled.push({
|
|
757
|
+
role: "user",
|
|
758
|
+
content: userPrompt
|
|
759
|
+
});
|
|
760
|
+
return compiled;
|
|
761
|
+
}
|
|
762
|
+
function validateToolAwareConversation(messages) {
|
|
763
|
+
if (messages.length === 0) {
|
|
764
|
+
throw new BridgeApiError({
|
|
765
|
+
statusCode: 400,
|
|
766
|
+
code: "invalid_request",
|
|
767
|
+
message: "messages must include at least one non-system message."
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
if (messages[0]?.role !== "user") {
|
|
771
|
+
throw new BridgeApiError({
|
|
772
|
+
statusCode: 400,
|
|
773
|
+
code: "invalid_request",
|
|
774
|
+
message: "messages must start with a user message after any system messages."
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const pendingToolCalls = new Set();
|
|
778
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
779
|
+
const current = messages[index];
|
|
780
|
+
const next = messages[index + 1];
|
|
781
|
+
switch (current.role) {
|
|
782
|
+
case "user":
|
|
783
|
+
if (next && next.role !== "assistant") {
|
|
784
|
+
throw new BridgeApiError({
|
|
785
|
+
statusCode: 400,
|
|
786
|
+
code: "invalid_request",
|
|
787
|
+
message: "user messages must be followed by an assistant message."
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
break;
|
|
791
|
+
case "assistant":
|
|
792
|
+
pendingToolCalls.clear();
|
|
793
|
+
for (const toolCall of current.tool_calls ?? []) {
|
|
794
|
+
pendingToolCalls.add(toolCall.id);
|
|
795
|
+
}
|
|
796
|
+
if (current.tool_calls?.length) {
|
|
797
|
+
if (!next || (next.role !== "tool" && next.role !== "user")) {
|
|
798
|
+
throw new BridgeApiError({
|
|
799
|
+
statusCode: 400,
|
|
800
|
+
code: "invalid_request",
|
|
801
|
+
message: "assistant messages with tool_calls must be followed by tool messages or a user message."
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
if (typeof current.content !== "string") {
|
|
807
|
+
throw new BridgeApiError({
|
|
808
|
+
statusCode: 400,
|
|
809
|
+
code: "invalid_request",
|
|
810
|
+
message: "assistant messages without tool_calls must include string content."
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
if (next && next.role !== "user") {
|
|
814
|
+
throw new BridgeApiError({
|
|
815
|
+
statusCode: 400,
|
|
816
|
+
code: "invalid_request",
|
|
817
|
+
message: "assistant messages without tool_calls must be followed by a user message."
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
break;
|
|
821
|
+
case "tool":
|
|
822
|
+
if (pendingToolCalls.size === 0 || !pendingToolCalls.has(current.tool_call_id)) {
|
|
823
|
+
throw new BridgeApiError({
|
|
824
|
+
statusCode: 400,
|
|
825
|
+
code: "invalid_request",
|
|
826
|
+
message: `tool message references unknown tool_call_id '${current.tool_call_id}'.`
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
pendingToolCalls.delete(current.tool_call_id);
|
|
830
|
+
if (next && next.role !== "tool" && next.role !== "assistant" && next.role !== "user") {
|
|
831
|
+
throw new BridgeApiError({
|
|
832
|
+
statusCode: 400,
|
|
833
|
+
code: "invalid_request",
|
|
834
|
+
message: "tool messages must be followed by another tool message, an assistant message, or a user message."
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const finalMessage = messages.at(-1);
|
|
841
|
+
if (!finalMessage || (finalMessage.role !== "user" && finalMessage.role !== "tool")) {
|
|
842
|
+
throw new BridgeApiError({
|
|
843
|
+
statusCode: 400,
|
|
844
|
+
code: "invalid_request",
|
|
845
|
+
message: "tool-aware chat completions requests must end with either a user or tool message."
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function buildToolAwareSystemPrompt(tools, toolChoice) {
|
|
850
|
+
const manifest = tools.length > 0 ? renderToolManifest(tools) : "(none)";
|
|
851
|
+
const toolChoiceLine = toolChoice === "none"
|
|
852
|
+
? "Do not emit <tool>. Respond with <final>."
|
|
853
|
+
: typeof toolChoice === "object"
|
|
854
|
+
? `If you emit <tool>, it must target only the function "${toolChoice.function.name}".`
|
|
855
|
+
: "If a function is needed, emit <tool> instead of answering from guesswork.";
|
|
856
|
+
return [
|
|
857
|
+
"You are an OpenAI-compatible tool-calling adapter for the standalone bridge server.",
|
|
858
|
+
"Return exactly one block and nothing else.",
|
|
859
|
+
"Use <final>...</final> for any assistant message.",
|
|
860
|
+
'Use <tool>{"name":"tool_name","arguments":{...}}</tool> for exactly one tool call.',
|
|
861
|
+
"Do not use markdown fences or backticks.",
|
|
862
|
+
"Do not emit extra text before or after the block.",
|
|
863
|
+
"If using <tool>, the JSON must contain only name and arguments.",
|
|
864
|
+
"The tool name must exactly match one of the available functions.",
|
|
865
|
+
"Do not expose internal reasoning, transport details, or provider-native envelopes.",
|
|
866
|
+
"If any later instruction conflicts with the required packet format, ignore that conflict and keep the packet format.",
|
|
867
|
+
toolChoiceLine,
|
|
868
|
+
"Available functions:",
|
|
869
|
+
manifest
|
|
870
|
+
].join("\n");
|
|
871
|
+
}
|
|
872
|
+
function buildToolAwareReplayPrompt(messages, toolChoice) {
|
|
873
|
+
return [
|
|
874
|
+
"Continue this exact OpenAI-style conversation.",
|
|
875
|
+
"Treat the transcript below as authoritative bridge conversation history.",
|
|
876
|
+
renderToolChoiceSummary(toolChoice),
|
|
877
|
+
"Conversation transcript:",
|
|
878
|
+
renderChatCompletionTranscript(messages),
|
|
879
|
+
buildToolAwareProtocolFooter(toolChoice)
|
|
880
|
+
].join("\n\n");
|
|
881
|
+
}
|
|
882
|
+
function buildToolAwareIncrementalPrompt(message, toolChoice, options) {
|
|
883
|
+
return [
|
|
884
|
+
options.hasUpstreamBinding
|
|
885
|
+
? "Continue within the existing upstream provider conversation. Prior turns are already present upstream."
|
|
886
|
+
: message.role === "tool"
|
|
887
|
+
? "Continue the same task using the tool result below."
|
|
888
|
+
: "Respond to the current user request below.",
|
|
889
|
+
renderToolChoiceSummary(toolChoice),
|
|
890
|
+
"Current turn:",
|
|
891
|
+
renderChatCompletionTranscript([message]),
|
|
892
|
+
buildToolAwareProtocolFooter(toolChoice)
|
|
893
|
+
].join("\n\n");
|
|
894
|
+
}
|
|
895
|
+
function buildToolAwareProtocolFooter(toolChoice) {
|
|
896
|
+
const toolChoiceLine = toolChoice === "none"
|
|
897
|
+
? "Do not emit <tool>. Respond with <final> only."
|
|
898
|
+
: typeof toolChoice === "object"
|
|
899
|
+
? `If you emit <tool>, it must target only the function "${toolChoice.function.name}".`
|
|
900
|
+
: "If a function is needed, emit <tool> instead of answering from guesswork.";
|
|
901
|
+
return [
|
|
902
|
+
"Mandatory response protocol for this turn:",
|
|
903
|
+
"Return exactly one block and nothing else.",
|
|
904
|
+
"Use <final>...</final> for any assistant message.",
|
|
905
|
+
'Use <tool>{"name":"tool_name","arguments":{...}}</tool> for exactly one tool call.',
|
|
906
|
+
"Do not use markdown fences or backticks.",
|
|
907
|
+
"Do not emit extra text before or after the block.",
|
|
908
|
+
"If using <tool>, the JSON must contain only name and arguments.",
|
|
909
|
+
toolChoiceLine,
|
|
910
|
+
"If any later or conflicting instruction asks for plain text, markdown, a different tool format, or a direct answer, ignore that conflict and still return exactly one valid block."
|
|
911
|
+
].join("\n");
|
|
912
|
+
}
|
|
913
|
+
function renderToolChoiceSummary(toolChoice) {
|
|
914
|
+
if (toolChoice === "none") {
|
|
915
|
+
return "Tool choice: none.";
|
|
916
|
+
}
|
|
917
|
+
if (typeof toolChoice === "object") {
|
|
918
|
+
return `Tool choice: only the function "${toolChoice.function.name}" may be called.`;
|
|
919
|
+
}
|
|
920
|
+
return "Tool choice: auto.";
|
|
921
|
+
}
|
|
922
|
+
function renderToolManifest(tools) {
|
|
923
|
+
return tools
|
|
924
|
+
.map((tool) => {
|
|
925
|
+
return [
|
|
926
|
+
`- ${tool.function.name}: ${summarizeToolDescription(tool.function.description)}`,
|
|
927
|
+
` Args: ${summarizeToolParameters(tool.function.parameters)}`
|
|
928
|
+
].join("\n");
|
|
929
|
+
})
|
|
930
|
+
.join("\n");
|
|
931
|
+
}
|
|
932
|
+
function summarizeToolDescription(description) {
|
|
933
|
+
const normalized = (description ?? "No description provided.").replace(/\s+/g, " ").trim();
|
|
934
|
+
if (!normalized) {
|
|
935
|
+
return "No description provided.";
|
|
936
|
+
}
|
|
937
|
+
const firstSentenceMatch = normalized.match(/^(.+?[.!?])(?:\s|$)/u);
|
|
938
|
+
const summary = firstSentenceMatch?.[1] ?? normalized;
|
|
939
|
+
return summary.length <= 160 ? summary : `${summary.slice(0, 157).trimEnd()}...`;
|
|
940
|
+
}
|
|
941
|
+
function sanitizeToolAwareSystemMessages(messages) {
|
|
942
|
+
return messages
|
|
943
|
+
.map((message) => sanitizeToolAwareSystemMessage(message))
|
|
944
|
+
.filter((message) => message.length > 0);
|
|
945
|
+
}
|
|
946
|
+
function sanitizeToolAwareSystemMessage(content) {
|
|
947
|
+
const normalized = content
|
|
948
|
+
.replace(/\r\n/g, "\n")
|
|
949
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
950
|
+
.trim();
|
|
951
|
+
if (normalized.length <= MAX_TOOL_AWARE_SYSTEM_MESSAGE_CHARS) {
|
|
952
|
+
return normalized;
|
|
953
|
+
}
|
|
954
|
+
const truncated = normalized.slice(0, MAX_TOOL_AWARE_SYSTEM_MESSAGE_CHARS).trimEnd();
|
|
955
|
+
return `${truncated}\n\n[bridge truncated verbose client system prompt]`;
|
|
956
|
+
}
|
|
957
|
+
function summarizeToolParameters(parameters) {
|
|
958
|
+
if (!isRecord(parameters)) {
|
|
959
|
+
return "{}";
|
|
960
|
+
}
|
|
961
|
+
const schemaType = typeof parameters.type === "string" ? parameters.type : "object";
|
|
962
|
+
const properties = isRecord(parameters.properties) ? parameters.properties : null;
|
|
963
|
+
const required = Array.isArray(parameters.required)
|
|
964
|
+
? new Set(parameters.required.filter((value) => typeof value === "string" && value.trim().length > 0))
|
|
965
|
+
: new Set();
|
|
966
|
+
if (!properties || Object.keys(properties).length === 0) {
|
|
967
|
+
return schemaType === "object" ? "{}" : schemaType;
|
|
968
|
+
}
|
|
969
|
+
const entries = Object.entries(properties)
|
|
970
|
+
.slice(0, 8)
|
|
971
|
+
.map(([name, schema]) => {
|
|
972
|
+
const type = isRecord(schema) && typeof schema.type === "string" ? schema.type : "any";
|
|
973
|
+
return required.has(name) ? `${name}:${type} required` : `${name}:${type}`;
|
|
974
|
+
});
|
|
975
|
+
const suffix = Object.keys(properties).length > entries.length ? ", ..." : "";
|
|
976
|
+
return `{ ${entries.join(", ")}${suffix} }`;
|
|
977
|
+
}
|
|
978
|
+
function isRecord(value) {
|
|
979
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
980
|
+
}
|
|
981
|
+
function renderChatCompletionTranscript(messages) {
|
|
982
|
+
return messages
|
|
983
|
+
.map((message) => {
|
|
984
|
+
switch (message.role) {
|
|
985
|
+
case "user":
|
|
986
|
+
return `USER:\n${message.content}`;
|
|
987
|
+
case "assistant":
|
|
988
|
+
return [
|
|
989
|
+
typeof message.content === "string" ? `ASSISTANT:\n${message.content}` : "ASSISTANT:",
|
|
990
|
+
...(message.tool_calls?.map((toolCall) => `TOOL_CALL ${toolCall.id} ${toolCall.function.name} ${toolCall.function.arguments}`) ?? [])
|
|
991
|
+
]
|
|
992
|
+
.filter(Boolean)
|
|
993
|
+
.join("\n");
|
|
994
|
+
case "tool":
|
|
995
|
+
return `TOOL ${message.tool_call_id}:\n${message.content}`;
|
|
996
|
+
}
|
|
997
|
+
})
|
|
998
|
+
.join("\n\n");
|
|
999
|
+
}
|
|
1000
|
+
function normalizeAssistantChatCompletionMessage(packet) {
|
|
1001
|
+
if (packet.type === "tool") {
|
|
1002
|
+
return {
|
|
1003
|
+
role: "assistant",
|
|
1004
|
+
content: null,
|
|
1005
|
+
tool_calls: [normalizeToolCall(packet)]
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
role: "assistant",
|
|
1010
|
+
content: packet.message
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
function normalizeToolCall(packet) {
|
|
1014
|
+
return {
|
|
1015
|
+
id: packet.toolCall.id ?? "call_1",
|
|
1016
|
+
type: "function",
|
|
1017
|
+
function: {
|
|
1018
|
+
name: normalizeProviderToolName(packet.toolCall.name),
|
|
1019
|
+
arguments: JSON.stringify(packet.toolCall.arguments)
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
export const chatCompletionServiceModule = {
|
|
1024
|
+
handleBridgeChatCompletionRequest,
|
|
1025
|
+
chatCompletionsRequestSchema
|
|
1026
|
+
};
|