pi-sap-aicore 0.1.1 → 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 CHANGED
@@ -7,6 +7,43 @@ 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
+
34
+ ## [0.1.2] - 2026-06-06
35
+
36
+ ### Added
37
+
38
+ - Package-catalog preview image (`pi.image`) so the pi.dev gallery card shows a
39
+ `pi --list-models` screenshot.
40
+ - Dependabot config: weekly grouped `npm` updates and `github-actions` updates.
41
+
42
+ ### Changed
43
+
44
+ - CI: bump `actions/checkout` and `actions/setup-node` to v6 (off the deprecated
45
+ Node 20 action runtime).
46
+
10
47
  ## [0.1.1] - 2026-06-06
11
48
 
12
49
  ### Added
@@ -38,6 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
75
  `reasoning_effort` for OpenAI).
39
76
  - MIT license and npm packaging.
40
77
 
41
- [Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.1...HEAD
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
80
+ [0.1.2]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.1...v0.1.2
42
81
  [0.1.1]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.1.0...v0.1.1
43
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
- `FOUNDATION_MODEL_IDS` in [`src/models-config.ts`](./src/models-config.ts)
146
- (definitions are reused from the shared snapshot). An id with no matching
147
- deployment 404s at call time. Run `node scripts/list-sap-models.mjs` to see what
148
- your tenant actually deploys.
144
+ model name, so no deployment IDs to wire in. Then add its `id` to the per-machine
145
+ extension overlay at `~/.pi/agent/pi-sap-aicore/models.json`:
146
+
147
+ ```json
148
+ {
149
+ "foundation": { "enabledModelIds": ["gpt-5.5"] }
150
+ }
151
+ ```
152
+
153
+ Definitions are reused from the shared catalog, so an id only has to be present
154
+ there. An id with no matching deployment 404s at call time. Run
155
+ `/sap-models discover` in pi (or `node scripts/list-sap-models.mjs` from this
156
+ repo) to see what your tenant actually deploys.
149
157
 
150
158
  ## Models
151
159
 
152
- The model list is composed of two sources, merged at startup:
160
+ The model list is composed of three sources, merged at startup:
153
161
 
154
- 1. **`src/models-snapshot.json`** — auto-generated from
155
- [models.dev](https://models.dev)'s SAP AI Core catalog. Refresh with:
162
+ 1. **`src/models-snapshot.json`** — packaged fallback catalog, auto-generated
163
+ from [models.dev](https://models.dev)'s SAP AI Core catalog. Maintainers
164
+ refresh it with:
156
165
  ```bash
157
166
  npm run update-models
158
167
  ```
@@ -160,15 +169,126 @@ The model list is composed of two sources, merged at startup:
160
169
  (currently anthropic claude-4.x, gpt-5*, gemini-2.5*), and writes the
161
170
  snapshot to disk. Commit the result.
162
171
 
163
- 2. **`TENANT_EXTRAS` in [`src/models-config.ts`](./src/models-config.ts)**
164
- hand-maintained list of models that exist in your SAP tenant but
165
- aren't (yet) in the models.dev catalog. Same `SapModel` shape. Extras
166
- win over snapshot on duplicate `id`.
172
+ 2. **`~/.pi/agent/pi-sap-aicore/models-cache.json`** per-machine public
173
+ catalog cache. Users refresh it inside pi with:
174
+ ```text
175
+ /sap-models update
176
+ ```
177
+ This does not edit the installed npm package and is safe across extension
178
+ updates. The command re-registers the SAP providers for the current session;
179
+ restart pi or `/reload` if another session should pick it up.
180
+
181
+ 3. **`~/.pi/agent/pi-sap-aicore/models.json`** — per-machine tenant overlay.
182
+ Use it for models in your tenant that are not in the public catalog yet,
183
+ model overrides, exclusions, and foundation-route enablement. Overlay models
184
+ win over cache/snapshot on duplicate `id`.
185
+
186
+ Example overlay:
187
+
188
+ ```json
189
+ {
190
+ "models": [
191
+ {
192
+ "id": "some-preview-model",
193
+ "name": "Some Preview Model",
194
+ "reasoning": true,
195
+ "tool_call": true,
196
+ "temperature": true,
197
+ "modalities": { "input": ["text"], "output": ["text"] },
198
+ "limit": { "context": 200000, "output": 32000 },
199
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
200
+ "thinkingLevelMap": {
201
+ "minimal": "low",
202
+ "low": "low",
203
+ "medium": "medium",
204
+ "high": "high",
205
+ "xhigh": "high"
206
+ }
207
+ }
208
+ ],
209
+ "overrides": {
210
+ "gemini-2.5-pro": { "reasoning": false }
211
+ },
212
+ "exclude": ["gpt-5.5"],
213
+ "foundation": {
214
+ "enabledModelIds": ["some-preview-model"]
215
+ }
216
+ }
217
+ ```
218
+
219
+ Use `/sap-models paths` to print the exact cache and overlay paths, and
220
+ `/sap-models discover` to compare the loaded catalog against the models your SAP
221
+ tenant reports.
222
+
223
+ ### `/sap-models` commands
224
+
225
+ Run these inside pi after installing/loading the extension:
226
+
227
+ | Command | What it does |
228
+ |---|---|
229
+ | `/sap-models update` | Fetches the latest public SAP AI Core model metadata from models.dev, writes `~/.pi/agent/pi-sap-aicore/models-cache.json`, and re-registers the SAP providers for the current session. |
230
+ | `/sap-models discover` | Uses your configured SAP service key to query the tenant's `foundation-models` scenario, then reports models that are missing from the local catalog and catalog entries absent from the tenant. Honors `AICORE_RESOURCE_GROUP` / service-key `resourceGroup`. |
231
+ | `/sap-models list` | Shows how many orchestration models and foundation-enabled models are currently loaded after snapshot/cache/overlay merging. |
232
+ | `/sap-models paths` | Prints the cache and overlay file paths for this machine. |
233
+ | `/sap-models help` | Shows the command summary in pi. |
234
+
235
+ A typical refresh workflow is:
167
236
 
168
- To add a model that everyone on your team should see, add it to
169
- `TENANT_EXTRAS` and commit. To add a per-machine custom (your own tenant
170
- only), use pi's built-in custom-models mechanism by editing
171
- `~/.pi/agent/models.json` — no extension changes required.
237
+ ```text
238
+ /sap-models update
239
+ /sap-models discover
240
+ /model
241
+ ```
242
+
243
+ If `discover` reports a tenant model that is missing from the catalog, add it to
244
+ `~/.pi/agent/pi-sap-aicore/models.json` under `models`. If it reports a catalog
245
+ model that is absent from your tenant and selection causes SAP 400s, add the id
246
+ to `exclude`.
247
+
248
+ ### Overlay reference
249
+
250
+ `~/.pi/agent/pi-sap-aicore/models.json` supports these top-level fields:
251
+
252
+ | Field | Type | Purpose |
253
+ |---|---|---|
254
+ | `models` | `SapModel[]` | Adds tenant-only/pre-release models or replaces catalog models with the same `id`. |
255
+ | `overrides` | object keyed by model id | Partially overrides an existing model. Nested `limit`, `cost`, `modalities`, and `thinkingLevelMap` fields are merged. Unknown ids are ignored. |
256
+ | `exclude` | `string[]` | Removes model ids after snapshot/cache/overlay merging. Useful for public catalog entries your SAP tenant does not deploy. |
257
+ | `foundation.enabledModelIds` | `string[]` | Also exposes matching model ids through `sap-aicore-foundation/*`. Each id must exist in the merged catalog and have a foundation deployment in the selected resource group. |
258
+
259
+ Minimal tenant-only model:
260
+
261
+ ```json
262
+ {
263
+ "models": [
264
+ {
265
+ "id": "gpt-5.4-nano",
266
+ "name": "GPT-5.4 Nano",
267
+ "reasoning": true,
268
+ "tool_call": true,
269
+ "temperature": true,
270
+ "modalities": { "input": ["text", "image"], "output": ["text"] },
271
+ "limit": { "context": 1050000, "output": 128000 },
272
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
273
+ "thinkingLevelMap": {
274
+ "minimal": "low",
275
+ "low": "low",
276
+ "medium": "medium",
277
+ "high": "high",
278
+ "xhigh": "high"
279
+ }
280
+ }
281
+ ]
282
+ }
283
+ ```
284
+
285
+ Minimal foundation enablement for a model already in the catalog:
286
+
287
+ ```json
288
+ {
289
+ "foundation": { "enabledModelIds": ["gpt-5.5"] }
290
+ }
291
+ ```
172
292
 
173
293
  The `cost` fields are vendor list prices (USD per million tokens) from
174
294
  models.dev. Used **only** for pi's in-UI cost display — your actual SAP
@@ -214,8 +334,8 @@ cycle is a no-op there. The models still reason (reasoning tokens are billed
214
334
  and show in `output`); the depth just isn't tunable. Use the orchestration
215
335
  route (`sap-aicore/*`) when you need to set the effort level.
216
336
 
217
- To override budgets per model, edit `thinkingLevelMap` on the relevant
218
- entry in `TENANT_EXTRAS`, or override per-user via pi's `models.json`.
337
+ To override budgets per model, edit `thinkingLevelMap` on the relevant entry in
338
+ `~/.pi/agent/pi-sap-aicore/models.json`.
219
339
 
220
340
  ## AI Resource Group
221
341
 
@@ -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
- ├── models-config.ts # loads snapshot, merges TENANT_EXTRAS, exposes FOUNDATION_MODELS
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 { FOUNDATION_MODELS, MODELS } from "./src/models-config.ts";
5
+ import { loadModelCatalog } from "./src/model-catalog.ts";
6
+ import { registerSapModelCommands } from "./src/sap-model-commands.ts";
6
7
  import { streamSapAiCore } from "./src/stream.ts";
7
8
  import { streamSapFoundation } from "./src/stream-foundation.ts";
8
9
  import { toPiModel } from "./src/to-pi-model.ts";
@@ -25,44 +26,57 @@ const FOUNDATION_PROVIDER_API = "sap-aicore-foundation" as Api;
25
26
  const PLACEHOLDER_API_KEY = "managed-by-extension-oauth";
26
27
 
27
28
  export default function (pi: ExtensionAPI) {
28
- pi.registerProvider(PROVIDER_NAME, {
29
- name: "SAP AI Core",
30
- baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
31
- apiKey: PLACEHOLDER_API_KEY,
32
- api: PROVIDER_API,
33
- // Credentials flow through pi's `oauth` path — its escape hatch from the
34
- // $-interpolating config-value resolver that corrupts service keys
35
- // containing `$` (SAP keys have one in `clientsecret`). `/login → Use a
36
- // subscription → SAP AI Core` captures the service-key JSON; `getApiKey`
37
- // returns it verbatim as `options.apiKey` to `streamSimple`.
38
- oauth: sapAiCoreOAuth,
39
- // Resource-group selection lives in stream.ts (passed to
40
- // OrchestrationClient's deploymentConfig); SAP's typings reject
41
- // it as a header (`'AI-Resource-Group'?: never`). A `headers`
42
- // entry here would also be a no-op anyway — pi only forwards
43
- // `headers` when it makes the HTTP request itself, but we use
44
- // `streamSimple` and the SAP SDK handles transport.
45
- models: MODELS.map((m) => toPiModel(m, PROVIDER_API)),
46
- // Synchronous, as pi's provider contract requires. The SAP SDK is still
47
- // deferred to first use — `stream.ts` only `import type`s it at module
48
- // load and dynamically imports the OrchestrationClient inside the stream
49
- // producer, surfacing a missing-dependency error through the stream.
50
- streamSimple: streamSapAiCore,
51
- });
29
+ const registerProviders = () => {
30
+ const catalog = loadModelCatalog();
31
+ const models = catalog.models;
32
+ const foundationModels = models.filter((m) =>
33
+ catalog.foundationModelIds.has(m.id),
34
+ );
52
35
 
53
- // Foundation provider — shares the exact same credential. Both providers
54
- // reference the same `sapAiCoreOAuth` (oauth name "SAP AI Core"), so a single
55
- // `/login` serves both and the service key is never entered twice. Models
56
- // appear under `sap-aicore-foundation/…`; streaming runs natively here (no
57
- // orchestration streaming-unsupported fallback). The foundation SDK is
58
- // dynamically imported inside `streamSapFoundation`, same deferral as above.
59
- pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
60
- name: "SAP AI Core (Foundation)",
61
- baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
62
- apiKey: PLACEHOLDER_API_KEY,
63
- api: FOUNDATION_PROVIDER_API,
64
- oauth: sapAiCoreOAuth,
65
- models: FOUNDATION_MODELS.map((m) => toPiModel(m, FOUNDATION_PROVIDER_API)),
66
- streamSimple: streamSapFoundation,
67
- });
36
+ pi.registerProvider(PROVIDER_NAME, {
37
+ name: "SAP AI Core",
38
+ baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
39
+ apiKey: PLACEHOLDER_API_KEY,
40
+ api: PROVIDER_API,
41
+ // Credentials flow through pi's `oauth` path its escape hatch from the
42
+ // $-interpolating config-value resolver that corrupts service keys
43
+ // containing `$` (SAP keys have one in `clientsecret`). `/login → Use a
44
+ // subscription → SAP AI Core` captures the service-key JSON; `getApiKey`
45
+ // returns it verbatim as `options.apiKey` to `streamSimple`.
46
+ oauth: sapAiCoreOAuth,
47
+ // Resource-group selection lives in stream.ts (passed to
48
+ // OrchestrationClient's deploymentConfig); SAP's typings reject
49
+ // it as a header (`'AI-Resource-Group'?: never`). A `headers`
50
+ // entry here would also be a no-op anyway — pi only forwards
51
+ // `headers` when it makes the HTTP request itself, but we use
52
+ // `streamSimple` and the SAP SDK handles transport.
53
+ models: models.map((m) => toPiModel(m, PROVIDER_API)),
54
+ // Synchronous, as pi's provider contract requires. The SAP SDK is still
55
+ // deferred to first use — `stream.ts` only `import type`s it at module
56
+ // load and dynamically imports the OrchestrationClient inside the stream
57
+ // producer, surfacing a missing-dependency error through the stream.
58
+ streamSimple: streamSapAiCore,
59
+ });
60
+
61
+ // Foundation provider — shares the exact same credential. Both providers
62
+ // reference the same `sapAiCoreOAuth` (oauth name "SAP AI Core"), so a single
63
+ // `/login` serves both and the service key is never entered twice. Models
64
+ // appear under `sap-aicore-foundation/…`; streaming runs natively here (no
65
+ // orchestration streaming-unsupported fallback). The foundation SDK is
66
+ // dynamically imported inside `streamSapFoundation`, same deferral as above.
67
+ pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
68
+ name: "SAP AI Core (Foundation)",
69
+ baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
70
+ apiKey: PLACEHOLDER_API_KEY,
71
+ api: FOUNDATION_PROVIDER_API,
72
+ oauth: sapAiCoreOAuth,
73
+ models: foundationModels.map((m) =>
74
+ toPiModel(m, FOUNDATION_PROVIDER_API),
75
+ ),
76
+ streamSimple: streamSapFoundation,
77
+ });
78
+ };
79
+
80
+ registerSapModelCommands(pi, registerProviders);
81
+ registerProviders();
68
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.1.1",
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)",
@@ -26,13 +26,15 @@
26
26
  "pi": {
27
27
  "extensions": [
28
28
  "./index.ts"
29
- ]
29
+ ],
30
+ "image": "https://raw.githubusercontent.com/ttiimmaahh/pi-sap-aicore/main/docs/sap-model-list.jpg"
30
31
  },
31
32
  "scripts": {
32
33
  "update-models": "node scripts/update-models.mjs",
33
34
  "prepublishOnly": "tsc --noEmit"
34
35
  },
35
36
  "dependencies": {
37
+ "@sap-ai-sdk/ai-api": "^2.10.0",
36
38
  "@sap-ai-sdk/foundation-models": "^2.10.0",
37
39
  "@sap-ai-sdk/orchestration": "^2.10.0"
38
40
  },
@@ -43,7 +45,7 @@
43
45
  "devDependencies": {
44
46
  "@earendil-works/pi-ai": "^0.78.0",
45
47
  "@earendil-works/pi-coding-agent": "^0.78.0",
46
- "typescript": "^5.6.0"
48
+ "typescript": "^6.0.3"
47
49
  },
48
50
  "engines": {
49
51
  "node": ">=20"
@@ -12,11 +12,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const OUT = join(__dirname, "..", "src", "models-snapshot.json");
13
13
  const SOURCE = "https://models.dev/api.json";
14
14
 
15
- // SAP orchestration uses provider-native reasoning shapes (see
16
- // src/stream.ts:reasoningParams). What's *common* across providers is the
17
- // effort tier pi has 5 above-off levels, the provider tiers are 3
18
- // (low/medium/high). Fold minimal→low and xhigh→high so every pi level
19
- // still does something (rather than dropping minimal/xhigh silently).
15
+ // Keep this script self-contained instead of importing src/model-catalog.ts so
16
+ // `npm run update-models` works on every supported Node >=20 runtime. pi loads
17
+ // extension TypeScript through jiti, but plain Node 20 does not import .ts files.
20
18
  const SAP_EFFORT_BY_LEVEL = {
21
19
  minimal: "low",
22
20
  low: "low",
@@ -0,0 +1,291 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export type ThinkingLevel =
8
+ | "off"
9
+ | "minimal"
10
+ | "low"
11
+ | "medium"
12
+ | "high"
13
+ | "xhigh";
14
+
15
+ export type SapModel = {
16
+ id: string;
17
+ name: string;
18
+ reasoning: boolean;
19
+ tool_call: boolean;
20
+ temperature: boolean;
21
+ modalities: {
22
+ input: ("text" | "image" | "pdf")[];
23
+ output: "text"[];
24
+ };
25
+ limit: {
26
+ context: number;
27
+ output: number;
28
+ };
29
+ cost: {
30
+ input: number;
31
+ output: number;
32
+ cacheRead: number;
33
+ cacheWrite: number;
34
+ };
35
+ thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
36
+ };
37
+
38
+ export type SapModelOverlay = {
39
+ models?: SapModel[];
40
+ overrides?: Record<string, Partial<SapModel>>;
41
+ exclude?: string[];
42
+ foundation?: {
43
+ enabledModelIds?: string[];
44
+ };
45
+ };
46
+
47
+ export type SapModelsSnapshot = {
48
+ source?: string;
49
+ fetchedAt?: string;
50
+ count?: number;
51
+ models?: SapModel[];
52
+ };
53
+
54
+ export const MODELS_DEV_SOURCE = "https://models.dev/api.json";
55
+ export const DEFAULT_FOUNDATION_MODEL_IDS = ["gpt-5.5"] as const;
56
+
57
+ const SAP_EFFORT_BY_LEVEL: SapModel["thinkingLevelMap"] = {
58
+ minimal: "low",
59
+ low: "low",
60
+ medium: "medium",
61
+ high: "high",
62
+ xhigh: "high",
63
+ };
64
+
65
+ function packageDir(): string {
66
+ return dirname(fileURLToPath(import.meta.url));
67
+ }
68
+
69
+ export function sapModelsDir(): string {
70
+ return join(getAgentDir(), "pi-sap-aicore");
71
+ }
72
+
73
+ export function userOverlayPath(): string {
74
+ return join(sapModelsDir(), "models.json");
75
+ }
76
+
77
+ export function userCachePath(): string {
78
+ return join(sapModelsDir(), "models-cache.json");
79
+ }
80
+
81
+ export function packagedSnapshotPath(): string {
82
+ return join(packageDir(), "models-snapshot.json");
83
+ }
84
+
85
+ export function readJsonFile<T>(path: string): T | undefined {
86
+ if (!existsSync(path)) return undefined;
87
+ return JSON.parse(readFileSync(path, "utf8")) as T;
88
+ }
89
+
90
+ function readUserJsonFile<T>(path: string, label: string): T | undefined {
91
+ try {
92
+ return readJsonFile<T>(path);
93
+ } catch (error) {
94
+ console.warn(
95
+ `Ignoring invalid pi-sap-aicore ${label} file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
96
+ );
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ export function writeJsonFile(path: string, value: unknown): void {
102
+ mkdirSync(dirname(path), { recursive: true });
103
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
104
+ }
105
+
106
+ export function loadPackagedSnapshot(): SapModelsSnapshot {
107
+ return (
108
+ readJsonFile<SapModelsSnapshot>(packagedSnapshotPath()) ?? { models: [] }
109
+ );
110
+ }
111
+
112
+ export function loadUserCache(): SapModelsSnapshot | undefined {
113
+ return readUserJsonFile<SapModelsSnapshot>(userCachePath(), "cache");
114
+ }
115
+
116
+ export function loadUserOverlay(): SapModelOverlay | undefined {
117
+ const overlay = readUserJsonFile<SapModelOverlay>(
118
+ userOverlayPath(),
119
+ "overlay",
120
+ );
121
+ if (!overlay) return undefined;
122
+ return {
123
+ ...overlay,
124
+ models: overlay.models ?? [],
125
+ overrides: overlay.overrides ?? {},
126
+ exclude: overlay.exclude ?? [],
127
+ foundation: {
128
+ ...overlay.foundation,
129
+ enabledModelIds: overlay.foundation?.enabledModelIds ?? [],
130
+ },
131
+ };
132
+ }
133
+
134
+ function mergeModel(base: SapModel, override: Partial<SapModel>): SapModel {
135
+ return {
136
+ ...base,
137
+ ...override,
138
+ modalities: override.modalities
139
+ ? {
140
+ input: override.modalities.input ?? base.modalities.input,
141
+ output: override.modalities.output ?? base.modalities.output,
142
+ }
143
+ : base.modalities,
144
+ limit: override.limit ? { ...base.limit, ...override.limit } : base.limit,
145
+ cost: override.cost ? { ...base.cost, ...override.cost } : base.cost,
146
+ thinkingLevelMap: override.thinkingLevelMap
147
+ ? { ...base.thinkingLevelMap, ...override.thinkingLevelMap }
148
+ : base.thinkingLevelMap,
149
+ };
150
+ }
151
+
152
+ export function mergeSapModels(options: {
153
+ packaged: SapModel[];
154
+ cache?: SapModel[];
155
+ overlay?: SapModelOverlay;
156
+ }): SapModel[] {
157
+ const byId = new Map<string, SapModel>();
158
+ for (const model of options.packaged) byId.set(model.id, model);
159
+ for (const model of options.cache ?? []) byId.set(model.id, model);
160
+ for (const model of options.overlay?.models ?? []) byId.set(model.id, model);
161
+
162
+ for (const [id, override] of Object.entries(
163
+ options.overlay?.overrides ?? {},
164
+ )) {
165
+ const existing = byId.get(id);
166
+ if (existing) byId.set(id, mergeModel(existing, override));
167
+ }
168
+
169
+ for (const id of options.overlay?.exclude ?? []) byId.delete(id);
170
+
171
+ return Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id));
172
+ }
173
+
174
+ export function loadModelCatalog(): {
175
+ models: SapModel[];
176
+ foundationModelIds: Set<string>;
177
+ sources: {
178
+ packaged: SapModelsSnapshot;
179
+ cache?: SapModelsSnapshot;
180
+ overlay?: SapModelOverlay;
181
+ };
182
+ } {
183
+ const packaged = loadPackagedSnapshot();
184
+ const cache = loadUserCache();
185
+ const overlay = loadUserOverlay();
186
+ const models = mergeSapModels({
187
+ packaged: packaged.models ?? [],
188
+ cache: cache?.models,
189
+ overlay,
190
+ });
191
+ const foundationModelIds = new Set([
192
+ ...DEFAULT_FOUNDATION_MODEL_IDS,
193
+ ...(overlay?.foundation?.enabledModelIds ?? []),
194
+ ]);
195
+ return { models, foundationModelIds, sources: { packaged, cache, overlay } };
196
+ }
197
+
198
+ function thinkingMapFor(
199
+ reasoning: boolean,
200
+ ): SapModel["thinkingLevelMap"] | undefined {
201
+ return reasoning ? { ...SAP_EFFORT_BY_LEVEL } : undefined;
202
+ }
203
+
204
+ function supportsReasoning(model: {
205
+ id: string;
206
+ reasoning?: boolean;
207
+ }): boolean {
208
+ if (!model.reasoning) return false;
209
+ if (model.id.startsWith("gemini-")) return false;
210
+ return true;
211
+ }
212
+
213
+ export function adaptModelsDevModel(model: {
214
+ id: string;
215
+ name?: string;
216
+ reasoning?: boolean;
217
+ tool_call?: boolean;
218
+ temperature?: boolean;
219
+ modalities?: { input?: string[] };
220
+ limit?: { context?: number; output?: number };
221
+ cost?: {
222
+ input?: number;
223
+ output?: number;
224
+ cache_read?: number;
225
+ cache_write?: number;
226
+ };
227
+ }): SapModel {
228
+ const input = (model.modalities?.input ?? ["text"]).filter(
229
+ (m): m is "text" | "image" | "pdf" =>
230
+ m === "text" || m === "image" || m === "pdf",
231
+ );
232
+ const reasoning = supportsReasoning(model);
233
+ const adapted: SapModel = {
234
+ id: model.id,
235
+ name: model.name ?? model.id,
236
+ reasoning,
237
+ tool_call: !!model.tool_call,
238
+ temperature: model.temperature !== false,
239
+ modalities: {
240
+ input,
241
+ output: ["text"],
242
+ },
243
+ limit: {
244
+ context: model.limit?.context ?? 0,
245
+ output: model.limit?.output ?? 0,
246
+ },
247
+ cost: {
248
+ input: model.cost?.input ?? 0,
249
+ output: model.cost?.output ?? 0,
250
+ cacheRead: model.cost?.cache_read ?? 0,
251
+ cacheWrite: model.cost?.cache_write ?? 0,
252
+ },
253
+ };
254
+ const thinkingMap = thinkingMapFor(reasoning);
255
+ if (thinkingMap) adapted.thinkingLevelMap = thinkingMap;
256
+ return adapted;
257
+ }
258
+
259
+ export function shouldIncludeModelsDevModel(id: string): boolean {
260
+ return (
261
+ id.startsWith("anthropic--claude-4") ||
262
+ id.startsWith("gpt-5") ||
263
+ id.startsWith("gemini-2.5")
264
+ );
265
+ }
266
+
267
+ export async function fetchModelsDevSapSnapshot(): Promise<SapModelsSnapshot> {
268
+ const res = await fetch(MODELS_DEV_SOURCE);
269
+ if (!res.ok) {
270
+ throw new Error(
271
+ `Failed to fetch ${MODELS_DEV_SOURCE}: ${res.status} ${res.statusText}`,
272
+ );
273
+ }
274
+ const all = (await res.json()) as {
275
+ "sap-ai-core"?: {
276
+ models?: Record<string, Parameters<typeof adaptModelsDevModel>[0]>;
277
+ };
278
+ };
279
+ const sapModels = all["sap-ai-core"]?.models ?? {};
280
+ const adapted = Object.values(sapModels)
281
+ .filter((m) => shouldIncludeModelsDevModel(m.id))
282
+ .map(adaptModelsDevModel)
283
+ .sort((a, b) => a.id.localeCompare(b.id));
284
+
285
+ return {
286
+ source: MODELS_DEV_SOURCE,
287
+ fetchedAt: new Date().toISOString(),
288
+ count: adapted.length,
289
+ models: adapted,
290
+ };
291
+ }
@@ -1,92 +1,29 @@
1
- import { readFileSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
- import { dirname, join } from "node:path";
1
+ import {
2
+ DEFAULT_FOUNDATION_MODEL_IDS,
3
+ loadModelCatalog,
4
+ type SapModel,
5
+ } from "./model-catalog.ts";
4
6
 
5
- type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
7
+ export type { SapModel } from "./model-catalog.ts";
6
8
 
7
- export type SapModel = {
8
- id: string;
9
- name: string;
10
- reasoning: boolean;
11
- tool_call: boolean;
12
- temperature: boolean;
13
- modalities: {
14
- input: ("text" | "image" | "pdf")[];
15
- output: ("text")[];
16
- };
17
- limit: {
18
- context: number;
19
- output: number;
20
- };
21
- cost: {
22
- input: number;
23
- output: number;
24
- cacheRead: number;
25
- cacheWrite: number;
26
- };
27
- thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
28
- };
9
+ const catalog = loadModelCatalog();
29
10
 
30
- // Tenant-specific or pre-release models not yet in models.dev's SAP catalog.
31
- // Anything in your SAP tenant that the snapshot doesn't include — add here.
32
- // User-side additions (per-machine, not in source control) should go in
33
- // ~/.pi/agent/models.json using pi's built-in custom-models mechanism.
34
- // SAP orchestration unifies reasoning across providers as
35
- // output_config.effort: "low" | "medium" | "high". See scripts/update-models.mjs
36
- // and stream.ts for the full mapping rationale.
37
- const SAP_EFFORT: SapModel["thinkingLevelMap"] = {
38
- minimal: "low",
39
- low: "low",
40
- medium: "medium",
41
- high: "high",
42
- xhigh: "high",
43
- };
44
-
45
- // Currently empty — models.dev's SAP catalog covers everything in our
46
- // tenant. Add entries here when SAP exposes a tenant-only or pre-release
47
- // model that hasn't landed in the public catalog yet, e.g.:
48
- //
49
- // {
50
- // id: "some-preview-model",
51
- // name: "Some Preview Model",
52
- // reasoning: true,
53
- // tool_call: true,
54
- // temperature: true,
55
- // modalities: { input: ["text"], output: ["text"] },
56
- // limit: { context: 200_000, output: 32_000 },
57
- // cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
58
- // thinkingLevelMap: SAP_EFFORT,
59
- // },
60
- const TENANT_EXTRAS: SapModel[] = [];
61
-
62
- function loadSnapshot(): SapModel[] {
63
- const snapshotPath = join(
64
- dirname(fileURLToPath(import.meta.url)),
65
- "models-snapshot.json",
66
- );
67
- const raw = readFileSync(snapshotPath, "utf8");
68
- const parsed = JSON.parse(raw) as { models?: SapModel[] };
69
- return parsed.models ?? [];
70
- }
71
-
72
- const SNAPSHOT_MODELS = loadSnapshot();
73
-
74
- // Merge: snapshot first, then extras (extras win on duplicate id).
75
- const byId = new Map<string, SapModel>();
76
- for (const m of SNAPSHOT_MODELS) byId.set(m.id, m);
77
- for (const m of TENANT_EXTRAS) byId.set(m.id, m);
78
-
79
- export const MODELS: SapModel[] = Array.from(byId.values()).sort((a, b) =>
80
- a.id.localeCompare(b.id),
81
- );
11
+ export const MODELS: SapModel[] = catalog.models;
82
12
 
83
13
  // Models exposed via the direct *foundation* (Azure OpenAI) provider, which
84
14
  // routes through a per-model SAP AI Core deployment instead of orchestration.
85
15
  // List ONLY ids you've created a foundation-models deployment for — SAP needs
86
16
  // one deployment per (model, version, resource group), and an id with no
87
17
  // deployment 404s at call time. Definitions (cost/limits/modalities) are reused
88
- // from the shared snapshot above, so an id only has to be present there.
89
- const FOUNDATION_MODEL_IDS = new Set(["gpt-5.5"]);
18
+ // from the shared catalog above, so an id only has to be present there.
19
+ //
20
+ // Per-machine additions should go in:
21
+ // ~/.pi/agent/pi-sap-aicore/models.json
22
+ //
23
+ // Example:
24
+ // { "foundation": { "enabledModelIds": ["gpt-5.5"] } }
25
+ export const FOUNDATION_MODEL_IDS = catalog.foundationModelIds;
26
+ export const DEFAULT_FOUNDATION_IDS = DEFAULT_FOUNDATION_MODEL_IDS;
90
27
 
91
28
  export const FOUNDATION_MODELS: SapModel[] = MODELS.filter((m) =>
92
29
  FOUNDATION_MODEL_IDS.has(m.id),
@@ -0,0 +1,149 @@
1
+ import {
2
+ AuthStorage,
3
+ type ExtensionAPI,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { ScenarioApi } from "@sap-ai-sdk/ai-api";
6
+
7
+ import { parseAndValidateServiceKey } from "./auth.ts";
8
+ import {
9
+ fetchModelsDevSapSnapshot,
10
+ loadModelCatalog,
11
+ userCachePath,
12
+ userOverlayPath,
13
+ writeJsonFile,
14
+ } from "./model-catalog.ts";
15
+ import { ensureServiceKey, resolveResourceGroup } from "./stream.ts";
16
+
17
+ function sharedServiceKeyFromAuthStore(): string | undefined {
18
+ try {
19
+ const store = AuthStorage.create();
20
+ for (const provider of store.list()) {
21
+ const cred = store.get(provider);
22
+ if (cred?.type !== "oauth") continue;
23
+ const serviceKey = (cred as { serviceKey?: unknown }).serviceKey;
24
+ if (
25
+ typeof serviceKey === "string" &&
26
+ serviceKey.trimStart().startsWith("{")
27
+ ) {
28
+ return serviceKey;
29
+ }
30
+ }
31
+ } catch {
32
+ // Let callers produce the actionable no-key message.
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ function resolveCommandServiceKey(): ReturnType<
38
+ typeof parseAndValidateServiceKey
39
+ > {
40
+ const raw = process.env.AICORE_SERVICE_KEY ?? sharedServiceKeyFromAuthStore();
41
+ return ensureServiceKey(raw);
42
+ }
43
+
44
+ function formatModelList(ids: string[], max = 30): string {
45
+ if (ids.length === 0) return "none";
46
+ const head = ids.slice(0, max).join(", ");
47
+ const rest = ids.length > max ? ` … +${ids.length - max} more` : "";
48
+ return `${head}${rest}`;
49
+ }
50
+
51
+ async function tenantModelIds(): Promise<Set<string>> {
52
+ const key = resolveCommandServiceKey();
53
+ parseAndValidateServiceKey(key.raw);
54
+ process.env.AICORE_SERVICE_KEY = key.raw;
55
+ const resourceGroup = resolveResourceGroup(key) ?? "default";
56
+ const response = await ScenarioApi.scenarioQueryModels("foundation-models", {
57
+ "AI-Resource-Group": resourceGroup,
58
+ }).execute();
59
+ const resources = response?.resources ?? [];
60
+ return new Set(resources.map((r) => r.model));
61
+ }
62
+
63
+ export function registerSapModelCommands(
64
+ pi: ExtensionAPI,
65
+ onModelsChanged?: () => void,
66
+ ): void {
67
+ pi.registerCommand("sap-models", {
68
+ description:
69
+ "Manage pi-sap-aicore model metadata: update, discover, list, paths",
70
+ getArgumentCompletions: (prefix) => {
71
+ const commands = ["update", "discover", "list", "paths", "help"];
72
+ const items = commands.map((command) => ({
73
+ value: command,
74
+ label: command,
75
+ }));
76
+ const filtered = items.filter((item) =>
77
+ item.value.startsWith(prefix.trim()),
78
+ );
79
+ return filtered.length > 0 ? filtered : items;
80
+ },
81
+ handler: async (args, ctx) => {
82
+ const [subcommand = "help"] = args.trim().split(/\s+/, 1);
83
+ try {
84
+ switch (subcommand) {
85
+ case "update": {
86
+ ctx.ui.setStatus("sap-models", "updating model cache…");
87
+ const snapshot = await fetchModelsDevSapSnapshot();
88
+ writeJsonFile(userCachePath(), snapshot);
89
+ onModelsChanged?.();
90
+ ctx.ui.notify(
91
+ `Updated SAP model cache: ${snapshot.count ?? snapshot.models?.length ?? 0} models. Refreshed sap-aicore providers for this session.`,
92
+ "info",
93
+ );
94
+ ctx.ui.setStatus("sap-models", undefined);
95
+ return;
96
+ }
97
+ case "discover": {
98
+ ctx.ui.setStatus("sap-models", "querying SAP tenant…");
99
+ const tenant = await tenantModelIds();
100
+ const catalog = loadModelCatalog();
101
+ const known = new Set(catalog.models.map((m) => m.id));
102
+ const tenantSorted = [...tenant].sort();
103
+ const missing = tenantSorted.filter((id) => !known.has(id));
104
+ const phantom = catalog.models
105
+ .map((m) => m.id)
106
+ .filter((id) => !tenant.has(id))
107
+ .sort();
108
+ ctx.ui.notify(
109
+ `SAP tenant discovery: ${tenant.size} tenant models. Missing from pi-sap-aicore catalog: ${formatModelList(missing)}. In catalog but absent from tenant: ${formatModelList(phantom)}.`,
110
+ missing.length > 0 || phantom.length > 0 ? "warning" : "info",
111
+ );
112
+ ctx.ui.setStatus("sap-models", undefined);
113
+ return;
114
+ }
115
+ case "list": {
116
+ const catalog = loadModelCatalog();
117
+ ctx.ui.notify(
118
+ `pi-sap-aicore catalog has ${catalog.models.length} orchestration models and ${catalog.models.filter((m) => catalog.foundationModelIds.has(m.id)).length} foundation-enabled models.`,
119
+ "info",
120
+ );
121
+ return;
122
+ }
123
+ case "paths": {
124
+ ctx.ui.notify(
125
+ `SAP model files:\ncache: ${userCachePath()}\noverlay: ${userOverlayPath()}`,
126
+ "info",
127
+ );
128
+ return;
129
+ }
130
+ case "help":
131
+ default:
132
+ ctx.ui.notify(
133
+ "/sap-models update — refresh public SAP model metadata\n" +
134
+ "/sap-models discover — compare catalog against your SAP tenant\n" +
135
+ "/sap-models list — summarize loaded catalog\n" +
136
+ "/sap-models paths — show user cache/overlay paths",
137
+ "info",
138
+ );
139
+ }
140
+ } catch (error) {
141
+ ctx.ui.setStatus("sap-models", undefined);
142
+ ctx.ui.notify(
143
+ `SAP model command failed: ${error instanceof Error ? error.message : String(error)}`,
144
+ "error",
145
+ );
146
+ }
147
+ },
148
+ });
149
+ }