pi-sap-aicore 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/README.md +142 -20
- 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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-06-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- User-refreshable SAP model catalog cache at
|
|
15
|
+
`~/.pi/agent/pi-sap-aicore/models-cache.json`.
|
|
16
|
+
- Per-machine SAP model overlay at `~/.pi/agent/pi-sap-aicore/models.json`, with
|
|
17
|
+
support for `models`, `overrides`, `exclude`, and `foundation.enabledModelIds`.
|
|
18
|
+
- `/sap-models` command family:
|
|
19
|
+
- `/sap-models update` refreshes public SAP model metadata without editing the
|
|
20
|
+
installed npm package.
|
|
21
|
+
- `/sap-models discover` compares the merged catalog against the SAP tenant's
|
|
22
|
+
`foundation-models` scenario model list.
|
|
23
|
+
- `/sap-models list`, `/sap-models paths`, and `/sap-models help` provide local
|
|
24
|
+
catalog diagnostics.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Model registration now merges packaged snapshot, user cache, and user overlay
|
|
29
|
+
at extension load time; `/sap-models update` re-registers providers in the
|
|
30
|
+
current session after refreshing the cache.
|
|
31
|
+
- Foundation-route enablement is now configurable from the user overlay instead
|
|
32
|
+
of requiring source edits.
|
|
33
|
+
|
|
10
34
|
## [0.1.2] - 2026-06-06
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -51,7 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
51
75
|
`reasoning_effort` for OpenAI).
|
|
52
76
|
- MIT license and npm packaging.
|
|
53
77
|
|
|
54
|
-
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.
|
|
78
|
+
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.2.0...HEAD
|
|
79
|
+
[0.2.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.2...v0.2.0
|
|
55
80
|
[0.1.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.1...v0.1.2
|
|
56
81
|
[0.1.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.0...v0.1.1
|
|
57
82
|
[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
|
|
|
@@ -320,13 +440,15 @@ npmjs.com:
|
|
|
320
440
|
│ └── publish.yml # tag-driven npm publish via OIDC trusted publishing
|
|
321
441
|
├── index.ts # ExtensionAPI factory + registerProvider calls (both providers)
|
|
322
442
|
├── scripts/
|
|
323
|
-
│ ├── update-models.mjs # fetches models.dev, writes models-snapshot.json
|
|
443
|
+
│ ├── update-models.mjs # maintainer script: fetches models.dev, writes models-snapshot.json
|
|
324
444
|
│ ├── list-sap-models.mjs # lists models your tenant actually deploys (diff vs snapshot)
|
|
325
445
|
│ └── diagnose-streaming.mjs # probes orchestration streaming support per model
|
|
326
446
|
└── src/
|
|
327
447
|
├── auth.ts # service-key validation + pi oauth registration
|
|
328
|
-
├──
|
|
448
|
+
├── model-catalog.ts # loads snapshot/cache/overlay and adapts models.dev metadata
|
|
449
|
+
├── models-config.ts # exposes merged MODELS and FOUNDATION_MODELS
|
|
329
450
|
├── models-snapshot.json # auto-generated from models.dev (committed)
|
|
451
|
+
├── sap-model-commands.ts # /sap-models update/discover/list/paths
|
|
330
452
|
├── to-pi-model.ts # SapModel → pi's ProviderModelConfig mapper
|
|
331
453
|
├── stream.ts # orchestration streamSimple adapter + shared helpers (auth, usage, errors)
|
|
332
454
|
├── 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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "SAP AI Core (orchestration + foundation) provider for the pi coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tim Pearson (https://github.com/ttiimmaahh)",
|
|
@@ -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
|
+
}
|