pi-sap-aicore 0.2.0 → 0.2.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/CHANGELOG.md +24 -1
- package/README.md +3 -1
- package/index.ts +7 -6
- package/package.json +3 -3
- package/src/stream-foundation.ts +3 -1
- package/src/stream.ts +20 -12
- package/src/translate-foundation.ts +136 -40
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2026-06-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `/login → Use a subscription` no longer shows a duplicate `SAP AI Core`
|
|
15
|
+
entry. The foundation provider now shares the orchestration provider's stored
|
|
16
|
+
service key through the existing auth-store fallback instead of registering a
|
|
17
|
+
second OAuth provider.
|
|
18
|
+
|
|
19
|
+
## [0.2.1] - 2026-06-10
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Foundation-provider tool-call history now preserves Azure/OpenAI's required
|
|
24
|
+
ordering by emitting all matching `role: "tool"` replies immediately after an
|
|
25
|
+
assistant `tool_calls` message, then hoisting screenshot/image outputs into
|
|
26
|
+
synthetic user messages afterward.
|
|
27
|
+
- Streaming turns that contain tool calls are now finalized as `toolUse` even
|
|
28
|
+
when SAP reports a trailing `stop`, preventing skipped tool execution and
|
|
29
|
+
follow-up `invalid_request_error` failures about missing tool-call responses.
|
|
30
|
+
|
|
10
31
|
## [0.2.0] - 2026-06-06
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -75,7 +96,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
75
96
|
`reasoning_effort` for OpenAI).
|
|
76
97
|
- MIT license and npm packaging.
|
|
77
98
|
|
|
78
|
-
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.
|
|
99
|
+
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.2...HEAD
|
|
100
|
+
[0.2.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...v0.2.2
|
|
101
|
+
[0.2.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...v0.2.1
|
|
79
102
|
[0.2.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.2...v0.2.0
|
|
80
103
|
[0.1.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.1...v0.1.2
|
|
81
104
|
[0.1.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.0...v0.1.1
|
package/README.md
CHANGED
|
@@ -406,7 +406,9 @@ build step — pi loads the `.ts` sources directly via jiti — so a release is
|
|
|
406
406
|
git push --follow-tags
|
|
407
407
|
```
|
|
408
408
|
3. The [`Publish`](.github/workflows/publish.yml) workflow fires on the `v*` tag,
|
|
409
|
-
asserts the tag matches `package.json`, typechecks, and
|
|
409
|
+
asserts the tag matches `package.json`, typechecks, publishes to npm, and
|
|
410
|
+
creates/updates the matching GitHub Release from that version's
|
|
411
|
+
`CHANGELOG.md` notes.
|
|
410
412
|
|
|
411
413
|
Every push to `main` and every PR also runs the [`CI`](.github/workflows/ci.yml)
|
|
412
414
|
typecheck gate.
|
package/index.ts
CHANGED
|
@@ -58,18 +58,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
58
58
|
streamSimple: streamSapAiCore,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
// Foundation provider — shares the exact same credential
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
61
|
+
// Foundation provider — shares the exact same credential via
|
|
62
|
+
// `ensureServiceKey`'s auth-store fallback instead of registering its own
|
|
63
|
+
// OAuth provider. Registering `oauth: sapAiCoreOAuth` here would make `/login`
|
|
64
|
+
// show a second, confusing "SAP AI Core" subscription entry because pi keys
|
|
65
|
+
// OAuth providers by provider id (`sap-aicore-foundation`), not by OAuth name.
|
|
66
|
+
// Models appear under `sap-aicore-foundation/…`; streaming runs natively here
|
|
67
|
+
// (no orchestration streaming-unsupported fallback). The foundation SDK is
|
|
66
68
|
// dynamically imported inside `streamSapFoundation`, same deferral as above.
|
|
67
69
|
pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
|
|
68
70
|
name: "SAP AI Core (Foundation)",
|
|
69
71
|
baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
|
|
70
72
|
apiKey: PLACEHOLDER_API_KEY,
|
|
71
73
|
api: FOUNDATION_PROVIDER_API,
|
|
72
|
-
oauth: sapAiCoreOAuth,
|
|
73
74
|
models: foundationModels.map((m) =>
|
|
74
75
|
toPiModel(m, FOUNDATION_PROVIDER_API),
|
|
75
76
|
),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sap-aicore",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "SAP AI Core (orchestration + foundation) provider for the pi coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tim Pearson (https://github.com/ttiimmaahh)",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"@earendil-works/pi-coding-agent": "*"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@earendil-works/pi-ai": "^0.
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
46
|
+
"@earendil-works/pi-ai": "^0.79.2",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.79.2",
|
|
48
48
|
"typescript": "^6.0.3"
|
|
49
49
|
},
|
|
50
50
|
"engines": {
|
package/src/stream-foundation.ts
CHANGED
|
@@ -329,7 +329,9 @@ export function streamSapFoundation(
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
output.stopReason = mapFinishReason(
|
|
332
|
-
|
|
332
|
+
toolSlots.size > 0
|
|
333
|
+
? "tool_calls"
|
|
334
|
+
: (finishReason ?? response.getFinishReason() ?? undefined),
|
|
333
335
|
);
|
|
334
336
|
stream.push({
|
|
335
337
|
type: "done",
|
package/src/stream.ts
CHANGED
|
@@ -15,10 +15,7 @@ import {
|
|
|
15
15
|
type Usage,
|
|
16
16
|
} from "@earendil-works/pi-ai";
|
|
17
17
|
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import type {
|
|
19
|
-
ChatModel,
|
|
20
|
-
LlmModelParams,
|
|
21
|
-
} from "@sap-ai-sdk/orchestration";
|
|
18
|
+
import type { ChatModel, LlmModelParams } from "@sap-ai-sdk/orchestration";
|
|
22
19
|
import type { TokenUsage } from "@sap-ai-sdk/orchestration/internal.js";
|
|
23
20
|
|
|
24
21
|
import { parseAndValidateServiceKey, type ValidatedKey } from "./auth.ts";
|
|
@@ -430,7 +427,11 @@ function buildLlmParams(
|
|
|
430
427
|
// to reserve room for thinking). Respect it; otherwise fall back to the
|
|
431
428
|
// model's documented max output.
|
|
432
429
|
const effectiveMaxTokens = options?.maxTokens ?? model.maxTokens;
|
|
433
|
-
const reasoning = reasoningParams(
|
|
430
|
+
const reasoning = reasoningParams(
|
|
431
|
+
model,
|
|
432
|
+
options?.reasoning,
|
|
433
|
+
effectiveMaxTokens,
|
|
434
|
+
);
|
|
434
435
|
const params: LlmModelParams = {
|
|
435
436
|
max_tokens: effectiveMaxTokens,
|
|
436
437
|
};
|
|
@@ -591,9 +592,7 @@ export function ensureServiceKey(apiKey: string | undefined): ValidatedKey {
|
|
|
591
592
|
// (foundation) that shares the oauth but has no credential of its own.
|
|
592
593
|
const fromPi = apiKey?.trimStart().startsWith("{") ? apiKey : undefined;
|
|
593
594
|
const raw =
|
|
594
|
-
fromPi ??
|
|
595
|
-
process.env.AICORE_SERVICE_KEY ??
|
|
596
|
-
readSharedServiceKeyFromStore();
|
|
595
|
+
fromPi ?? process.env.AICORE_SERVICE_KEY ?? readSharedServiceKeyFromStore();
|
|
597
596
|
if (!raw) {
|
|
598
597
|
throw new Error(
|
|
599
598
|
"No SAP AI Core service key configured. Run `/login` in pi, " +
|
|
@@ -691,6 +690,10 @@ export function streamSapAiCore(
|
|
|
691
690
|
// Shared finalizer for both paths: map+cost usage once, promote a
|
|
692
691
|
// refusal to a visible error, otherwise emit the `done` event.
|
|
693
692
|
const finishTurn = (result: TurnResult) => {
|
|
693
|
+
const hasToolCalls = output.content.some(
|
|
694
|
+
(block) => block.type === "toolCall",
|
|
695
|
+
);
|
|
696
|
+
|
|
694
697
|
if (result.usage) {
|
|
695
698
|
output.usage = mapUsage(result.usage);
|
|
696
699
|
calculateCost(model, output.usage);
|
|
@@ -707,7 +710,9 @@ export function streamSapAiCore(
|
|
|
707
710
|
return;
|
|
708
711
|
}
|
|
709
712
|
|
|
710
|
-
output.stopReason = mapFinishReason(
|
|
713
|
+
output.stopReason = mapFinishReason(
|
|
714
|
+
hasToolCalls ? "tool_calls" : result.finishReason,
|
|
715
|
+
);
|
|
711
716
|
stream.push({
|
|
712
717
|
type: "done",
|
|
713
718
|
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
@@ -740,7 +745,11 @@ export function streamSapAiCore(
|
|
|
740
745
|
if (content) {
|
|
741
746
|
output.content.push({ type: "text", text: content });
|
|
742
747
|
const idx = output.content.length - 1;
|
|
743
|
-
stream.push({
|
|
748
|
+
stream.push({
|
|
749
|
+
type: "text_start",
|
|
750
|
+
contentIndex: idx,
|
|
751
|
+
partial: output,
|
|
752
|
+
});
|
|
744
753
|
stream.push({
|
|
745
754
|
type: "text_delta",
|
|
746
755
|
contentIndex: idx,
|
|
@@ -808,8 +817,7 @@ export function streamSapAiCore(
|
|
|
808
817
|
// Stream by default; on SAP's "Streaming is not supported" 400 —
|
|
809
818
|
// and only before any chunk has been emitted — remember the model
|
|
810
819
|
// and fall back to the blocking path so the turn still completes.
|
|
811
|
-
let response: Awaited<ReturnType<typeof client.stream>> | undefined
|
|
812
|
-
undefined;
|
|
820
|
+
let response: Awaited<ReturnType<typeof client.stream>> | undefined;
|
|
813
821
|
if (!STREAMING_UNSUPPORTED.has(model.id)) {
|
|
814
822
|
try {
|
|
815
823
|
response = await client.stream({ messages }, options?.signal, {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AssistantMessage,
|
|
3
3
|
Context,
|
|
4
|
-
Message,
|
|
5
4
|
TextContent,
|
|
6
5
|
Tool,
|
|
7
6
|
ToolResultMessage,
|
|
@@ -33,27 +32,81 @@ export function piContextToAzureOpenAi(context: Context): {
|
|
|
33
32
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const pi = context.messages;
|
|
36
|
+
for (let i = 0; i < pi.length; i++) {
|
|
37
|
+
const msg = pi[i];
|
|
38
|
+
|
|
39
|
+
if (msg.role === "assistant") {
|
|
40
|
+
const assistant = piAssistantToAzureOpenAi(msg);
|
|
41
|
+
const toolCalls = assistant.tool_calls ?? [];
|
|
42
|
+
if (toolCalls.length === 0) {
|
|
43
|
+
messages.push(assistant);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
messages.push(assistant);
|
|
48
|
+
|
|
49
|
+
// OpenAI/Azure require every assistant tool_call to be followed
|
|
50
|
+
// immediately by role:"tool" messages for *all* tool_call_ids.
|
|
51
|
+
// Pi stores each tool result as a separate top-level message, and this
|
|
52
|
+
// translator may need to hoist image results into synthetic user messages.
|
|
53
|
+
// If we translate tool results one-by-one, multiple screenshot-producing
|
|
54
|
+
// tool results become: tool, user(image), tool — and Azure rejects the
|
|
55
|
+
// second tool call as unanswered. Batch all contiguous tool results here:
|
|
56
|
+
// assistant(tool_calls), all tool messages, then any hoisted images.
|
|
57
|
+
const expectedIds: string[] = [];
|
|
58
|
+
for (const toolCall of toolCalls) expectedIds.push(toolCall.id);
|
|
59
|
+
const expected = new Set(expectedIds);
|
|
60
|
+
const byId = new Map<string, AzureToolResultParts>();
|
|
61
|
+
const orphaned: AzureOpenAiChatCompletionRequestUserMessage[] = [];
|
|
62
|
+
|
|
63
|
+
let j = i + 1;
|
|
64
|
+
while (j < pi.length) {
|
|
65
|
+
const toolResult = pi[j];
|
|
66
|
+
if (toolResult.role !== "toolResult") break;
|
|
67
|
+
if (
|
|
68
|
+
expected.has(toolResult.toolCallId) &&
|
|
69
|
+
!byId.has(toolResult.toolCallId)
|
|
70
|
+
) {
|
|
71
|
+
byId.set(
|
|
72
|
+
toolResult.toolCallId,
|
|
73
|
+
piToolResultToAzureOpenAiParts(toolResult),
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
orphaned.push(...piToolResultToSyntheticUserMessages(toolResult));
|
|
77
|
+
}
|
|
78
|
+
j++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const imageMessages: AzureOpenAiChatCompletionRequestUserMessage[] = [];
|
|
82
|
+
for (const id of expectedIds) {
|
|
83
|
+
const translated = byId.get(id);
|
|
84
|
+
if (translated) {
|
|
85
|
+
messages.push(translated.toolMessage);
|
|
86
|
+
imageMessages.push(...translated.imageMessages);
|
|
87
|
+
} else {
|
|
88
|
+
messages.push(missingToolResultMessage(id));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
messages.push(...imageMessages, ...orphaned);
|
|
92
|
+
i = j - 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (msg.role === "toolResult") {
|
|
97
|
+
// A standalone tool message is invalid for OpenAI. Keep the information
|
|
98
|
+
// available to the model, but present it as user-visible transcript text.
|
|
99
|
+
messages.push(...piToolResultToSyntheticUserMessages(msg));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
messages.push(piUserToAzureOpenAi(msg));
|
|
38
104
|
}
|
|
39
105
|
|
|
40
106
|
const tools = (context.tools ?? []).map(piToolToAzureOpenAi);
|
|
41
107
|
return { messages, tools };
|
|
42
108
|
}
|
|
43
109
|
|
|
44
|
-
function piMessageToAzureOpenAi(
|
|
45
|
-
msg: Message,
|
|
46
|
-
): AzureOpenAiChatCompletionRequestMessage[] {
|
|
47
|
-
switch (msg.role) {
|
|
48
|
-
case "user":
|
|
49
|
-
return [piUserToAzureOpenAi(msg)];
|
|
50
|
-
case "assistant":
|
|
51
|
-
return [piAssistantToAzureOpenAi(msg)];
|
|
52
|
-
case "toolResult":
|
|
53
|
-
return piToolResultToAzureOpenAi(msg);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
110
|
function piUserToAzureOpenAi(
|
|
58
111
|
msg: UserMessage,
|
|
59
112
|
): AzureOpenAiChatCompletionRequestUserMessage {
|
|
@@ -108,38 +161,81 @@ function piAssistantToAzureOpenAi(
|
|
|
108
161
|
return result;
|
|
109
162
|
}
|
|
110
163
|
|
|
111
|
-
|
|
164
|
+
type AzureToolResultParts = {
|
|
165
|
+
toolMessage: AzureOpenAiChatCompletionRequestToolMessage;
|
|
166
|
+
imageMessages: AzureOpenAiChatCompletionRequestUserMessage[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function piToolResultToAzureOpenAiParts(
|
|
112
170
|
msg: ToolResultMessage,
|
|
113
|
-
):
|
|
114
|
-
const text = msg
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
171
|
+
): AzureToolResultParts {
|
|
172
|
+
const text = toolResultText(msg);
|
|
173
|
+
const imageMessages = toolResultImages(msg).map((img) => ({
|
|
174
|
+
role: "user" as const,
|
|
175
|
+
content: [
|
|
176
|
+
{
|
|
177
|
+
type: "image_url" as const,
|
|
178
|
+
image_url: { url: `data:${img.mimeType};base64,${img.data}` },
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}));
|
|
118
182
|
|
|
119
|
-
|
|
183
|
+
return {
|
|
184
|
+
toolMessage: {
|
|
185
|
+
role: "tool",
|
|
186
|
+
tool_call_id: msg.toolCallId,
|
|
187
|
+
content:
|
|
188
|
+
text ||
|
|
189
|
+
(imageMessages.length > 0
|
|
190
|
+
? "Tool returned image content; image(s) follow in the next user message."
|
|
191
|
+
: " "),
|
|
192
|
+
},
|
|
193
|
+
imageMessages,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function missingToolResultMessage(
|
|
198
|
+
toolCallId: string,
|
|
199
|
+
): AzureOpenAiChatCompletionRequestToolMessage {
|
|
200
|
+
return {
|
|
120
201
|
role: "tool",
|
|
121
|
-
tool_call_id:
|
|
122
|
-
content:
|
|
202
|
+
tool_call_id: toolCallId,
|
|
203
|
+
content: "[Tool result missing from local transcript.]",
|
|
123
204
|
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function piToolResultToSyntheticUserMessages(
|
|
208
|
+
msg: ToolResultMessage,
|
|
209
|
+
): AzureOpenAiChatCompletionRequestUserMessage[] {
|
|
210
|
+
const content = [
|
|
211
|
+
{
|
|
212
|
+
type: "text" as const,
|
|
213
|
+
text: `Tool result for ${msg.toolName} (${msg.toolCallId}):\n${
|
|
214
|
+
toolResultText(msg) || "[no textual output]"
|
|
215
|
+
}`,
|
|
216
|
+
},
|
|
217
|
+
...toolResultImages(msg).map((img) => ({
|
|
218
|
+
type: "image_url" as const,
|
|
219
|
+
image_url: { url: `data:${img.mimeType};base64,${img.data}` },
|
|
220
|
+
})),
|
|
221
|
+
];
|
|
222
|
+
return [{ role: "user", content }];
|
|
223
|
+
}
|
|
124
224
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
225
|
+
function toolResultText(msg: ToolResultMessage): string {
|
|
226
|
+
return msg.content
|
|
227
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
228
|
+
.map((part) => part.text)
|
|
229
|
+
.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function toolResultImages(
|
|
233
|
+
msg: ToolResultMessage,
|
|
234
|
+
): { type: "image"; data: string; mimeType: string }[] {
|
|
235
|
+
return msg.content.filter(
|
|
129
236
|
(part): part is { type: "image"; data: string; mimeType: string } =>
|
|
130
237
|
part.type === "image",
|
|
131
238
|
);
|
|
132
|
-
if (images.length === 0) return [toolMessage];
|
|
133
|
-
|
|
134
|
-
const imageItems = images.map((img) => ({
|
|
135
|
-
type: "image_url" as const,
|
|
136
|
-
image_url: { url: `data:${img.mimeType};base64,${img.data}` },
|
|
137
|
-
}));
|
|
138
|
-
const imageMessage: AzureOpenAiChatCompletionRequestUserMessage = {
|
|
139
|
-
role: "user",
|
|
140
|
-
content: imageItems,
|
|
141
|
-
};
|
|
142
|
-
return [toolMessage, imageMessage];
|
|
143
239
|
}
|
|
144
240
|
|
|
145
241
|
function piToolToAzureOpenAi(tool: Tool): AzureOpenAiChatCompletionTool {
|