pi-sap-aicore 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-07-01
11
+
12
+ ### Added
13
+
14
+ - Direct foundation route support for Gemini models deployed through SAP AI Core
15
+ `foundation-models` with the `gcp-vertexai` executable. The provider now calls
16
+ SAP AI Core's Vertex-compatible `generateContent` endpoint and replays the
17
+ non-streaming response into pi stream events.
18
+ - Vertex AI/Gemini message translation for text, images, assistant function-call
19
+ history, and function-response history on the direct foundation route.
20
+
21
+ ### Changed
22
+
23
+ - Gemini direct foundation requests set `generationConfig.thinkingConfig.thinkingBudget`
24
+ to `0` by default so small pi output budgets produce visible text instead of
25
+ being consumed entirely by hidden thoughts.
26
+ - Refreshed npm dependency lockfile after `npm update` to pick up patched SAP SDK
27
+ transitive dependencies.
28
+
29
+ ## [0.3.0] - 2026-07-01
30
+
31
+ ### Added
32
+
33
+ - Direct foundation route support for Anthropic/Claude models deployed through SAP AI Core
34
+ `foundation-models` with the `aws-bedrock` executable. The provider now calls SAP
35
+ AI Core's Bedrock-compatible `/converse` endpoint and replays the non-streaming
36
+ response into pi stream events.
37
+ - Foundation executable routing by model family: `gpt-*` uses `azure-openai`,
38
+ `anthropic--*` uses `aws-bedrock`, and `gemini-*` is reserved for a future
39
+ `gcp-vertexai` adapter.
40
+ - Bedrock Converse message translation for text, images, assistant tool-use history,
41
+ and tool-result history on the direct foundation route.
42
+
43
+ ### Changed
44
+
45
+ - Refactored the foundation provider into an executable dispatcher while preserving
46
+ the existing Azure OpenAI streaming path for GPT models.
47
+ - Documentation now describes Azure OpenAI and AWS Bedrock direct foundation routes.
48
+
10
49
  ## [0.2.2] - 2026-06-16
11
50
 
12
51
  ### Fixed
@@ -96,7 +135,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
96
135
  `reasoning_effort` for OpenAI).
97
136
  - MIT license and npm packaging.
98
137
 
99
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.2...HEAD
138
+ [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.1...HEAD
139
+ [0.3.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.0...v0.3.1
140
+ [0.3.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.2...v0.3.0
100
141
  [0.2.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...v0.2.2
101
142
  [0.2.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...v0.2.1
102
143
  [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 endpoints with native streaming). Both register at once and share one
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 OpenAI model you want to route directly
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,26 @@ 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 (`gpt-*`) only |
133
- | Streaming | subject to orchestration's per-model allow-list — new models can 400 `Streaming is not supported` (we fall back to non-streaming) | **native** streams straight from the Azure OpenAI endpoint |
134
- | Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only (SDK pins Azure API `2024-10-21`, which has no `reasoning_effort`) |
134
+ | Models | Claude, GPT-5*, Gemini | GPT/OpenAI (`azure-openai`), Anthropic/Claude (`aws-bedrock`), and Gemini (`gcp-vertexai`) |
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 and Vertex AI currently use non-streaming endpoints and replay responses into pi stream events |
136
+ | Reasoning effort | tunable (`reasoning_effort` / `thinking`) | model **default** only for Azure; Bedrock/Anthropic and Vertex/Gemini thinking controls are not fully wired yet |
135
137
  | Content filter / grounding / templating | yes | no — raw model access |
136
- | SDK | `@sap-ai-sdk/orchestration` | `@sap-ai-sdk/foundation-models` (`AzureOpenAiChatClient`) |
138
+ | SDK / endpoint | `@sap-ai-sdk/orchestration` | `AzureOpenAiChatClient` for `azure-openai`; SAP `/inference/deployments/{id}/converse` for `aws-bedrock`; SAP `/inference/deployments/{id}/models/{model}:generateContent` for `gcp-vertexai` |
137
139
 
138
140
  Both routes appear in the model list simultaneously, so you choose per model. The
139
- foundation route exists mainly to get **native streaming** for new OpenAI models
140
- that orchestration hasn't enabled streaming for yet (e.g. `gpt-5.5`).
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); the SDK resolves it by
144
- model name, so no deployment IDs to wire in. Then add its `id` to the per-machine
145
- extension overlay at `~/.pi/agent/pi-sap-aicore/models.json`:
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`. Then add its `id` to the per-machine extension overlay at
149
+ `~/.pi/agent/pi-sap-aicore/models.json`:
146
150
 
147
151
  ```json
148
152
  {
149
- "foundation": { "enabledModelIds": ["gpt-5.5"] }
153
+ "foundation": { "enabledModelIds": ["gpt-5.5", "anthropic--claude-4.8-opus"] }
150
154
  }
151
155
  ```
152
156
 
@@ -327,12 +331,14 @@ future, our `pickReasoning` probe is wired and ready in `stream.ts`.
327
331
  shape SAP may reject. Wire-up (likely `thinking_config.thinking_budget`)
328
332
  is a future TODO in `src/stream.ts:reasoningParams`.
329
333
 
330
- **Foundation route caveat:** on `sap-aicore-foundation/*` the direct Azure
331
- OpenAI SDK pins API version `2024-10-21`, which has no `reasoning_effort`
332
- field — so gpt-5\* reason at their **default** effort and pi's thinking-level
333
- cycle is a no-op there. The models still reason (reasoning tokens are billed
334
- and show in `output`); the depth just isn't tunable. Use the orchestration
335
- route (`sap-aicore/*`) when you need to set the effort level.
334
+ **Foundation route caveat:** on `sap-aicore-foundation/*`, GPT/OpenAI models use
335
+ the direct Azure OpenAI SDK pinned to API version `2024-10-21`, which has no
336
+ `reasoning_effort` field — so gpt-5\* reason at their **default** effort and pi's
337
+ thinking-level cycle is a no-op there. Anthropic/Claude models use SAP's AWS
338
+ Bedrock `/converse` endpoint; Gemini models use SAP's Vertex AI `generateContent`
339
+ endpoint with `thinkingBudget: 0` by default so small pi output budgets produce
340
+ visible text instead of only hidden thoughts. Use the orchestration route if you
341
+ need 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 foundation
359
- provider applies the same resolved group via
360
- `AzureOpenAiChatClient({ modelName, resourceGroup })`; both a model's foundation
361
- deployment and the orchestration deployment must live in the resolved group for
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 it talks to the Azure OpenAI endpoint directly
390
- (not through orchestration's usage-stripping), `prompt_tokens_details.cached_tokens`
391
- *may* come back populated — `mapUsage` reads it, so `cacheRead` could be non-zero
392
- on `sap-aicore-foundation/*` turns where orchestration always reports 0. Unverified
393
- against SAP's proxy; treat as best-effort.
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,14 @@ 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-params.ts # Azure OpenAI request params (max_completion_tokens, temperature gating)
458
- ├── stream-foundation.ts # foundation streamSimple adapter (AzureOpenAiChatClient, native streaming)
459
- └── translate-foundation.ts # pi Context Azure OpenAI message shape
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
+ ├── stream-foundation-vertexai.ts # Vertex AI generateContent adapter for Gemini foundation deployments
470
+ ├── translate-foundation.ts # pi Context ↔ Azure OpenAI message shape
471
+ ├── translate-foundation-bedrock.ts # pi Context ↔ Bedrock Converse message shape
472
+ └── translate-foundation-vertexai.ts # pi Context ↔ Vertex AI generateContent message shape
460
473
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
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)",
@@ -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
+ }