pi-sap-aicore 0.3.3 → 0.3.5
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 +41 -0
- package/package.json +3 -2
- package/scripts/fixtures/complex-tool-extension.mjs +59 -0
- package/scripts/test-bedrock-tool-results.mjs +109 -0
- package/scripts/test-vertex-tool-schema.mjs +95 -0
- package/scripts/validate-foundation-executables.mjs +55 -3
- package/src/stream.ts +19 -1
- package/src/translate-foundation-bedrock.ts +143 -35
- package/src/translate-foundation-vertexai.ts +10 -2
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.5] - 2026-07-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Direct AWS Bedrock (Anthropic/Claude) foundation route now batches all
|
|
15
|
+
corresponding `toolResult` blocks immediately after assistant `toolUse` blocks
|
|
16
|
+
before appending screenshot/image content. This fixes Anthropic HTTP 400
|
|
17
|
+
failures from Opus 4.8 such as "`tool_use` ids were found without
|
|
18
|
+
`tool_result` blocks immediately after" on tool-heavy coding-agent turns.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added `scripts/test-bedrock-tool-results.mjs`, a credential-free regression
|
|
23
|
+
test for Bedrock/Anthropic tool-result ordering, and wired it into `npm test`.
|
|
24
|
+
|
|
25
|
+
## [0.3.4] - 2026-07-01
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Direct GCP Vertex AI (Gemini) foundation route now sends pi tool schemas via
|
|
30
|
+
Gemini's `parametersJsonSchema` (full JSON Schema) instead of the legacy
|
|
31
|
+
`parameters` field (a restricted OpenAPI 3.0 subset). The legacy field rejected
|
|
32
|
+
common JSON Schema constructs with HTTP 400 — `const` ("Unknown name const")
|
|
33
|
+
and non-string `enum` values ("enum[0] (TYPE_STRING)") — which broke Gemini
|
|
34
|
+
foundation models (e.g. `gemini-3.5-flash`) on tool-heavy coding-agent turns.
|
|
35
|
+
- SAP AI Core orchestration route no longer sends `reasoning_effort` alongside
|
|
36
|
+
function tools for `gpt-*` models. SAP's `/v1/chat/completions` (the only
|
|
37
|
+
endpoint reachable through the orchestration SDK) rejects that combination for
|
|
38
|
+
gpt-5.x with a 400 pointing to `/v1/responses`, which is a foundation-models
|
|
39
|
+
deployment endpoint the orchestration SDK cannot target. `reasoning_effort` is
|
|
40
|
+
still sent on text-only turns; SAP continues to spend reasoning tokens
|
|
41
|
+
internally on tool turns (governed by `max_completion_tokens`).
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Added `npm test` (`scripts/test-vertex-tool-schema.mjs`), a fast,
|
|
46
|
+
credential-free regression test asserting the Vertex/Gemini adapter emits
|
|
47
|
+
`parametersJsonSchema` and preserves `const`/boolean-`enum` constructs. Wired
|
|
48
|
+
into `prepublishOnly`. The live `validate:foundation` matrix also gained a
|
|
49
|
+
complex-tool-schema scenario that exercises the real HTTP tool path.
|
|
50
|
+
|
|
10
51
|
## [0.3.3] - 2026-07-01
|
|
11
52
|
|
|
12
53
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sap-aicore",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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,8 +31,9 @@
|
|
|
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
35
|
"validate:foundation": "node scripts/validate-foundation-executables.mjs",
|
|
35
|
-
"prepublishOnly": "tsc --noEmit"
|
|
36
|
+
"prepublishOnly": "tsc --noEmit && npm test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@sap-ai-sdk/ai-api": "^2.12.0",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Test-only pi extension: registers a `record_choice` tool whose parameter
|
|
2
|
+
// schema contains the exact JSON Schema constructs that Gemini's legacy
|
|
3
|
+
// OpenAPI `parameters` field rejects (`const`, boolean `enum`, nested anyOf).
|
|
4
|
+
//
|
|
5
|
+
// Loaded by scripts/validate-foundation-executables.mjs to exercise the real
|
|
6
|
+
// HTTP tool-schema path end-to-end. If an adapter serializes tool schemas
|
|
7
|
+
// incorrectly, the provider returns HTTP 400 and the live scenario fails.
|
|
8
|
+
//
|
|
9
|
+
// The tool simply writes its stringified args to OUTPUT_FILE so the validator
|
|
10
|
+
// can assert a real side effect (proving the model both received the schema
|
|
11
|
+
// and successfully issued a tool call against it).
|
|
12
|
+
|
|
13
|
+
import { writeFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
/** @param {import("@earendil-works/pi-coding-agent").ExtensionAPI} pi */
|
|
16
|
+
export default function (pi) {
|
|
17
|
+
pi.registerTool({
|
|
18
|
+
name: "record_choice",
|
|
19
|
+
label: "Record Choice",
|
|
20
|
+
description:
|
|
21
|
+
"Record a processing choice. Call this once with mode set to 'fast'.",
|
|
22
|
+
promptSnippet: "record_choice: record a processing choice",
|
|
23
|
+
// Raw JSON Schema (cast through unknown) with the constructs that broke
|
|
24
|
+
// Gemini's legacy `parameters` field. TypeBox typing is bypassed on
|
|
25
|
+
// purpose — we are validating wire serialization, not TS ergonomics.
|
|
26
|
+
parameters: /** @type {any} */ ({
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
mode: {
|
|
30
|
+
anyOf: [{ const: "fast" }, { const: "slow" }],
|
|
31
|
+
description: "Processing mode",
|
|
32
|
+
},
|
|
33
|
+
verbose: {
|
|
34
|
+
anyOf: [{ type: "boolean" }, { enum: [false] }],
|
|
35
|
+
description: "Verbose flag",
|
|
36
|
+
},
|
|
37
|
+
steps: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
kind: { anyOf: [{ const: "read" }, { const: "write" }] },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
required: ["mode"],
|
|
48
|
+
}),
|
|
49
|
+
async execute(_toolCallId, params) {
|
|
50
|
+
const outputFile = process.env.COMPLEX_TOOL_OUTPUT_FILE;
|
|
51
|
+
if (outputFile) {
|
|
52
|
+
writeFileSync(outputFile, JSON.stringify(params ?? {}));
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
content: [{ type: "text", text: "COMPLEX_TOOL_OK" }],
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Offline regression test for Bedrock/Anthropic tool_result ordering.
|
|
3
|
+
//
|
|
4
|
+
// Anthropic models behind Bedrock require the user message immediately after an
|
|
5
|
+
// assistant tool_use turn to begin with one tool_result block per tool_use id.
|
|
6
|
+
// Pi stores each tool result as a separate top-level message, and screenshot
|
|
7
|
+
// tool results can include images. A naive role coalesce produces:
|
|
8
|
+
// assistant(tool_use a,b), user(tool_result a, image, tool_result b)
|
|
9
|
+
// which Anthropic rejects because tool_result b is not in the required prefix.
|
|
10
|
+
// This test makes no network calls.
|
|
11
|
+
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const ROOT = new URL("..", import.meta.url).pathname;
|
|
16
|
+
|
|
17
|
+
const { piContextToBedrockConverse } = await import(
|
|
18
|
+
pathToFileURL(join(ROOT, "src/translate-foundation-bedrock.ts")).href
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
let failures = 0;
|
|
22
|
+
function check(condition, message) {
|
|
23
|
+
if (condition) {
|
|
24
|
+
console.log(` ✓ ${message}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
console.error(` ❌ ${message}`);
|
|
28
|
+
failures++;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const context = {
|
|
32
|
+
messages: [
|
|
33
|
+
{
|
|
34
|
+
role: "assistant",
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "toolCall",
|
|
38
|
+
id: "tooluse_a",
|
|
39
|
+
name: "screenshot",
|
|
40
|
+
arguments: {},
|
|
41
|
+
},
|
|
42
|
+
{ type: "toolCall", id: "tooluse_b", name: "bash", arguments: {} },
|
|
43
|
+
],
|
|
44
|
+
api: "test",
|
|
45
|
+
provider: "test",
|
|
46
|
+
model: "test",
|
|
47
|
+
usage: {},
|
|
48
|
+
stopReason: "toolUse",
|
|
49
|
+
timestamp: 1,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
role: "toolResult",
|
|
53
|
+
toolCallId: "tooluse_a",
|
|
54
|
+
toolName: "screenshot",
|
|
55
|
+
content: [
|
|
56
|
+
{ type: "text", text: "screenshot captured" },
|
|
57
|
+
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
|
58
|
+
],
|
|
59
|
+
isError: false,
|
|
60
|
+
timestamp: 2,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
role: "toolResult",
|
|
64
|
+
toolCallId: "tooluse_b",
|
|
65
|
+
toolName: "bash",
|
|
66
|
+
content: [{ type: "text", text: "command output" }],
|
|
67
|
+
isError: false,
|
|
68
|
+
timestamp: 3,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
tools: [],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const out = piContextToBedrockConverse(context);
|
|
75
|
+
const [assistant, user] = out.messages;
|
|
76
|
+
|
|
77
|
+
check(
|
|
78
|
+
out.messages.length === 2,
|
|
79
|
+
"assistant turn and batched user result turn are emitted",
|
|
80
|
+
);
|
|
81
|
+
check(
|
|
82
|
+
assistant?.content?.map((block) => block.toolUse?.toolUseId).join(",") ===
|
|
83
|
+
"tooluse_a,tooluse_b",
|
|
84
|
+
"assistant contains both toolUse blocks in order",
|
|
85
|
+
);
|
|
86
|
+
check(user?.role === "user", "next message after assistant toolUse is user");
|
|
87
|
+
|
|
88
|
+
const kinds = user?.content?.map((block) => Object.keys(block)[0]) ?? [];
|
|
89
|
+
check(
|
|
90
|
+
kinds.slice(0, 2).join(",") === "toolResult,toolResult",
|
|
91
|
+
"all expected toolResult blocks are the first blocks in the immediate user message",
|
|
92
|
+
);
|
|
93
|
+
check(
|
|
94
|
+
kinds[2] === "image",
|
|
95
|
+
"image content is preserved after the required toolResult prefix",
|
|
96
|
+
);
|
|
97
|
+
check(
|
|
98
|
+
user?.content?.[0]?.toolResult?.toolUseId === "tooluse_a" &&
|
|
99
|
+
user?.content?.[1]?.toolResult?.toolUseId === "tooluse_b",
|
|
100
|
+
"toolResult ids match the assistant toolUse ids in order",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (failures > 0) {
|
|
104
|
+
console.error(
|
|
105
|
+
`\n❌ bedrock tool-result ordering test: ${failures} check(s) failed`,
|
|
106
|
+
);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
console.log("\n✅ bedrock tool-result ordering test passed");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Offline regression test for the Vertex/Gemini tool-schema translation.
|
|
3
|
+
//
|
|
4
|
+
// The live validator (validate-foundation-executables.mjs) only ever sent the
|
|
5
|
+
// trivial `bash` tool, whose schema is a flat object with one string property.
|
|
6
|
+
// That never exercised the JSON Schema constructs Gemini's legacy `parameters`
|
|
7
|
+
// (OpenAPI 3.0 subset) rejects — `const` and non-string `enum` values — so a
|
|
8
|
+
// real 400 in production slipped past a "green" validation run.
|
|
9
|
+
//
|
|
10
|
+
// This test asserts the translator emits `parametersJsonSchema` (full JSON
|
|
11
|
+
// Schema) rather than the legacy `parameters` field, and that the failing
|
|
12
|
+
// constructs survive the translation intact. It makes NO network calls.
|
|
13
|
+
//
|
|
14
|
+
// Usage from repo root:
|
|
15
|
+
// node scripts/test-vertex-tool-schema.mjs
|
|
16
|
+
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const ROOT = new URL("..", import.meta.url).pathname;
|
|
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
|
+
// A schema that reproduces every class from the reported production 400:
|
|
37
|
+
// - `const` keyword → "Unknown name const"
|
|
38
|
+
// - boolean `enum` value (`false`) → "enum[0] (TYPE_STRING), false"
|
|
39
|
+
// - nested anyOf/items → deep property paths in the error
|
|
40
|
+
const context = {
|
|
41
|
+
systemPrompt: "system",
|
|
42
|
+
messages: [{ role: "user", content: "hi" }],
|
|
43
|
+
tools: [
|
|
44
|
+
{
|
|
45
|
+
name: "complex_tool",
|
|
46
|
+
description: "reproduces the reported Gemini 400 schema classes",
|
|
47
|
+
parameters: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
mode: { anyOf: [{ const: "fast" }, { const: "slow" }] },
|
|
51
|
+
flag: { anyOf: [{ type: "boolean" }, { enum: [false] }] },
|
|
52
|
+
items: {
|
|
53
|
+
type: "array",
|
|
54
|
+
items: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
kind: { anyOf: [{ const: "a" }, { const: "b" }] },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ["mode"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const out = piContextToVertexGenerateContent(context);
|
|
69
|
+
const fd = out.tools?.[0]?.functionDeclarations?.[0];
|
|
70
|
+
|
|
71
|
+
check(!!fd, "tool is translated to a functionDeclaration");
|
|
72
|
+
check(
|
|
73
|
+
fd && Object.hasOwn(fd, "parametersJsonSchema"),
|
|
74
|
+
"emits `parametersJsonSchema` (full JSON Schema field Gemini accepts)",
|
|
75
|
+
);
|
|
76
|
+
check(
|
|
77
|
+
fd && !Object.hasOwn(fd, "parameters"),
|
|
78
|
+
"does NOT emit legacy `parameters` (OpenAPI subset that 400s on const/enum)",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const serialized = JSON.stringify(fd?.parametersJsonSchema ?? {});
|
|
82
|
+
check(
|
|
83
|
+
serialized.includes('"const"'),
|
|
84
|
+
"`const` keyword survives translation (not stripped/rewritten)",
|
|
85
|
+
);
|
|
86
|
+
check(
|
|
87
|
+
serialized.includes("false"),
|
|
88
|
+
"boolean `enum` value survives translation",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (failures > 0) {
|
|
92
|
+
console.error(`\n❌ vertex tool-schema test: ${failures} check(s) failed`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
console.log("\n✅ vertex tool-schema translation test passed");
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
// - aws-bedrock → Anthropic/Claude models
|
|
8
8
|
// - gcp-vertexai → Gemini models
|
|
9
9
|
//
|
|
10
|
+
// Each family now also runs a complex-tool-schema scenario (const/enum/anyOf)
|
|
11
|
+
// that reproduces the real HTTP tool path — the trivial bash tool alone never
|
|
12
|
+
// exercised the JSON Schema constructs that broke Gemini's `parameters` field.
|
|
13
|
+
//
|
|
14
|
+
// For a fast, credential-free regression check of the Vertex tool-schema
|
|
15
|
+
// serialization specifically, run:
|
|
16
|
+
// node scripts/test-vertex-tool-schema.mjs
|
|
17
|
+
//
|
|
10
18
|
// Usage from repo root:
|
|
11
19
|
// node scripts/validate-foundation-executables.mjs
|
|
12
20
|
//
|
|
@@ -65,11 +73,12 @@ function runPi(args, options = {}) {
|
|
|
65
73
|
};
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
function baseArgs(model) {
|
|
76
|
+
function baseArgs(model, extraExtensions = []) {
|
|
77
|
+
const extensions = ["-e", "./index.ts"];
|
|
78
|
+
for (const ext of extraExtensions) extensions.push("-e", ext);
|
|
69
79
|
return [
|
|
70
80
|
"--no-extensions",
|
|
71
|
-
|
|
72
|
-
"./index.ts",
|
|
81
|
+
...extensions,
|
|
73
82
|
"--model",
|
|
74
83
|
`sap-aicore-foundation/${model}`,
|
|
75
84
|
"--no-context-files",
|
|
@@ -131,6 +140,48 @@ function validateToolUse(scenario, workDir) {
|
|
|
131
140
|
console.log(` ✓ tool execution side effect`);
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
// Exercises the real HTTP tool-schema path with a schema containing the
|
|
144
|
+
// constructs that broke Gemini in production (`const`, boolean `enum`, nested
|
|
145
|
+
// anyOf). The trivial `bash` tool never covered these, which is why the
|
|
146
|
+
// production 400 slipped past an otherwise-green validation run.
|
|
147
|
+
function validateComplexToolSchema(scenario, workDir) {
|
|
148
|
+
const extension = join(ROOT, "scripts/fixtures/complex-tool-extension.mjs");
|
|
149
|
+
const outputFile = join(
|
|
150
|
+
workDir,
|
|
151
|
+
`${scenario.name.replaceAll("/", "-")}-complex-tool.json`,
|
|
152
|
+
);
|
|
153
|
+
const prompt =
|
|
154
|
+
"Call the record_choice tool exactly once with mode set to 'fast'. " +
|
|
155
|
+
"Then say done.";
|
|
156
|
+
const result = runPi([...baseArgs(scenario.model, [extension]), prompt], {
|
|
157
|
+
env: { ...process.env, COMPLEX_TOOL_OUTPUT_FILE: outputFile },
|
|
158
|
+
});
|
|
159
|
+
assert(
|
|
160
|
+
!/status code 400|Invalid (JSON payload|value at)|Unknown name/.test(
|
|
161
|
+
result.combined,
|
|
162
|
+
),
|
|
163
|
+
`${scenario.name} complex-tool scenario hit a schema rejection (HTTP 400)`,
|
|
164
|
+
truncate(result.combined),
|
|
165
|
+
);
|
|
166
|
+
assert(
|
|
167
|
+
result.status === 0,
|
|
168
|
+
`${scenario.name} complex-tool scenario exited ${result.status}`,
|
|
169
|
+
truncate(result.combined),
|
|
170
|
+
);
|
|
171
|
+
let actual = "";
|
|
172
|
+
try {
|
|
173
|
+
actual = readFileSync(outputFile, "utf8");
|
|
174
|
+
} catch {
|
|
175
|
+
// handled below
|
|
176
|
+
}
|
|
177
|
+
assert(
|
|
178
|
+
/"mode"\s*:\s*"fast"/.test(actual),
|
|
179
|
+
`${scenario.name} complex-tool scenario did not produce a valid tool call`,
|
|
180
|
+
`expected ${outputFile} to contain mode=fast\nmodel output:\n${truncate(result.combined)}`,
|
|
181
|
+
);
|
|
182
|
+
console.log(` \u2713 complex tool schema (const/enum/anyOf)`);
|
|
183
|
+
}
|
|
184
|
+
|
|
134
185
|
function validateImage(scenario, workDir) {
|
|
135
186
|
const imagePath = join(workDir, "tiny.png");
|
|
136
187
|
writeFileSync(imagePath, tinyPng);
|
|
@@ -163,6 +214,7 @@ try {
|
|
|
163
214
|
console.log(`${scenario.name} model=${scenario.model}`);
|
|
164
215
|
validateText(scenario);
|
|
165
216
|
validateToolUse(scenario, workDir);
|
|
217
|
+
validateComplexToolSchema(scenario, workDir);
|
|
166
218
|
if (!SKIP_IMAGE) validateImage(scenario, workDir);
|
|
167
219
|
console.log("");
|
|
168
220
|
}
|
package/src/stream.ts
CHANGED
|
@@ -363,6 +363,7 @@ function reasoningParams(
|
|
|
363
363
|
model: Model<Api>,
|
|
364
364
|
reasoning: string | undefined,
|
|
365
365
|
effectiveMaxTokens: number,
|
|
366
|
+
hasTools: boolean,
|
|
366
367
|
): Partial<LlmModelParams> {
|
|
367
368
|
if (!reasoning || reasoning === "off") return {};
|
|
368
369
|
|
|
@@ -402,6 +403,21 @@ function reasoningParams(
|
|
|
402
403
|
};
|
|
403
404
|
}
|
|
404
405
|
if (model.id.startsWith("gpt-")) {
|
|
406
|
+
// SAP orchestration's chat/completions endpoint does NOT accept
|
|
407
|
+
// `reasoning_effort` alongside function tools for gpt-5.x. Confirmed
|
|
408
|
+
// against SAP's Supported Parameters list (which omits reasoning_effort
|
|
409
|
+
// entirely) and the runtime 400 from gpt-5.5:
|
|
410
|
+
// "Function tools with reasoning_effort are not supported for
|
|
411
|
+
// gpt-5.5-...-dz-std in /v1/chat/completions. Please use /v1/responses
|
|
412
|
+
// instead."
|
|
413
|
+
// The Responses API (/v1/responses) is a foundation-models deployment
|
|
414
|
+
// endpoint, unreachable through the orchestration SDK, so we can't switch
|
|
415
|
+
// endpoints here. Drop reasoning_effort on tool turns so the request
|
|
416
|
+
// succeeds; SAP still spends reasoning tokens internally (governed by
|
|
417
|
+
// max_completion_tokens / max_tokens). Text-only turns keep explicit
|
|
418
|
+
// reasoning depth. Applies to all gpt-* since SAP may tighten the older
|
|
419
|
+
// gpt-5/5.4 ids the same way.
|
|
420
|
+
if (hasTools) return {};
|
|
405
421
|
const effort =
|
|
406
422
|
model.thinkingLevelMap?.[
|
|
407
423
|
reasoning as keyof NonNullable<typeof model.thinkingLevelMap>
|
|
@@ -422,6 +438,7 @@ function modelSupportsTemperature(modelId: string): boolean {
|
|
|
422
438
|
function buildLlmParams(
|
|
423
439
|
model: Model<Api>,
|
|
424
440
|
options: SimpleStreamOptions | undefined,
|
|
441
|
+
hasTools: boolean,
|
|
425
442
|
): LlmModelParams {
|
|
426
443
|
// Pi may pass a maxTokens budget smaller than the model's hard cap (e.g.
|
|
427
444
|
// to reserve room for thinking). Respect it; otherwise fall back to the
|
|
@@ -431,6 +448,7 @@ function buildLlmParams(
|
|
|
431
448
|
model,
|
|
432
449
|
options?.reasoning,
|
|
433
450
|
effectiveMaxTokens,
|
|
451
|
+
hasTools,
|
|
434
452
|
);
|
|
435
453
|
const params: LlmModelParams = {
|
|
436
454
|
max_tokens: effectiveMaxTokens,
|
|
@@ -655,7 +673,7 @@ export function streamSapAiCore(
|
|
|
655
673
|
const { messages, tools } = piContextToOrchestration(context);
|
|
656
674
|
|
|
657
675
|
const { OrchestrationClient } = await importOrchestration();
|
|
658
|
-
const llmParams = buildLlmParams(model, options);
|
|
676
|
+
const llmParams = buildLlmParams(model, options, tools.length > 0);
|
|
659
677
|
|
|
660
678
|
debugLog({
|
|
661
679
|
requestId,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AssistantMessage,
|
|
3
3
|
Context,
|
|
4
|
-
Message,
|
|
5
4
|
TextContent,
|
|
6
5
|
Tool,
|
|
7
6
|
ToolResultMessage,
|
|
@@ -52,33 +51,96 @@ export function piContextToBedrockConverse(context: Context): {
|
|
|
52
51
|
toolConfig?: BedrockToolConfig;
|
|
53
52
|
} {
|
|
54
53
|
const messages: BedrockConverseMessage[] = [];
|
|
54
|
+
const pi = context.messages;
|
|
55
55
|
|
|
56
|
-
for (
|
|
57
|
-
const
|
|
58
|
-
|
|
56
|
+
for (let i = 0; i < pi.length; i++) {
|
|
57
|
+
const msg = pi[i];
|
|
58
|
+
|
|
59
|
+
if (msg.role === "assistant") {
|
|
60
|
+
const assistant = piAssistantToBedrockConverse(msg);
|
|
61
|
+
const toolUseIds = assistant.content.flatMap((block) =>
|
|
62
|
+
"toolUse" in block ? [block.toolUse.toolUseId] : [],
|
|
63
|
+
);
|
|
64
|
+
if (toolUseIds.length === 0) {
|
|
65
|
+
messages.push(assistant);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
messages.push(assistant);
|
|
70
|
+
|
|
71
|
+
// Bedrock/Anthropic require the next user message after assistant
|
|
72
|
+
// toolUse blocks to begin with the corresponding toolResult blocks,
|
|
73
|
+
// one per toolUseId. Pi stores tool results as separate top-level
|
|
74
|
+
// messages, and screenshot tool results may contain images. If those
|
|
75
|
+
// image blocks are interleaved between toolResult blocks after role
|
|
76
|
+
// coalescing, Anthropic rejects the request with:
|
|
77
|
+
// "tool_use ids were found without tool_result blocks immediately after".
|
|
78
|
+
// Batch all contiguous expected tool results first, then append any
|
|
79
|
+
// images/orphaned result transcript content after the required prefix.
|
|
80
|
+
const expected = new Set(toolUseIds);
|
|
81
|
+
const byId = new Map<string, BedrockToolResultParts>();
|
|
82
|
+
const orphaned: BedrockConverseContentBlock[] = [];
|
|
83
|
+
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < pi.length) {
|
|
86
|
+
const toolResult = pi[j];
|
|
87
|
+
if (toolResult.role !== "toolResult") break;
|
|
88
|
+
if (
|
|
89
|
+
expected.has(toolResult.toolCallId) &&
|
|
90
|
+
!byId.has(toolResult.toolCallId)
|
|
91
|
+
) {
|
|
92
|
+
byId.set(
|
|
93
|
+
toolResult.toolCallId,
|
|
94
|
+
piToolResultToBedrockContentParts(toolResult),
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
orphaned.push(...piToolResultToSyntheticUserContent(toolResult));
|
|
98
|
+
}
|
|
99
|
+
j++;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const content: BedrockConverseContentBlock[] = [];
|
|
103
|
+
const images: BedrockConverseContentBlock[] = [];
|
|
104
|
+
for (const id of toolUseIds) {
|
|
105
|
+
const translated = byId.get(id);
|
|
106
|
+
if (translated) {
|
|
107
|
+
content.push(translated.toolResult);
|
|
108
|
+
images.push(...translated.images);
|
|
109
|
+
} else {
|
|
110
|
+
content.push(missingToolResultBlock(id));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
messages.push({
|
|
114
|
+
role: "user",
|
|
115
|
+
content: [...content, ...images, ...orphaned],
|
|
116
|
+
});
|
|
117
|
+
i = j - 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (msg.role === "toolResult") {
|
|
122
|
+
// A standalone toolResult block is invalid in Bedrock/Anthropic. Keep
|
|
123
|
+
// the information available to the model as normal user-visible text.
|
|
124
|
+
messages.push({
|
|
125
|
+
role: "user",
|
|
126
|
+
content: piToolResultToSyntheticUserContent(msg),
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
messages.push(piUserToBedrockConverse(msg));
|
|
59
132
|
}
|
|
60
133
|
|
|
61
134
|
const tools = (context.tools ?? []).map(piToolToBedrockToolSpec);
|
|
62
135
|
return {
|
|
63
|
-
...(context.systemPrompt
|
|
136
|
+
...(context.systemPrompt
|
|
137
|
+
? { system: [{ text: context.systemPrompt }] }
|
|
138
|
+
: {}),
|
|
64
139
|
messages: coalesceAdjacentMessages(messages),
|
|
65
140
|
...(tools.length > 0 ? { toolConfig: { tools } } : {}),
|
|
66
141
|
};
|
|
67
142
|
}
|
|
68
143
|
|
|
69
|
-
function piMessageToBedrockConverse(
|
|
70
|
-
msg: Message,
|
|
71
|
-
): BedrockConverseMessage | undefined {
|
|
72
|
-
switch (msg.role) {
|
|
73
|
-
case "user":
|
|
74
|
-
return piUserToBedrockConverse(msg);
|
|
75
|
-
case "assistant":
|
|
76
|
-
return piAssistantToBedrockConverse(msg);
|
|
77
|
-
case "toolResult":
|
|
78
|
-
return piToolResultToBedrockConverse(msg);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
144
|
function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
|
|
83
145
|
if (typeof msg.content === "string") {
|
|
84
146
|
return { role: "user", content: [{ text: msg.content }] };
|
|
@@ -94,7 +156,10 @@ function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
|
|
|
94
156
|
};
|
|
95
157
|
});
|
|
96
158
|
|
|
97
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
role: "user",
|
|
161
|
+
content: content.length > 0 ? content : [{ text: " " }],
|
|
162
|
+
};
|
|
98
163
|
}
|
|
99
164
|
|
|
100
165
|
function piAssistantToBedrockConverse(
|
|
@@ -122,25 +187,61 @@ function piAssistantToBedrockConverse(
|
|
|
122
187
|
};
|
|
123
188
|
}
|
|
124
189
|
|
|
125
|
-
|
|
190
|
+
type BedrockToolResultParts = {
|
|
191
|
+
toolResult: BedrockConverseContentBlock;
|
|
192
|
+
images: BedrockConverseContentBlock[];
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function piToolResultToBedrockContentParts(
|
|
126
196
|
msg: ToolResultMessage,
|
|
127
|
-
):
|
|
128
|
-
const text = toolResultText(msg) || " ";
|
|
197
|
+
): BedrockToolResultParts {
|
|
129
198
|
return {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
199
|
+
toolResult: bedrockToolResultBlock(
|
|
200
|
+
msg.toolCallId,
|
|
201
|
+
toolResultText(msg) || " ",
|
|
202
|
+
msg.isError,
|
|
203
|
+
),
|
|
204
|
+
images: toolResultImagesAsUserContent(msg),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function missingToolResultBlock(
|
|
209
|
+
toolUseId: string,
|
|
210
|
+
): BedrockConverseContentBlock {
|
|
211
|
+
return bedrockToolResultBlock(
|
|
212
|
+
toolUseId,
|
|
213
|
+
`Tool result missing for ${toolUseId}.`,
|
|
214
|
+
true,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function bedrockToolResultBlock(
|
|
219
|
+
toolUseId: string,
|
|
220
|
+
text: string,
|
|
221
|
+
isError: boolean,
|
|
222
|
+
): BedrockConverseContentBlock {
|
|
223
|
+
return {
|
|
224
|
+
toolResult: {
|
|
225
|
+
toolUseId,
|
|
226
|
+
content: [{ text }],
|
|
227
|
+
status: isError ? "error" : "success",
|
|
228
|
+
},
|
|
141
229
|
};
|
|
142
230
|
}
|
|
143
231
|
|
|
232
|
+
function piToolResultToSyntheticUserContent(
|
|
233
|
+
msg: ToolResultMessage,
|
|
234
|
+
): BedrockConverseContentBlock[] {
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
text:
|
|
238
|
+
`Tool result for ${msg.toolName} (${msg.toolCallId})` +
|
|
239
|
+
`${msg.isError ? " failed" : ""}:\n${toolResultText(msg) || " "}`,
|
|
240
|
+
},
|
|
241
|
+
...toolResultImagesAsUserContent(msg),
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
|
|
144
245
|
function toolResultText(msg: ToolResultMessage): string {
|
|
145
246
|
return msg.content
|
|
146
247
|
.filter((part): part is TextContent => part.type === "text")
|
|
@@ -167,13 +268,20 @@ function toolResultImagesAsUserContent(
|
|
|
167
268
|
function imageFormatFromMimeType(mimeType: string): string {
|
|
168
269
|
const format = mimeType.split("/")[1]?.toLowerCase();
|
|
169
270
|
if (format === "jpg") return "jpeg";
|
|
170
|
-
if (
|
|
271
|
+
if (
|
|
272
|
+
format === "jpeg" ||
|
|
273
|
+
format === "png" ||
|
|
274
|
+
format === "gif" ||
|
|
275
|
+
format === "webp"
|
|
276
|
+
) {
|
|
171
277
|
return format;
|
|
172
278
|
}
|
|
173
279
|
return "png";
|
|
174
280
|
}
|
|
175
281
|
|
|
176
|
-
function piToolToBedrockToolSpec(
|
|
282
|
+
function piToolToBedrockToolSpec(
|
|
283
|
+
tool: Tool,
|
|
284
|
+
): BedrockToolConfig["tools"][number] {
|
|
177
285
|
return {
|
|
178
286
|
toolSpec: {
|
|
179
287
|
name: tool.name,
|
|
@@ -23,7 +23,11 @@ export type VertexTool = {
|
|
|
23
23
|
functionDeclarations: Array<{
|
|
24
24
|
name: string;
|
|
25
25
|
description: string;
|
|
26
|
-
parameters
|
|
26
|
+
// Gemini's `parameters` field is a restricted OpenAPI 3.0 Schema subset
|
|
27
|
+
// that rejects `const` and non-string `enum` values. `parametersJsonSchema`
|
|
28
|
+
// accepts full JSON Schema (anyOf/oneOf/const/boolean enums), which is what
|
|
29
|
+
// pi tool definitions actually use. See pi-ai google-shared convertTools.
|
|
30
|
+
parametersJsonSchema: Record<string, unknown>;
|
|
27
31
|
}>;
|
|
28
32
|
};
|
|
29
33
|
|
|
@@ -139,7 +143,11 @@ function piToolToVertexFunctionDeclaration(
|
|
|
139
143
|
return {
|
|
140
144
|
name: tool.name,
|
|
141
145
|
description: tool.description,
|
|
142
|
-
|
|
146
|
+
// Use `parametersJsonSchema` (full JSON Schema) instead of the legacy
|
|
147
|
+
// `parameters` OpenAPI subset. The latter rejects `const` and non-string
|
|
148
|
+
// `enum` values that pi tool schemas commonly contain, causing HTTP 400
|
|
149
|
+
// "Unknown name const" / "enum[0] (TYPE_STRING)" errors from Gemini.
|
|
150
|
+
parametersJsonSchema: tool.parameters as unknown as Record<string, unknown>,
|
|
143
151
|
};
|
|
144
152
|
}
|
|
145
153
|
|