pi-sap-aicore 0.3.5 → 0.3.6

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.6] - 2026-07-04
11
+
12
+ ### Fixed
13
+
14
+ - Bedrock (Anthropic/Claude) and Vertex AI (Gemini) foundation routes no
15
+ longer emit empty or whitespace-only text blocks. Pi contexts legitimately
16
+ contain them — errored assistant turns persist with `content: []`,
17
+ thinking-only turns carry no text — and the old `{ text: " " }` placeholder
18
+ was itself whitespace-only, so Anthropic rejected every subsequent request
19
+ in the conversation with HTTP 400 "messages: text content blocks must
20
+ contain non-whitespace text". Empty messages are now dropped (role
21
+ coalescing re-merges the neighbours), empty text parts are filtered, and
22
+ empty tool-result text gets a non-whitespace fallback.
23
+
24
+ ### Added
25
+
26
+ - Added `scripts/test-empty-content-blocks.mjs`, a credential-free regression
27
+ test covering both translators, and wired it into `npm test`.
28
+
10
29
  ## [0.3.5] - 2026-07-01
11
30
 
12
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "update-models": "node scripts/update-models.mjs",
34
- "test": "node scripts/test-vertex-tool-schema.mjs && node scripts/test-bedrock-tool-results.mjs",
34
+ "test": "node scripts/test-vertex-tool-schema.mjs && node scripts/test-bedrock-tool-results.mjs && node scripts/test-empty-content-blocks.mjs",
35
35
  "validate:foundation": "node scripts/validate-foundation-executables.mjs",
36
36
  "prepublishOnly": "tsc --noEmit && npm test"
37
37
  },
@@ -45,8 +45,8 @@
45
45
  "@earendil-works/pi-coding-agent": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "^0.79.2",
49
- "@earendil-works/pi-coding-agent": "^0.79.2",
48
+ "@earendil-works/pi-ai": "^0.80.3",
49
+ "@earendil-works/pi-coding-agent": "^0.80.3",
50
50
  "typescript": "^6.0.3"
51
51
  },
52
52
  "engines": {
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // Offline regression test for empty/whitespace-only content blocks.
3
+ //
4
+ // Anthropic (via Bedrock Converse) rejects any text content block that is
5
+ // empty or whitespace-only: "messages: text content blocks must contain
6
+ // non-whitespace text". Pi contexts legitimately contain such shapes —
7
+ // errored assistant turns persist with content: [], thinking-only turns
8
+ // carry no text — and the translators used to emit a `{ text: " " }`
9
+ // placeholder for them, which is itself whitespace-only and poisoned every
10
+ // subsequent request in the conversation. The translators must drop those
11
+ // messages (coalescing re-merges the neighbours) and never emit a text block
12
+ // without non-whitespace text. This test makes no network calls.
13
+
14
+ import { pathToFileURL } from "node:url";
15
+ import { join } from "node:path";
16
+
17
+ const ROOT = new URL("..", import.meta.url).pathname;
18
+
19
+ const { piContextToBedrockConverse } = await import(
20
+ pathToFileURL(join(ROOT, "src/translate-foundation-bedrock.ts")).href
21
+ );
22
+ const { piContextToVertexGenerateContent } = await import(
23
+ pathToFileURL(join(ROOT, "src/translate-foundation-vertexai.ts")).href
24
+ );
25
+
26
+ let failures = 0;
27
+ function check(condition, message) {
28
+ if (condition) {
29
+ console.log(` ✓ ${message}`);
30
+ return;
31
+ }
32
+ console.error(` ❌ ${message}`);
33
+ failures++;
34
+ }
35
+
36
+ function bedrockTextBlocks(payload) {
37
+ return payload.messages.flatMap((message) =>
38
+ message.content.flatMap((block) => {
39
+ const texts = [];
40
+ if ("text" in block) texts.push(block.text);
41
+ if ("toolResult" in block) {
42
+ for (const part of block.toolResult.content) {
43
+ if ("text" in part) texts.push(part.text);
44
+ }
45
+ }
46
+ return texts;
47
+ }),
48
+ );
49
+ }
50
+
51
+ function vertexTextParts(payload) {
52
+ return payload.contents.flatMap((content) =>
53
+ content.parts.flatMap((part) => ("text" in part ? [part.text] : [])),
54
+ );
55
+ }
56
+
57
+ const erroredAssistant = {
58
+ role: "assistant",
59
+ content: [],
60
+ stopReason: "error",
61
+ errorMessage: "Model error: 400",
62
+ };
63
+ const thinkingOnlyAssistant = {
64
+ role: "assistant",
65
+ content: [{ type: "thinking", thinking: "planning..." }],
66
+ stopReason: "stop",
67
+ };
68
+
69
+ console.log("Bedrock: history with an errored assistant turn (content: [])");
70
+ {
71
+ const payload = piContextToBedrockConverse({
72
+ systemPrompt: "system",
73
+ messages: [
74
+ { role: "user", content: "hello" },
75
+ erroredAssistant,
76
+ { role: "user", content: "hello again" },
77
+ ],
78
+ });
79
+ check(
80
+ bedrockTextBlocks(payload).every((text) => text.trim().length > 0),
81
+ "no empty/whitespace-only text blocks",
82
+ );
83
+ check(
84
+ payload.messages.length === 1 && payload.messages[0].role === "user",
85
+ "dropped turn's neighbours coalesce into one user message",
86
+ );
87
+ check(
88
+ payload.messages[0].content.length === 2,
89
+ "both user texts survive the coalesce",
90
+ );
91
+ }
92
+
93
+ console.log("Bedrock: thinking-only assistant turn is dropped");
94
+ {
95
+ const payload = piContextToBedrockConverse({
96
+ messages: [
97
+ { role: "user", content: "hello" },
98
+ thinkingOnlyAssistant,
99
+ { role: "user", content: "again" },
100
+ ],
101
+ });
102
+ check(
103
+ payload.messages.every((message) =>
104
+ message.content.every(
105
+ (block) => !("text" in block) || block.text.trim().length > 0,
106
+ ),
107
+ ),
108
+ "no whitespace placeholder emitted",
109
+ );
110
+ }
111
+
112
+ console.log("Bedrock: empty user text parts are filtered, images survive");
113
+ {
114
+ const payload = piContextToBedrockConverse({
115
+ messages: [
116
+ {
117
+ role: "user",
118
+ content: [
119
+ { type: "text", text: "" },
120
+ { type: "image", mimeType: "image/png", data: "aGk=" },
121
+ ],
122
+ },
123
+ ],
124
+ });
125
+ check(payload.messages.length === 1, "image-only user message survives");
126
+ check(
127
+ payload.messages[0].content.every((block) => !("text" in block)),
128
+ "empty text part removed",
129
+ );
130
+ }
131
+
132
+ console.log("Bedrock: whitespace-only turns leave a valid degenerate payload");
133
+ {
134
+ const payload = piContextToBedrockConverse({
135
+ messages: [{ role: "user", content: " " }, erroredAssistant],
136
+ });
137
+ check(payload.messages.length === 1, "one fallback message");
138
+ check(
139
+ payload.messages[0].content[0].text.trim().length > 0,
140
+ "fallback text is non-whitespace",
141
+ );
142
+ }
143
+
144
+ console.log("Bedrock: empty tool-result text gets a non-whitespace fallback");
145
+ {
146
+ const payload = piContextToBedrockConverse({
147
+ messages: [
148
+ { role: "user", content: "run it" },
149
+ {
150
+ role: "assistant",
151
+ content: [
152
+ { type: "toolCall", id: "call_1", name: "tool", arguments: {} },
153
+ ],
154
+ stopReason: "toolUse",
155
+ },
156
+ { role: "toolResult", toolCallId: "call_1", content: [], isError: false },
157
+ ],
158
+ });
159
+ check(
160
+ bedrockTextBlocks(payload).every((text) => text.trim().length > 0),
161
+ "tool_result content text is non-whitespace",
162
+ );
163
+ }
164
+
165
+ console.log("Bedrock: a plain conversation is unchanged");
166
+ {
167
+ const payload = piContextToBedrockConverse({
168
+ systemPrompt: "system",
169
+ messages: [
170
+ { role: "user", content: "hello" },
171
+ {
172
+ role: "assistant",
173
+ content: [{ type: "text", text: "hi there" }],
174
+ stopReason: "stop",
175
+ },
176
+ { role: "user", content: "how are you?" },
177
+ ],
178
+ });
179
+ check(payload.messages.length === 3, "three messages, none dropped");
180
+ check(
181
+ payload.messages.map((message) => message.role).join(",") ===
182
+ "user,assistant,user",
183
+ "role alternation preserved",
184
+ );
185
+ }
186
+
187
+ console.log("Vertex: mirrors the drop behaviour");
188
+ {
189
+ const payload = piContextToVertexGenerateContent({
190
+ systemPrompt: "system",
191
+ messages: [
192
+ { role: "user", content: "hello" },
193
+ erroredAssistant,
194
+ { role: "user", content: "hello again" },
195
+ ],
196
+ });
197
+ check(
198
+ vertexTextParts(payload).every((text) => text.trim().length > 0),
199
+ "no empty/whitespace-only text parts",
200
+ );
201
+ check(
202
+ payload.contents.length === 1 && payload.contents[0].role === "user",
203
+ "dropped turn's neighbours coalesce into one user content",
204
+ );
205
+ }
206
+
207
+ console.log("Vertex: whitespace-only turns leave a valid degenerate payload");
208
+ {
209
+ const payload = piContextToVertexGenerateContent({
210
+ messages: [{ role: "user", content: " " }],
211
+ });
212
+ check(payload.contents.length === 1, "one fallback content entry");
213
+ check(
214
+ payload.contents[0].parts[0].text.trim().length > 0,
215
+ "fallback text is non-whitespace",
216
+ );
217
+ }
218
+
219
+ if (failures > 0) {
220
+ console.error(`\n${failures} check(s) failed`);
221
+ process.exit(1);
222
+ }
223
+ console.log("\nAll checks passed");
@@ -58,6 +58,10 @@ export function piContextToBedrockConverse(context: Context): {
58
58
 
59
59
  if (msg.role === "assistant") {
60
60
  const assistant = piAssistantToBedrockConverse(msg);
61
+ // Errored turns (content: []) and thinking-only turns translate to
62
+ // nothing Anthropic accepts — drop them; coalescing re-merges the
63
+ // surrounding user messages. See piAssistantToBedrockConverse.
64
+ if (!assistant) continue;
61
65
  const toolUseIds = assistant.content.flatMap((block) =>
62
66
  "toolUse" in block ? [block.toolUse.toolUseId] : [],
63
67
  );
@@ -128,47 +132,68 @@ export function piContextToBedrockConverse(context: Context): {
128
132
  continue;
129
133
  }
130
134
 
131
- messages.push(piUserToBedrockConverse(msg));
135
+ const user = piUserToBedrockConverse(msg);
136
+ if (user) messages.push(user);
132
137
  }
133
138
 
134
139
  const tools = (context.tools ?? []).map(piToolToBedrockToolSpec);
140
+ const coalesced = coalesceAdjacentMessages(messages);
135
141
  return {
136
142
  ...(context.systemPrompt
137
143
  ? { system: [{ text: context.systemPrompt }] }
138
144
  : {}),
139
- messages: coalesceAdjacentMessages(messages),
145
+ // Degenerate guard: if every message was dropped as empty, Bedrock still
146
+ // requires at least one message.
147
+ messages:
148
+ coalesced.length > 0
149
+ ? coalesced
150
+ : [{ role: "user", content: [{ text: "(no conversation content)" }] }],
140
151
  ...(tools.length > 0 ? { toolConfig: { tools } } : {}),
141
152
  };
142
153
  }
143
154
 
144
- function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
155
+ // Anthropic (via Bedrock Converse) rejects any text content block that is
156
+ // empty or whitespace-only: "messages: text content blocks must contain
157
+ // non-whitespace text". Pi contexts legitimately contain such blocks — errored
158
+ // assistant turns persist with content: [], thinking-only turns carry no text,
159
+ // and replayed histories can hold empty text parts — so both translators drop
160
+ // them (returning undefined for messages left with no content) instead of
161
+ // emitting the old `{ text: " " }` placeholder, which was itself
162
+ // whitespace-only and poisoned every request in the conversation.
163
+ function piUserToBedrockConverse(
164
+ msg: UserMessage,
165
+ ): BedrockConverseMessage | undefined {
145
166
  if (typeof msg.content === "string") {
167
+ if (msg.content.trim().length === 0) return undefined;
146
168
  return { role: "user", content: [{ text: msg.content }] };
147
169
  }
148
170
 
149
- const content = msg.content.map((part): BedrockConverseContentBlock => {
150
- if (part.type === "text") return { text: part.text };
151
- return {
152
- image: {
153
- format: imageFormatFromMimeType(part.mimeType),
154
- source: { bytes: part.data },
155
- },
156
- };
157
- });
171
+ const content = msg.content.flatMap(
172
+ (part): BedrockConverseContentBlock[] => {
173
+ if (part.type === "text") {
174
+ return part.text.trim().length > 0 ? [{ text: part.text }] : [];
175
+ }
176
+ return [
177
+ {
178
+ image: {
179
+ format: imageFormatFromMimeType(part.mimeType),
180
+ source: { bytes: part.data },
181
+ },
182
+ },
183
+ ];
184
+ },
185
+ );
158
186
 
159
- return {
160
- role: "user",
161
- content: content.length > 0 ? content : [{ text: " " }],
162
- };
187
+ return content.length > 0 ? { role: "user", content } : undefined;
163
188
  }
164
189
 
165
190
  function piAssistantToBedrockConverse(
166
191
  msg: AssistantMessage,
167
- ): BedrockConverseMessage {
192
+ ): BedrockConverseMessage | undefined {
168
193
  const content: BedrockConverseContentBlock[] = [];
169
194
 
170
195
  for (const block of msg.content) {
171
- if (block.type === "text" && block.text) {
196
+ if (block.type === "text" && block.text.trim().length > 0) {
172
197
  content.push({ text: block.text });
173
198
  } else if (block.type === "toolCall") {
174
199
  content.push({
@@ -181,10 +206,7 @@ function piAssistantToBedrockConverse(
181
206
  }
182
207
  }
183
208
 
184
- return {
185
- role: "assistant",
186
- content: content.length > 0 ? content : [{ text: " " }],
187
- };
209
+ return content.length > 0 ? { role: "assistant", content } : undefined;
188
210
  }
189
211
 
190
212
  type BedrockToolResultParts = {
@@ -198,7 +220,9 @@ function piToolResultToBedrockContentParts(
198
220
  return {
199
221
  toolResult: bedrockToolResultBlock(
200
222
  msg.toolCallId,
201
- toolResultText(msg) || " ",
223
+ // Non-whitespace fallback: Anthropic's text-block validation applies
224
+ // inside tool_result content too.
225
+ toolResultText(msg).trim() || "(empty result)",
202
226
  msg.isError,
203
227
  ),
204
228
  images: toolResultImagesAsUserContent(msg),
@@ -50,7 +50,12 @@ export function piContextToVertexGenerateContent(context: Context): {
50
50
  ...(context.systemPrompt
51
51
  ? { systemInstruction: { parts: [{ text: context.systemPrompt }] } }
52
52
  : {}),
53
- contents: coalesceAdjacentContents(contents),
53
+ // Degenerate guard: if every message was dropped as empty, Gemini still
54
+ // requires at least one content entry.
55
+ contents:
56
+ contents.length > 0
57
+ ? coalesceAdjacentContents(contents)
58
+ : [{ role: "user", parts: [{ text: "(no conversation content)" }] }],
54
59
  ...(functionDeclarations.length > 0
55
60
  ? { tools: [{ functionDeclarations }] }
56
61
  : {}),
@@ -68,23 +73,34 @@ function piMessageToVertexContent(msg: Message): VertexContent | undefined {
68
73
  }
69
74
  }
70
75
 
71
- function piUserToVertexContent(msg: UserMessage): VertexContent {
76
+ // Mirrors the Bedrock translator: drop empty/whitespace-only text parts and
77
+ // messages left with no parts (errored turns persist with content: [],
78
+ // thinking-only turns carry no text) instead of emitting a whitespace
79
+ // placeholder. See translate-foundation-bedrock.ts for the Anthropic
80
+ // rejection this class of payload causes; Gemini gets the same treatment for
81
+ // symmetry and to avoid burning tokens on placeholder turns.
82
+ function piUserToVertexContent(msg: UserMessage): VertexContent | undefined {
72
83
  if (typeof msg.content === "string") {
84
+ if (msg.content.trim().length === 0) return undefined;
73
85
  return { role: "user", parts: [{ text: msg.content }] };
74
86
  }
75
87
 
76
- const parts = msg.content.map((part): VertexPart => {
77
- if (part.type === "text") return { text: part.text };
78
- return { inlineData: { mimeType: part.mimeType, data: part.data } };
88
+ const parts = msg.content.flatMap((part): VertexPart[] => {
89
+ if (part.type === "text") {
90
+ return part.text.trim().length > 0 ? [{ text: part.text }] : [];
91
+ }
92
+ return [{ inlineData: { mimeType: part.mimeType, data: part.data } }];
79
93
  });
80
94
 
81
- return { role: "user", parts: parts.length > 0 ? parts : [{ text: " " }] };
95
+ return parts.length > 0 ? { role: "user", parts } : undefined;
82
96
  }
83
97
 
84
- function piAssistantToVertexContent(msg: AssistantMessage): VertexContent {
98
+ function piAssistantToVertexContent(
99
+ msg: AssistantMessage,
100
+ ): VertexContent | undefined {
85
101
  const parts: VertexPart[] = [];
86
102
  for (const block of msg.content) {
87
- if (block.type === "text" && block.text) {
103
+ if (block.type === "text" && block.text.trim().length > 0) {
88
104
  parts.push({ text: block.text });
89
105
  } else if (block.type === "toolCall") {
90
106
  parts.push({
@@ -98,7 +114,7 @@ function piAssistantToVertexContent(msg: AssistantMessage): VertexContent {
98
114
  });
99
115
  }
100
116
  }
101
- return { role: "model", parts: parts.length > 0 ? parts : [{ text: " " }] };
117
+ return parts.length > 0 ? { role: "model", parts } : undefined;
102
118
  }
103
119
 
104
120
  function piToolResultToVertexContent(msg: ToolResultMessage): VertexContent {