pi-sap-aicore 0.3.5 → 0.3.7

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,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.7] - 2026-07-16
11
+
12
+ ### Fixed
13
+
14
+ - Restored shared `/login` credentials for `sap-aicore-foundation` and
15
+ `/sap-models discover` on Pi 0.80.9+. Pi replaced the old synchronous
16
+ `AuthStorage.list()` / `get()` API; the extension now uses the public
17
+ `readStoredCredential()` helper and reads only the `sap-aicore` credential.
18
+ Previously the compatibility error was swallowed and every model request
19
+ incorrectly reported that no service key was configured.
20
+
21
+ ### Added
22
+
23
+ - Added `scripts/test-auth-storage.mjs`, an isolated regression test that checks
24
+ shared credential lookup, literal `$` preservation, provider isolation, and
25
+ credential-type validation.
26
+
27
+ ### Changed
28
+
29
+ - Raised the Pi peer and development dependency floor to 0.80.9, where the
30
+ supported synchronous stored-credential helper is available.
31
+
32
+ ## [0.3.6] - 2026-07-04
33
+
34
+ ### Fixed
35
+
36
+ - Bedrock (Anthropic/Claude) and Vertex AI (Gemini) foundation routes no
37
+ longer emit empty or whitespace-only text blocks. Pi contexts legitimately
38
+ contain them — errored assistant turns persist with `content: []`,
39
+ thinking-only turns carry no text — and the old `{ text: " " }` placeholder
40
+ was itself whitespace-only, so Anthropic rejected every subsequent request
41
+ in the conversation with HTTP 400 "messages: text content blocks must
42
+ contain non-whitespace text". Empty messages are now dropped (role
43
+ coalescing re-merges the neighbours), empty text parts are filtered, and
44
+ empty tool-result text gets a non-whitespace fallback.
45
+
46
+ ### Added
47
+
48
+ - Added `scripts/test-empty-content-blocks.mjs`, a credential-free regression
49
+ test covering both translators, and wired it into `npm test`.
50
+
10
51
  ## [0.3.5] - 2026-07-01
11
52
 
12
53
  ### Fixed
@@ -202,7 +243,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
202
243
  `reasoning_effort` for OpenAI).
203
244
  - MIT license and npm packaging.
204
245
 
205
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.3...HEAD
246
+ [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.7...HEAD
247
+ [0.3.7]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.6...v0.3.7
248
+ [0.3.6]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.5...v0.3.6
249
+ [0.3.5]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.4...v0.3.5
250
+ [0.3.4]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.3...v0.3.4
206
251
  [0.3.3]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.2...v0.3.3
207
252
  [0.3.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.1...v0.3.2
208
253
  [0.3.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.0...v0.3.1
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.7",
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-auth-storage.mjs && 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
  },
@@ -41,13 +41,13 @@
41
41
  "@sap-ai-sdk/orchestration": "^2.12.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@earendil-works/pi-ai": "*",
45
- "@earendil-works/pi-coding-agent": "*"
44
+ "@earendil-works/pi-ai": ">=0.80.9",
45
+ "@earendil-works/pi-coding-agent": ">=0.80.9"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "^0.79.2",
49
- "@earendil-works/pi-coding-agent": "^0.79.2",
50
- "typescript": "^6.0.3"
48
+ "@earendil-works/pi-ai": "^0.80.9",
49
+ "@earendil-works/pi-coding-agent": "^0.80.9",
50
+ "typescript": "^7.0.2"
51
51
  },
52
52
  "engines": {
53
53
  "node": ">=20"
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // Offline regression test for sharing the orchestration provider's stored
3
+ // subscription credential with the foundation provider. This makes no network
4
+ // calls and uses an isolated temporary auth file.
5
+
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ const ROOT = new URL("..", import.meta.url).pathname;
12
+ const { readSharedServiceKeyFromStore } = await import(
13
+ pathToFileURL(join(ROOT, "src/stream.ts")).href
14
+ );
15
+
16
+ let failures = 0;
17
+ function check(condition, message) {
18
+ if (condition) {
19
+ console.log(` ✓ ${message}`);
20
+ return;
21
+ }
22
+ console.error(` ❌ ${message}`);
23
+ failures++;
24
+ }
25
+
26
+ const serviceKey = JSON.stringify({
27
+ clientid: "client-id",
28
+ clientsecret: "literal$secret",
29
+ url: "https://auth.example.test",
30
+ serviceurls: { AI_API_URL: "https://api.example.test" },
31
+ });
32
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-sap-aicore-auth-"));
33
+ const authPath = join(tempDir, "auth.json");
34
+
35
+ try {
36
+ console.log("Shared OAuth credential lookup");
37
+ writeFileSync(
38
+ authPath,
39
+ JSON.stringify({
40
+ "sap-aicore": {
41
+ type: "oauth",
42
+ serviceKey,
43
+ access: "",
44
+ refresh: "",
45
+ expires: Number.MAX_SAFE_INTEGER,
46
+ },
47
+ }),
48
+ );
49
+ check(
50
+ readSharedServiceKeyFromStore(authPath) === serviceKey,
51
+ "reads the sap-aicore OAuth credential verbatim",
52
+ );
53
+ check(
54
+ readSharedServiceKeyFromStore(authPath)?.includes("literal$secret"),
55
+ "preserves literal dollar signs",
56
+ );
57
+
58
+ console.log("Provider isolation");
59
+ writeFileSync(
60
+ authPath,
61
+ JSON.stringify({
62
+ "another-provider": {
63
+ type: "oauth",
64
+ serviceKey,
65
+ access: "",
66
+ refresh: "",
67
+ expires: Number.MAX_SAFE_INTEGER,
68
+ },
69
+ }),
70
+ );
71
+ check(
72
+ readSharedServiceKeyFromStore(authPath) === undefined,
73
+ "does not borrow another provider's credential",
74
+ );
75
+
76
+ console.log("Credential type validation");
77
+ writeFileSync(
78
+ authPath,
79
+ JSON.stringify({
80
+ "sap-aicore": { type: "api_key", key: serviceKey },
81
+ }),
82
+ );
83
+ check(
84
+ readSharedServiceKeyFromStore(authPath) === undefined,
85
+ "ignores non-OAuth credentials",
86
+ );
87
+ } finally {
88
+ rmSync(tempDir, { recursive: true, force: true });
89
+ }
90
+
91
+ if (failures > 0) {
92
+ console.error(`\n${failures} check(s) failed`);
93
+ process.exit(1);
94
+ }
95
+ console.log("\nAll checks passed");
@@ -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");
@@ -1,7 +1,4 @@
1
- import {
2
- AuthStorage,
3
- type ExtensionAPI,
4
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
2
  import { ScenarioApi } from "@sap-ai-sdk/ai-api";
6
3
 
7
4
  import { parseAndValidateServiceKey } from "./auth.ts";
@@ -14,31 +11,10 @@ import {
14
11
  } from "./model-catalog.ts";
15
12
  import { ensureServiceKey, resolveResourceGroup } from "./stream.ts";
16
13
 
17
- function sharedServiceKeyFromAuthStore(): string | undefined {
18
- try {
19
- const store = AuthStorage.create();
20
- for (const provider of store.list()) {
21
- const cred = store.get(provider);
22
- if (cred?.type !== "oauth") continue;
23
- const serviceKey = (cred as { serviceKey?: unknown }).serviceKey;
24
- if (
25
- typeof serviceKey === "string" &&
26
- serviceKey.trimStart().startsWith("{")
27
- ) {
28
- return serviceKey;
29
- }
30
- }
31
- } catch {
32
- // Let callers produce the actionable no-key message.
33
- }
34
- return undefined;
35
- }
36
-
37
14
  function resolveCommandServiceKey(): ReturnType<
38
15
  typeof parseAndValidateServiceKey
39
16
  > {
40
- const raw = process.env.AICORE_SERVICE_KEY ?? sharedServiceKeyFromAuthStore();
41
- return ensureServiceKey(raw);
17
+ return ensureServiceKey(undefined);
42
18
  }
43
19
 
44
20
  function formatModelList(ids: string[], max = 30): string {
package/src/stream.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type SimpleStreamOptions,
15
15
  type Usage,
16
16
  } from "@earendil-works/pi-ai";
17
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
17
+ import { readStoredCredential } from "@earendil-works/pi-coding-agent";
18
18
  import type { ChatModel, LlmModelParams } from "@sap-ai-sdk/orchestration";
19
19
  import type { TokenUsage } from "@sap-ai-sdk/orchestration/internal.js";
20
20
 
@@ -575,27 +575,22 @@ export function latchFinishReason(
575
575
 
576
576
  let lastValidatedKey: ValidatedKey | undefined;
577
577
 
578
- // pi stores oauth credentials keyed by PROVIDER name, not by the oauth `name`.
579
- // So a `/login` under `sap-aicore` is NOT automatically handed to a second
580
- // provider (`sap-aicore-foundation`) that shares the same oauth object — pi
581
- // passes that provider the registration placeholder instead, and we'd wrongly
582
- // report "no key configured". Recover the shared login by reading pi's own auth
583
- // store directly and returning the first sibling oauth credential that carries
584
- // a service-key JSON. This is what makes one `/login` serve both providers.
585
- function readSharedServiceKeyFromStore(): string | undefined {
586
- try {
587
- const store = AuthStorage.create();
588
- for (const provider of store.list()) {
589
- const cred = store.get(provider);
590
- if (cred?.type !== "oauth") continue;
591
- const sk = (cred as { serviceKey?: unknown }).serviceKey;
592
- if (typeof sk === "string" && sk.trimStart().startsWith("{")) return sk;
593
- }
594
- } catch {
595
- // Auth store unreadable (missing/locked/format change) — fall through to
596
- // the actionable "no key configured" error below.
597
- }
598
- return undefined;
578
+ // pi stores OAuth credentials by provider id, not by the OAuth display name.
579
+ // The foundation provider therefore reads the orchestration provider's stored
580
+ // login explicitly. `readStoredCredential` is the synchronous, public helper
581
+ // for stream setup; AuthStorage's old synchronous list/get API was removed in
582
+ // pi 0.80.9.
583
+ export function readSharedServiceKeyFromStore(
584
+ authPath?: string,
585
+ ): string | undefined {
586
+ const credential = readStoredCredential("sap-aicore", authPath);
587
+ if (credential?.type !== "oauth") return undefined;
588
+
589
+ const serviceKey = (credential as { serviceKey?: unknown }).serviceKey;
590
+ return typeof serviceKey === "string" &&
591
+ serviceKey.trimStart().startsWith("{")
592
+ ? serviceKey
593
+ : undefined;
599
594
  }
600
595
 
601
596
  export function ensureServiceKey(apiKey: string | undefined): ValidatedKey {
@@ -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 {