pi-sap-aicore 0.1.2 → 0.2.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,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-06-10
11
+
12
+ ### Fixed
13
+
14
+ - Foundation-provider tool-call history now preserves Azure/OpenAI's required
15
+ ordering by emitting all matching `role: "tool"` replies immediately after an
16
+ assistant `tool_calls` message, then hoisting screenshot/image outputs into
17
+ synthetic user messages afterward.
18
+ - Streaming turns that contain tool calls are now finalized as `toolUse` even
19
+ when SAP reports a trailing `stop`, preventing skipped tool execution and
20
+ follow-up `invalid_request_error` failures about missing tool-call responses.
21
+
22
+ ## [0.2.0] - 2026-06-06
23
+
24
+ ### Added
25
+
26
+ - User-refreshable SAP model catalog cache at
27
+ `~/.pi/agent/pi-sap-aicore/models-cache.json`.
28
+ - Per-machine SAP model overlay at `~/.pi/agent/pi-sap-aicore/models.json`, with
29
+ support for `models`, `overrides`, `exclude`, and `foundation.enabledModelIds`.
30
+ - `/sap-models` command family:
31
+ - `/sap-models update` refreshes public SAP model metadata without editing the
32
+ installed npm package.
33
+ - `/sap-models discover` compares the merged catalog against the SAP tenant's
34
+ `foundation-models` scenario model list.
35
+ - `/sap-models list`, `/sap-models paths`, and `/sap-models help` provide local
36
+ catalog diagnostics.
37
+
38
+ ### Changed
39
+
40
+ - Model registration now merges packaged snapshot, user cache, and user overlay
41
+ at extension load time; `/sap-models update` re-registers providers in the
42
+ current session after refreshing the cache.
43
+ - Foundation-route enablement is now configurable from the user overlay instead
44
+ of requiring source edits.
45
+
10
46
  ## [0.1.2] - 2026-06-06
11
47
 
12
48
  ### Added
@@ -51,7 +87,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
87
  `reasoning_effort` for OpenAI).
52
88
  - MIT license and npm packaging.
53
89
 
54
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.2...HEAD
90
+ [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.1...HEAD
91
+ [0.2.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...v0.2.1
92
+ [0.2.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.2...v0.2.0
55
93
  [0.1.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.1...v0.1.2
56
94
  [0.1.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.0...v0.1.1
57
95
  [0.1.0]: https://github.com/ttiimmaahh/pi-sap-aicore/releases/tag/v0.1.0
package/README.md CHANGED
@@ -141,18 +141,27 @@ that orchestration hasn't enabled streaming for yet (e.g. `gpt-5.5`).
141
141
 
142
142
  **Adding a foundation model:** it needs its own foundation-models deployment in
143
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
145
- `FOUNDATION_MODEL_IDS` in [`src/models-config.ts`](./src/models-config.ts)
146
- (definitions are reused from the shared snapshot). An id with no matching
147
- deployment 404s at call time. Run `node scripts/list-sap-models.mjs` to see what
148
- your tenant actually deploys.
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
+
147
+ ```json
148
+ {
149
+ "foundation": { "enabledModelIds": ["gpt-5.5"] }
150
+ }
151
+ ```
152
+
153
+ Definitions are reused from the shared catalog, so an id only has to be present
154
+ there. An id with no matching deployment 404s at call time. Run
155
+ `/sap-models discover` in pi (or `node scripts/list-sap-models.mjs` from this
156
+ repo) to see what your tenant actually deploys.
149
157
 
150
158
  ## Models
151
159
 
152
- The model list is composed of two sources, merged at startup:
160
+ The model list is composed of three sources, merged at startup:
153
161
 
154
- 1. **`src/models-snapshot.json`** — auto-generated from
155
- [models.dev](https://models.dev)'s SAP AI Core catalog. Refresh with:
162
+ 1. **`src/models-snapshot.json`** — packaged fallback catalog, auto-generated
163
+ from [models.dev](https://models.dev)'s SAP AI Core catalog. Maintainers
164
+ refresh it with:
156
165
  ```bash
157
166
  npm run update-models
158
167
  ```
@@ -160,15 +169,126 @@ The model list is composed of two sources, merged at startup:
160
169
  (currently anthropic claude-4.x, gpt-5*, gemini-2.5*), and writes the
161
170
  snapshot to disk. Commit the result.
162
171
 
163
- 2. **`TENANT_EXTRAS` in [`src/models-config.ts`](./src/models-config.ts)**
164
- hand-maintained list of models that exist in your SAP tenant but
165
- aren't (yet) in the models.dev catalog. Same `SapModel` shape. Extras
166
- win over snapshot on duplicate `id`.
172
+ 2. **`~/.pi/agent/pi-sap-aicore/models-cache.json`** per-machine public
173
+ catalog cache. Users refresh it inside pi with:
174
+ ```text
175
+ /sap-models update
176
+ ```
177
+ This does not edit the installed npm package and is safe across extension
178
+ updates. The command re-registers the SAP providers for the current session;
179
+ restart pi or `/reload` if another session should pick it up.
180
+
181
+ 3. **`~/.pi/agent/pi-sap-aicore/models.json`** — per-machine tenant overlay.
182
+ Use it for models in your tenant that are not in the public catalog yet,
183
+ model overrides, exclusions, and foundation-route enablement. Overlay models
184
+ win over cache/snapshot on duplicate `id`.
185
+
186
+ Example overlay:
187
+
188
+ ```json
189
+ {
190
+ "models": [
191
+ {
192
+ "id": "some-preview-model",
193
+ "name": "Some Preview Model",
194
+ "reasoning": true,
195
+ "tool_call": true,
196
+ "temperature": true,
197
+ "modalities": { "input": ["text"], "output": ["text"] },
198
+ "limit": { "context": 200000, "output": 32000 },
199
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
200
+ "thinkingLevelMap": {
201
+ "minimal": "low",
202
+ "low": "low",
203
+ "medium": "medium",
204
+ "high": "high",
205
+ "xhigh": "high"
206
+ }
207
+ }
208
+ ],
209
+ "overrides": {
210
+ "gemini-2.5-pro": { "reasoning": false }
211
+ },
212
+ "exclude": ["gpt-5.5"],
213
+ "foundation": {
214
+ "enabledModelIds": ["some-preview-model"]
215
+ }
216
+ }
217
+ ```
218
+
219
+ Use `/sap-models paths` to print the exact cache and overlay paths, and
220
+ `/sap-models discover` to compare the loaded catalog against the models your SAP
221
+ tenant reports.
222
+
223
+ ### `/sap-models` commands
224
+
225
+ Run these inside pi after installing/loading the extension:
226
+
227
+ | Command | What it does |
228
+ |---|---|
229
+ | `/sap-models update` | Fetches the latest public SAP AI Core model metadata from models.dev, writes `~/.pi/agent/pi-sap-aicore/models-cache.json`, and re-registers the SAP providers for the current session. |
230
+ | `/sap-models discover` | Uses your configured SAP service key to query the tenant's `foundation-models` scenario, then reports models that are missing from the local catalog and catalog entries absent from the tenant. Honors `AICORE_RESOURCE_GROUP` / service-key `resourceGroup`. |
231
+ | `/sap-models list` | Shows how many orchestration models and foundation-enabled models are currently loaded after snapshot/cache/overlay merging. |
232
+ | `/sap-models paths` | Prints the cache and overlay file paths for this machine. |
233
+ | `/sap-models help` | Shows the command summary in pi. |
234
+
235
+ A typical refresh workflow is:
167
236
 
168
- To add a model that everyone on your team should see, add it to
169
- `TENANT_EXTRAS` and commit. To add a per-machine custom (your own tenant
170
- only), use pi's built-in custom-models mechanism by editing
171
- `~/.pi/agent/models.json` — no extension changes required.
237
+ ```text
238
+ /sap-models update
239
+ /sap-models discover
240
+ /model
241
+ ```
242
+
243
+ If `discover` reports a tenant model that is missing from the catalog, add it to
244
+ `~/.pi/agent/pi-sap-aicore/models.json` under `models`. If it reports a catalog
245
+ model that is absent from your tenant and selection causes SAP 400s, add the id
246
+ to `exclude`.
247
+
248
+ ### Overlay reference
249
+
250
+ `~/.pi/agent/pi-sap-aicore/models.json` supports these top-level fields:
251
+
252
+ | Field | Type | Purpose |
253
+ |---|---|---|
254
+ | `models` | `SapModel[]` | Adds tenant-only/pre-release models or replaces catalog models with the same `id`. |
255
+ | `overrides` | object keyed by model id | Partially overrides an existing model. Nested `limit`, `cost`, `modalities`, and `thinkingLevelMap` fields are merged. Unknown ids are ignored. |
256
+ | `exclude` | `string[]` | Removes model ids after snapshot/cache/overlay merging. Useful for public catalog entries your SAP tenant does not deploy. |
257
+ | `foundation.enabledModelIds` | `string[]` | Also exposes matching model ids through `sap-aicore-foundation/*`. Each id must exist in the merged catalog and have a foundation deployment in the selected resource group. |
258
+
259
+ Minimal tenant-only model:
260
+
261
+ ```json
262
+ {
263
+ "models": [
264
+ {
265
+ "id": "gpt-5.4-nano",
266
+ "name": "GPT-5.4 Nano",
267
+ "reasoning": true,
268
+ "tool_call": true,
269
+ "temperature": true,
270
+ "modalities": { "input": ["text", "image"], "output": ["text"] },
271
+ "limit": { "context": 1050000, "output": 128000 },
272
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
273
+ "thinkingLevelMap": {
274
+ "minimal": "low",
275
+ "low": "low",
276
+ "medium": "medium",
277
+ "high": "high",
278
+ "xhigh": "high"
279
+ }
280
+ }
281
+ ]
282
+ }
283
+ ```
284
+
285
+ Minimal foundation enablement for a model already in the catalog:
286
+
287
+ ```json
288
+ {
289
+ "foundation": { "enabledModelIds": ["gpt-5.5"] }
290
+ }
291
+ ```
172
292
 
173
293
  The `cost` fields are vendor list prices (USD per million tokens) from
174
294
  models.dev. Used **only** for pi's in-UI cost display — your actual SAP
@@ -214,8 +334,8 @@ cycle is a no-op there. The models still reason (reasoning tokens are billed
214
334
  and show in `output`); the depth just isn't tunable. Use the orchestration
215
335
  route (`sap-aicore/*`) when you need to set the effort level.
216
336
 
217
- To override budgets per model, edit `thinkingLevelMap` on the relevant
218
- entry in `TENANT_EXTRAS`, or override per-user via pi's `models.json`.
337
+ To override budgets per model, edit `thinkingLevelMap` on the relevant entry in
338
+ `~/.pi/agent/pi-sap-aicore/models.json`.
219
339
 
220
340
  ## AI Resource Group
221
341
 
@@ -286,7 +406,9 @@ build step — pi loads the `.ts` sources directly via jiti — so a release is
286
406
  git push --follow-tags
287
407
  ```
288
408
  3. The [`Publish`](.github/workflows/publish.yml) workflow fires on the `v*` tag,
289
- asserts the tag matches `package.json`, typechecks, and publishes.
409
+ asserts the tag matches `package.json`, typechecks, publishes to npm, and
410
+ creates/updates the matching GitHub Release from that version's
411
+ `CHANGELOG.md` notes.
290
412
 
291
413
  Every push to `main` and every PR also runs the [`CI`](.github/workflows/ci.yml)
292
414
  typecheck gate.
@@ -320,13 +442,15 @@ npmjs.com:
320
442
  │ └── publish.yml # tag-driven npm publish via OIDC trusted publishing
321
443
  ├── index.ts # ExtensionAPI factory + registerProvider calls (both providers)
322
444
  ├── scripts/
323
- │ ├── update-models.mjs # fetches models.dev, writes models-snapshot.json
445
+ │ ├── update-models.mjs # maintainer script: fetches models.dev, writes models-snapshot.json
324
446
  │ ├── list-sap-models.mjs # lists models your tenant actually deploys (diff vs snapshot)
325
447
  │ └── diagnose-streaming.mjs # probes orchestration streaming support per model
326
448
  └── src/
327
449
  ├── auth.ts # service-key validation + pi oauth registration
328
- ├── models-config.ts # loads snapshot, merges TENANT_EXTRAS, exposes FOUNDATION_MODELS
450
+ ├── model-catalog.ts # loads snapshot/cache/overlay and adapts models.dev metadata
451
+ ├── models-config.ts # exposes merged MODELS and FOUNDATION_MODELS
329
452
  ├── models-snapshot.json # auto-generated from models.dev (committed)
453
+ ├── sap-model-commands.ts # /sap-models update/discover/list/paths
330
454
  ├── to-pi-model.ts # SapModel → pi's ProviderModelConfig mapper
331
455
  ├── stream.ts # orchestration streamSimple adapter + shared helpers (auth, usage, errors)
332
456
  ├── translate.ts # pi Context ↔ orchestration message shape
package/index.ts CHANGED
@@ -2,7 +2,8 @@ import type { Api } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  import { sapAiCoreOAuth } from "./src/auth.ts";
5
- import { FOUNDATION_MODELS, MODELS } from "./src/models-config.ts";
5
+ import { loadModelCatalog } from "./src/model-catalog.ts";
6
+ import { registerSapModelCommands } from "./src/sap-model-commands.ts";
6
7
  import { streamSapAiCore } from "./src/stream.ts";
7
8
  import { streamSapFoundation } from "./src/stream-foundation.ts";
8
9
  import { toPiModel } from "./src/to-pi-model.ts";
@@ -25,44 +26,57 @@ const FOUNDATION_PROVIDER_API = "sap-aicore-foundation" as Api;
25
26
  const PLACEHOLDER_API_KEY = "managed-by-extension-oauth";
26
27
 
27
28
  export default function (pi: ExtensionAPI) {
28
- pi.registerProvider(PROVIDER_NAME, {
29
- name: "SAP AI Core",
30
- baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
31
- apiKey: PLACEHOLDER_API_KEY,
32
- api: PROVIDER_API,
33
- // Credentials flow through pi's `oauth` path — its escape hatch from the
34
- // $-interpolating config-value resolver that corrupts service keys
35
- // containing `$` (SAP keys have one in `clientsecret`). `/login → Use a
36
- // subscription → SAP AI Core` captures the service-key JSON; `getApiKey`
37
- // returns it verbatim as `options.apiKey` to `streamSimple`.
38
- oauth: sapAiCoreOAuth,
39
- // Resource-group selection lives in stream.ts (passed to
40
- // OrchestrationClient's deploymentConfig); SAP's typings reject
41
- // it as a header (`'AI-Resource-Group'?: never`). A `headers`
42
- // entry here would also be a no-op anyway — pi only forwards
43
- // `headers` when it makes the HTTP request itself, but we use
44
- // `streamSimple` and the SAP SDK handles transport.
45
- models: MODELS.map((m) => toPiModel(m, PROVIDER_API)),
46
- // Synchronous, as pi's provider contract requires. The SAP SDK is still
47
- // deferred to first use — `stream.ts` only `import type`s it at module
48
- // load and dynamically imports the OrchestrationClient inside the stream
49
- // producer, surfacing a missing-dependency error through the stream.
50
- streamSimple: streamSapAiCore,
51
- });
29
+ const registerProviders = () => {
30
+ const catalog = loadModelCatalog();
31
+ const models = catalog.models;
32
+ const foundationModels = models.filter((m) =>
33
+ catalog.foundationModelIds.has(m.id),
34
+ );
52
35
 
53
- // Foundation provider — shares the exact same credential. Both providers
54
- // reference the same `sapAiCoreOAuth` (oauth name "SAP AI Core"), so a single
55
- // `/login` serves both and the service key is never entered twice. Models
56
- // appear under `sap-aicore-foundation/…`; streaming runs natively here (no
57
- // orchestration streaming-unsupported fallback). The foundation SDK is
58
- // dynamically imported inside `streamSapFoundation`, same deferral as above.
59
- pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
60
- name: "SAP AI Core (Foundation)",
61
- baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
62
- apiKey: PLACEHOLDER_API_KEY,
63
- api: FOUNDATION_PROVIDER_API,
64
- oauth: sapAiCoreOAuth,
65
- models: FOUNDATION_MODELS.map((m) => toPiModel(m, FOUNDATION_PROVIDER_API)),
66
- streamSimple: streamSapFoundation,
67
- });
36
+ pi.registerProvider(PROVIDER_NAME, {
37
+ name: "SAP AI Core",
38
+ baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
39
+ apiKey: PLACEHOLDER_API_KEY,
40
+ api: PROVIDER_API,
41
+ // Credentials flow through pi's `oauth` path its escape hatch from the
42
+ // $-interpolating config-value resolver that corrupts service keys
43
+ // containing `$` (SAP keys have one in `clientsecret`). `/login → Use a
44
+ // subscription → SAP AI Core` captures the service-key JSON; `getApiKey`
45
+ // returns it verbatim as `options.apiKey` to `streamSimple`.
46
+ oauth: sapAiCoreOAuth,
47
+ // Resource-group selection lives in stream.ts (passed to
48
+ // OrchestrationClient's deploymentConfig); SAP's typings reject
49
+ // it as a header (`'AI-Resource-Group'?: never`). A `headers`
50
+ // entry here would also be a no-op anyway — pi only forwards
51
+ // `headers` when it makes the HTTP request itself, but we use
52
+ // `streamSimple` and the SAP SDK handles transport.
53
+ models: models.map((m) => toPiModel(m, PROVIDER_API)),
54
+ // Synchronous, as pi's provider contract requires. The SAP SDK is still
55
+ // deferred to first use — `stream.ts` only `import type`s it at module
56
+ // load and dynamically imports the OrchestrationClient inside the stream
57
+ // producer, surfacing a missing-dependency error through the stream.
58
+ streamSimple: streamSapAiCore,
59
+ });
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
66
+ // dynamically imported inside `streamSapFoundation`, same deferral as above.
67
+ pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
68
+ name: "SAP AI Core (Foundation)",
69
+ baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
70
+ apiKey: PLACEHOLDER_API_KEY,
71
+ api: FOUNDATION_PROVIDER_API,
72
+ oauth: sapAiCoreOAuth,
73
+ models: foundationModels.map((m) =>
74
+ toPiModel(m, FOUNDATION_PROVIDER_API),
75
+ ),
76
+ streamSimple: streamSapFoundation,
77
+ });
78
+ };
79
+
80
+ registerSapModelCommands(pi, registerProviders);
81
+ registerProviders();
68
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.1.2",
3
+ "version": "0.2.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)",
@@ -34,6 +34,7 @@
34
34
  "prepublishOnly": "tsc --noEmit"
35
35
  },
36
36
  "dependencies": {
37
+ "@sap-ai-sdk/ai-api": "^2.10.0",
37
38
  "@sap-ai-sdk/foundation-models": "^2.10.0",
38
39
  "@sap-ai-sdk/orchestration": "^2.10.0"
39
40
  },
@@ -44,7 +45,7 @@
44
45
  "devDependencies": {
45
46
  "@earendil-works/pi-ai": "^0.78.0",
46
47
  "@earendil-works/pi-coding-agent": "^0.78.0",
47
- "typescript": "^5.6.0"
48
+ "typescript": "^6.0.3"
48
49
  },
49
50
  "engines": {
50
51
  "node": ">=20"
@@ -12,11 +12,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const OUT = join(__dirname, "..", "src", "models-snapshot.json");
13
13
  const SOURCE = "https://models.dev/api.json";
14
14
 
15
- // SAP orchestration uses provider-native reasoning shapes (see
16
- // src/stream.ts:reasoningParams). What's *common* across providers is the
17
- // effort tier pi has 5 above-off levels, the provider tiers are 3
18
- // (low/medium/high). Fold minimal→low and xhigh→high so every pi level
19
- // still does something (rather than dropping minimal/xhigh silently).
15
+ // Keep this script self-contained instead of importing src/model-catalog.ts so
16
+ // `npm run update-models` works on every supported Node >=20 runtime. pi loads
17
+ // extension TypeScript through jiti, but plain Node 20 does not import .ts files.
20
18
  const SAP_EFFORT_BY_LEVEL = {
21
19
  minimal: "low",
22
20
  low: "low",
@@ -0,0 +1,291 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export type ThinkingLevel =
8
+ | "off"
9
+ | "minimal"
10
+ | "low"
11
+ | "medium"
12
+ | "high"
13
+ | "xhigh";
14
+
15
+ export type SapModel = {
16
+ id: string;
17
+ name: string;
18
+ reasoning: boolean;
19
+ tool_call: boolean;
20
+ temperature: boolean;
21
+ modalities: {
22
+ input: ("text" | "image" | "pdf")[];
23
+ output: "text"[];
24
+ };
25
+ limit: {
26
+ context: number;
27
+ output: number;
28
+ };
29
+ cost: {
30
+ input: number;
31
+ output: number;
32
+ cacheRead: number;
33
+ cacheWrite: number;
34
+ };
35
+ thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
36
+ };
37
+
38
+ export type SapModelOverlay = {
39
+ models?: SapModel[];
40
+ overrides?: Record<string, Partial<SapModel>>;
41
+ exclude?: string[];
42
+ foundation?: {
43
+ enabledModelIds?: string[];
44
+ };
45
+ };
46
+
47
+ export type SapModelsSnapshot = {
48
+ source?: string;
49
+ fetchedAt?: string;
50
+ count?: number;
51
+ models?: SapModel[];
52
+ };
53
+
54
+ export const MODELS_DEV_SOURCE = "https://models.dev/api.json";
55
+ export const DEFAULT_FOUNDATION_MODEL_IDS = ["gpt-5.5"] as const;
56
+
57
+ const SAP_EFFORT_BY_LEVEL: SapModel["thinkingLevelMap"] = {
58
+ minimal: "low",
59
+ low: "low",
60
+ medium: "medium",
61
+ high: "high",
62
+ xhigh: "high",
63
+ };
64
+
65
+ function packageDir(): string {
66
+ return dirname(fileURLToPath(import.meta.url));
67
+ }
68
+
69
+ export function sapModelsDir(): string {
70
+ return join(getAgentDir(), "pi-sap-aicore");
71
+ }
72
+
73
+ export function userOverlayPath(): string {
74
+ return join(sapModelsDir(), "models.json");
75
+ }
76
+
77
+ export function userCachePath(): string {
78
+ return join(sapModelsDir(), "models-cache.json");
79
+ }
80
+
81
+ export function packagedSnapshotPath(): string {
82
+ return join(packageDir(), "models-snapshot.json");
83
+ }
84
+
85
+ export function readJsonFile<T>(path: string): T | undefined {
86
+ if (!existsSync(path)) return undefined;
87
+ return JSON.parse(readFileSync(path, "utf8")) as T;
88
+ }
89
+
90
+ function readUserJsonFile<T>(path: string, label: string): T | undefined {
91
+ try {
92
+ return readJsonFile<T>(path);
93
+ } catch (error) {
94
+ console.warn(
95
+ `Ignoring invalid pi-sap-aicore ${label} file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
96
+ );
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ export function writeJsonFile(path: string, value: unknown): void {
102
+ mkdirSync(dirname(path), { recursive: true });
103
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
104
+ }
105
+
106
+ export function loadPackagedSnapshot(): SapModelsSnapshot {
107
+ return (
108
+ readJsonFile<SapModelsSnapshot>(packagedSnapshotPath()) ?? { models: [] }
109
+ );
110
+ }
111
+
112
+ export function loadUserCache(): SapModelsSnapshot | undefined {
113
+ return readUserJsonFile<SapModelsSnapshot>(userCachePath(), "cache");
114
+ }
115
+
116
+ export function loadUserOverlay(): SapModelOverlay | undefined {
117
+ const overlay = readUserJsonFile<SapModelOverlay>(
118
+ userOverlayPath(),
119
+ "overlay",
120
+ );
121
+ if (!overlay) return undefined;
122
+ return {
123
+ ...overlay,
124
+ models: overlay.models ?? [],
125
+ overrides: overlay.overrides ?? {},
126
+ exclude: overlay.exclude ?? [],
127
+ foundation: {
128
+ ...overlay.foundation,
129
+ enabledModelIds: overlay.foundation?.enabledModelIds ?? [],
130
+ },
131
+ };
132
+ }
133
+
134
+ function mergeModel(base: SapModel, override: Partial<SapModel>): SapModel {
135
+ return {
136
+ ...base,
137
+ ...override,
138
+ modalities: override.modalities
139
+ ? {
140
+ input: override.modalities.input ?? base.modalities.input,
141
+ output: override.modalities.output ?? base.modalities.output,
142
+ }
143
+ : base.modalities,
144
+ limit: override.limit ? { ...base.limit, ...override.limit } : base.limit,
145
+ cost: override.cost ? { ...base.cost, ...override.cost } : base.cost,
146
+ thinkingLevelMap: override.thinkingLevelMap
147
+ ? { ...base.thinkingLevelMap, ...override.thinkingLevelMap }
148
+ : base.thinkingLevelMap,
149
+ };
150
+ }
151
+
152
+ export function mergeSapModels(options: {
153
+ packaged: SapModel[];
154
+ cache?: SapModel[];
155
+ overlay?: SapModelOverlay;
156
+ }): SapModel[] {
157
+ const byId = new Map<string, SapModel>();
158
+ for (const model of options.packaged) byId.set(model.id, model);
159
+ for (const model of options.cache ?? []) byId.set(model.id, model);
160
+ for (const model of options.overlay?.models ?? []) byId.set(model.id, model);
161
+
162
+ for (const [id, override] of Object.entries(
163
+ options.overlay?.overrides ?? {},
164
+ )) {
165
+ const existing = byId.get(id);
166
+ if (existing) byId.set(id, mergeModel(existing, override));
167
+ }
168
+
169
+ for (const id of options.overlay?.exclude ?? []) byId.delete(id);
170
+
171
+ return Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id));
172
+ }
173
+
174
+ export function loadModelCatalog(): {
175
+ models: SapModel[];
176
+ foundationModelIds: Set<string>;
177
+ sources: {
178
+ packaged: SapModelsSnapshot;
179
+ cache?: SapModelsSnapshot;
180
+ overlay?: SapModelOverlay;
181
+ };
182
+ } {
183
+ const packaged = loadPackagedSnapshot();
184
+ const cache = loadUserCache();
185
+ const overlay = loadUserOverlay();
186
+ const models = mergeSapModels({
187
+ packaged: packaged.models ?? [],
188
+ cache: cache?.models,
189
+ overlay,
190
+ });
191
+ const foundationModelIds = new Set([
192
+ ...DEFAULT_FOUNDATION_MODEL_IDS,
193
+ ...(overlay?.foundation?.enabledModelIds ?? []),
194
+ ]);
195
+ return { models, foundationModelIds, sources: { packaged, cache, overlay } };
196
+ }
197
+
198
+ function thinkingMapFor(
199
+ reasoning: boolean,
200
+ ): SapModel["thinkingLevelMap"] | undefined {
201
+ return reasoning ? { ...SAP_EFFORT_BY_LEVEL } : undefined;
202
+ }
203
+
204
+ function supportsReasoning(model: {
205
+ id: string;
206
+ reasoning?: boolean;
207
+ }): boolean {
208
+ if (!model.reasoning) return false;
209
+ if (model.id.startsWith("gemini-")) return false;
210
+ return true;
211
+ }
212
+
213
+ export function adaptModelsDevModel(model: {
214
+ id: string;
215
+ name?: string;
216
+ reasoning?: boolean;
217
+ tool_call?: boolean;
218
+ temperature?: boolean;
219
+ modalities?: { input?: string[] };
220
+ limit?: { context?: number; output?: number };
221
+ cost?: {
222
+ input?: number;
223
+ output?: number;
224
+ cache_read?: number;
225
+ cache_write?: number;
226
+ };
227
+ }): SapModel {
228
+ const input = (model.modalities?.input ?? ["text"]).filter(
229
+ (m): m is "text" | "image" | "pdf" =>
230
+ m === "text" || m === "image" || m === "pdf",
231
+ );
232
+ const reasoning = supportsReasoning(model);
233
+ const adapted: SapModel = {
234
+ id: model.id,
235
+ name: model.name ?? model.id,
236
+ reasoning,
237
+ tool_call: !!model.tool_call,
238
+ temperature: model.temperature !== false,
239
+ modalities: {
240
+ input,
241
+ output: ["text"],
242
+ },
243
+ limit: {
244
+ context: model.limit?.context ?? 0,
245
+ output: model.limit?.output ?? 0,
246
+ },
247
+ cost: {
248
+ input: model.cost?.input ?? 0,
249
+ output: model.cost?.output ?? 0,
250
+ cacheRead: model.cost?.cache_read ?? 0,
251
+ cacheWrite: model.cost?.cache_write ?? 0,
252
+ },
253
+ };
254
+ const thinkingMap = thinkingMapFor(reasoning);
255
+ if (thinkingMap) adapted.thinkingLevelMap = thinkingMap;
256
+ return adapted;
257
+ }
258
+
259
+ export function shouldIncludeModelsDevModel(id: string): boolean {
260
+ return (
261
+ id.startsWith("anthropic--claude-4") ||
262
+ id.startsWith("gpt-5") ||
263
+ id.startsWith("gemini-2.5")
264
+ );
265
+ }
266
+
267
+ export async function fetchModelsDevSapSnapshot(): Promise<SapModelsSnapshot> {
268
+ const res = await fetch(MODELS_DEV_SOURCE);
269
+ if (!res.ok) {
270
+ throw new Error(
271
+ `Failed to fetch ${MODELS_DEV_SOURCE}: ${res.status} ${res.statusText}`,
272
+ );
273
+ }
274
+ const all = (await res.json()) as {
275
+ "sap-ai-core"?: {
276
+ models?: Record<string, Parameters<typeof adaptModelsDevModel>[0]>;
277
+ };
278
+ };
279
+ const sapModels = all["sap-ai-core"]?.models ?? {};
280
+ const adapted = Object.values(sapModels)
281
+ .filter((m) => shouldIncludeModelsDevModel(m.id))
282
+ .map(adaptModelsDevModel)
283
+ .sort((a, b) => a.id.localeCompare(b.id));
284
+
285
+ return {
286
+ source: MODELS_DEV_SOURCE,
287
+ fetchedAt: new Date().toISOString(),
288
+ count: adapted.length,
289
+ models: adapted,
290
+ };
291
+ }
@@ -1,92 +1,29 @@
1
- import { readFileSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
- import { dirname, join } from "node:path";
1
+ import {
2
+ DEFAULT_FOUNDATION_MODEL_IDS,
3
+ loadModelCatalog,
4
+ type SapModel,
5
+ } from "./model-catalog.ts";
4
6
 
5
- type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
7
+ export type { SapModel } from "./model-catalog.ts";
6
8
 
7
- export type SapModel = {
8
- id: string;
9
- name: string;
10
- reasoning: boolean;
11
- tool_call: boolean;
12
- temperature: boolean;
13
- modalities: {
14
- input: ("text" | "image" | "pdf")[];
15
- output: ("text")[];
16
- };
17
- limit: {
18
- context: number;
19
- output: number;
20
- };
21
- cost: {
22
- input: number;
23
- output: number;
24
- cacheRead: number;
25
- cacheWrite: number;
26
- };
27
- thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
28
- };
9
+ const catalog = loadModelCatalog();
29
10
 
30
- // Tenant-specific or pre-release models not yet in models.dev's SAP catalog.
31
- // Anything in your SAP tenant that the snapshot doesn't include — add here.
32
- // User-side additions (per-machine, not in source control) should go in
33
- // ~/.pi/agent/models.json using pi's built-in custom-models mechanism.
34
- // SAP orchestration unifies reasoning across providers as
35
- // output_config.effort: "low" | "medium" | "high". See scripts/update-models.mjs
36
- // and stream.ts for the full mapping rationale.
37
- const SAP_EFFORT: SapModel["thinkingLevelMap"] = {
38
- minimal: "low",
39
- low: "low",
40
- medium: "medium",
41
- high: "high",
42
- xhigh: "high",
43
- };
44
-
45
- // Currently empty — models.dev's SAP catalog covers everything in our
46
- // tenant. Add entries here when SAP exposes a tenant-only or pre-release
47
- // model that hasn't landed in the public catalog yet, e.g.:
48
- //
49
- // {
50
- // id: "some-preview-model",
51
- // name: "Some Preview Model",
52
- // reasoning: true,
53
- // tool_call: true,
54
- // temperature: true,
55
- // modalities: { input: ["text"], output: ["text"] },
56
- // limit: { context: 200_000, output: 32_000 },
57
- // cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
58
- // thinkingLevelMap: SAP_EFFORT,
59
- // },
60
- const TENANT_EXTRAS: SapModel[] = [];
61
-
62
- function loadSnapshot(): SapModel[] {
63
- const snapshotPath = join(
64
- dirname(fileURLToPath(import.meta.url)),
65
- "models-snapshot.json",
66
- );
67
- const raw = readFileSync(snapshotPath, "utf8");
68
- const parsed = JSON.parse(raw) as { models?: SapModel[] };
69
- return parsed.models ?? [];
70
- }
71
-
72
- const SNAPSHOT_MODELS = loadSnapshot();
73
-
74
- // Merge: snapshot first, then extras (extras win on duplicate id).
75
- const byId = new Map<string, SapModel>();
76
- for (const m of SNAPSHOT_MODELS) byId.set(m.id, m);
77
- for (const m of TENANT_EXTRAS) byId.set(m.id, m);
78
-
79
- export const MODELS: SapModel[] = Array.from(byId.values()).sort((a, b) =>
80
- a.id.localeCompare(b.id),
81
- );
11
+ export const MODELS: SapModel[] = catalog.models;
82
12
 
83
13
  // Models exposed via the direct *foundation* (Azure OpenAI) provider, which
84
14
  // routes through a per-model SAP AI Core deployment instead of orchestration.
85
15
  // List ONLY ids you've created a foundation-models deployment for — SAP needs
86
16
  // one deployment per (model, version, resource group), and an id with no
87
17
  // deployment 404s at call time. Definitions (cost/limits/modalities) are reused
88
- // from the shared snapshot above, so an id only has to be present there.
89
- const FOUNDATION_MODEL_IDS = new Set(["gpt-5.5"]);
18
+ // from the shared catalog above, so an id only has to be present there.
19
+ //
20
+ // Per-machine additions should go in:
21
+ // ~/.pi/agent/pi-sap-aicore/models.json
22
+ //
23
+ // Example:
24
+ // { "foundation": { "enabledModelIds": ["gpt-5.5"] } }
25
+ export const FOUNDATION_MODEL_IDS = catalog.foundationModelIds;
26
+ export const DEFAULT_FOUNDATION_IDS = DEFAULT_FOUNDATION_MODEL_IDS;
90
27
 
91
28
  export const FOUNDATION_MODELS: SapModel[] = MODELS.filter((m) =>
92
29
  FOUNDATION_MODEL_IDS.has(m.id),
@@ -0,0 +1,149 @@
1
+ import {
2
+ AuthStorage,
3
+ type ExtensionAPI,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { ScenarioApi } from "@sap-ai-sdk/ai-api";
6
+
7
+ import { parseAndValidateServiceKey } from "./auth.ts";
8
+ import {
9
+ fetchModelsDevSapSnapshot,
10
+ loadModelCatalog,
11
+ userCachePath,
12
+ userOverlayPath,
13
+ writeJsonFile,
14
+ } from "./model-catalog.ts";
15
+ import { ensureServiceKey, resolveResourceGroup } from "./stream.ts";
16
+
17
+ function sharedServiceKeyFromAuthStore(): string | undefined {
18
+ try {
19
+ const store = AuthStorage.create();
20
+ for (const provider of store.list()) {
21
+ const cred = store.get(provider);
22
+ if (cred?.type !== "oauth") continue;
23
+ const serviceKey = (cred as { serviceKey?: unknown }).serviceKey;
24
+ if (
25
+ typeof serviceKey === "string" &&
26
+ serviceKey.trimStart().startsWith("{")
27
+ ) {
28
+ return serviceKey;
29
+ }
30
+ }
31
+ } catch {
32
+ // Let callers produce the actionable no-key message.
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ function resolveCommandServiceKey(): ReturnType<
38
+ typeof parseAndValidateServiceKey
39
+ > {
40
+ const raw = process.env.AICORE_SERVICE_KEY ?? sharedServiceKeyFromAuthStore();
41
+ return ensureServiceKey(raw);
42
+ }
43
+
44
+ function formatModelList(ids: string[], max = 30): string {
45
+ if (ids.length === 0) return "none";
46
+ const head = ids.slice(0, max).join(", ");
47
+ const rest = ids.length > max ? ` … +${ids.length - max} more` : "";
48
+ return `${head}${rest}`;
49
+ }
50
+
51
+ async function tenantModelIds(): Promise<Set<string>> {
52
+ const key = resolveCommandServiceKey();
53
+ parseAndValidateServiceKey(key.raw);
54
+ process.env.AICORE_SERVICE_KEY = key.raw;
55
+ const resourceGroup = resolveResourceGroup(key) ?? "default";
56
+ const response = await ScenarioApi.scenarioQueryModels("foundation-models", {
57
+ "AI-Resource-Group": resourceGroup,
58
+ }).execute();
59
+ const resources = response?.resources ?? [];
60
+ return new Set(resources.map((r) => r.model));
61
+ }
62
+
63
+ export function registerSapModelCommands(
64
+ pi: ExtensionAPI,
65
+ onModelsChanged?: () => void,
66
+ ): void {
67
+ pi.registerCommand("sap-models", {
68
+ description:
69
+ "Manage pi-sap-aicore model metadata: update, discover, list, paths",
70
+ getArgumentCompletions: (prefix) => {
71
+ const commands = ["update", "discover", "list", "paths", "help"];
72
+ const items = commands.map((command) => ({
73
+ value: command,
74
+ label: command,
75
+ }));
76
+ const filtered = items.filter((item) =>
77
+ item.value.startsWith(prefix.trim()),
78
+ );
79
+ return filtered.length > 0 ? filtered : items;
80
+ },
81
+ handler: async (args, ctx) => {
82
+ const [subcommand = "help"] = args.trim().split(/\s+/, 1);
83
+ try {
84
+ switch (subcommand) {
85
+ case "update": {
86
+ ctx.ui.setStatus("sap-models", "updating model cache…");
87
+ const snapshot = await fetchModelsDevSapSnapshot();
88
+ writeJsonFile(userCachePath(), snapshot);
89
+ onModelsChanged?.();
90
+ ctx.ui.notify(
91
+ `Updated SAP model cache: ${snapshot.count ?? snapshot.models?.length ?? 0} models. Refreshed sap-aicore providers for this session.`,
92
+ "info",
93
+ );
94
+ ctx.ui.setStatus("sap-models", undefined);
95
+ return;
96
+ }
97
+ case "discover": {
98
+ ctx.ui.setStatus("sap-models", "querying SAP tenant…");
99
+ const tenant = await tenantModelIds();
100
+ const catalog = loadModelCatalog();
101
+ const known = new Set(catalog.models.map((m) => m.id));
102
+ const tenantSorted = [...tenant].sort();
103
+ const missing = tenantSorted.filter((id) => !known.has(id));
104
+ const phantom = catalog.models
105
+ .map((m) => m.id)
106
+ .filter((id) => !tenant.has(id))
107
+ .sort();
108
+ ctx.ui.notify(
109
+ `SAP tenant discovery: ${tenant.size} tenant models. Missing from pi-sap-aicore catalog: ${formatModelList(missing)}. In catalog but absent from tenant: ${formatModelList(phantom)}.`,
110
+ missing.length > 0 || phantom.length > 0 ? "warning" : "info",
111
+ );
112
+ ctx.ui.setStatus("sap-models", undefined);
113
+ return;
114
+ }
115
+ case "list": {
116
+ const catalog = loadModelCatalog();
117
+ ctx.ui.notify(
118
+ `pi-sap-aicore catalog has ${catalog.models.length} orchestration models and ${catalog.models.filter((m) => catalog.foundationModelIds.has(m.id)).length} foundation-enabled models.`,
119
+ "info",
120
+ );
121
+ return;
122
+ }
123
+ case "paths": {
124
+ ctx.ui.notify(
125
+ `SAP model files:\ncache: ${userCachePath()}\noverlay: ${userOverlayPath()}`,
126
+ "info",
127
+ );
128
+ return;
129
+ }
130
+ case "help":
131
+ default:
132
+ ctx.ui.notify(
133
+ "/sap-models update — refresh public SAP model metadata\n" +
134
+ "/sap-models discover — compare catalog against your SAP tenant\n" +
135
+ "/sap-models list — summarize loaded catalog\n" +
136
+ "/sap-models paths — show user cache/overlay paths",
137
+ "info",
138
+ );
139
+ }
140
+ } catch (error) {
141
+ ctx.ui.setStatus("sap-models", undefined);
142
+ ctx.ui.notify(
143
+ `SAP model command failed: ${error instanceof Error ? error.message : String(error)}`,
144
+ "error",
145
+ );
146
+ }
147
+ },
148
+ });
149
+ }
@@ -329,7 +329,9 @@ export function streamSapFoundation(
329
329
  }
330
330
 
331
331
  output.stopReason = mapFinishReason(
332
- finishReason ?? response.getFinishReason() ?? undefined,
332
+ toolSlots.size > 0
333
+ ? "tool_calls"
334
+ : (finishReason ?? response.getFinishReason() ?? undefined),
333
335
  );
334
336
  stream.push({
335
337
  type: "done",
package/src/stream.ts CHANGED
@@ -15,10 +15,7 @@ import {
15
15
  type Usage,
16
16
  } from "@earendil-works/pi-ai";
17
17
  import { AuthStorage } from "@earendil-works/pi-coding-agent";
18
- import type {
19
- ChatModel,
20
- LlmModelParams,
21
- } from "@sap-ai-sdk/orchestration";
18
+ import type { ChatModel, LlmModelParams } from "@sap-ai-sdk/orchestration";
22
19
  import type { TokenUsage } from "@sap-ai-sdk/orchestration/internal.js";
23
20
 
24
21
  import { parseAndValidateServiceKey, type ValidatedKey } from "./auth.ts";
@@ -430,7 +427,11 @@ function buildLlmParams(
430
427
  // to reserve room for thinking). Respect it; otherwise fall back to the
431
428
  // model's documented max output.
432
429
  const effectiveMaxTokens = options?.maxTokens ?? model.maxTokens;
433
- const reasoning = reasoningParams(model, options?.reasoning, effectiveMaxTokens);
430
+ const reasoning = reasoningParams(
431
+ model,
432
+ options?.reasoning,
433
+ effectiveMaxTokens,
434
+ );
434
435
  const params: LlmModelParams = {
435
436
  max_tokens: effectiveMaxTokens,
436
437
  };
@@ -591,9 +592,7 @@ export function ensureServiceKey(apiKey: string | undefined): ValidatedKey {
591
592
  // (foundation) that shares the oauth but has no credential of its own.
592
593
  const fromPi = apiKey?.trimStart().startsWith("{") ? apiKey : undefined;
593
594
  const raw =
594
- fromPi ??
595
- process.env.AICORE_SERVICE_KEY ??
596
- readSharedServiceKeyFromStore();
595
+ fromPi ?? process.env.AICORE_SERVICE_KEY ?? readSharedServiceKeyFromStore();
597
596
  if (!raw) {
598
597
  throw new Error(
599
598
  "No SAP AI Core service key configured. Run `/login` in pi, " +
@@ -691,6 +690,10 @@ export function streamSapAiCore(
691
690
  // Shared finalizer for both paths: map+cost usage once, promote a
692
691
  // refusal to a visible error, otherwise emit the `done` event.
693
692
  const finishTurn = (result: TurnResult) => {
693
+ const hasToolCalls = output.content.some(
694
+ (block) => block.type === "toolCall",
695
+ );
696
+
694
697
  if (result.usage) {
695
698
  output.usage = mapUsage(result.usage);
696
699
  calculateCost(model, output.usage);
@@ -707,7 +710,9 @@ export function streamSapAiCore(
707
710
  return;
708
711
  }
709
712
 
710
- output.stopReason = mapFinishReason(result.finishReason);
713
+ output.stopReason = mapFinishReason(
714
+ hasToolCalls ? "tool_calls" : result.finishReason,
715
+ );
711
716
  stream.push({
712
717
  type: "done",
713
718
  reason: output.stopReason as "stop" | "length" | "toolUse",
@@ -740,7 +745,11 @@ export function streamSapAiCore(
740
745
  if (content) {
741
746
  output.content.push({ type: "text", text: content });
742
747
  const idx = output.content.length - 1;
743
- stream.push({ type: "text_start", contentIndex: idx, partial: output });
748
+ stream.push({
749
+ type: "text_start",
750
+ contentIndex: idx,
751
+ partial: output,
752
+ });
744
753
  stream.push({
745
754
  type: "text_delta",
746
755
  contentIndex: idx,
@@ -808,8 +817,7 @@ export function streamSapAiCore(
808
817
  // Stream by default; on SAP's "Streaming is not supported" 400 —
809
818
  // and only before any chunk has been emitted — remember the model
810
819
  // and fall back to the blocking path so the turn still completes.
811
- let response: Awaited<ReturnType<typeof client.stream>> | undefined =
812
- undefined;
820
+ let response: Awaited<ReturnType<typeof client.stream>> | undefined;
813
821
  if (!STREAMING_UNSUPPORTED.has(model.id)) {
814
822
  try {
815
823
  response = await client.stream({ messages }, options?.signal, {
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  AssistantMessage,
3
3
  Context,
4
- Message,
5
4
  TextContent,
6
5
  Tool,
7
6
  ToolResultMessage,
@@ -33,27 +32,81 @@ export function piContextToAzureOpenAi(context: Context): {
33
32
  messages.push({ role: "system", content: context.systemPrompt });
34
33
  }
35
34
 
36
- for (const msg of context.messages) {
37
- messages.push(...piMessageToAzureOpenAi(msg));
35
+ const pi = context.messages;
36
+ for (let i = 0; i < pi.length; i++) {
37
+ const msg = pi[i];
38
+
39
+ if (msg.role === "assistant") {
40
+ const assistant = piAssistantToAzureOpenAi(msg);
41
+ const toolCalls = assistant.tool_calls ?? [];
42
+ if (toolCalls.length === 0) {
43
+ messages.push(assistant);
44
+ continue;
45
+ }
46
+
47
+ messages.push(assistant);
48
+
49
+ // OpenAI/Azure require every assistant tool_call to be followed
50
+ // immediately by role:"tool" messages for *all* tool_call_ids.
51
+ // Pi stores each tool result as a separate top-level message, and this
52
+ // translator may need to hoist image results into synthetic user messages.
53
+ // If we translate tool results one-by-one, multiple screenshot-producing
54
+ // tool results become: tool, user(image), tool — and Azure rejects the
55
+ // second tool call as unanswered. Batch all contiguous tool results here:
56
+ // assistant(tool_calls), all tool messages, then any hoisted images.
57
+ const expectedIds: string[] = [];
58
+ for (const toolCall of toolCalls) expectedIds.push(toolCall.id);
59
+ const expected = new Set(expectedIds);
60
+ const byId = new Map<string, AzureToolResultParts>();
61
+ const orphaned: AzureOpenAiChatCompletionRequestUserMessage[] = [];
62
+
63
+ let j = i + 1;
64
+ while (j < pi.length) {
65
+ const toolResult = pi[j];
66
+ if (toolResult.role !== "toolResult") break;
67
+ if (
68
+ expected.has(toolResult.toolCallId) &&
69
+ !byId.has(toolResult.toolCallId)
70
+ ) {
71
+ byId.set(
72
+ toolResult.toolCallId,
73
+ piToolResultToAzureOpenAiParts(toolResult),
74
+ );
75
+ } else {
76
+ orphaned.push(...piToolResultToSyntheticUserMessages(toolResult));
77
+ }
78
+ j++;
79
+ }
80
+
81
+ const imageMessages: AzureOpenAiChatCompletionRequestUserMessage[] = [];
82
+ for (const id of expectedIds) {
83
+ const translated = byId.get(id);
84
+ if (translated) {
85
+ messages.push(translated.toolMessage);
86
+ imageMessages.push(...translated.imageMessages);
87
+ } else {
88
+ messages.push(missingToolResultMessage(id));
89
+ }
90
+ }
91
+ messages.push(...imageMessages, ...orphaned);
92
+ i = j - 1;
93
+ continue;
94
+ }
95
+
96
+ if (msg.role === "toolResult") {
97
+ // A standalone tool message is invalid for OpenAI. Keep the information
98
+ // available to the model, but present it as user-visible transcript text.
99
+ messages.push(...piToolResultToSyntheticUserMessages(msg));
100
+ continue;
101
+ }
102
+
103
+ messages.push(piUserToAzureOpenAi(msg));
38
104
  }
39
105
 
40
106
  const tools = (context.tools ?? []).map(piToolToAzureOpenAi);
41
107
  return { messages, tools };
42
108
  }
43
109
 
44
- function piMessageToAzureOpenAi(
45
- msg: Message,
46
- ): AzureOpenAiChatCompletionRequestMessage[] {
47
- switch (msg.role) {
48
- case "user":
49
- return [piUserToAzureOpenAi(msg)];
50
- case "assistant":
51
- return [piAssistantToAzureOpenAi(msg)];
52
- case "toolResult":
53
- return piToolResultToAzureOpenAi(msg);
54
- }
55
- }
56
-
57
110
  function piUserToAzureOpenAi(
58
111
  msg: UserMessage,
59
112
  ): AzureOpenAiChatCompletionRequestUserMessage {
@@ -108,38 +161,81 @@ function piAssistantToAzureOpenAi(
108
161
  return result;
109
162
  }
110
163
 
111
- function piToolResultToAzureOpenAi(
164
+ type AzureToolResultParts = {
165
+ toolMessage: AzureOpenAiChatCompletionRequestToolMessage;
166
+ imageMessages: AzureOpenAiChatCompletionRequestUserMessage[];
167
+ };
168
+
169
+ function piToolResultToAzureOpenAiParts(
112
170
  msg: ToolResultMessage,
113
- ): AzureOpenAiChatCompletionRequestMessage[] {
114
- const text = msg.content
115
- .filter((part): part is TextContent => part.type === "text")
116
- .map((part) => part.text)
117
- .join("\n");
171
+ ): AzureToolResultParts {
172
+ const text = toolResultText(msg);
173
+ const imageMessages = toolResultImages(msg).map((img) => ({
174
+ role: "user" as const,
175
+ content: [
176
+ {
177
+ type: "image_url" as const,
178
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` },
179
+ },
180
+ ],
181
+ }));
118
182
 
119
- const toolMessage: AzureOpenAiChatCompletionRequestToolMessage = {
183
+ return {
184
+ toolMessage: {
185
+ role: "tool",
186
+ tool_call_id: msg.toolCallId,
187
+ content:
188
+ text ||
189
+ (imageMessages.length > 0
190
+ ? "Tool returned image content; image(s) follow in the next user message."
191
+ : " "),
192
+ },
193
+ imageMessages,
194
+ };
195
+ }
196
+
197
+ function missingToolResultMessage(
198
+ toolCallId: string,
199
+ ): AzureOpenAiChatCompletionRequestToolMessage {
200
+ return {
120
201
  role: "tool",
121
- tool_call_id: msg.toolCallId,
122
- content: text,
202
+ tool_call_id: toolCallId,
203
+ content: "[Tool result missing from local transcript.]",
123
204
  };
205
+ }
206
+
207
+ function piToolResultToSyntheticUserMessages(
208
+ msg: ToolResultMessage,
209
+ ): AzureOpenAiChatCompletionRequestUserMessage[] {
210
+ const content = [
211
+ {
212
+ type: "text" as const,
213
+ text: `Tool result for ${msg.toolName} (${msg.toolCallId}):\n${
214
+ toolResultText(msg) || "[no textual output]"
215
+ }`,
216
+ },
217
+ ...toolResultImages(msg).map((img) => ({
218
+ type: "image_url" as const,
219
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` },
220
+ })),
221
+ ];
222
+ return [{ role: "user", content }];
223
+ }
124
224
 
125
- // The tool message schema is text-only, so image blocks produced by pi
126
- // tools (e.g. `read` on an image) are hoisted into a synthetic user message
127
- // right after the tool result so vision-capable models still see the bytes.
128
- const images = msg.content.filter(
225
+ function toolResultText(msg: ToolResultMessage): string {
226
+ return msg.content
227
+ .filter((part): part is TextContent => part.type === "text")
228
+ .map((part) => part.text)
229
+ .join("\n");
230
+ }
231
+
232
+ function toolResultImages(
233
+ msg: ToolResultMessage,
234
+ ): { type: "image"; data: string; mimeType: string }[] {
235
+ return msg.content.filter(
129
236
  (part): part is { type: "image"; data: string; mimeType: string } =>
130
237
  part.type === "image",
131
238
  );
132
- if (images.length === 0) return [toolMessage];
133
-
134
- const imageItems = images.map((img) => ({
135
- type: "image_url" as const,
136
- image_url: { url: `data:${img.mimeType};base64,${img.data}` },
137
- }));
138
- const imageMessage: AzureOpenAiChatCompletionRequestUserMessage = {
139
- role: "user",
140
- content: imageItems,
141
- };
142
- return [toolMessage, imageMessage];
143
239
  }
144
240
 
145
241
  function piToolToAzureOpenAi(tool: Tool): AzureOpenAiChatCompletionTool {