pi-sap-aicore 0.2.1 → 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 CHANGED
@@ -7,6 +7,35 @@ 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
+
30
+ ## [0.2.2] - 2026-06-16
31
+
32
+ ### Fixed
33
+
34
+ - `/login → Use a subscription` no longer shows a duplicate `SAP AI Core`
35
+ entry. The foundation provider now shares the orchestration provider's stored
36
+ service key through the existing auth-store fallback instead of registering a
37
+ second OAuth provider.
38
+
10
39
  ## [0.2.1] - 2026-06-10
11
40
 
12
41
  ### Fixed
@@ -87,7 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
87
116
  `reasoning_effort` for OpenAI).
88
117
  - MIT license and npm packaging.
89
118
 
90
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...HEAD
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
121
+ [0.2.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...v0.2.2
91
122
  [0.2.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...v0.2.1
92
123
  [0.2.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.2...v0.2.0
93
124
  [0.1.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.1...v0.1.2
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,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 (`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`) 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` | `@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` |
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` (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/*` 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.
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 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,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-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
+ ├── translate-foundation.ts # pi Context ↔ Azure OpenAI message shape
470
+ └── translate-foundation-bedrock.ts # pi Context ↔ Bedrock Converse message shape
460
471
  ```
package/index.ts CHANGED
@@ -58,18 +58,19 @@ export default function (pi: ExtensionAPI) {
58
58
  streamSimple: streamSapAiCore,
59
59
  });
60
60
 
61
- // Foundation provider — shares the exact same credential. Both providers
62
- // reference the same `sapAiCoreOAuth` (oauth name "SAP AI Core"), so a single
63
- // `/login` serves both and the service key is never entered twice. Models
64
- // appear under `sap-aicore-foundation/…`; streaming runs natively here (no
65
- // orchestration streaming-unsupported fallback). The foundation SDK is
61
+ // Foundation provider — shares the exact same credential via
62
+ // `ensureServiceKey`'s auth-store fallback instead of registering its own
63
+ // OAuth provider. Registering `oauth: sapAiCoreOAuth` here would make `/login`
64
+ // show a second, confusing "SAP AI Core" subscription entry because pi keys
65
+ // OAuth providers by provider id (`sap-aicore-foundation`), not by OAuth name.
66
+ // Models appear under `sap-aicore-foundation/…`; streaming runs natively here
67
+ // (no orchestration streaming-unsupported fallback). The foundation SDK is
66
68
  // dynamically imported inside `streamSapFoundation`, same deferral as above.
67
69
  pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
68
70
  name: "SAP AI Core (Foundation)",
69
71
  baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
70
72
  apiKey: PLACEHOLDER_API_KEY,
71
73
  api: FOUNDATION_PROVIDER_API,
72
- oauth: sapAiCoreOAuth,
73
74
  models: foundationModels.map((m) =>
74
75
  toPiModel(m, FOUNDATION_PROVIDER_API),
75
76
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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)",
@@ -43,8 +43,8 @@
43
43
  "@earendil-works/pi-coding-agent": "*"
44
44
  },
45
45
  "devDependencies": {
46
- "@earendil-works/pi-ai": "^0.78.0",
47
- "@earendil-works/pi-coding-agent": "^0.78.0",
46
+ "@earendil-works/pi-ai": "^0.79.2",
47
+ "@earendil-works/pi-coding-agent": "^0.79.2",
48
48
  "typescript": "^6.0.3"
49
49
  },
50
50
  "engines": {
@@ -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
+ }