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 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.0...HEAD
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 publishes.
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. Both providers
62
- // reference the same `sapAiCoreOAuth` (oauth name "SAP AI Core"), so a single
63
- // `/login` serves both and the service key is never entered twice. Models
64
- // appear under `sap-aicore-foundation/…`; streaming runs natively here (no
65
- // orchestration streaming-unsupported fallback). The foundation SDK is
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.0",
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.78.0",
47
- "@earendil-works/pi-coding-agent": "^0.78.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": {
@@ -329,7 +329,9 @@ export function streamSapFoundation(
329
329
  }
330
330
 
331
331
  output.stopReason = mapFinishReason(
332
- finishReason ?? response.getFinishReason() ?? undefined,
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(model, options?.reasoning, effectiveMaxTokens);
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(result.finishReason);
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({ type: "text_start", contentIndex: idx, partial: output });
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
- for (const msg of context.messages) {
37
- messages.push(...piMessageToAzureOpenAi(msg));
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
- function piToolResultToAzureOpenAi(
164
+ type AzureToolResultParts = {
165
+ toolMessage: AzureOpenAiChatCompletionRequestToolMessage;
166
+ imageMessages: AzureOpenAiChatCompletionRequestUserMessage[];
167
+ };
168
+
169
+ function piToolResultToAzureOpenAiParts(
112
170
  msg: ToolResultMessage,
113
- ): AzureOpenAiChatCompletionRequestMessage[] {
114
- const text = msg.content
115
- .filter((part): part is TextContent => part.type === "text")
116
- .map((part) => part.text)
117
- .join("\n");
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
- const toolMessage: AzureOpenAiChatCompletionRequestToolMessage = {
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: msg.toolCallId,
122
- content: text,
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
- // The tool message schema is text-only, so image blocks produced by pi
126
- // tools (e.g. `read` on an image) are hoisted into a synthetic user message
127
- // right after the tool result so vision-capable models still see the bytes.
128
- const images = msg.content.filter(
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 {