pi-sap-aicore 0.3.1 → 0.3.3

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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3] - 2026-07-01
11
+
12
+ ### Added
13
+
14
+ - Added `npm run validate:foundation`, a live SAP AI Core validation matrix for
15
+ direct foundation executables. It verifies text generation, real tool execution
16
+ side effects, and image input across Azure OpenAI, AWS Bedrock, and GCP Vertex
17
+ AI foundation routes.
18
+
19
+ ### Changed
20
+
21
+ - Raised SAP AI SDK dependency floors to `^2.12.0` so fresh installs prefer the
22
+ patched transitive `axios` / `form-data` tree that resolves the npm audit
23
+ advisory for `form-data <4.0.6`.
24
+
25
+ ## [0.3.2] - 2026-07-01
26
+
27
+ ### Fixed
28
+
29
+ - Direct AWS Bedrock and GCP Vertex AI foundation routes now advertise pi tools
30
+ using provider-native tool/function declaration schemas, so Claude and Gemini
31
+ foundation models can perform real coding-agent tool calls instead of acting as
32
+ text-only generators.
33
+ - Vertex/Gemini foundation tool-call replay now preserves Gemini thought signatures
34
+ and normalizes provider-prefixed function names before emitting pi tool calls.
35
+
10
36
  ## [0.3.1] - 2026-07-01
11
37
 
12
38
  ### Added
@@ -135,7 +161,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
135
161
  `reasoning_effort` for OpenAI).
136
162
  - MIT license and npm packaging.
137
163
 
138
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.1...HEAD
164
+ [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.3...HEAD
165
+ [0.3.3]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.2...v0.3.3
166
+ [0.3.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.1...v0.3.2
139
167
  [0.3.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.0...v0.3.1
140
168
  [0.3.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.2...v0.3.0
141
169
  [0.2.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...v0.2.2
package/README.md CHANGED
@@ -132,8 +132,9 @@ The extension registers **two providers**, both backed by the same service key:
132
132
  |---|---|---|
133
133
  | SAP deployment | one orchestration deployment fronts **every** model | one foundation deployment **per model** |
134
134
  | Models | Claude, GPT-5*, Gemini | GPT/OpenAI (`azure-openai`), Anthropic/Claude (`aws-bedrock`), and Gemini (`gcp-vertexai`) |
135
+ | Tool use | yes | yes — tools are translated to OpenAI, Bedrock Converse, or Vertex/Gemini function declarations by executable |
135
136
  | 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
+ | Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only for Azure; Bedrock/Anthropic and Vertex/Gemini effort controls are intentionally conservative |
137
138
  | Content filter / grounding / templating | yes | no — raw model access |
138
139
  | 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
140
 
@@ -159,6 +160,26 @@ there. An id with no matching deployment 404s at call time. Run
159
160
  `/sap-models discover` in pi (or `node scripts/list-sap-models.mjs` from this
160
161
  repo) to see what your tenant actually deploys.
161
162
 
163
+ ### Live foundation validation
164
+
165
+ To validate normal coding-agent behavior across the three direct foundation
166
+ executables, run the live smoke-test matrix from this repo:
167
+
168
+ ```bash
169
+ npm run validate:foundation
170
+ ```
171
+
172
+ The script uses `pi --no-extensions -e ./index.ts` so it tests the local checkout,
173
+ not a globally installed package. It makes real SAP model calls and validates:
174
+
175
+ - text generation
176
+ - real tool execution via side-effect files
177
+ - image input smoke tests
178
+
179
+ Default models are `gpt-5.5`, `anthropic--claude-4.8-opus`, and
180
+ `gemini-3.5-flash`. Override them with `GPT_MODEL`, `BEDROCK_MODEL`, and
181
+ `VERTEX_MODEL`; set `SKIP_IMAGE=1` to skip vision tests.
182
+
162
183
  ## Models
163
184
 
164
185
  The model list is composed of three sources, merged at startup:
@@ -337,7 +358,8 @@ the direct Azure OpenAI SDK pinned to API version `2024-10-21`, which has no
337
358
  thinking-level cycle is a no-op there. Anthropic/Claude models use SAP's AWS
338
359
  Bedrock `/converse` endpoint; Gemini models use SAP's Vertex AI `generateContent`
339
360
  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
361
+ visible text instead of only hidden thoughts. Tool/function calling is supported
362
+ on all three direct foundation executables. Use the orchestration route if you
341
363
  need explicit effort control.
342
364
 
343
365
  To override budgets per model, edit `thinkingLevelMap` on the relevant entry in
@@ -448,9 +470,10 @@ npmjs.com:
448
470
  │ └── publish.yml # tag-driven npm publish via OIDC trusted publishing
449
471
  ├── index.ts # ExtensionAPI factory + registerProvider calls (both providers)
450
472
  ├── scripts/
451
- │ ├── update-models.mjs # maintainer script: fetches models.dev, writes models-snapshot.json
452
- │ ├── list-sap-models.mjs # lists models your tenant actually deploys (diff vs snapshot)
453
- └── diagnose-streaming.mjs # probes orchestration streaming support per model
473
+ │ ├── update-models.mjs # maintainer script: fetches models.dev, writes models-snapshot.json
474
+ │ ├── list-sap-models.mjs # lists models your tenant actually deploys (diff vs snapshot)
475
+ ├── diagnose-streaming.mjs # probes orchestration streaming support per model
476
+ │ └── validate-foundation-executables.mjs # live text/tool/image smoke tests for direct foundation executables
454
477
  └── src/
455
478
  ├── auth.ts # service-key validation + pi oauth registration
456
479
  ├── model-catalog.ts # loads snapshot/cache/overlay and adapts models.dev metadata
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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)",
@@ -31,12 +31,13 @@
31
31
  },
32
32
  "scripts": {
33
33
  "update-models": "node scripts/update-models.mjs",
34
+ "validate:foundation": "node scripts/validate-foundation-executables.mjs",
34
35
  "prepublishOnly": "tsc --noEmit"
35
36
  },
36
37
  "dependencies": {
37
- "@sap-ai-sdk/ai-api": "^2.10.0",
38
- "@sap-ai-sdk/foundation-models": "^2.10.0",
39
- "@sap-ai-sdk/orchestration": "^2.10.0"
38
+ "@sap-ai-sdk/ai-api": "^2.12.0",
39
+ "@sap-ai-sdk/foundation-models": "^2.12.0",
40
+ "@sap-ai-sdk/orchestration": "^2.12.0"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@earendil-works/pi-ai": "*",
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // Live validation for SAP AI Core direct foundation executables.
3
+ //
4
+ // This script makes real, billed model calls through pi using the local extension.
5
+ // It validates the three high-value foundation executable families:
6
+ // - azure-openai → GPT/OpenAI models
7
+ // - aws-bedrock → Anthropic/Claude models
8
+ // - gcp-vertexai → Gemini models
9
+ //
10
+ // Usage from repo root:
11
+ // node scripts/validate-foundation-executables.mjs
12
+ //
13
+ // Optional model overrides:
14
+ // GPT_MODEL=gpt-5.5 \
15
+ // BEDROCK_MODEL=anthropic--claude-4.8-opus \
16
+ // VERTEX_MODEL=gemini-3.5-flash \
17
+ // node scripts/validate-foundation-executables.mjs
18
+ //
19
+ // Optional: SKIP_IMAGE=1 to skip vision tests.
20
+
21
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { spawnSync } from "node:child_process";
25
+
26
+ const ROOT = new URL("..", import.meta.url).pathname;
27
+ const PI = process.env.PI_BIN ?? "pi";
28
+ const SKIP_IMAGE = process.env.SKIP_IMAGE === "1";
29
+
30
+ const SCENARIOS = [
31
+ {
32
+ name: "azure-openai/gpt",
33
+ model: process.env.GPT_MODEL ?? "gpt-5.5",
34
+ marker: "GPT_TOOL_OK",
35
+ },
36
+ {
37
+ name: "aws-bedrock/anthropic",
38
+ model: process.env.BEDROCK_MODEL ?? "anthropic--claude-4.8-opus",
39
+ marker: "BEDROCK_TOOL_OK",
40
+ },
41
+ {
42
+ name: "gcp-vertexai/gemini",
43
+ model: process.env.VERTEX_MODEL ?? "gemini-3.5-flash",
44
+ marker: "VERTEX_TOOL_OK",
45
+ },
46
+ ];
47
+
48
+ const tinyPng = Buffer.from(
49
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
50
+ "base64",
51
+ );
52
+
53
+ function runPi(args, options = {}) {
54
+ const result = spawnSync(PI, args, {
55
+ cwd: ROOT,
56
+ encoding: "utf8",
57
+ maxBuffer: 10 * 1024 * 1024,
58
+ ...options,
59
+ });
60
+ return {
61
+ status: result.status ?? 1,
62
+ stdout: result.stdout ?? "",
63
+ stderr: result.stderr ?? "",
64
+ combined: `${result.stdout ?? ""}${result.stderr ?? ""}`,
65
+ };
66
+ }
67
+
68
+ function baseArgs(model) {
69
+ return [
70
+ "--no-extensions",
71
+ "-e",
72
+ "./index.ts",
73
+ "--model",
74
+ `sap-aicore-foundation/${model}`,
75
+ "--no-context-files",
76
+ "--no-skills",
77
+ "-p",
78
+ ];
79
+ }
80
+
81
+ function assert(condition, message, detail) {
82
+ if (condition) return;
83
+ console.error(`\n❌ ${message}`);
84
+ if (detail) console.error(detail);
85
+ process.exitCode = 1;
86
+ throw new Error(message);
87
+ }
88
+
89
+ function truncate(s, max = 2000) {
90
+ return s.length > max ? `${s.slice(0, max)}…[+${s.length - max} chars]` : s;
91
+ }
92
+
93
+ function validateText(scenario) {
94
+ const prompt = "Reply with exactly the single token: TEXT_OK";
95
+ const result = runPi([...baseArgs(scenario.model), prompt]);
96
+ assert(
97
+ result.status === 0,
98
+ `${scenario.name} text scenario exited ${result.status}`,
99
+ truncate(result.combined),
100
+ );
101
+ assert(
102
+ /TEXT_OK/.test(result.combined),
103
+ `${scenario.name} text scenario did not contain TEXT_OK`,
104
+ truncate(result.combined),
105
+ );
106
+ console.log(` ✓ text generation`);
107
+ }
108
+
109
+ function validateToolUse(scenario, workDir) {
110
+ const file = join(workDir, `${scenario.name.replaceAll("/", "-")}.txt`);
111
+ const prompt =
112
+ `Use the bash tool to run exactly: printf ${scenario.marker} > ${file}. ` +
113
+ "Then say done.";
114
+ const result = runPi([...baseArgs(scenario.model), prompt]);
115
+ assert(
116
+ result.status === 0,
117
+ `${scenario.name} tool scenario exited ${result.status}`,
118
+ truncate(result.combined),
119
+ );
120
+ let actual = "";
121
+ try {
122
+ actual = readFileSync(file, "utf8");
123
+ } catch {
124
+ // handled below
125
+ }
126
+ assert(
127
+ actual === scenario.marker,
128
+ `${scenario.name} tool scenario did not create expected side-effect`,
129
+ `expected file ${file} to contain ${scenario.marker}\nmodel output:\n${truncate(result.combined)}`,
130
+ );
131
+ console.log(` ✓ tool execution side effect`);
132
+ }
133
+
134
+ function validateImage(scenario, workDir) {
135
+ const imagePath = join(workDir, "tiny.png");
136
+ writeFileSync(imagePath, tinyPng);
137
+ const prompt =
138
+ "Look at @" +
139
+ imagePath +
140
+ " and reply with exactly IMAGE_OK if you can inspect the attached image.";
141
+ const result = runPi([...baseArgs(scenario.model), prompt]);
142
+ assert(
143
+ result.status === 0,
144
+ `${scenario.name} image scenario exited ${result.status}`,
145
+ truncate(result.combined),
146
+ );
147
+ assert(
148
+ /IMAGE_OK/.test(result.combined),
149
+ `${scenario.name} image scenario did not contain IMAGE_OK`,
150
+ truncate(result.combined),
151
+ );
152
+ console.log(` ✓ image input smoke test`);
153
+ }
154
+
155
+ const workDir = mkdtempSync(join(tmpdir(), "pi-sap-aicore-foundation-"));
156
+ console.log(`Live SAP AI Core foundation validation`);
157
+ console.log(`repo: ${ROOT}`);
158
+ console.log(`workDir: ${workDir}`);
159
+ console.log(`skip image: ${SKIP_IMAGE ? "yes" : "no"}\n`);
160
+
161
+ try {
162
+ for (const scenario of SCENARIOS) {
163
+ console.log(`${scenario.name} model=${scenario.model}`);
164
+ validateText(scenario);
165
+ validateToolUse(scenario, workDir);
166
+ if (!SKIP_IMAGE) validateImage(scenario, workDir);
167
+ console.log("");
168
+ }
169
+ console.log("✅ all foundation executable scenarios passed");
170
+ } catch {
171
+ // assert() already printed details and set exitCode.
172
+ } finally {
173
+ if (process.env.KEEP_VALIDATION_ARTIFACTS !== "1") {
174
+ rmSync(workDir, { recursive: true, force: true });
175
+ } else {
176
+ console.log(`kept artifacts: ${workDir}`);
177
+ }
178
+ }
@@ -29,6 +29,7 @@ type VertexGenerateContentResponse = {
29
29
  role?: string;
30
30
  parts?: Array<{
31
31
  text?: string;
32
+ thoughtSignature?: string;
32
33
  functionCall?: { name?: string; args?: unknown };
33
34
  }>;
34
35
  };
@@ -192,14 +193,18 @@ function replayVertexGenerateContentResponse(
192
193
 
193
194
  if (part.functionCall) {
194
195
  const contentIndex = output.content.length;
196
+ const providerName = part.functionCall.name ?? "";
195
197
  const toolCall = {
196
198
  type: "toolCall" as const,
197
199
  id: randomUUID(),
198
- name: part.functionCall.name ?? "",
200
+ name: normalizeVertexFunctionName(providerName),
199
201
  arguments:
200
202
  part.functionCall.args && typeof part.functionCall.args === "object"
201
203
  ? (part.functionCall.args as Record<string, unknown>)
202
204
  : {},
205
+ ...(part.thoughtSignature
206
+ ? { thoughtSignature: part.thoughtSignature }
207
+ : {}),
203
208
  };
204
209
  output.content.push(toolCall);
205
210
  stream.push({ type: "toolcall_start", contentIndex, partial: output });
@@ -212,3 +217,8 @@ function replayVertexGenerateContentResponse(
212
217
  }
213
218
  }
214
219
  }
220
+
221
+ function normalizeVertexFunctionName(name: string): string {
222
+ const index = name.indexOf(":");
223
+ return index >= 0 ? name.slice(index + 1) : name;
224
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  Context,
4
4
  Message,
5
5
  TextContent,
6
+ Tool,
6
7
  ToolResultMessage,
7
8
  UserMessage,
8
9
  } from "@earendil-works/pi-ai";
@@ -35,9 +36,20 @@ export type BedrockConverseMessage = {
35
36
  content: BedrockConverseContentBlock[];
36
37
  };
37
38
 
39
+ export type BedrockToolConfig = {
40
+ tools: Array<{
41
+ toolSpec: {
42
+ name: string;
43
+ description: string;
44
+ inputSchema: { json: Record<string, unknown> };
45
+ };
46
+ }>;
47
+ };
48
+
38
49
  export function piContextToBedrockConverse(context: Context): {
39
50
  system?: Array<{ text: string }>;
40
51
  messages: BedrockConverseMessage[];
52
+ toolConfig?: BedrockToolConfig;
41
53
  } {
42
54
  const messages: BedrockConverseMessage[] = [];
43
55
 
@@ -46,9 +58,11 @@ export function piContextToBedrockConverse(context: Context): {
46
58
  if (translated) messages.push(translated);
47
59
  }
48
60
 
61
+ const tools = (context.tools ?? []).map(piToolToBedrockToolSpec);
49
62
  return {
50
63
  ...(context.systemPrompt ? { system: [{ text: context.systemPrompt }] } : {}),
51
64
  messages: coalesceAdjacentMessages(messages),
65
+ ...(tools.length > 0 ? { toolConfig: { tools } } : {}),
52
66
  };
53
67
  }
54
68
 
@@ -159,6 +173,18 @@ function imageFormatFromMimeType(mimeType: string): string {
159
173
  return "png";
160
174
  }
161
175
 
176
+ function piToolToBedrockToolSpec(tool: Tool): BedrockToolConfig["tools"][number] {
177
+ return {
178
+ toolSpec: {
179
+ name: tool.name,
180
+ description: tool.description,
181
+ inputSchema: {
182
+ json: tool.parameters as unknown as Record<string, unknown>,
183
+ },
184
+ },
185
+ };
186
+ }
187
+
162
188
  function coalesceAdjacentMessages(
163
189
  messages: BedrockConverseMessage[],
164
190
  ): BedrockConverseMessage[] {
@@ -3,6 +3,7 @@ import type {
3
3
  Context,
4
4
  Message,
5
5
  TextContent,
6
+ Tool,
6
7
  ToolResultMessage,
7
8
  UserMessage,
8
9
  } from "@earendil-works/pi-ai";
@@ -10,7 +11,7 @@ import type {
10
11
  export type VertexPart =
11
12
  | { text: string }
12
13
  | { inlineData: { mimeType: string; data: string } }
13
- | { functionCall: { name: string; args: unknown } }
14
+ | { functionCall: { name: string; args: unknown }; thoughtSignature?: string }
14
15
  | { functionResponse: { name: string; response: Record<string, unknown> } };
15
16
 
16
17
  export type VertexContent = {
@@ -18,9 +19,18 @@ export type VertexContent = {
18
19
  parts: VertexPart[];
19
20
  };
20
21
 
22
+ export type VertexTool = {
23
+ functionDeclarations: Array<{
24
+ name: string;
25
+ description: string;
26
+ parameters: Record<string, unknown>;
27
+ }>;
28
+ };
29
+
21
30
  export function piContextToVertexGenerateContent(context: Context): {
22
31
  systemInstruction?: { parts: Array<{ text: string }> };
23
32
  contents: VertexContent[];
33
+ tools?: VertexTool[];
24
34
  } {
25
35
  const contents: VertexContent[] = [];
26
36
 
@@ -29,11 +39,17 @@ export function piContextToVertexGenerateContent(context: Context): {
29
39
  if (translated) contents.push(translated);
30
40
  }
31
41
 
42
+ const functionDeclarations = (context.tools ?? []).map(
43
+ piToolToVertexFunctionDeclaration,
44
+ );
32
45
  return {
33
46
  ...(context.systemPrompt
34
47
  ? { systemInstruction: { parts: [{ text: context.systemPrompt }] } }
35
48
  : {}),
36
49
  contents: coalesceAdjacentContents(contents),
50
+ ...(functionDeclarations.length > 0
51
+ ? { tools: [{ functionDeclarations }] }
52
+ : {}),
37
53
  };
38
54
  }
39
55
 
@@ -68,7 +84,13 @@ function piAssistantToVertexContent(msg: AssistantMessage): VertexContent {
68
84
  parts.push({ text: block.text });
69
85
  } else if (block.type === "toolCall") {
70
86
  parts.push({
71
- functionCall: { name: block.name, args: block.arguments },
87
+ functionCall: {
88
+ name: block.name,
89
+ args: block.arguments,
90
+ },
91
+ ...(block.thoughtSignature
92
+ ? { thoughtSignature: block.thoughtSignature }
93
+ : {}),
72
94
  });
73
95
  }
74
96
  }
@@ -111,6 +133,16 @@ function toolResultImagesAsUserParts(msg: ToolResultMessage): VertexPart[] {
111
133
  }));
112
134
  }
113
135
 
136
+ function piToolToVertexFunctionDeclaration(
137
+ tool: Tool,
138
+ ): VertexTool["functionDeclarations"][number] {
139
+ return {
140
+ name: tool.name,
141
+ description: tool.description,
142
+ parameters: tool.parameters as unknown as Record<string, unknown>,
143
+ };
144
+ }
145
+
114
146
  function coalesceAdjacentContents(contents: VertexContent[]): VertexContent[] {
115
147
  const result: VertexContent[] = [];
116
148
  for (const content of contents) {