pi-sap-aicore 0.2.2 → 0.3.1
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/CHANGELOG.md +42 -1
- package/README.md +46 -33
- package/package.json +1 -1
- package/src/foundation-deployment.ts +24 -0
- package/src/foundation-executables.ts +12 -0
- package/src/stream-foundation-azure-openai.ts +363 -0
- package/src/stream-foundation-bedrock.ts +223 -0
- package/src/stream-foundation-vertexai.ts +214 -0
- package/src/stream-foundation.ts +19 -354
- package/src/translate-foundation-bedrock.ts +175 -0
- package/src/translate-foundation-vertexai.ts +125 -0
package/src/stream-foundation.ts
CHANGED
|
@@ -1,363 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
calculateCost,
|
|
8
|
-
type Context,
|
|
9
|
-
createAssistantMessageEventStream,
|
|
10
|
-
type Model,
|
|
11
|
-
type SimpleStreamOptions,
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
AssistantMessageEventStream,
|
|
4
|
+
Context,
|
|
5
|
+
Model,
|
|
6
|
+
SimpleStreamOptions,
|
|
12
7
|
} from "@earendil-works/pi-ai";
|
|
13
|
-
import type { AzureOpenAiChatCompletionParameters } from "@sap-ai-sdk/foundation-models";
|
|
14
|
-
|
|
15
|
-
import { buildAzureOpenAiParams } from "./foundation-params.ts";
|
|
16
|
-
import {
|
|
17
|
-
debugLog,
|
|
18
|
-
ensureServiceKey,
|
|
19
|
-
type ExtendedDelta,
|
|
20
|
-
formatError,
|
|
21
|
-
latchFinishReason,
|
|
22
|
-
mapUsage,
|
|
23
|
-
pickReasoning,
|
|
24
|
-
resolveResourceGroup,
|
|
25
|
-
type ToolCallSlot,
|
|
26
|
-
} from "./stream.ts";
|
|
27
|
-
import { mapFinishReason } from "./translate.ts";
|
|
28
|
-
import { piContextToAzureOpenAi } from "./translate-foundation.ts";
|
|
29
|
-
|
|
30
|
-
// Loaded dynamically (not at module load) so a missing dependency surfaces as
|
|
31
|
-
// an actionable in-stream error instead of an ERR_MODULE_NOT_FOUND crash at pi
|
|
32
|
-
// startup. Mirrors `importOrchestration` in stream.ts.
|
|
33
|
-
async function importFoundation(): Promise<
|
|
34
|
-
typeof import("@sap-ai-sdk/foundation-models")
|
|
35
|
-
> {
|
|
36
|
-
try {
|
|
37
|
-
return await import("@sap-ai-sdk/foundation-models");
|
|
38
|
-
} catch (err) {
|
|
39
|
-
const code = (err as NodeJS.ErrnoException)?.code;
|
|
40
|
-
const msg = (err as Error)?.message ?? "";
|
|
41
|
-
const isMissing =
|
|
42
|
-
code === "ERR_MODULE_NOT_FOUND" &&
|
|
43
|
-
msg.includes("@sap-ai-sdk/foundation-models");
|
|
44
|
-
if (!isMissing) throw err;
|
|
45
8
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"it, e.g. under ~/.pi/agent/), then restart pi.",
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
9
|
+
import { foundationExecutableForModel } from "./foundation-executables.ts";
|
|
10
|
+
import { streamSapFoundationAzureOpenAi } from "./stream-foundation-azure-openai.ts";
|
|
11
|
+
import { streamSapFoundationBedrock } from "./stream-foundation-bedrock.ts";
|
|
12
|
+
import { streamSapFoundationVertexAi } from "./stream-foundation-vertexai.ts";
|
|
54
13
|
|
|
55
|
-
// Direct (foundation) provider: routes OpenAI models through their own
|
|
56
|
-
// SAP AI Core deployment via @sap-ai-sdk/foundation-models'
|
|
57
|
-
// AzureOpenAiChatClient — bypassing the orchestration service entirely.
|
|
58
|
-
// Unlike streamSapAiCore there is NO streaming-unsupported fallback: the
|
|
59
|
-
// direct Azure OpenAI endpoint streams natively (that's the whole reason this
|
|
60
|
-
// path exists for new models orchestration won't stream). The SDK injects
|
|
61
|
-
// `stream_options: { include_usage: true }` itself, so usage arrives on the
|
|
62
|
-
// final chunk and `response.getTokenUsage()` is populated.
|
|
63
14
|
export function streamSapFoundation(
|
|
64
15
|
model: Model<Api>,
|
|
65
16
|
context: Context,
|
|
66
17
|
options?: SimpleStreamOptions,
|
|
67
18
|
): AssistantMessageEventStream {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
input: 0,
|
|
78
|
-
output: 0,
|
|
79
|
-
cacheRead: 0,
|
|
80
|
-
cacheWrite: 0,
|
|
81
|
-
totalTokens: 0,
|
|
82
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
83
|
-
},
|
|
84
|
-
stopReason: "stop",
|
|
85
|
-
timestamp: Date.now(),
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
(async () => {
|
|
89
|
-
const requestId = randomUUID();
|
|
90
|
-
try {
|
|
91
|
-
stream.push({ type: "start", partial: output });
|
|
92
|
-
|
|
93
|
-
const serviceKey = ensureServiceKey(options?.apiKey);
|
|
94
|
-
process.env.AICORE_SERVICE_KEY = serviceKey.raw;
|
|
95
|
-
const resourceGroup = resolveResourceGroup(serviceKey);
|
|
96
|
-
|
|
97
|
-
const { messages, tools } = piContextToAzureOpenAi(context);
|
|
98
|
-
const params = buildAzureOpenAiParams(model, options);
|
|
99
|
-
|
|
100
|
-
const { AzureOpenAiChatClient } = await importFoundation();
|
|
101
|
-
|
|
102
|
-
const request: AzureOpenAiChatCompletionParameters = {
|
|
103
|
-
messages,
|
|
104
|
-
...(tools.length > 0 ? { tools } : {}),
|
|
105
|
-
...params,
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
debugLog({
|
|
109
|
-
requestId,
|
|
110
|
-
kind: "request",
|
|
111
|
-
provider: "foundation",
|
|
112
|
-
model: model.id,
|
|
113
|
-
resourceGroup,
|
|
114
|
-
params,
|
|
115
|
-
messageRoles: messages.map((m) => m.role),
|
|
116
|
-
messages,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Name-based deployment resolution: the SDK finds THE foundation
|
|
120
|
-
// deployment serving this model in the resource group. SAP allows
|
|
121
|
-
// only one deployment per (model, version, resource group), so the
|
|
122
|
-
// match is unambiguous — no deployment ID needed.
|
|
123
|
-
const client = new AzureOpenAiChatClient({
|
|
124
|
-
modelName: model.id,
|
|
125
|
-
...(resourceGroup ? { resourceGroup } : {}),
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
const response = await client.stream(request, options?.signal);
|
|
129
|
-
|
|
130
|
-
let textIndex = -1;
|
|
131
|
-
let thinkingIndex = -1;
|
|
132
|
-
let reasoningField: string | undefined;
|
|
133
|
-
let refusalText = "";
|
|
134
|
-
const toolSlots = new Map<number, ToolCallSlot>();
|
|
135
|
-
let finishReason: string | undefined;
|
|
136
|
-
|
|
137
|
-
const closeText = () => {
|
|
138
|
-
if (textIndex < 0) return;
|
|
139
|
-
const block = output.content[textIndex];
|
|
140
|
-
if (block?.type === "text") {
|
|
141
|
-
stream.push({
|
|
142
|
-
type: "text_end",
|
|
143
|
-
contentIndex: textIndex,
|
|
144
|
-
content: block.text,
|
|
145
|
-
partial: output,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
textIndex = -1;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const closeThinking = () => {
|
|
152
|
-
if (thinkingIndex < 0) return;
|
|
153
|
-
const block = output.content[thinkingIndex];
|
|
154
|
-
if (block?.type === "thinking") {
|
|
155
|
-
stream.push({
|
|
156
|
-
type: "thinking_end",
|
|
157
|
-
contentIndex: thinkingIndex,
|
|
158
|
-
content: block.thinking,
|
|
159
|
-
partial: output,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
thinkingIndex = -1;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
for await (const chunk of response.stream) {
|
|
166
|
-
if (options?.signal?.aborted) break;
|
|
167
|
-
|
|
168
|
-
const choice = chunk.findChoiceByIndex(0);
|
|
169
|
-
const rawDelta = (choice?.delta ?? {}) as ExtendedDelta;
|
|
170
|
-
|
|
171
|
-
// Reasoning first — providers emit it before visible text, and
|
|
172
|
-
// pi's UI expects the thinking block to precede the text block.
|
|
173
|
-
// (gpt-5* on the direct route are unlikely to pass structured
|
|
174
|
-
// reasoning through, but we handle it for free if they do.)
|
|
175
|
-
const reasoning = pickReasoning(rawDelta, reasoningField);
|
|
176
|
-
if (reasoning) {
|
|
177
|
-
reasoningField = reasoning.field;
|
|
178
|
-
if (thinkingIndex < 0) {
|
|
179
|
-
closeText();
|
|
180
|
-
output.content.push({ type: "thinking", thinking: "" });
|
|
181
|
-
thinkingIndex = output.content.length - 1;
|
|
182
|
-
stream.push({
|
|
183
|
-
type: "thinking_start",
|
|
184
|
-
contentIndex: thinkingIndex,
|
|
185
|
-
partial: output,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
const block = output.content[thinkingIndex];
|
|
189
|
-
if (block?.type === "thinking") {
|
|
190
|
-
block.thinking += reasoning.text;
|
|
191
|
-
stream.push({
|
|
192
|
-
type: "thinking_delta",
|
|
193
|
-
contentIndex: thinkingIndex,
|
|
194
|
-
delta: reasoning.text,
|
|
195
|
-
partial: output,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const delta = chunk.getDeltaContent();
|
|
201
|
-
if (delta) {
|
|
202
|
-
if (textIndex < 0) {
|
|
203
|
-
closeThinking();
|
|
204
|
-
output.content.push({ type: "text", text: "" });
|
|
205
|
-
textIndex = output.content.length - 1;
|
|
206
|
-
stream.push({
|
|
207
|
-
type: "text_start",
|
|
208
|
-
contentIndex: textIndex,
|
|
209
|
-
partial: output,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
const block = output.content[textIndex];
|
|
213
|
-
if (block?.type === "text") {
|
|
214
|
-
block.text += delta;
|
|
215
|
-
stream.push({
|
|
216
|
-
type: "text_delta",
|
|
217
|
-
contentIndex: textIndex,
|
|
218
|
-
delta,
|
|
219
|
-
partial: output,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
typeof rawDelta.refusal === "string" &&
|
|
226
|
-
rawDelta.refusal.length > 0
|
|
227
|
-
) {
|
|
228
|
-
refusalText += rawDelta.refusal;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const toolDeltas = chunk.getDeltaToolCalls();
|
|
232
|
-
if (toolDeltas && toolDeltas.length > 0) {
|
|
233
|
-
closeText();
|
|
234
|
-
closeThinking();
|
|
235
|
-
|
|
236
|
-
for (const td of toolDeltas) {
|
|
237
|
-
let slot = toolSlots.get(td.index);
|
|
238
|
-
if (!slot) {
|
|
239
|
-
output.content.push({
|
|
240
|
-
type: "toolCall",
|
|
241
|
-
id: td.id ?? "",
|
|
242
|
-
name: td.function?.name ?? "",
|
|
243
|
-
arguments: {},
|
|
244
|
-
});
|
|
245
|
-
slot = {
|
|
246
|
-
contentIndex: output.content.length - 1,
|
|
247
|
-
partialJson: "",
|
|
248
|
-
};
|
|
249
|
-
toolSlots.set(td.index, slot);
|
|
250
|
-
stream.push({
|
|
251
|
-
type: "toolcall_start",
|
|
252
|
-
contentIndex: slot.contentIndex,
|
|
253
|
-
partial: output,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const block = output.content[slot.contentIndex];
|
|
258
|
-
if (block?.type === "toolCall") {
|
|
259
|
-
if (td.id && !block.id) block.id = td.id;
|
|
260
|
-
if (td.function?.name && !block.name)
|
|
261
|
-
block.name = td.function.name;
|
|
262
|
-
|
|
263
|
-
const fragment = td.function?.arguments ?? "";
|
|
264
|
-
if (fragment) {
|
|
265
|
-
slot.partialJson += fragment;
|
|
266
|
-
try {
|
|
267
|
-
block.arguments = JSON.parse(slot.partialJson);
|
|
268
|
-
} catch {
|
|
269
|
-
// Partial JSON — keep accumulating until valid
|
|
270
|
-
}
|
|
271
|
-
stream.push({
|
|
272
|
-
type: "toolcall_delta",
|
|
273
|
-
contentIndex: slot.contentIndex,
|
|
274
|
-
delta: fragment,
|
|
275
|
-
partial: output,
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
finishReason = latchFinishReason(
|
|
283
|
-
finishReason,
|
|
284
|
-
chunk.getFinishReason() ?? undefined,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
closeText();
|
|
289
|
-
closeThinking();
|
|
290
|
-
|
|
291
|
-
for (const slot of toolSlots.values()) {
|
|
292
|
-
const block = output.content[slot.contentIndex];
|
|
293
|
-
if (block?.type === "toolCall") {
|
|
294
|
-
if (slot.partialJson) {
|
|
295
|
-
try {
|
|
296
|
-
block.arguments = JSON.parse(slot.partialJson);
|
|
297
|
-
} catch {
|
|
298
|
-
// Leave arguments as last successfully-parsed value
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
stream.push({
|
|
302
|
-
type: "toolcall_end",
|
|
303
|
-
contentIndex: slot.contentIndex,
|
|
304
|
-
toolCall: {
|
|
305
|
-
type: "toolCall",
|
|
306
|
-
id: block.id,
|
|
307
|
-
name: block.name,
|
|
308
|
-
arguments: block.arguments,
|
|
309
|
-
},
|
|
310
|
-
partial: output,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const usage = response.getTokenUsage();
|
|
316
|
-
if (usage) {
|
|
317
|
-
output.usage = mapUsage(usage);
|
|
318
|
-
calculateCost(model, output.usage);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// A refusal terminates the turn with no real content. Promote it to a
|
|
322
|
-
// visible error so pi doesn't render an empty assistant turn.
|
|
323
|
-
if (refusalText) {
|
|
324
|
-
output.stopReason = "error";
|
|
325
|
-
output.errorMessage = `Model refused: ${refusalText}`;
|
|
326
|
-
stream.push({ type: "error", reason: "error", error: output });
|
|
327
|
-
stream.end();
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
output.stopReason = mapFinishReason(
|
|
332
|
-
toolSlots.size > 0
|
|
333
|
-
? "tool_calls"
|
|
334
|
-
: (finishReason ?? response.getFinishReason() ?? undefined),
|
|
335
|
-
);
|
|
336
|
-
stream.push({
|
|
337
|
-
type: "done",
|
|
338
|
-
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
339
|
-
message: output,
|
|
340
|
-
});
|
|
341
|
-
stream.end();
|
|
342
|
-
} catch (error) {
|
|
343
|
-
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
344
|
-
output.errorMessage = formatError(error);
|
|
345
|
-
debugLog({
|
|
346
|
-
requestId,
|
|
347
|
-
kind: "error",
|
|
348
|
-
provider: "foundation",
|
|
349
|
-
model: model.id,
|
|
350
|
-
stopReason: output.stopReason,
|
|
351
|
-
error: output.errorMessage,
|
|
352
|
-
});
|
|
353
|
-
stream.push({
|
|
354
|
-
type: "error",
|
|
355
|
-
reason: output.stopReason as "error" | "aborted",
|
|
356
|
-
error: output,
|
|
357
|
-
});
|
|
358
|
-
stream.end();
|
|
359
|
-
}
|
|
360
|
-
})();
|
|
361
|
-
|
|
362
|
-
return stream;
|
|
19
|
+
const executable = foundationExecutableForModel(model.id);
|
|
20
|
+
switch (executable) {
|
|
21
|
+
case "azure-openai":
|
|
22
|
+
return streamSapFoundationAzureOpenAi(model, context, options);
|
|
23
|
+
case "aws-bedrock":
|
|
24
|
+
return streamSapFoundationBedrock(model, context, options);
|
|
25
|
+
case "gcp-vertexai":
|
|
26
|
+
return streamSapFoundationVertexAi(model, context, options);
|
|
27
|
+
}
|
|
363
28
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
Context,
|
|
4
|
+
Message,
|
|
5
|
+
TextContent,
|
|
6
|
+
ToolResultMessage,
|
|
7
|
+
UserMessage,
|
|
8
|
+
} from "@earendil-works/pi-ai";
|
|
9
|
+
|
|
10
|
+
export type BedrockConverseContentBlock =
|
|
11
|
+
| { text: string }
|
|
12
|
+
| {
|
|
13
|
+
image: {
|
|
14
|
+
format: string;
|
|
15
|
+
source: { bytes: string };
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
toolUse: {
|
|
20
|
+
toolUseId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
input: unknown;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
toolResult: {
|
|
27
|
+
toolUseId: string;
|
|
28
|
+
content: Array<{ text: string }>;
|
|
29
|
+
status?: "success" | "error";
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type BedrockConverseMessage = {
|
|
34
|
+
role: "user" | "assistant";
|
|
35
|
+
content: BedrockConverseContentBlock[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function piContextToBedrockConverse(context: Context): {
|
|
39
|
+
system?: Array<{ text: string }>;
|
|
40
|
+
messages: BedrockConverseMessage[];
|
|
41
|
+
} {
|
|
42
|
+
const messages: BedrockConverseMessage[] = [];
|
|
43
|
+
|
|
44
|
+
for (const msg of context.messages) {
|
|
45
|
+
const translated = piMessageToBedrockConverse(msg);
|
|
46
|
+
if (translated) messages.push(translated);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...(context.systemPrompt ? { system: [{ text: context.systemPrompt }] } : {}),
|
|
51
|
+
messages: coalesceAdjacentMessages(messages),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function piMessageToBedrockConverse(
|
|
56
|
+
msg: Message,
|
|
57
|
+
): BedrockConverseMessage | undefined {
|
|
58
|
+
switch (msg.role) {
|
|
59
|
+
case "user":
|
|
60
|
+
return piUserToBedrockConverse(msg);
|
|
61
|
+
case "assistant":
|
|
62
|
+
return piAssistantToBedrockConverse(msg);
|
|
63
|
+
case "toolResult":
|
|
64
|
+
return piToolResultToBedrockConverse(msg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
|
|
69
|
+
if (typeof msg.content === "string") {
|
|
70
|
+
return { role: "user", content: [{ text: msg.content }] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const content = msg.content.map((part): BedrockConverseContentBlock => {
|
|
74
|
+
if (part.type === "text") return { text: part.text };
|
|
75
|
+
return {
|
|
76
|
+
image: {
|
|
77
|
+
format: imageFormatFromMimeType(part.mimeType),
|
|
78
|
+
source: { bytes: part.data },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { role: "user", content: content.length > 0 ? content : [{ text: " " }] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function piAssistantToBedrockConverse(
|
|
87
|
+
msg: AssistantMessage,
|
|
88
|
+
): BedrockConverseMessage {
|
|
89
|
+
const content: BedrockConverseContentBlock[] = [];
|
|
90
|
+
|
|
91
|
+
for (const block of msg.content) {
|
|
92
|
+
if (block.type === "text" && block.text) {
|
|
93
|
+
content.push({ text: block.text });
|
|
94
|
+
} else if (block.type === "toolCall") {
|
|
95
|
+
content.push({
|
|
96
|
+
toolUse: {
|
|
97
|
+
toolUseId: block.id,
|
|
98
|
+
name: block.name,
|
|
99
|
+
input: block.arguments,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
role: "assistant",
|
|
107
|
+
content: content.length > 0 ? content : [{ text: " " }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function piToolResultToBedrockConverse(
|
|
112
|
+
msg: ToolResultMessage,
|
|
113
|
+
): BedrockConverseMessage {
|
|
114
|
+
const text = toolResultText(msg) || " ";
|
|
115
|
+
return {
|
|
116
|
+
role: "user",
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
toolResult: {
|
|
120
|
+
toolUseId: msg.toolCallId,
|
|
121
|
+
content: [{ text }],
|
|
122
|
+
status: msg.isError ? "error" : "success",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
...toolResultImagesAsUserContent(msg),
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toolResultText(msg: ToolResultMessage): string {
|
|
131
|
+
return msg.content
|
|
132
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
133
|
+
.map((part) => part.text)
|
|
134
|
+
.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toolResultImagesAsUserContent(
|
|
138
|
+
msg: ToolResultMessage,
|
|
139
|
+
): BedrockConverseContentBlock[] {
|
|
140
|
+
return msg.content
|
|
141
|
+
.filter(
|
|
142
|
+
(part): part is { type: "image"; data: string; mimeType: string } =>
|
|
143
|
+
part.type === "image",
|
|
144
|
+
)
|
|
145
|
+
.map((part) => ({
|
|
146
|
+
image: {
|
|
147
|
+
format: imageFormatFromMimeType(part.mimeType),
|
|
148
|
+
source: { bytes: part.data },
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function imageFormatFromMimeType(mimeType: string): string {
|
|
154
|
+
const format = mimeType.split("/")[1]?.toLowerCase();
|
|
155
|
+
if (format === "jpg") return "jpeg";
|
|
156
|
+
if (format === "jpeg" || format === "png" || format === "gif" || format === "webp") {
|
|
157
|
+
return format;
|
|
158
|
+
}
|
|
159
|
+
return "png";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function coalesceAdjacentMessages(
|
|
163
|
+
messages: BedrockConverseMessage[],
|
|
164
|
+
): BedrockConverseMessage[] {
|
|
165
|
+
const result: BedrockConverseMessage[] = [];
|
|
166
|
+
for (const msg of messages) {
|
|
167
|
+
const previous = result[result.length - 1];
|
|
168
|
+
if (previous && previous.role === msg.role) {
|
|
169
|
+
previous.content.push(...msg.content);
|
|
170
|
+
} else {
|
|
171
|
+
result.push({ role: msg.role, content: [...msg.content] });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
Context,
|
|
4
|
+
Message,
|
|
5
|
+
TextContent,
|
|
6
|
+
ToolResultMessage,
|
|
7
|
+
UserMessage,
|
|
8
|
+
} from "@earendil-works/pi-ai";
|
|
9
|
+
|
|
10
|
+
export type VertexPart =
|
|
11
|
+
| { text: string }
|
|
12
|
+
| { inlineData: { mimeType: string; data: string } }
|
|
13
|
+
| { functionCall: { name: string; args: unknown } }
|
|
14
|
+
| { functionResponse: { name: string; response: Record<string, unknown> } };
|
|
15
|
+
|
|
16
|
+
export type VertexContent = {
|
|
17
|
+
role: "user" | "model";
|
|
18
|
+
parts: VertexPart[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function piContextToVertexGenerateContent(context: Context): {
|
|
22
|
+
systemInstruction?: { parts: Array<{ text: string }> };
|
|
23
|
+
contents: VertexContent[];
|
|
24
|
+
} {
|
|
25
|
+
const contents: VertexContent[] = [];
|
|
26
|
+
|
|
27
|
+
for (const msg of context.messages) {
|
|
28
|
+
const translated = piMessageToVertexContent(msg);
|
|
29
|
+
if (translated) contents.push(translated);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...(context.systemPrompt
|
|
34
|
+
? { systemInstruction: { parts: [{ text: context.systemPrompt }] } }
|
|
35
|
+
: {}),
|
|
36
|
+
contents: coalesceAdjacentContents(contents),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function piMessageToVertexContent(msg: Message): VertexContent | undefined {
|
|
41
|
+
switch (msg.role) {
|
|
42
|
+
case "user":
|
|
43
|
+
return piUserToVertexContent(msg);
|
|
44
|
+
case "assistant":
|
|
45
|
+
return piAssistantToVertexContent(msg);
|
|
46
|
+
case "toolResult":
|
|
47
|
+
return piToolResultToVertexContent(msg);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function piUserToVertexContent(msg: UserMessage): VertexContent {
|
|
52
|
+
if (typeof msg.content === "string") {
|
|
53
|
+
return { role: "user", parts: [{ text: msg.content }] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const parts = msg.content.map((part): VertexPart => {
|
|
57
|
+
if (part.type === "text") return { text: part.text };
|
|
58
|
+
return { inlineData: { mimeType: part.mimeType, data: part.data } };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { role: "user", parts: parts.length > 0 ? parts : [{ text: " " }] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function piAssistantToVertexContent(msg: AssistantMessage): VertexContent {
|
|
65
|
+
const parts: VertexPart[] = [];
|
|
66
|
+
for (const block of msg.content) {
|
|
67
|
+
if (block.type === "text" && block.text) {
|
|
68
|
+
parts.push({ text: block.text });
|
|
69
|
+
} else if (block.type === "toolCall") {
|
|
70
|
+
parts.push({
|
|
71
|
+
functionCall: { name: block.name, args: block.arguments },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { role: "model", parts: parts.length > 0 ? parts : [{ text: " " }] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function piToolResultToVertexContent(msg: ToolResultMessage): VertexContent {
|
|
79
|
+
return {
|
|
80
|
+
role: "user",
|
|
81
|
+
parts: [
|
|
82
|
+
{
|
|
83
|
+
functionResponse: {
|
|
84
|
+
name: msg.toolName ?? msg.toolCallId,
|
|
85
|
+
response: {
|
|
86
|
+
content: toolResultText(msg) || " ",
|
|
87
|
+
...(msg.isError ? { error: true } : {}),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
...toolResultImagesAsUserParts(msg),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toolResultText(msg: ToolResultMessage): string {
|
|
97
|
+
return msg.content
|
|
98
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
99
|
+
.map((part) => part.text)
|
|
100
|
+
.join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toolResultImagesAsUserParts(msg: ToolResultMessage): VertexPart[] {
|
|
104
|
+
return msg.content
|
|
105
|
+
.filter(
|
|
106
|
+
(part): part is { type: "image"; data: string; mimeType: string } =>
|
|
107
|
+
part.type === "image",
|
|
108
|
+
)
|
|
109
|
+
.map((part) => ({
|
|
110
|
+
inlineData: { mimeType: part.mimeType, data: part.data },
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function coalesceAdjacentContents(contents: VertexContent[]): VertexContent[] {
|
|
115
|
+
const result: VertexContent[] = [];
|
|
116
|
+
for (const content of contents) {
|
|
117
|
+
const previous = result[result.length - 1];
|
|
118
|
+
if (previous && previous.role === content.role) {
|
|
119
|
+
previous.parts.push(...content.parts);
|
|
120
|
+
} else {
|
|
121
|
+
result.push({ role: content.role, parts: [...content.parts] });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|