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 +39 -1
- package/README.md +145 -21
- package/index.ts +54 -40
- package/package.json +3 -2
- package/scripts/update-models.mjs +3 -5
- package/src/model-catalog.ts +291 -0
- package/src/models-config.ts +17 -80
- package/src/sap-model-commands.ts +149 -0
- package/src/stream-foundation.ts +3 -1
- package/src/stream.ts +20 -12
- package/src/translate-foundation.ts +136 -40
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
160
|
+
The model list is composed of three sources, merged at startup:
|
|
153
161
|
|
|
154
|
-
1. **`src/models-snapshot.json`** — auto-generated
|
|
155
|
-
[models.dev](https://models.dev)'s SAP AI Core catalog.
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
├──
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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": "^
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
+
}
|
package/src/models-config.ts
CHANGED
|
@@ -1,92 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_FOUNDATION_MODEL_IDS,
|
|
3
|
+
loadModelCatalog,
|
|
4
|
+
type SapModel,
|
|
5
|
+
} from "./model-catalog.ts";
|
|
4
6
|
|
|
5
|
-
type
|
|
7
|
+
export type { SapModel } from "./model-catalog.ts";
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
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
|
+
}
|
package/src/stream-foundation.ts
CHANGED
|
@@ -329,7 +329,9 @@ export function streamSapFoundation(
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
output.stopReason = mapFinishReason(
|
|
332
|
-
|
|
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(
|
|
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(
|
|
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({
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
164
|
+
type AzureToolResultParts = {
|
|
165
|
+
toolMessage: AzureOpenAiChatCompletionRequestToolMessage;
|
|
166
|
+
imageMessages: AzureOpenAiChatCompletionRequestUserMessage[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function piToolResultToAzureOpenAiParts(
|
|
112
170
|
msg: ToolResultMessage,
|
|
113
|
-
):
|
|
114
|
-
const text = msg
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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:
|
|
122
|
-
content:
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 {
|