pi-sap-aicore 0.3.0 → 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 CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-07-01
11
+
12
+ ### Added
13
+
14
+ - Direct foundation route support for Gemini models deployed through SAP AI Core
15
+ `foundation-models` with the `gcp-vertexai` executable. The provider now calls
16
+ SAP AI Core's Vertex-compatible `generateContent` endpoint and replays the
17
+ non-streaming response into pi stream events.
18
+ - Vertex AI/Gemini message translation for text, images, assistant function-call
19
+ history, and function-response history on the direct foundation route.
20
+
21
+ ### Changed
22
+
23
+ - Gemini direct foundation requests set `generationConfig.thinkingConfig.thinkingBudget`
24
+ to `0` by default so small pi output budgets produce visible text instead of
25
+ being consumed entirely by hidden thoughts.
26
+ - Refreshed npm dependency lockfile after `npm update` to pick up patched SAP SDK
27
+ transitive dependencies.
28
+
10
29
  ## [0.3.0] - 2026-07-01
11
30
 
12
31
  ### Added
@@ -116,7 +135,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
116
135
  `reasoning_effort` for OpenAI).
117
136
  - MIT license and npm packaging.
118
137
 
119
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.0...HEAD
138
+ [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.1...HEAD
139
+ [0.3.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.0...v0.3.1
120
140
  [0.3.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.2...v0.3.0
121
141
  [0.2.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...v0.2.2
122
142
  [0.2.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -131,11 +131,11 @@ The extension registers **two providers**, both backed by the same service key:
131
131
  | | `sap-aicore` (orchestration) | `sap-aicore-foundation` (direct) |
132
132
  |---|---|---|
133
133
  | SAP deployment | one orchestration deployment fronts **every** model | one foundation deployment **per model** |
134
- | Models | Claude, GPT-5*, Gemini | GPT/OpenAI (`azure-openai`) and Anthropic/Claude (`aws-bedrock`); Gemini/Vertex mapping is reserved but not implemented yet |
135
- | Streaming | subject to orchestration's per-model allow-list — new models can 400 `Streaming is not supported` (we fall back to non-streaming) | Azure OpenAI streams natively; AWS Bedrock currently uses non-streaming `/converse` and replays the response into pi stream events |
136
- | Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only for Azure; Bedrock/Anthropic thinking controls are not wired yet |
134
+ | Models | Claude, GPT-5*, Gemini | GPT/OpenAI (`azure-openai`), Anthropic/Claude (`aws-bedrock`), and Gemini (`gcp-vertexai`) |
135
+ | Streaming | subject to orchestration's per-model allow-list — new models can 400 `Streaming is not supported` (we fall back to non-streaming) | Azure OpenAI streams natively; AWS Bedrock and Vertex AI currently use non-streaming endpoints and replay responses into pi stream events |
136
+ | Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only for Azure; Bedrock/Anthropic and Vertex/Gemini thinking controls are not fully wired yet |
137
137
  | Content filter / grounding / templating | yes | no — raw model access |
138
- | SDK / endpoint | `@sap-ai-sdk/orchestration` | `AzureOpenAiChatClient` for `azure-openai`; SAP `/inference/deployments/{id}/converse` for `aws-bedrock` |
138
+ | SDK / endpoint | `@sap-ai-sdk/orchestration` | `AzureOpenAiChatClient` for `azure-openai`; SAP `/inference/deployments/{id}/converse` for `aws-bedrock`; SAP `/inference/deployments/{id}/models/{model}:generateContent` for `gcp-vertexai` |
139
139
 
140
140
  Both routes appear in the model list simultaneously, so you choose per model. The
141
141
  foundation route exists mainly to access new models directly when orchestration
@@ -145,8 +145,7 @@ Azure OpenAI or a newly deployed Claude model on AWS Bedrock).
145
145
  **Adding a foundation model:** it needs its own foundation-models deployment in
146
146
  SAP AI Core — one per (model, version, resource group). The extension chooses the
147
147
  foundation executable from the model id: `gpt-*` → `azure-openai`,
148
- `anthropic--*` → `aws-bedrock`, and `gemini-*` → `gcp-vertexai` (reserved; adapter
149
- not implemented yet). Then add its `id` to the per-machine extension overlay at
148
+ `anthropic--*` → `aws-bedrock`, and `gemini-*` → `gcp-vertexai`. Then add its `id` to the per-machine extension overlay at
150
149
  `~/.pi/agent/pi-sap-aicore/models.json`:
151
150
 
152
151
  ```json
@@ -336,9 +335,10 @@ future, our `pickReasoning` probe is wired and ready in `stream.ts`.
336
335
  the direct Azure OpenAI SDK pinned to API version `2024-10-21`, which has no
337
336
  `reasoning_effort` field — so gpt-5\* reason at their **default** effort and pi's
338
337
  thinking-level cycle is a no-op there. Anthropic/Claude models use SAP's AWS
339
- Bedrock `/converse` endpoint; model-default reasoning works, but explicit Claude
340
- thinking controls are not wired yet. Use the orchestration route if you need
341
- explicit effort control.
338
+ Bedrock `/converse` endpoint; Gemini models use SAP's Vertex AI `generateContent`
339
+ endpoint with `thinkingBudget: 0` by default so small pi output budgets produce
340
+ visible text instead of only hidden thoughts. Use the orchestration route if you
341
+ need explicit effort control.
342
342
 
343
343
  To override budgets per model, edit `thinkingLevelMap` on the relevant entry in
344
344
  `~/.pi/agent/pi-sap-aicore/models.json`.
@@ -466,6 +466,8 @@ npmjs.com:
466
466
  ├── stream-foundation.ts # foundation dispatcher
467
467
  ├── stream-foundation-azure-openai.ts # AzureOpenAiChatClient adapter with native streaming
468
468
  ├── stream-foundation-bedrock.ts # AWS Bedrock /converse adapter for Anthropic foundation deployments
469
+ ├── stream-foundation-vertexai.ts # Vertex AI generateContent adapter for Gemini foundation deployments
469
470
  ├── translate-foundation.ts # pi Context ↔ Azure OpenAI message shape
470
- └── translate-foundation-bedrock.ts # pi Context ↔ Bedrock Converse message shape
471
+ ├── translate-foundation-bedrock.ts # pi Context ↔ Bedrock Converse message shape
472
+ └── translate-foundation-vertexai.ts # pi Context ↔ Vertex AI generateContent message shape
471
473
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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)",
@@ -0,0 +1,214 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import {
4
+ type Api,
5
+ type AssistantMessage,
6
+ type AssistantMessageEventStream,
7
+ calculateCost,
8
+ type Context,
9
+ createAssistantMessageEventStream,
10
+ type Model,
11
+ type SimpleStreamOptions,
12
+ } from "@earendil-works/pi-ai";
13
+ import { executeRequest } from "@sap-ai-sdk/core";
14
+
15
+ import { resolveFoundationDeploymentId } from "./foundation-deployment.ts";
16
+ import {
17
+ debugLog,
18
+ ensureServiceKey,
19
+ formatError,
20
+ mapUsage,
21
+ resolveResourceGroup,
22
+ } from "./stream.ts";
23
+ import { mapFinishReason } from "./translate.ts";
24
+ import { piContextToVertexGenerateContent } from "./translate-foundation-vertexai.ts";
25
+
26
+ type VertexGenerateContentResponse = {
27
+ candidates?: Array<{
28
+ content?: {
29
+ role?: string;
30
+ parts?: Array<{
31
+ text?: string;
32
+ functionCall?: { name?: string; args?: unknown };
33
+ }>;
34
+ };
35
+ finishReason?: string;
36
+ }>;
37
+ usageMetadata?: {
38
+ promptTokenCount?: number;
39
+ candidatesTokenCount?: number;
40
+ thoughtsTokenCount?: number;
41
+ totalTokenCount?: number;
42
+ };
43
+ };
44
+
45
+ export function streamSapFoundationVertexAi(
46
+ model: Model<Api>,
47
+ context: Context,
48
+ options?: SimpleStreamOptions,
49
+ ): AssistantMessageEventStream {
50
+ const stream = createAssistantMessageEventStream();
51
+ const output: AssistantMessage = {
52
+ role: "assistant",
53
+ content: [],
54
+ api: model.api,
55
+ provider: model.provider,
56
+ model: model.id,
57
+ usage: {
58
+ input: 0,
59
+ output: 0,
60
+ cacheRead: 0,
61
+ cacheWrite: 0,
62
+ totalTokens: 0,
63
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
64
+ },
65
+ stopReason: "stop",
66
+ timestamp: Date.now(),
67
+ };
68
+
69
+ (async () => {
70
+ const requestId = randomUUID();
71
+ try {
72
+ stream.push({ type: "start", partial: output });
73
+
74
+ const serviceKey = ensureServiceKey(options?.apiKey);
75
+ process.env.AICORE_SERVICE_KEY = serviceKey.raw;
76
+ const resourceGroup = resolveResourceGroup(serviceKey);
77
+ const deploymentId = await resolveFoundationDeploymentId({
78
+ modelId: model.id,
79
+ executableId: "gcp-vertexai",
80
+ resourceGroup,
81
+ });
82
+
83
+ const translated = piContextToVertexGenerateContent(context);
84
+ const maxOutputTokens = options?.maxTokens ?? model.maxTokens;
85
+ const request = {
86
+ ...translated,
87
+ generationConfig: {
88
+ maxOutputTokens,
89
+ ...(options?.temperature !== undefined
90
+ ? { temperature: options.temperature }
91
+ : {}),
92
+ // Gemini 3.x can spend small max-token budgets entirely on
93
+ // thinking. Keep foundation route responsive by default; use
94
+ // orchestration once SAP exposes/validates richer thinking controls.
95
+ thinkingConfig: { thinkingBudget: 0 },
96
+ },
97
+ };
98
+
99
+ debugLog({
100
+ requestId,
101
+ kind: "request",
102
+ provider: "foundation-gcp-vertexai",
103
+ model: model.id,
104
+ resourceGroup,
105
+ deploymentId,
106
+ params: request.generationConfig,
107
+ messageRoles: request.contents.map((m) => m.role),
108
+ messages: request.contents,
109
+ });
110
+
111
+ const response = await executeRequest(
112
+ {
113
+ url: `/inference/deployments/${deploymentId}/models/${model.id}:generateContent`,
114
+ resourceGroup,
115
+ },
116
+ request,
117
+ { signal: options?.signal },
118
+ );
119
+
120
+ const data = response.data as VertexGenerateContentResponse;
121
+ replayVertexGenerateContentResponse(stream, output, data);
122
+
123
+ if (data.usageMetadata) {
124
+ output.usage = mapUsage({
125
+ prompt_tokens: data.usageMetadata.promptTokenCount ?? 0,
126
+ completion_tokens:
127
+ (data.usageMetadata.candidatesTokenCount ?? 0) +
128
+ (data.usageMetadata.thoughtsTokenCount ?? 0),
129
+ });
130
+ calculateCost(model, output.usage);
131
+ }
132
+
133
+ const finishReason = data.candidates?.[0]?.finishReason;
134
+ output.stopReason = mapFinishReason(
135
+ finishReason === "MAX_TOKENS" ? "length" : finishReason,
136
+ );
137
+ stream.push({
138
+ type: "done",
139
+ reason: output.stopReason as "stop" | "length" | "toolUse",
140
+ message: output,
141
+ });
142
+ stream.end();
143
+ } catch (error) {
144
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
145
+ output.errorMessage = formatError(error);
146
+ debugLog({
147
+ requestId,
148
+ kind: "error",
149
+ provider: "foundation-gcp-vertexai",
150
+ model: model.id,
151
+ stopReason: output.stopReason,
152
+ error: output.errorMessage,
153
+ });
154
+ stream.push({
155
+ type: "error",
156
+ reason: output.stopReason as "error" | "aborted",
157
+ error: output,
158
+ });
159
+ stream.end();
160
+ }
161
+ })();
162
+
163
+ return stream;
164
+ }
165
+
166
+ function replayVertexGenerateContentResponse(
167
+ stream: AssistantMessageEventStream,
168
+ output: AssistantMessage,
169
+ data: VertexGenerateContentResponse,
170
+ ): void {
171
+ const parts = data.candidates?.[0]?.content?.parts ?? [];
172
+ for (const part of parts) {
173
+ if (typeof part.text === "string" && part.text.length > 0) {
174
+ const contentIndex = output.content.length;
175
+ output.content.push({ type: "text", text: "" });
176
+ stream.push({ type: "text_start", contentIndex, partial: output });
177
+ const block = output.content[contentIndex];
178
+ if (block?.type === "text") block.text = part.text;
179
+ stream.push({
180
+ type: "text_delta",
181
+ contentIndex,
182
+ delta: part.text,
183
+ partial: output,
184
+ });
185
+ stream.push({
186
+ type: "text_end",
187
+ contentIndex,
188
+ content: part.text,
189
+ partial: output,
190
+ });
191
+ }
192
+
193
+ if (part.functionCall) {
194
+ const contentIndex = output.content.length;
195
+ const toolCall = {
196
+ type: "toolCall" as const,
197
+ id: randomUUID(),
198
+ name: part.functionCall.name ?? "",
199
+ arguments:
200
+ part.functionCall.args && typeof part.functionCall.args === "object"
201
+ ? (part.functionCall.args as Record<string, unknown>)
202
+ : {},
203
+ };
204
+ output.content.push(toolCall);
205
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
206
+ stream.push({
207
+ type: "toolcall_end",
208
+ contentIndex,
209
+ toolCall,
210
+ partial: output,
211
+ });
212
+ }
213
+ }
214
+ }
@@ -9,6 +9,7 @@ import type {
9
9
  import { foundationExecutableForModel } from "./foundation-executables.ts";
10
10
  import { streamSapFoundationAzureOpenAi } from "./stream-foundation-azure-openai.ts";
11
11
  import { streamSapFoundationBedrock } from "./stream-foundation-bedrock.ts";
12
+ import { streamSapFoundationVertexAi } from "./stream-foundation-vertexai.ts";
12
13
 
13
14
  export function streamSapFoundation(
14
15
  model: Model<Api>,
@@ -22,10 +23,6 @@ export function streamSapFoundation(
22
23
  case "aws-bedrock":
23
24
  return streamSapFoundationBedrock(model, context, options);
24
25
  case "gcp-vertexai":
25
- throw new Error(
26
- `SAP AI Core foundation executable '${executable}' is mapped for '${model.id}', ` +
27
- "but the Vertex AI/Gemini foundation adapter has not been implemented yet. " +
28
- "Use sap-aicore orchestration for this model until the gcp-vertexai adapter is added.",
29
- );
26
+ return streamSapFoundationVertexAi(model, context, options);
30
27
  }
31
28
  }
@@ -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
+ }