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 +46 -1
- package/package.json +7 -7
- package/scripts/test-auth-storage.mjs +95 -0
- package/scripts/test-empty-content-blocks.mjs +223 -0
- package/src/sap-model-commands.ts +2 -26
- package/src/stream.ts +17 -22
- package/src/translate-foundation-bedrock.ts +47 -23
- package/src/translate-foundation-vertexai.ts +25 -9
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.
|
|
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.
|
|
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.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
50
|
-
"typescript": "^
|
|
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
|
-
|
|
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 {
|
|
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
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
77
|
-
if (part.type === "text")
|
|
78
|
-
|
|
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
|
|
95
|
+
return parts.length > 0 ? { role: "user", parts } : undefined;
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
function piAssistantToVertexContent(
|
|
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
|
|
117
|
+
return parts.length > 0 ? { role: "model", parts } : undefined;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
function piToolResultToVertexContent(msg: ToolResultMessage): VertexContent {
|