pi-sap-aicore 0.2.2 → 0.3.0
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 +22 -1
- package/README.md +44 -33
- package/package.json +1 -1
- package/src/foundation-deployment.ts +24 -0
- package/src/foundation-executables.ts +12 -0
- package/src/stream-foundation-azure-openai.ts +363 -0
- package/src/stream-foundation-bedrock.ts +223 -0
- package/src/stream-foundation.ts +21 -353
- package/src/translate-foundation-bedrock.ts +175 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-07-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Direct foundation route support for Anthropic/Claude models deployed through SAP AI Core
|
|
15
|
+
`foundation-models` with the `aws-bedrock` executable. The provider now calls SAP
|
|
16
|
+
AI Core's Bedrock-compatible `/converse` endpoint and replays the non-streaming
|
|
17
|
+
response into pi stream events.
|
|
18
|
+
- Foundation executable routing by model family: `gpt-*` uses `azure-openai`,
|
|
19
|
+
`anthropic--*` uses `aws-bedrock`, and `gemini-*` is reserved for a future
|
|
20
|
+
`gcp-vertexai` adapter.
|
|
21
|
+
- Bedrock Converse message translation for text, images, assistant tool-use history,
|
|
22
|
+
and tool-result history on the direct foundation route.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Refactored the foundation provider into an executable dispatcher while preserving
|
|
27
|
+
the existing Azure OpenAI streaming path for GPT models.
|
|
28
|
+
- Documentation now describes Azure OpenAI and AWS Bedrock direct foundation routes.
|
|
29
|
+
|
|
10
30
|
## [0.2.2] - 2026-06-16
|
|
11
31
|
|
|
12
32
|
### Fixed
|
|
@@ -96,7 +116,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
96
116
|
`reasoning_effort` for OpenAI).
|
|
97
117
|
- MIT license and npm packaging.
|
|
98
118
|
|
|
99
|
-
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.
|
|
119
|
+
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.0...HEAD
|
|
120
|
+
[0.3.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.2...v0.3.0
|
|
100
121
|
[0.2.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...v0.2.2
|
|
101
122
|
[0.2.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...v0.2.1
|
|
102
123
|
[0.2.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.2...v0.2.0
|
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
A custom provider extension for the [pi coding agent](https://pi.dev) that routes
|
|
4
4
|
inference through **SAP AI Core** — via the **orchestration** service (every model
|
|
5
5
|
from a single deployment) and/or **direct foundation deployments** (per-model
|
|
6
|
-
Azure OpenAI
|
|
7
|
-
login, so you pick the route per model. See
|
|
6
|
+
foundation endpoints such as Azure OpenAI or AWS Bedrock). Both register at once
|
|
7
|
+
and share one login, so you pick the route per model. See
|
|
8
8
|
[Orchestration vs. Foundation](#orchestration-vs-foundation).
|
|
9
9
|
|
|
10
10
|
## Prerequisites
|
|
@@ -12,7 +12,8 @@ login, so you pick the route per model. See
|
|
|
12
12
|
- pi **0.78.0 or newer** installed (`npm install -g @earendil-works/pi-coding-agent`)
|
|
13
13
|
- An SAP BTP account with AI Core entitlement and an **orchestration deployment**
|
|
14
14
|
- *(optional, for the foundation provider)* one or more **foundation-models
|
|
15
|
-
deployments** — one per
|
|
15
|
+
deployments** — one per model you want to route directly (`azure-openai` for
|
|
16
|
+
GPT/OpenAI models, `aws-bedrock` for Anthropic/Claude models)
|
|
16
17
|
- The service key JSON for your AI Core service binding
|
|
17
18
|
|
|
18
19
|
## Credentials
|
|
@@ -103,7 +104,8 @@ You'll see the orchestration models under `sap-aicore/` (Claude, GPT-5*, Gemini)
|
|
|
103
104
|
plus any direct **foundation** models under `sap-aicore-foundation/`:
|
|
104
105
|
- `sap-aicore/anthropic--claude-4.7-opus` — Claude Opus 4.7 (orchestration)
|
|
105
106
|
- `sap-aicore/gpt-5.5` — GPT-5.5 via orchestration
|
|
106
|
-
- `sap-aicore-foundation/gpt-5.5` — GPT-5.5 via its direct foundation deployment
|
|
107
|
+
- `sap-aicore-foundation/gpt-5.5` — GPT-5.5 via its direct Azure OpenAI foundation deployment
|
|
108
|
+
- `sap-aicore-foundation/anthropic--claude-4.8-opus` — Claude Opus 4.8 via its direct AWS Bedrock foundation deployment
|
|
107
109
|
|
|
108
110
|
Run `pi -e ./index.ts` to launch pi with the local extension loaded; this
|
|
109
111
|
overrides any globally-installed version for the session, which is the fastest
|
|
@@ -129,24 +131,27 @@ The extension registers **two providers**, both backed by the same service key:
|
|
|
129
131
|
| | `sap-aicore` (orchestration) | `sap-aicore-foundation` (direct) |
|
|
130
132
|
|---|---|---|
|
|
131
133
|
| SAP deployment | one orchestration deployment fronts **every** model | one foundation deployment **per model** |
|
|
132
|
-
| Models | Claude, GPT-5*, Gemini | OpenAI (`
|
|
133
|
-
| Streaming | subject to orchestration's per-model allow-list — new models can 400 `Streaming is not supported` (we fall back to non-streaming) |
|
|
134
|
-
| Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only
|
|
134
|
+
| Models | Claude, GPT-5*, Gemini | GPT/OpenAI (`azure-openai`) and Anthropic/Claude (`aws-bedrock`); Gemini/Vertex mapping is reserved but not implemented yet |
|
|
135
|
+
| Streaming | subject to orchestration's per-model allow-list — new models can 400 `Streaming is not supported` (we fall back to non-streaming) | Azure OpenAI streams natively; AWS Bedrock currently uses non-streaming `/converse` and replays the response into pi stream events |
|
|
136
|
+
| Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only for Azure; Bedrock/Anthropic thinking controls are not wired yet |
|
|
135
137
|
| Content filter / grounding / templating | yes | no — raw model access |
|
|
136
|
-
| SDK | `@sap-ai-sdk/orchestration` |
|
|
138
|
+
| SDK / endpoint | `@sap-ai-sdk/orchestration` | `AzureOpenAiChatClient` for `azure-openai`; SAP `/inference/deployments/{id}/converse` for `aws-bedrock` |
|
|
137
139
|
|
|
138
140
|
Both routes appear in the model list simultaneously, so you choose per model. The
|
|
139
|
-
foundation route exists mainly to
|
|
140
|
-
|
|
141
|
+
foundation route exists mainly to access new models directly when orchestration
|
|
142
|
+
lags behind model deployment or streaming support (for example `gpt-5.5` on
|
|
143
|
+
Azure OpenAI or a newly deployed Claude model on AWS Bedrock).
|
|
141
144
|
|
|
142
145
|
**Adding a foundation model:** it needs its own foundation-models deployment in
|
|
143
|
-
SAP AI Core — one per (model, version, resource group)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
SAP AI Core — one per (model, version, resource group). The extension chooses the
|
|
147
|
+
foundation executable from the model id: `gpt-*` → `azure-openai`,
|
|
148
|
+
`anthropic--*` → `aws-bedrock`, and `gemini-*` → `gcp-vertexai` (reserved; adapter
|
|
149
|
+
not implemented yet). Then add its `id` to the per-machine extension overlay at
|
|
150
|
+
`~/.pi/agent/pi-sap-aicore/models.json`:
|
|
146
151
|
|
|
147
152
|
```json
|
|
148
153
|
{
|
|
149
|
-
"foundation": { "enabledModelIds": ["gpt-5.5"] }
|
|
154
|
+
"foundation": { "enabledModelIds": ["gpt-5.5", "anthropic--claude-4.8-opus"] }
|
|
150
155
|
}
|
|
151
156
|
```
|
|
152
157
|
|
|
@@ -327,12 +332,13 @@ future, our `pickReasoning` probe is wired and ready in `stream.ts`.
|
|
|
327
332
|
shape SAP may reject. Wire-up (likely `thinking_config.thinking_budget`)
|
|
328
333
|
is a future TODO in `src/stream.ts:reasoningParams`.
|
|
329
334
|
|
|
330
|
-
**Foundation route caveat:** on `sap-aicore-foundation
|
|
331
|
-
OpenAI SDK
|
|
332
|
-
field — so gpt-5\* reason at their **default** effort and pi's
|
|
333
|
-
cycle is a no-op there.
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
**Foundation route caveat:** on `sap-aicore-foundation/*`, GPT/OpenAI models use
|
|
336
|
+
the direct Azure OpenAI SDK pinned to API version `2024-10-21`, which has no
|
|
337
|
+
`reasoning_effort` field — so gpt-5\* reason at their **default** effort and pi's
|
|
338
|
+
thinking-level cycle is a no-op there. Anthropic/Claude models use SAP's AWS
|
|
339
|
+
Bedrock `/converse` endpoint; model-default reasoning works, but explicit Claude
|
|
340
|
+
thinking controls are not wired yet. Use the orchestration route if you need
|
|
341
|
+
explicit effort control.
|
|
336
342
|
|
|
337
343
|
To override budgets per model, edit `thinkingLevelMap` on the relevant entry in
|
|
338
344
|
`~/.pi/agent/pi-sap-aicore/models.json`.
|
|
@@ -355,11 +361,10 @@ Resolved in this order:
|
|
|
355
361
|
|
|
356
362
|
The value is passed via SAP's `OrchestrationClient(..., {resourceGroup})`
|
|
357
363
|
constructor arg, which is the only supported channel — `AI-Resource-Group`
|
|
358
|
-
as a request header is explicitly rejected by SAP's typings. The
|
|
359
|
-
provider applies the same resolved group
|
|
360
|
-
|
|
361
|
-
deployment
|
|
362
|
-
name-based resolution to find them.
|
|
364
|
+
as a request header is explicitly rejected by SAP's orchestration typings. The
|
|
365
|
+
foundation provider applies the same resolved group when resolving and invoking
|
|
366
|
+
direct deployments; both a model's foundation deployment and the orchestration
|
|
367
|
+
deployment must live in the resolved group for name-based resolution to find them.
|
|
363
368
|
|
|
364
369
|
## Prompt caching & cost reporting
|
|
365
370
|
|
|
@@ -386,11 +391,12 @@ currently don't.
|
|
|
386
391
|
OpenAI/Gemini routes ignore the flag — they have their own automatic
|
|
387
392
|
caching with no breakpoint API.
|
|
388
393
|
|
|
389
|
-
**Foundation route:** because
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
+
**Foundation route:** because direct foundation endpoints bypass orchestration's
|
|
395
|
+
usage-stripping, provider-specific cache fields *may* come back populated.
|
|
396
|
+
`mapUsage` reads OpenAI `prompt_tokens_details.cached_tokens` and Anthropic-style
|
|
397
|
+
cache-read fields when SAP exposes them, so `cacheRead` could be non-zero on
|
|
398
|
+
`sap-aicore-foundation/*` turns where orchestration always reports 0. Treat as
|
|
399
|
+
best-effort and provider-dependent.
|
|
394
400
|
|
|
395
401
|
## Releasing (maintainers)
|
|
396
402
|
|
|
@@ -454,7 +460,12 @@ npmjs.com:
|
|
|
454
460
|
├── to-pi-model.ts # SapModel → pi's ProviderModelConfig mapper
|
|
455
461
|
├── stream.ts # orchestration streamSimple adapter + shared helpers (auth, usage, errors)
|
|
456
462
|
├── translate.ts # pi Context ↔ orchestration message shape
|
|
457
|
-
├── foundation-
|
|
458
|
-
├──
|
|
459
|
-
|
|
463
|
+
├── foundation-executables.ts # model id → SAP foundation executable mapping
|
|
464
|
+
├── foundation-deployment.ts # shared foundation deployment resolution helpers
|
|
465
|
+
├── foundation-params.ts # Azure OpenAI request params (max_completion_tokens, temperature gating)
|
|
466
|
+
├── stream-foundation.ts # foundation dispatcher
|
|
467
|
+
├── stream-foundation-azure-openai.ts # AzureOpenAiChatClient adapter with native streaming
|
|
468
|
+
├── stream-foundation-bedrock.ts # AWS Bedrock /converse adapter for Anthropic foundation deployments
|
|
469
|
+
├── translate-foundation.ts # pi Context ↔ Azure OpenAI message shape
|
|
470
|
+
└── translate-foundation-bedrock.ts # pi Context ↔ Bedrock Converse message shape
|
|
460
471
|
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DeploymentApi } from "@sap-ai-sdk/ai-api";
|
|
2
|
+
import { resolveDeploymentId } from "@sap-ai-sdk/ai-api/internal.js";
|
|
3
|
+
|
|
4
|
+
import type { FoundationExecutable } from "./foundation-executables.ts";
|
|
5
|
+
|
|
6
|
+
export async function resolveFoundationDeploymentId(options: {
|
|
7
|
+
modelId: string;
|
|
8
|
+
executableId: FoundationExecutable;
|
|
9
|
+
resourceGroup?: string;
|
|
10
|
+
}): Promise<string> {
|
|
11
|
+
return resolveDeploymentId({
|
|
12
|
+
scenarioId: "foundation-models",
|
|
13
|
+
executableId: options.executableId,
|
|
14
|
+
model: { name: options.modelId },
|
|
15
|
+
resourceGroup: options.resourceGroup,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function listRunningFoundationDeployments(resourceGroup?: string) {
|
|
20
|
+
return DeploymentApi.deploymentQuery(
|
|
21
|
+
{ scenarioId: "foundation-models", status: "RUNNING", $top: 100 },
|
|
22
|
+
{ "AI-Resource-Group": resourceGroup ?? "default" },
|
|
23
|
+
).execute();
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type FoundationExecutable = "azure-openai" | "aws-bedrock" | "gcp-vertexai";
|
|
2
|
+
|
|
3
|
+
export function foundationExecutableForModel(modelId: string): FoundationExecutable {
|
|
4
|
+
if (modelId.startsWith("anthropic--")) return "aws-bedrock";
|
|
5
|
+
if (modelId.startsWith("gemini-")) return "gcp-vertexai";
|
|
6
|
+
if (modelId.startsWith("gpt-")) return "azure-openai";
|
|
7
|
+
|
|
8
|
+
throw new Error(
|
|
9
|
+
`No SAP AI Core foundation executable mapping for model '${modelId}'. ` +
|
|
10
|
+
"Add a mapping in foundation-executables.ts or route this model through orchestration.",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type Api,
|
|
5
|
+
type AssistantMessage,
|
|
6
|
+
type AssistantMessageEventStream,
|
|
7
|
+
calculateCost,
|
|
8
|
+
type Context,
|
|
9
|
+
createAssistantMessageEventStream,
|
|
10
|
+
type Model,
|
|
11
|
+
type SimpleStreamOptions,
|
|
12
|
+
} from "@earendil-works/pi-ai";
|
|
13
|
+
import type { AzureOpenAiChatCompletionParameters } from "@sap-ai-sdk/foundation-models";
|
|
14
|
+
|
|
15
|
+
import { buildAzureOpenAiParams } from "./foundation-params.ts";
|
|
16
|
+
import {
|
|
17
|
+
debugLog,
|
|
18
|
+
ensureServiceKey,
|
|
19
|
+
type ExtendedDelta,
|
|
20
|
+
formatError,
|
|
21
|
+
latchFinishReason,
|
|
22
|
+
mapUsage,
|
|
23
|
+
pickReasoning,
|
|
24
|
+
resolveResourceGroup,
|
|
25
|
+
type ToolCallSlot,
|
|
26
|
+
} from "./stream.ts";
|
|
27
|
+
import { mapFinishReason } from "./translate.ts";
|
|
28
|
+
import { piContextToAzureOpenAi } from "./translate-foundation.ts";
|
|
29
|
+
|
|
30
|
+
// Loaded dynamically (not at module load) so a missing dependency surfaces as
|
|
31
|
+
// an actionable in-stream error instead of an ERR_MODULE_NOT_FOUND crash at pi
|
|
32
|
+
// startup. Mirrors `importOrchestration` in stream.ts.
|
|
33
|
+
async function importFoundation(): Promise<
|
|
34
|
+
typeof import("@sap-ai-sdk/foundation-models")
|
|
35
|
+
> {
|
|
36
|
+
try {
|
|
37
|
+
return await import("@sap-ai-sdk/foundation-models");
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
40
|
+
const msg = (err as Error)?.message ?? "";
|
|
41
|
+
const isMissing =
|
|
42
|
+
code === "ERR_MODULE_NOT_FOUND" &&
|
|
43
|
+
msg.includes("@sap-ai-sdk/foundation-models");
|
|
44
|
+
if (!isMissing) throw err;
|
|
45
|
+
|
|
46
|
+
throw new Error(
|
|
47
|
+
"The SAP AI Core foundation-models SDK (@sap-ai-sdk/foundation-models) " +
|
|
48
|
+
"isn't installed, so the foundation provider can't make requests. Fix: " +
|
|
49
|
+
"run `npm install` in the pi-sap-aicore directory (where pi installed " +
|
|
50
|
+
"it, e.g. under ~/.pi/agent/), then restart pi.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Direct (foundation) provider: routes OpenAI models through their own
|
|
56
|
+
// SAP AI Core deployment via @sap-ai-sdk/foundation-models'
|
|
57
|
+
// AzureOpenAiChatClient — bypassing the orchestration service entirely.
|
|
58
|
+
// Unlike streamSapAiCore there is NO streaming-unsupported fallback: the
|
|
59
|
+
// direct Azure OpenAI endpoint streams natively (that's the whole reason this
|
|
60
|
+
// path exists for new models orchestration won't stream). The SDK injects
|
|
61
|
+
// `stream_options: { include_usage: true }` itself, so usage arrives on the
|
|
62
|
+
// final chunk and `response.getTokenUsage()` is populated.
|
|
63
|
+
export function streamSapFoundationAzureOpenAi(
|
|
64
|
+
model: Model<Api>,
|
|
65
|
+
context: Context,
|
|
66
|
+
options?: SimpleStreamOptions,
|
|
67
|
+
): AssistantMessageEventStream {
|
|
68
|
+
const stream = createAssistantMessageEventStream();
|
|
69
|
+
|
|
70
|
+
const output: AssistantMessage = {
|
|
71
|
+
role: "assistant",
|
|
72
|
+
content: [],
|
|
73
|
+
api: model.api,
|
|
74
|
+
provider: model.provider,
|
|
75
|
+
model: model.id,
|
|
76
|
+
usage: {
|
|
77
|
+
input: 0,
|
|
78
|
+
output: 0,
|
|
79
|
+
cacheRead: 0,
|
|
80
|
+
cacheWrite: 0,
|
|
81
|
+
totalTokens: 0,
|
|
82
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
83
|
+
},
|
|
84
|
+
stopReason: "stop",
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
(async () => {
|
|
89
|
+
const requestId = randomUUID();
|
|
90
|
+
try {
|
|
91
|
+
stream.push({ type: "start", partial: output });
|
|
92
|
+
|
|
93
|
+
const serviceKey = ensureServiceKey(options?.apiKey);
|
|
94
|
+
process.env.AICORE_SERVICE_KEY = serviceKey.raw;
|
|
95
|
+
const resourceGroup = resolveResourceGroup(serviceKey);
|
|
96
|
+
|
|
97
|
+
const { messages, tools } = piContextToAzureOpenAi(context);
|
|
98
|
+
const params = buildAzureOpenAiParams(model, options);
|
|
99
|
+
|
|
100
|
+
const { AzureOpenAiChatClient } = await importFoundation();
|
|
101
|
+
|
|
102
|
+
const request: AzureOpenAiChatCompletionParameters = {
|
|
103
|
+
messages,
|
|
104
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
105
|
+
...params,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
debugLog({
|
|
109
|
+
requestId,
|
|
110
|
+
kind: "request",
|
|
111
|
+
provider: "foundation-azure-openai",
|
|
112
|
+
model: model.id,
|
|
113
|
+
resourceGroup,
|
|
114
|
+
params,
|
|
115
|
+
messageRoles: messages.map((m) => m.role),
|
|
116
|
+
messages,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Name-based deployment resolution: the SDK finds THE foundation
|
|
120
|
+
// deployment serving this model in the resource group. SAP allows
|
|
121
|
+
// only one deployment per (model, version, resource group), so the
|
|
122
|
+
// match is unambiguous — no deployment ID needed.
|
|
123
|
+
const client = new AzureOpenAiChatClient({
|
|
124
|
+
modelName: model.id,
|
|
125
|
+
...(resourceGroup ? { resourceGroup } : {}),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const response = await client.stream(request, options?.signal);
|
|
129
|
+
|
|
130
|
+
let textIndex = -1;
|
|
131
|
+
let thinkingIndex = -1;
|
|
132
|
+
let reasoningField: string | undefined;
|
|
133
|
+
let refusalText = "";
|
|
134
|
+
const toolSlots = new Map<number, ToolCallSlot>();
|
|
135
|
+
let finishReason: string | undefined;
|
|
136
|
+
|
|
137
|
+
const closeText = () => {
|
|
138
|
+
if (textIndex < 0) return;
|
|
139
|
+
const block = output.content[textIndex];
|
|
140
|
+
if (block?.type === "text") {
|
|
141
|
+
stream.push({
|
|
142
|
+
type: "text_end",
|
|
143
|
+
contentIndex: textIndex,
|
|
144
|
+
content: block.text,
|
|
145
|
+
partial: output,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
textIndex = -1;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const closeThinking = () => {
|
|
152
|
+
if (thinkingIndex < 0) return;
|
|
153
|
+
const block = output.content[thinkingIndex];
|
|
154
|
+
if (block?.type === "thinking") {
|
|
155
|
+
stream.push({
|
|
156
|
+
type: "thinking_end",
|
|
157
|
+
contentIndex: thinkingIndex,
|
|
158
|
+
content: block.thinking,
|
|
159
|
+
partial: output,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
thinkingIndex = -1;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
for await (const chunk of response.stream) {
|
|
166
|
+
if (options?.signal?.aborted) break;
|
|
167
|
+
|
|
168
|
+
const choice = chunk.findChoiceByIndex(0);
|
|
169
|
+
const rawDelta = (choice?.delta ?? {}) as ExtendedDelta;
|
|
170
|
+
|
|
171
|
+
// Reasoning first — providers emit it before visible text, and
|
|
172
|
+
// pi's UI expects the thinking block to precede the text block.
|
|
173
|
+
// (gpt-5* on the direct route are unlikely to pass structured
|
|
174
|
+
// reasoning through, but we handle it for free if they do.)
|
|
175
|
+
const reasoning = pickReasoning(rawDelta, reasoningField);
|
|
176
|
+
if (reasoning) {
|
|
177
|
+
reasoningField = reasoning.field;
|
|
178
|
+
if (thinkingIndex < 0) {
|
|
179
|
+
closeText();
|
|
180
|
+
output.content.push({ type: "thinking", thinking: "" });
|
|
181
|
+
thinkingIndex = output.content.length - 1;
|
|
182
|
+
stream.push({
|
|
183
|
+
type: "thinking_start",
|
|
184
|
+
contentIndex: thinkingIndex,
|
|
185
|
+
partial: output,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const block = output.content[thinkingIndex];
|
|
189
|
+
if (block?.type === "thinking") {
|
|
190
|
+
block.thinking += reasoning.text;
|
|
191
|
+
stream.push({
|
|
192
|
+
type: "thinking_delta",
|
|
193
|
+
contentIndex: thinkingIndex,
|
|
194
|
+
delta: reasoning.text,
|
|
195
|
+
partial: output,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const delta = chunk.getDeltaContent();
|
|
201
|
+
if (delta) {
|
|
202
|
+
if (textIndex < 0) {
|
|
203
|
+
closeThinking();
|
|
204
|
+
output.content.push({ type: "text", text: "" });
|
|
205
|
+
textIndex = output.content.length - 1;
|
|
206
|
+
stream.push({
|
|
207
|
+
type: "text_start",
|
|
208
|
+
contentIndex: textIndex,
|
|
209
|
+
partial: output,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const block = output.content[textIndex];
|
|
213
|
+
if (block?.type === "text") {
|
|
214
|
+
block.text += delta;
|
|
215
|
+
stream.push({
|
|
216
|
+
type: "text_delta",
|
|
217
|
+
contentIndex: textIndex,
|
|
218
|
+
delta,
|
|
219
|
+
partial: output,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
typeof rawDelta.refusal === "string" &&
|
|
226
|
+
rawDelta.refusal.length > 0
|
|
227
|
+
) {
|
|
228
|
+
refusalText += rawDelta.refusal;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const toolDeltas = chunk.getDeltaToolCalls();
|
|
232
|
+
if (toolDeltas && toolDeltas.length > 0) {
|
|
233
|
+
closeText();
|
|
234
|
+
closeThinking();
|
|
235
|
+
|
|
236
|
+
for (const td of toolDeltas) {
|
|
237
|
+
let slot = toolSlots.get(td.index);
|
|
238
|
+
if (!slot) {
|
|
239
|
+
output.content.push({
|
|
240
|
+
type: "toolCall",
|
|
241
|
+
id: td.id ?? "",
|
|
242
|
+
name: td.function?.name ?? "",
|
|
243
|
+
arguments: {},
|
|
244
|
+
});
|
|
245
|
+
slot = {
|
|
246
|
+
contentIndex: output.content.length - 1,
|
|
247
|
+
partialJson: "",
|
|
248
|
+
};
|
|
249
|
+
toolSlots.set(td.index, slot);
|
|
250
|
+
stream.push({
|
|
251
|
+
type: "toolcall_start",
|
|
252
|
+
contentIndex: slot.contentIndex,
|
|
253
|
+
partial: output,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const block = output.content[slot.contentIndex];
|
|
258
|
+
if (block?.type === "toolCall") {
|
|
259
|
+
if (td.id && !block.id) block.id = td.id;
|
|
260
|
+
if (td.function?.name && !block.name)
|
|
261
|
+
block.name = td.function.name;
|
|
262
|
+
|
|
263
|
+
const fragment = td.function?.arguments ?? "";
|
|
264
|
+
if (fragment) {
|
|
265
|
+
slot.partialJson += fragment;
|
|
266
|
+
try {
|
|
267
|
+
block.arguments = JSON.parse(slot.partialJson);
|
|
268
|
+
} catch {
|
|
269
|
+
// Partial JSON — keep accumulating until valid
|
|
270
|
+
}
|
|
271
|
+
stream.push({
|
|
272
|
+
type: "toolcall_delta",
|
|
273
|
+
contentIndex: slot.contentIndex,
|
|
274
|
+
delta: fragment,
|
|
275
|
+
partial: output,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
finishReason = latchFinishReason(
|
|
283
|
+
finishReason,
|
|
284
|
+
chunk.getFinishReason() ?? undefined,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
closeText();
|
|
289
|
+
closeThinking();
|
|
290
|
+
|
|
291
|
+
for (const slot of toolSlots.values()) {
|
|
292
|
+
const block = output.content[slot.contentIndex];
|
|
293
|
+
if (block?.type === "toolCall") {
|
|
294
|
+
if (slot.partialJson) {
|
|
295
|
+
try {
|
|
296
|
+
block.arguments = JSON.parse(slot.partialJson);
|
|
297
|
+
} catch {
|
|
298
|
+
// Leave arguments as last successfully-parsed value
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
stream.push({
|
|
302
|
+
type: "toolcall_end",
|
|
303
|
+
contentIndex: slot.contentIndex,
|
|
304
|
+
toolCall: {
|
|
305
|
+
type: "toolCall",
|
|
306
|
+
id: block.id,
|
|
307
|
+
name: block.name,
|
|
308
|
+
arguments: block.arguments,
|
|
309
|
+
},
|
|
310
|
+
partial: output,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const usage = response.getTokenUsage();
|
|
316
|
+
if (usage) {
|
|
317
|
+
output.usage = mapUsage(usage);
|
|
318
|
+
calculateCost(model, output.usage);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A refusal terminates the turn with no real content. Promote it to a
|
|
322
|
+
// visible error so pi doesn't render an empty assistant turn.
|
|
323
|
+
if (refusalText) {
|
|
324
|
+
output.stopReason = "error";
|
|
325
|
+
output.errorMessage = `Model refused: ${refusalText}`;
|
|
326
|
+
stream.push({ type: "error", reason: "error", error: output });
|
|
327
|
+
stream.end();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
output.stopReason = mapFinishReason(
|
|
332
|
+
toolSlots.size > 0
|
|
333
|
+
? "tool_calls"
|
|
334
|
+
: (finishReason ?? response.getFinishReason() ?? undefined),
|
|
335
|
+
);
|
|
336
|
+
stream.push({
|
|
337
|
+
type: "done",
|
|
338
|
+
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
339
|
+
message: output,
|
|
340
|
+
});
|
|
341
|
+
stream.end();
|
|
342
|
+
} catch (error) {
|
|
343
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
344
|
+
output.errorMessage = formatError(error);
|
|
345
|
+
debugLog({
|
|
346
|
+
requestId,
|
|
347
|
+
kind: "error",
|
|
348
|
+
provider: "foundation-azure-openai",
|
|
349
|
+
model: model.id,
|
|
350
|
+
stopReason: output.stopReason,
|
|
351
|
+
error: output.errorMessage,
|
|
352
|
+
});
|
|
353
|
+
stream.push({
|
|
354
|
+
type: "error",
|
|
355
|
+
reason: output.stopReason as "error" | "aborted",
|
|
356
|
+
error: output,
|
|
357
|
+
});
|
|
358
|
+
stream.end();
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
|
|
362
|
+
return stream;
|
|
363
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type Api,
|
|
5
|
+
type AssistantMessage,
|
|
6
|
+
type AssistantMessageEventStream,
|
|
7
|
+
calculateCost,
|
|
8
|
+
type Context,
|
|
9
|
+
createAssistantMessageEventStream,
|
|
10
|
+
type Model,
|
|
11
|
+
type SimpleStreamOptions,
|
|
12
|
+
} from "@earendil-works/pi-ai";
|
|
13
|
+
import { executeRequest } from "@sap-ai-sdk/core";
|
|
14
|
+
|
|
15
|
+
import { resolveFoundationDeploymentId } from "./foundation-deployment.ts";
|
|
16
|
+
import {
|
|
17
|
+
debugLog,
|
|
18
|
+
ensureServiceKey,
|
|
19
|
+
formatError,
|
|
20
|
+
mapUsage,
|
|
21
|
+
resolveResourceGroup,
|
|
22
|
+
} from "./stream.ts";
|
|
23
|
+
import { mapFinishReason } from "./translate.ts";
|
|
24
|
+
import { piContextToBedrockConverse } from "./translate-foundation-bedrock.ts";
|
|
25
|
+
|
|
26
|
+
type BedrockConverseResponse = {
|
|
27
|
+
output?: {
|
|
28
|
+
message?: {
|
|
29
|
+
role?: string;
|
|
30
|
+
content?: Array<{
|
|
31
|
+
text?: string;
|
|
32
|
+
toolUse?: {
|
|
33
|
+
toolUseId?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
input?: unknown;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
stopReason?: string;
|
|
41
|
+
usage?: {
|
|
42
|
+
inputTokens?: number;
|
|
43
|
+
outputTokens?: number;
|
|
44
|
+
totalTokens?: number;
|
|
45
|
+
cacheReadInputTokens?: number;
|
|
46
|
+
cacheReadInputTokenCount?: number;
|
|
47
|
+
cacheWriteInputTokens?: number;
|
|
48
|
+
cacheWriteInputTokenCount?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function streamSapFoundationBedrock(
|
|
53
|
+
model: Model<Api>,
|
|
54
|
+
context: Context,
|
|
55
|
+
options?: SimpleStreamOptions,
|
|
56
|
+
): AssistantMessageEventStream {
|
|
57
|
+
const stream = createAssistantMessageEventStream();
|
|
58
|
+
|
|
59
|
+
const output: AssistantMessage = {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: [],
|
|
62
|
+
api: model.api,
|
|
63
|
+
provider: model.provider,
|
|
64
|
+
model: model.id,
|
|
65
|
+
usage: {
|
|
66
|
+
input: 0,
|
|
67
|
+
output: 0,
|
|
68
|
+
cacheRead: 0,
|
|
69
|
+
cacheWrite: 0,
|
|
70
|
+
totalTokens: 0,
|
|
71
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
72
|
+
},
|
|
73
|
+
stopReason: "stop",
|
|
74
|
+
timestamp: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
(async () => {
|
|
78
|
+
const requestId = randomUUID();
|
|
79
|
+
try {
|
|
80
|
+
stream.push({ type: "start", partial: output });
|
|
81
|
+
|
|
82
|
+
const serviceKey = ensureServiceKey(options?.apiKey);
|
|
83
|
+
process.env.AICORE_SERVICE_KEY = serviceKey.raw;
|
|
84
|
+
const resourceGroup = resolveResourceGroup(serviceKey);
|
|
85
|
+
const deploymentId = await resolveFoundationDeploymentId({
|
|
86
|
+
modelId: model.id,
|
|
87
|
+
executableId: "aws-bedrock",
|
|
88
|
+
resourceGroup,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const translated = piContextToBedrockConverse(context);
|
|
92
|
+
const maxTokens = options?.maxTokens ?? model.maxTokens;
|
|
93
|
+
const request = {
|
|
94
|
+
...translated,
|
|
95
|
+
inferenceConfig: {
|
|
96
|
+
maxTokens,
|
|
97
|
+
...(options?.temperature !== undefined
|
|
98
|
+
? { temperature: options.temperature }
|
|
99
|
+
: {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
debugLog({
|
|
104
|
+
requestId,
|
|
105
|
+
kind: "request",
|
|
106
|
+
provider: "foundation-aws-bedrock",
|
|
107
|
+
model: model.id,
|
|
108
|
+
resourceGroup,
|
|
109
|
+
deploymentId,
|
|
110
|
+
params: request.inferenceConfig,
|
|
111
|
+
messageRoles: request.messages.map((m) => m.role),
|
|
112
|
+
messages: request.messages,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const response = await executeRequest(
|
|
116
|
+
{
|
|
117
|
+
url: `/inference/deployments/${deploymentId}/converse`,
|
|
118
|
+
resourceGroup,
|
|
119
|
+
},
|
|
120
|
+
request,
|
|
121
|
+
{ signal: options?.signal },
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const data = response.data as BedrockConverseResponse;
|
|
125
|
+
replayBedrockConverseResponse(stream, output, data);
|
|
126
|
+
|
|
127
|
+
if (data.usage) {
|
|
128
|
+
output.usage = mapUsage({
|
|
129
|
+
prompt_tokens: data.usage.inputTokens ?? 0,
|
|
130
|
+
completion_tokens: data.usage.outputTokens ?? 0,
|
|
131
|
+
cache_read_input_tokens:
|
|
132
|
+
data.usage.cacheReadInputTokens ??
|
|
133
|
+
data.usage.cacheReadInputTokenCount ??
|
|
134
|
+
0,
|
|
135
|
+
cache_creation_input_tokens:
|
|
136
|
+
data.usage.cacheWriteInputTokens ??
|
|
137
|
+
data.usage.cacheWriteInputTokenCount ??
|
|
138
|
+
0,
|
|
139
|
+
});
|
|
140
|
+
calculateCost(model, output.usage);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
output.stopReason = mapFinishReason(
|
|
144
|
+
data.stopReason === "max_tokens" ? "length" : data.stopReason,
|
|
145
|
+
);
|
|
146
|
+
stream.push({
|
|
147
|
+
type: "done",
|
|
148
|
+
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
149
|
+
message: output,
|
|
150
|
+
});
|
|
151
|
+
stream.end();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
154
|
+
output.errorMessage = formatError(error);
|
|
155
|
+
debugLog({
|
|
156
|
+
requestId,
|
|
157
|
+
kind: "error",
|
|
158
|
+
provider: "foundation-aws-bedrock",
|
|
159
|
+
model: model.id,
|
|
160
|
+
stopReason: output.stopReason,
|
|
161
|
+
error: output.errorMessage,
|
|
162
|
+
});
|
|
163
|
+
stream.push({
|
|
164
|
+
type: "error",
|
|
165
|
+
reason: output.stopReason as "error" | "aborted",
|
|
166
|
+
error: output,
|
|
167
|
+
});
|
|
168
|
+
stream.end();
|
|
169
|
+
}
|
|
170
|
+
})();
|
|
171
|
+
|
|
172
|
+
return stream;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function replayBedrockConverseResponse(
|
|
176
|
+
stream: AssistantMessageEventStream,
|
|
177
|
+
output: AssistantMessage,
|
|
178
|
+
data: BedrockConverseResponse,
|
|
179
|
+
): void {
|
|
180
|
+
const blocks = data.output?.message?.content ?? [];
|
|
181
|
+
for (const block of blocks) {
|
|
182
|
+
if (typeof block.text === "string" && block.text.length > 0) {
|
|
183
|
+
const contentIndex = output.content.length;
|
|
184
|
+
output.content.push({ type: "text", text: "" });
|
|
185
|
+
stream.push({ type: "text_start", contentIndex, partial: output });
|
|
186
|
+
const outBlock = output.content[contentIndex];
|
|
187
|
+
if (outBlock?.type === "text") outBlock.text = block.text;
|
|
188
|
+
stream.push({
|
|
189
|
+
type: "text_delta",
|
|
190
|
+
contentIndex,
|
|
191
|
+
delta: block.text,
|
|
192
|
+
partial: output,
|
|
193
|
+
});
|
|
194
|
+
stream.push({
|
|
195
|
+
type: "text_end",
|
|
196
|
+
contentIndex,
|
|
197
|
+
content: block.text,
|
|
198
|
+
partial: output,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (block.toolUse) {
|
|
203
|
+
const contentIndex = output.content.length;
|
|
204
|
+
const toolCall = {
|
|
205
|
+
type: "toolCall" as const,
|
|
206
|
+
id: block.toolUse.toolUseId ?? randomUUID(),
|
|
207
|
+
name: block.toolUse.name ?? "",
|
|
208
|
+
arguments:
|
|
209
|
+
block.toolUse.input && typeof block.toolUse.input === "object"
|
|
210
|
+
? (block.toolUse.input as Record<string, unknown>)
|
|
211
|
+
: {},
|
|
212
|
+
};
|
|
213
|
+
output.content.push(toolCall);
|
|
214
|
+
stream.push({ type: "toolcall_start", contentIndex, partial: output });
|
|
215
|
+
stream.push({
|
|
216
|
+
type: "toolcall_end",
|
|
217
|
+
contentIndex,
|
|
218
|
+
toolCall,
|
|
219
|
+
partial: output,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/stream-foundation.ts
CHANGED
|
@@ -1,363 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
calculateCost,
|
|
8
|
-
type Context,
|
|
9
|
-
createAssistantMessageEventStream,
|
|
10
|
-
type Model,
|
|
11
|
-
type SimpleStreamOptions,
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
AssistantMessageEventStream,
|
|
4
|
+
Context,
|
|
5
|
+
Model,
|
|
6
|
+
SimpleStreamOptions,
|
|
12
7
|
} from "@earendil-works/pi-ai";
|
|
13
|
-
import type { AzureOpenAiChatCompletionParameters } from "@sap-ai-sdk/foundation-models";
|
|
14
|
-
|
|
15
|
-
import { buildAzureOpenAiParams } from "./foundation-params.ts";
|
|
16
|
-
import {
|
|
17
|
-
debugLog,
|
|
18
|
-
ensureServiceKey,
|
|
19
|
-
type ExtendedDelta,
|
|
20
|
-
formatError,
|
|
21
|
-
latchFinishReason,
|
|
22
|
-
mapUsage,
|
|
23
|
-
pickReasoning,
|
|
24
|
-
resolveResourceGroup,
|
|
25
|
-
type ToolCallSlot,
|
|
26
|
-
} from "./stream.ts";
|
|
27
|
-
import { mapFinishReason } from "./translate.ts";
|
|
28
|
-
import { piContextToAzureOpenAi } from "./translate-foundation.ts";
|
|
29
|
-
|
|
30
|
-
// Loaded dynamically (not at module load) so a missing dependency surfaces as
|
|
31
|
-
// an actionable in-stream error instead of an ERR_MODULE_NOT_FOUND crash at pi
|
|
32
|
-
// startup. Mirrors `importOrchestration` in stream.ts.
|
|
33
|
-
async function importFoundation(): Promise<
|
|
34
|
-
typeof import("@sap-ai-sdk/foundation-models")
|
|
35
|
-
> {
|
|
36
|
-
try {
|
|
37
|
-
return await import("@sap-ai-sdk/foundation-models");
|
|
38
|
-
} catch (err) {
|
|
39
|
-
const code = (err as NodeJS.ErrnoException)?.code;
|
|
40
|
-
const msg = (err as Error)?.message ?? "";
|
|
41
|
-
const isMissing =
|
|
42
|
-
code === "ERR_MODULE_NOT_FOUND" &&
|
|
43
|
-
msg.includes("@sap-ai-sdk/foundation-models");
|
|
44
|
-
if (!isMissing) throw err;
|
|
45
8
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"run `npm install` in the pi-sap-aicore directory (where pi installed " +
|
|
50
|
-
"it, e.g. under ~/.pi/agent/), then restart pi.",
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
9
|
+
import { foundationExecutableForModel } from "./foundation-executables.ts";
|
|
10
|
+
import { streamSapFoundationAzureOpenAi } from "./stream-foundation-azure-openai.ts";
|
|
11
|
+
import { streamSapFoundationBedrock } from "./stream-foundation-bedrock.ts";
|
|
54
12
|
|
|
55
|
-
// Direct (foundation) provider: routes OpenAI models through their own
|
|
56
|
-
// SAP AI Core deployment via @sap-ai-sdk/foundation-models'
|
|
57
|
-
// AzureOpenAiChatClient — bypassing the orchestration service entirely.
|
|
58
|
-
// Unlike streamSapAiCore there is NO streaming-unsupported fallback: the
|
|
59
|
-
// direct Azure OpenAI endpoint streams natively (that's the whole reason this
|
|
60
|
-
// path exists for new models orchestration won't stream). The SDK injects
|
|
61
|
-
// `stream_options: { include_usage: true }` itself, so usage arrives on the
|
|
62
|
-
// final chunk and `response.getTokenUsage()` is populated.
|
|
63
13
|
export function streamSapFoundation(
|
|
64
14
|
model: Model<Api>,
|
|
65
15
|
context: Context,
|
|
66
16
|
options?: SimpleStreamOptions,
|
|
67
17
|
): AssistantMessageEventStream {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
cacheRead: 0,
|
|
80
|
-
cacheWrite: 0,
|
|
81
|
-
totalTokens: 0,
|
|
82
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
83
|
-
},
|
|
84
|
-
stopReason: "stop",
|
|
85
|
-
timestamp: Date.now(),
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
(async () => {
|
|
89
|
-
const requestId = randomUUID();
|
|
90
|
-
try {
|
|
91
|
-
stream.push({ type: "start", partial: output });
|
|
92
|
-
|
|
93
|
-
const serviceKey = ensureServiceKey(options?.apiKey);
|
|
94
|
-
process.env.AICORE_SERVICE_KEY = serviceKey.raw;
|
|
95
|
-
const resourceGroup = resolveResourceGroup(serviceKey);
|
|
96
|
-
|
|
97
|
-
const { messages, tools } = piContextToAzureOpenAi(context);
|
|
98
|
-
const params = buildAzureOpenAiParams(model, options);
|
|
99
|
-
|
|
100
|
-
const { AzureOpenAiChatClient } = await importFoundation();
|
|
101
|
-
|
|
102
|
-
const request: AzureOpenAiChatCompletionParameters = {
|
|
103
|
-
messages,
|
|
104
|
-
...(tools.length > 0 ? { tools } : {}),
|
|
105
|
-
...params,
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
debugLog({
|
|
109
|
-
requestId,
|
|
110
|
-
kind: "request",
|
|
111
|
-
provider: "foundation",
|
|
112
|
-
model: model.id,
|
|
113
|
-
resourceGroup,
|
|
114
|
-
params,
|
|
115
|
-
messageRoles: messages.map((m) => m.role),
|
|
116
|
-
messages,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Name-based deployment resolution: the SDK finds THE foundation
|
|
120
|
-
// deployment serving this model in the resource group. SAP allows
|
|
121
|
-
// only one deployment per (model, version, resource group), so the
|
|
122
|
-
// match is unambiguous — no deployment ID needed.
|
|
123
|
-
const client = new AzureOpenAiChatClient({
|
|
124
|
-
modelName: model.id,
|
|
125
|
-
...(resourceGroup ? { resourceGroup } : {}),
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
const response = await client.stream(request, options?.signal);
|
|
129
|
-
|
|
130
|
-
let textIndex = -1;
|
|
131
|
-
let thinkingIndex = -1;
|
|
132
|
-
let reasoningField: string | undefined;
|
|
133
|
-
let refusalText = "";
|
|
134
|
-
const toolSlots = new Map<number, ToolCallSlot>();
|
|
135
|
-
let finishReason: string | undefined;
|
|
136
|
-
|
|
137
|
-
const closeText = () => {
|
|
138
|
-
if (textIndex < 0) return;
|
|
139
|
-
const block = output.content[textIndex];
|
|
140
|
-
if (block?.type === "text") {
|
|
141
|
-
stream.push({
|
|
142
|
-
type: "text_end",
|
|
143
|
-
contentIndex: textIndex,
|
|
144
|
-
content: block.text,
|
|
145
|
-
partial: output,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
textIndex = -1;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const closeThinking = () => {
|
|
152
|
-
if (thinkingIndex < 0) return;
|
|
153
|
-
const block = output.content[thinkingIndex];
|
|
154
|
-
if (block?.type === "thinking") {
|
|
155
|
-
stream.push({
|
|
156
|
-
type: "thinking_end",
|
|
157
|
-
contentIndex: thinkingIndex,
|
|
158
|
-
content: block.thinking,
|
|
159
|
-
partial: output,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
thinkingIndex = -1;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
for await (const chunk of response.stream) {
|
|
166
|
-
if (options?.signal?.aborted) break;
|
|
167
|
-
|
|
168
|
-
const choice = chunk.findChoiceByIndex(0);
|
|
169
|
-
const rawDelta = (choice?.delta ?? {}) as ExtendedDelta;
|
|
170
|
-
|
|
171
|
-
// Reasoning first — providers emit it before visible text, and
|
|
172
|
-
// pi's UI expects the thinking block to precede the text block.
|
|
173
|
-
// (gpt-5* on the direct route are unlikely to pass structured
|
|
174
|
-
// reasoning through, but we handle it for free if they do.)
|
|
175
|
-
const reasoning = pickReasoning(rawDelta, reasoningField);
|
|
176
|
-
if (reasoning) {
|
|
177
|
-
reasoningField = reasoning.field;
|
|
178
|
-
if (thinkingIndex < 0) {
|
|
179
|
-
closeText();
|
|
180
|
-
output.content.push({ type: "thinking", thinking: "" });
|
|
181
|
-
thinkingIndex = output.content.length - 1;
|
|
182
|
-
stream.push({
|
|
183
|
-
type: "thinking_start",
|
|
184
|
-
contentIndex: thinkingIndex,
|
|
185
|
-
partial: output,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
const block = output.content[thinkingIndex];
|
|
189
|
-
if (block?.type === "thinking") {
|
|
190
|
-
block.thinking += reasoning.text;
|
|
191
|
-
stream.push({
|
|
192
|
-
type: "thinking_delta",
|
|
193
|
-
contentIndex: thinkingIndex,
|
|
194
|
-
delta: reasoning.text,
|
|
195
|
-
partial: output,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const delta = chunk.getDeltaContent();
|
|
201
|
-
if (delta) {
|
|
202
|
-
if (textIndex < 0) {
|
|
203
|
-
closeThinking();
|
|
204
|
-
output.content.push({ type: "text", text: "" });
|
|
205
|
-
textIndex = output.content.length - 1;
|
|
206
|
-
stream.push({
|
|
207
|
-
type: "text_start",
|
|
208
|
-
contentIndex: textIndex,
|
|
209
|
-
partial: output,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
const block = output.content[textIndex];
|
|
213
|
-
if (block?.type === "text") {
|
|
214
|
-
block.text += delta;
|
|
215
|
-
stream.push({
|
|
216
|
-
type: "text_delta",
|
|
217
|
-
contentIndex: textIndex,
|
|
218
|
-
delta,
|
|
219
|
-
partial: output,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
typeof rawDelta.refusal === "string" &&
|
|
226
|
-
rawDelta.refusal.length > 0
|
|
227
|
-
) {
|
|
228
|
-
refusalText += rawDelta.refusal;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const toolDeltas = chunk.getDeltaToolCalls();
|
|
232
|
-
if (toolDeltas && toolDeltas.length > 0) {
|
|
233
|
-
closeText();
|
|
234
|
-
closeThinking();
|
|
235
|
-
|
|
236
|
-
for (const td of toolDeltas) {
|
|
237
|
-
let slot = toolSlots.get(td.index);
|
|
238
|
-
if (!slot) {
|
|
239
|
-
output.content.push({
|
|
240
|
-
type: "toolCall",
|
|
241
|
-
id: td.id ?? "",
|
|
242
|
-
name: td.function?.name ?? "",
|
|
243
|
-
arguments: {},
|
|
244
|
-
});
|
|
245
|
-
slot = {
|
|
246
|
-
contentIndex: output.content.length - 1,
|
|
247
|
-
partialJson: "",
|
|
248
|
-
};
|
|
249
|
-
toolSlots.set(td.index, slot);
|
|
250
|
-
stream.push({
|
|
251
|
-
type: "toolcall_start",
|
|
252
|
-
contentIndex: slot.contentIndex,
|
|
253
|
-
partial: output,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const block = output.content[slot.contentIndex];
|
|
258
|
-
if (block?.type === "toolCall") {
|
|
259
|
-
if (td.id && !block.id) block.id = td.id;
|
|
260
|
-
if (td.function?.name && !block.name)
|
|
261
|
-
block.name = td.function.name;
|
|
262
|
-
|
|
263
|
-
const fragment = td.function?.arguments ?? "";
|
|
264
|
-
if (fragment) {
|
|
265
|
-
slot.partialJson += fragment;
|
|
266
|
-
try {
|
|
267
|
-
block.arguments = JSON.parse(slot.partialJson);
|
|
268
|
-
} catch {
|
|
269
|
-
// Partial JSON — keep accumulating until valid
|
|
270
|
-
}
|
|
271
|
-
stream.push({
|
|
272
|
-
type: "toolcall_delta",
|
|
273
|
-
contentIndex: slot.contentIndex,
|
|
274
|
-
delta: fragment,
|
|
275
|
-
partial: output,
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
finishReason = latchFinishReason(
|
|
283
|
-
finishReason,
|
|
284
|
-
chunk.getFinishReason() ?? undefined,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
closeText();
|
|
289
|
-
closeThinking();
|
|
290
|
-
|
|
291
|
-
for (const slot of toolSlots.values()) {
|
|
292
|
-
const block = output.content[slot.contentIndex];
|
|
293
|
-
if (block?.type === "toolCall") {
|
|
294
|
-
if (slot.partialJson) {
|
|
295
|
-
try {
|
|
296
|
-
block.arguments = JSON.parse(slot.partialJson);
|
|
297
|
-
} catch {
|
|
298
|
-
// Leave arguments as last successfully-parsed value
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
stream.push({
|
|
302
|
-
type: "toolcall_end",
|
|
303
|
-
contentIndex: slot.contentIndex,
|
|
304
|
-
toolCall: {
|
|
305
|
-
type: "toolCall",
|
|
306
|
-
id: block.id,
|
|
307
|
-
name: block.name,
|
|
308
|
-
arguments: block.arguments,
|
|
309
|
-
},
|
|
310
|
-
partial: output,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const usage = response.getTokenUsage();
|
|
316
|
-
if (usage) {
|
|
317
|
-
output.usage = mapUsage(usage);
|
|
318
|
-
calculateCost(model, output.usage);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// A refusal terminates the turn with no real content. Promote it to a
|
|
322
|
-
// visible error so pi doesn't render an empty assistant turn.
|
|
323
|
-
if (refusalText) {
|
|
324
|
-
output.stopReason = "error";
|
|
325
|
-
output.errorMessage = `Model refused: ${refusalText}`;
|
|
326
|
-
stream.push({ type: "error", reason: "error", error: output });
|
|
327
|
-
stream.end();
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
output.stopReason = mapFinishReason(
|
|
332
|
-
toolSlots.size > 0
|
|
333
|
-
? "tool_calls"
|
|
334
|
-
: (finishReason ?? response.getFinishReason() ?? undefined),
|
|
18
|
+
const executable = foundationExecutableForModel(model.id);
|
|
19
|
+
switch (executable) {
|
|
20
|
+
case "azure-openai":
|
|
21
|
+
return streamSapFoundationAzureOpenAi(model, context, options);
|
|
22
|
+
case "aws-bedrock":
|
|
23
|
+
return streamSapFoundationBedrock(model, context, options);
|
|
24
|
+
case "gcp-vertexai":
|
|
25
|
+
throw new Error(
|
|
26
|
+
`SAP AI Core foundation executable '${executable}' is mapped for '${model.id}', ` +
|
|
27
|
+
"but the Vertex AI/Gemini foundation adapter has not been implemented yet. " +
|
|
28
|
+
"Use sap-aicore orchestration for this model until the gcp-vertexai adapter is added.",
|
|
335
29
|
);
|
|
336
|
-
|
|
337
|
-
type: "done",
|
|
338
|
-
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
339
|
-
message: output,
|
|
340
|
-
});
|
|
341
|
-
stream.end();
|
|
342
|
-
} catch (error) {
|
|
343
|
-
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
344
|
-
output.errorMessage = formatError(error);
|
|
345
|
-
debugLog({
|
|
346
|
-
requestId,
|
|
347
|
-
kind: "error",
|
|
348
|
-
provider: "foundation",
|
|
349
|
-
model: model.id,
|
|
350
|
-
stopReason: output.stopReason,
|
|
351
|
-
error: output.errorMessage,
|
|
352
|
-
});
|
|
353
|
-
stream.push({
|
|
354
|
-
type: "error",
|
|
355
|
-
reason: output.stopReason as "error" | "aborted",
|
|
356
|
-
error: output,
|
|
357
|
-
});
|
|
358
|
-
stream.end();
|
|
359
|
-
}
|
|
360
|
-
})();
|
|
361
|
-
|
|
362
|
-
return stream;
|
|
30
|
+
}
|
|
363
31
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
Context,
|
|
4
|
+
Message,
|
|
5
|
+
TextContent,
|
|
6
|
+
ToolResultMessage,
|
|
7
|
+
UserMessage,
|
|
8
|
+
} from "@earendil-works/pi-ai";
|
|
9
|
+
|
|
10
|
+
export type BedrockConverseContentBlock =
|
|
11
|
+
| { text: string }
|
|
12
|
+
| {
|
|
13
|
+
image: {
|
|
14
|
+
format: string;
|
|
15
|
+
source: { bytes: string };
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
toolUse: {
|
|
20
|
+
toolUseId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
input: unknown;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
toolResult: {
|
|
27
|
+
toolUseId: string;
|
|
28
|
+
content: Array<{ text: string }>;
|
|
29
|
+
status?: "success" | "error";
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type BedrockConverseMessage = {
|
|
34
|
+
role: "user" | "assistant";
|
|
35
|
+
content: BedrockConverseContentBlock[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function piContextToBedrockConverse(context: Context): {
|
|
39
|
+
system?: Array<{ text: string }>;
|
|
40
|
+
messages: BedrockConverseMessage[];
|
|
41
|
+
} {
|
|
42
|
+
const messages: BedrockConverseMessage[] = [];
|
|
43
|
+
|
|
44
|
+
for (const msg of context.messages) {
|
|
45
|
+
const translated = piMessageToBedrockConverse(msg);
|
|
46
|
+
if (translated) messages.push(translated);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...(context.systemPrompt ? { system: [{ text: context.systemPrompt }] } : {}),
|
|
51
|
+
messages: coalesceAdjacentMessages(messages),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function piMessageToBedrockConverse(
|
|
56
|
+
msg: Message,
|
|
57
|
+
): BedrockConverseMessage | undefined {
|
|
58
|
+
switch (msg.role) {
|
|
59
|
+
case "user":
|
|
60
|
+
return piUserToBedrockConverse(msg);
|
|
61
|
+
case "assistant":
|
|
62
|
+
return piAssistantToBedrockConverse(msg);
|
|
63
|
+
case "toolResult":
|
|
64
|
+
return piToolResultToBedrockConverse(msg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
|
|
69
|
+
if (typeof msg.content === "string") {
|
|
70
|
+
return { role: "user", content: [{ text: msg.content }] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const content = msg.content.map((part): BedrockConverseContentBlock => {
|
|
74
|
+
if (part.type === "text") return { text: part.text };
|
|
75
|
+
return {
|
|
76
|
+
image: {
|
|
77
|
+
format: imageFormatFromMimeType(part.mimeType),
|
|
78
|
+
source: { bytes: part.data },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { role: "user", content: content.length > 0 ? content : [{ text: " " }] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function piAssistantToBedrockConverse(
|
|
87
|
+
msg: AssistantMessage,
|
|
88
|
+
): BedrockConverseMessage {
|
|
89
|
+
const content: BedrockConverseContentBlock[] = [];
|
|
90
|
+
|
|
91
|
+
for (const block of msg.content) {
|
|
92
|
+
if (block.type === "text" && block.text) {
|
|
93
|
+
content.push({ text: block.text });
|
|
94
|
+
} else if (block.type === "toolCall") {
|
|
95
|
+
content.push({
|
|
96
|
+
toolUse: {
|
|
97
|
+
toolUseId: block.id,
|
|
98
|
+
name: block.name,
|
|
99
|
+
input: block.arguments,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
role: "assistant",
|
|
107
|
+
content: content.length > 0 ? content : [{ text: " " }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function piToolResultToBedrockConverse(
|
|
112
|
+
msg: ToolResultMessage,
|
|
113
|
+
): BedrockConverseMessage {
|
|
114
|
+
const text = toolResultText(msg) || " ";
|
|
115
|
+
return {
|
|
116
|
+
role: "user",
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
toolResult: {
|
|
120
|
+
toolUseId: msg.toolCallId,
|
|
121
|
+
content: [{ text }],
|
|
122
|
+
status: msg.isError ? "error" : "success",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
...toolResultImagesAsUserContent(msg),
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toolResultText(msg: ToolResultMessage): string {
|
|
131
|
+
return msg.content
|
|
132
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
133
|
+
.map((part) => part.text)
|
|
134
|
+
.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toolResultImagesAsUserContent(
|
|
138
|
+
msg: ToolResultMessage,
|
|
139
|
+
): BedrockConverseContentBlock[] {
|
|
140
|
+
return msg.content
|
|
141
|
+
.filter(
|
|
142
|
+
(part): part is { type: "image"; data: string; mimeType: string } =>
|
|
143
|
+
part.type === "image",
|
|
144
|
+
)
|
|
145
|
+
.map((part) => ({
|
|
146
|
+
image: {
|
|
147
|
+
format: imageFormatFromMimeType(part.mimeType),
|
|
148
|
+
source: { bytes: part.data },
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function imageFormatFromMimeType(mimeType: string): string {
|
|
154
|
+
const format = mimeType.split("/")[1]?.toLowerCase();
|
|
155
|
+
if (format === "jpg") return "jpeg";
|
|
156
|
+
if (format === "jpeg" || format === "png" || format === "gif" || format === "webp") {
|
|
157
|
+
return format;
|
|
158
|
+
}
|
|
159
|
+
return "png";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function coalesceAdjacentMessages(
|
|
163
|
+
messages: BedrockConverseMessage[],
|
|
164
|
+
): BedrockConverseMessage[] {
|
|
165
|
+
const result: BedrockConverseMessage[] = [];
|
|
166
|
+
for (const msg of messages) {
|
|
167
|
+
const previous = result[result.length - 1];
|
|
168
|
+
if (previous && previous.role === msg.role) {
|
|
169
|
+
previous.content.push(...msg.content);
|
|
170
|
+
} else {
|
|
171
|
+
result.push({ role: msg.role, content: [...msg.content] });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|