pi-sap-aicore 0.3.7 → 0.4.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 +43 -1
- package/README.md +55 -27
- package/index.ts +15 -77
- package/package.json +8 -7
- package/scripts/test-auth-storage.mjs +20 -20
- package/scripts/test-model-refresh.mjs +168 -0
- package/scripts/test-native-providers.mjs +128 -0
- package/src/auth.ts +117 -36
- package/src/model-catalog-controller.ts +103 -0
- package/src/model-catalog.ts +52 -9
- package/src/providers.ts +118 -0
- package/src/sap-model-commands.ts +25 -41
- package/src/stream.ts +6 -34
- package/src/to-pi-model.ts +12 -5
- package/src/models-config.ts +0 -30
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-07-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Registered complete Pi 0.81 `Provider` objects for both orchestration and
|
|
15
|
+
foundation routes, including native authentication, synchronous model views,
|
|
16
|
+
provider refresh, and both streaming methods.
|
|
17
|
+
- Added native API-key login for SAP service-key JSON. Pi stores and resolves the
|
|
18
|
+
credential verbatim, preserving literal `$` characters in `clientsecret`.
|
|
19
|
+
- Integrated the layered SAP catalog with Pi's provider refresh lifecycle. `/model`
|
|
20
|
+
and `pi update --models` can refresh it automatically, while `/sap-models update`
|
|
21
|
+
forces an immediate refresh without replacing Provider objects.
|
|
22
|
+
- Added offline regression coverage for native Provider shape, API-key and legacy
|
|
23
|
+
auth, shared foundation credentials, refresh throttling, abort/failure retention,
|
|
24
|
+
concurrent refresh deduplication, and stale-cache protection.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Existing fake-OAuth credentials remain supported without re-login, but new users
|
|
29
|
+
should authenticate through `/login` → **Sign in with an API key**.
|
|
30
|
+
- Raised the minimum supported Pi version to 0.81.0 and Node.js to 22.19.0.
|
|
31
|
+
Users remaining on Pi 0.80.x should pin `pi-sap-aicore@0.3.8`.
|
|
32
|
+
- Older per-machine model caches no longer override a newer bundled snapshot after
|
|
33
|
+
an extension upgrade.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Declared `@sap-ai-sdk/core` as a direct dependency because the Bedrock and Vertex
|
|
38
|
+
adapters import it directly; strict and pnpm installations no longer depend on
|
|
39
|
+
transitive hoisting.
|
|
40
|
+
|
|
41
|
+
## [0.3.8] - 2026-07-18
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- SAP AI Core no longer appears configured in `/login` on a fresh install.
|
|
46
|
+
The orchestration provider now declares its `AICORE_SERVICE_KEY` environment
|
|
47
|
+
fallback explicitly instead of using an always-configured placeholder; stored
|
|
48
|
+
subscription credentials continue to use the existing OAuth-backed login.
|
|
49
|
+
|
|
10
50
|
## [0.3.7] - 2026-07-16
|
|
11
51
|
|
|
12
52
|
### Fixed
|
|
@@ -243,7 +283,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
243
283
|
`reasoning_effort` for OpenAI).
|
|
244
284
|
- MIT license and npm packaging.
|
|
245
285
|
|
|
246
|
-
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.
|
|
286
|
+
[Unreleased]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.4.0...HEAD
|
|
287
|
+
[0.4.0]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.8...v0.4.0
|
|
288
|
+
[0.3.8]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.7...v0.3.8
|
|
247
289
|
[0.3.7]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.6...v0.3.7
|
|
248
290
|
[0.3.6]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.5...v0.3.6
|
|
249
291
|
[0.3.5]: https://github.com/ttiimmaahh/pi-sap-aicore/compare/v0.3.4...v0.3.5
|
package/README.md
CHANGED
|
@@ -9,13 +9,23 @@ and share one login, so you pick the route per model. See
|
|
|
9
9
|
|
|
10
10
|
## Prerequisites
|
|
11
11
|
|
|
12
|
-
- pi **0.
|
|
12
|
+
- pi **0.81.0 or newer** installed (`npm install -g @earendil-works/pi-coding-agent`)
|
|
13
|
+
- Node.js **22.19.0 or newer**
|
|
13
14
|
- An SAP BTP account with AI Core entitlement and an **orchestration deployment**
|
|
14
15
|
- *(optional, for the foundation provider)* one or more **foundation-models
|
|
15
16
|
deployments** — one per model you want to route directly (`azure-openai` for
|
|
16
17
|
GPT/OpenAI models, `aws-bedrock` for Anthropic/Claude models)
|
|
17
18
|
- The service key JSON for your AI Core service binding
|
|
18
19
|
|
|
20
|
+
> [!IMPORTANT]
|
|
21
|
+
> `pi-sap-aicore` **0.4.0 and newer require pi 0.81.0 or newer** because they
|
|
22
|
+
> use pi's complete native Provider API. If you must remain on pi 0.80.x, pin
|
|
23
|
+
> the previous compatible extension release until you can upgrade:
|
|
24
|
+
>
|
|
25
|
+
> ```bash
|
|
26
|
+
> pi install npm:pi-sap-aicore@0.3.8
|
|
27
|
+
> ```
|
|
28
|
+
|
|
19
29
|
## Credentials
|
|
20
30
|
|
|
21
31
|
The extension looks for the SAP BTP service-key JSON in this order:
|
|
@@ -41,28 +51,28 @@ From inside pi:
|
|
|
41
51
|
```
|
|
42
52
|
|
|
43
53
|
Then:
|
|
44
|
-
|
|
54
|
+
|
|
55
|
+
1. Pick **Sign in with an API key**.
|
|
45
56
|
2. Pick **SAP AI Core**.
|
|
46
|
-
3.
|
|
57
|
+
3. Paste your BTP service-key JSON as a single line and hit enter.
|
|
47
58
|
It's validated immediately — if anything is missing or malformed, you'll get a
|
|
48
59
|
specific error pointing at the field, so you can re-run `/login` and fix it.
|
|
49
60
|
|
|
50
|
-
Pi stores the JSON
|
|
61
|
+
Pi 0.81 stores the JSON as a native provider credential in
|
|
62
|
+
`~/.pi/agent/auth.json`. The value is passed to the provider verbatim, so literal
|
|
63
|
+
`$` characters in `clientsecret` are preserved.
|
|
51
64
|
|
|
52
65
|
To get the JSON: BTP cockpit → your AI Core service instance → Service Keys
|
|
53
66
|
→ View. Copy the entire JSON object.
|
|
54
67
|
|
|
55
|
-
> **
|
|
56
|
-
> a
|
|
57
|
-
>
|
|
58
|
-
>
|
|
59
|
-
>
|
|
60
|
-
>
|
|
61
|
-
|
|
62
|
-
> **
|
|
63
|
-
> "Use an API key" (stored as `{"type":"api_key"}` in `auth.json`), re-run
|
|
64
|
-
> `/login` **once** via **Use a subscription** to convert the stored credential.
|
|
65
|
-
> A single re-login is all that's needed.
|
|
68
|
+
> **Upgrading from 0.3.x?** Existing credentials created through
|
|
69
|
+
> **Use a subscription → SAP AI Core** continue to work without re-login. The
|
|
70
|
+
> Pi 0.81 provider retains that legacy credential handler solely for migration;
|
|
71
|
+
> new logins should use the native API-key flow above. The foundation provider
|
|
72
|
+
> still borrows the primary `sap-aicore` credential and does not add a second
|
|
73
|
+
> subscription entry. Pi may list **SAP AI Core (Foundation)** in the API-key
|
|
74
|
+
> provider picker because every complete Provider declares auth; choose the
|
|
75
|
+
> primary **SAP AI Core** entry when you want one credential shared by both.
|
|
66
76
|
|
|
67
77
|
### Alternative: `AICORE_SERVICE_KEY` env var
|
|
68
78
|
|
|
@@ -102,6 +112,7 @@ pi -e ./index.ts --list-models
|
|
|
102
112
|
|
|
103
113
|
You'll see the orchestration models under `sap-aicore/` (Claude, GPT-5*, Gemini),
|
|
104
114
|
plus any direct **foundation** models under `sap-aicore-foundation/`:
|
|
115
|
+
|
|
105
116
|
- `sap-aicore/anthropic--claude-4.7-opus` — Claude Opus 4.7 (orchestration)
|
|
106
117
|
- `sap-aicore/gpt-5.5` — GPT-5.5 via orchestration
|
|
107
118
|
- `sap-aicore-foundation/gpt-5.5` — GPT-5.5 via its direct Azure OpenAI foundation deployment
|
|
@@ -129,7 +140,7 @@ updates.
|
|
|
129
140
|
The extension registers **two providers**, both backed by the same service key:
|
|
130
141
|
|
|
131
142
|
| | `sap-aicore` (orchestration) | `sap-aicore-foundation` (direct) |
|
|
132
|
-
|
|
143
|
+
| --- | --- | --- |
|
|
133
144
|
| SAP deployment | one orchestration deployment fronts **every** model | one foundation deployment **per model** |
|
|
134
145
|
| Models | Claude, GPT-5*, Gemini | GPT/OpenAI (`azure-openai`), Anthropic/Claude (`aws-bedrock`), and Gemini (`gcp-vertexai`) |
|
|
135
146
|
| Tool use | yes | yes — tools are translated to OpenAI, Bedrock Converse, or Vertex/Gemini function declarations by executable |
|
|
@@ -187,21 +198,28 @@ The model list is composed of three sources, merged at startup:
|
|
|
187
198
|
1. **`src/models-snapshot.json`** — packaged fallback catalog, auto-generated
|
|
188
199
|
from [models.dev](https://models.dev)'s SAP AI Core catalog. Maintainers
|
|
189
200
|
refresh it with:
|
|
201
|
+
|
|
190
202
|
```bash
|
|
191
203
|
npm run update-models
|
|
192
204
|
```
|
|
205
|
+
|
|
193
206
|
This re-fetches the live catalog, applies our family-specific filters
|
|
194
207
|
(currently anthropic claude-4.x, gpt-5*, gemini-2.5*), and writes the
|
|
195
208
|
snapshot to disk. Commit the result.
|
|
196
209
|
|
|
197
210
|
2. **`~/.pi/agent/pi-sap-aicore/models-cache.json`** — per-machine public
|
|
198
|
-
catalog cache.
|
|
211
|
+
catalog cache. Pi 0.81 refreshes authenticated providers in the background
|
|
212
|
+
when `/model` opens and through `pi update --models`; checks are throttled for
|
|
213
|
+
four hours. Force an immediate refresh inside pi with:
|
|
214
|
+
|
|
199
215
|
```text
|
|
200
216
|
/sap-models update
|
|
201
217
|
```
|
|
218
|
+
|
|
202
219
|
This does not edit the installed npm package and is safe across extension
|
|
203
|
-
updates. The
|
|
204
|
-
|
|
220
|
+
updates. The existing Provider objects publish the refreshed model list
|
|
221
|
+
immediately, without re-registration or `/reload`. A cache older than the
|
|
222
|
+
bundled snapshot is ignored after extension upgrades.
|
|
205
223
|
|
|
206
224
|
3. **`~/.pi/agent/pi-sap-aicore/models.json`** — per-machine tenant overlay.
|
|
207
225
|
Use it for models in your tenant that are not in the public catalog yet,
|
|
@@ -250,8 +268,8 @@ tenant reports.
|
|
|
250
268
|
Run these inside pi after installing/loading the extension:
|
|
251
269
|
|
|
252
270
|
| Command | What it does |
|
|
253
|
-
|
|
254
|
-
| `/sap-models update` |
|
|
271
|
+
| --- | --- |
|
|
272
|
+
| `/sap-models update` | Forces the latest public SAP AI Core model metadata from models.dev into `~/.pi/agent/pi-sap-aicore/models-cache.json` and updates both live Provider views. |
|
|
255
273
|
| `/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`. |
|
|
256
274
|
| `/sap-models list` | Shows how many orchestration models and foundation-enabled models are currently loaded after snapshot/cache/overlay merging. |
|
|
257
275
|
| `/sap-models paths` | Prints the cache and overlay file paths for this machine. |
|
|
@@ -275,7 +293,7 @@ to `exclude`.
|
|
|
275
293
|
`~/.pi/agent/pi-sap-aicore/models.json` supports these top-level fields:
|
|
276
294
|
|
|
277
295
|
| Field | Type | Purpose |
|
|
278
|
-
|
|
296
|
+
| --- | --- | --- |
|
|
279
297
|
| `models` | `SapModel[]` | Adds tenant-only/pre-release models or replaces catalog models with the same `id`. |
|
|
280
298
|
| `overrides` | object keyed by model id | Partially overrides an existing model. Nested `limit`, `cost`, `modalities`, and `thinkingLevelMap` fields are merged. Unknown ids are ignored. |
|
|
281
299
|
| `exclude` | `string[]` | Removes model ids after snapshot/cache/overlay merging. Useful for public catalog entries your SAP tenant does not deploy. |
|
|
@@ -343,6 +361,7 @@ into the visible answer text, and the tokens are billed via
|
|
|
343
361
|
"thinking" panel will stay empty for SAP-routed models — there's no
|
|
344
362
|
client-side fix. If SAP exposes a server-side flag for this in the
|
|
345
363
|
future, our `pickReasoning` probe is wired and ready in `stream.ts`.
|
|
364
|
+
|
|
346
365
|
- **OpenAI models** (`gpt-*`) use `reasoning_effort: "minimal" | "low"
|
|
347
366
|
| "medium" | "high"`. `xhigh` is omitted — OpenAI has no equivalent
|
|
348
367
|
tier; pi will skip it when cycling.
|
|
@@ -370,15 +389,19 @@ To override budgets per model, edit `thinkingLevelMap` on the relevant entry in
|
|
|
370
389
|
Resolved in this order:
|
|
371
390
|
|
|
372
391
|
1. **`AICORE_RESOURCE_GROUP` env var** — per-shell override. Example:
|
|
392
|
+
|
|
373
393
|
```bash
|
|
374
394
|
export AICORE_RESOURCE_GROUP=my-team-rg
|
|
375
395
|
```
|
|
396
|
+
|
|
376
397
|
2. **`resourceGroup` field on the service-key JSON** — convenient for teams
|
|
377
398
|
who manage multiple groups and want to bake the default into the key.
|
|
378
399
|
Non-standard, so add it yourself before pasting into pi:
|
|
400
|
+
|
|
379
401
|
```json
|
|
380
402
|
{ "clientid": "...", "clientsecret": "...", "resourceGroup": "my-team-rg", ... }
|
|
381
403
|
```
|
|
404
|
+
|
|
382
405
|
3. **SAP's server-side default** (`default`) — if neither of the above is set.
|
|
383
406
|
|
|
384
407
|
The value is passed via SAP's `OrchestrationClient(..., {resourceGroup})`
|
|
@@ -429,10 +452,12 @@ build step — pi loads the `.ts` sources directly via jiti — so a release is
|
|
|
429
452
|
1. Update `CHANGELOG.md`: move items from `[Unreleased]` into a new version
|
|
430
453
|
heading.
|
|
431
454
|
2. Bump the version (this commits `package.json` and creates a `vX.Y.Z` tag):
|
|
455
|
+
|
|
432
456
|
```bash
|
|
433
457
|
npm version patch # or minor / major
|
|
434
458
|
git push --follow-tags
|
|
435
459
|
```
|
|
460
|
+
|
|
436
461
|
3. The [`Publish`](.github/workflows/publish.yml) workflow fires on the `v*` tag,
|
|
437
462
|
asserts the tag matches `package.json`, typechecks, publishes to npm, and
|
|
438
463
|
creates/updates the matching GitHub Release from that version's
|
|
@@ -468,20 +493,23 @@ npmjs.com:
|
|
|
468
493
|
├── .github/workflows/
|
|
469
494
|
│ ├── ci.yml # typecheck gate on push to main + PRs
|
|
470
495
|
│ └── publish.yml # tag-driven npm publish via OIDC trusted publishing
|
|
471
|
-
├── index.ts # ExtensionAPI factory +
|
|
496
|
+
├── index.ts # ExtensionAPI factory + complete Provider registration
|
|
472
497
|
├── scripts/
|
|
473
498
|
│ ├── update-models.mjs # maintainer script: fetches models.dev, writes models-snapshot.json
|
|
474
499
|
│ ├── list-sap-models.mjs # lists models your tenant actually deploys (diff vs snapshot)
|
|
475
500
|
│ ├── diagnose-streaming.mjs # probes orchestration streaming support per model
|
|
501
|
+
│ ├── test-native-providers.mjs # offline Provider/auth compatibility checks
|
|
502
|
+
│ ├── test-model-refresh.mjs # offline refresh/cache/failure checks
|
|
476
503
|
│ └── validate-foundation-executables.mjs # live text/tool/image smoke tests for direct foundation executables
|
|
477
504
|
└── src/
|
|
478
|
-
├── auth.ts #
|
|
505
|
+
├── auth.ts # native API-key auth + legacy credential compatibility
|
|
479
506
|
├── model-catalog.ts # loads snapshot/cache/overlay and adapts models.dev metadata
|
|
480
|
-
├──
|
|
507
|
+
├── model-catalog-controller.ts # shared synchronous state + refresh lifecycle
|
|
481
508
|
├── models-snapshot.json # auto-generated from models.dev (committed)
|
|
509
|
+
├── providers.ts # complete Pi 0.81 Provider objects
|
|
482
510
|
├── sap-model-commands.ts # /sap-models update/discover/list/paths
|
|
483
|
-
├── to-pi-model.ts # SapModel → pi
|
|
484
|
-
├── stream.ts # orchestration
|
|
511
|
+
├── to-pi-model.ts # SapModel → pi-ai Model mapper
|
|
512
|
+
├── stream.ts # orchestration stream adapter + shared usage/error helpers
|
|
485
513
|
├── translate.ts # pi Context ↔ orchestration message shape
|
|
486
514
|
├── foundation-executables.ts # model id → SAP foundation executable mapping
|
|
487
515
|
├── foundation-deployment.ts # shared foundation deployment resolution helpers
|
package/index.ts
CHANGED
|
@@ -1,83 +1,21 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Provider } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { createSapModelCatalogController } from "./src/model-catalog-controller.ts";
|
|
5
|
+
import { createSapProviders } from "./src/providers.ts";
|
|
6
6
|
import { registerSapModelCommands } from "./src/sap-model-commands.ts";
|
|
7
|
-
import { streamSapAiCore } from "./src/stream.ts";
|
|
8
|
-
import { streamSapFoundation } from "./src/stream-foundation.ts";
|
|
9
|
-
import { toPiModel } from "./src/to-pi-model.ts";
|
|
10
|
-
|
|
11
|
-
const PROVIDER_NAME = "sap-aicore";
|
|
12
|
-
const PROVIDER_API = "sap-aicore-orchestration" as Api;
|
|
13
|
-
|
|
14
|
-
// Second provider: direct foundation (Azure OpenAI) deployments, registered
|
|
15
|
-
// alongside orchestration so both routes are independently selectable
|
|
16
|
-
// (e.g. `sap-aicore/gpt-5.5` vs `sap-aicore-foundation/gpt-5.5`).
|
|
17
|
-
const FOUNDATION_PROVIDER_NAME = "sap-aicore-foundation";
|
|
18
|
-
const FOUNDATION_PROVIDER_API = "sap-aicore-foundation" as Api;
|
|
19
|
-
|
|
20
|
-
// pi requires a non-empty `apiKey` for any custom provider that defines models
|
|
21
|
-
// (model-registry `validateConfig`), even when credentials come from `oauth`.
|
|
22
|
-
// This value is never used: the real key is supplied by `sapAiCoreOAuth`
|
|
23
|
-
// (after `/login`) or by AICORE_SERVICE_KEY (both handled in stream.ts). It is a
|
|
24
|
-
// plain lowercase literal so pi's config-value resolver returns it as-is — no
|
|
25
|
-
// `$` interpolation, no shell exec, and not mistaken for a legacy env-var name.
|
|
26
|
-
const PLACEHOLDER_API_KEY = "managed-by-extension-oauth";
|
|
27
7
|
|
|
28
8
|
export default function (pi: ExtensionAPI) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 via
|
|
62
|
-
// `ensureServiceKey`'s auth-store fallback instead of registering its own
|
|
63
|
-
// OAuth provider. Registering `oauth: sapAiCoreOAuth` here would make `/login`
|
|
64
|
-
// show a second, confusing "SAP AI Core" subscription entry because pi keys
|
|
65
|
-
// OAuth providers by provider id (`sap-aicore-foundation`), not by OAuth name.
|
|
66
|
-
// Models appear under `sap-aicore-foundation/…`; streaming runs natively here
|
|
67
|
-
// (no orchestration streaming-unsupported fallback). The foundation SDK is
|
|
68
|
-
// dynamically imported inside `streamSapFoundation`, same deferral as above.
|
|
69
|
-
pi.registerProvider(FOUNDATION_PROVIDER_NAME, {
|
|
70
|
-
name: "SAP AI Core (Foundation)",
|
|
71
|
-
baseUrl: "https://sap-aicore-handled-by-sdk.invalid",
|
|
72
|
-
apiKey: PLACEHOLDER_API_KEY,
|
|
73
|
-
api: FOUNDATION_PROVIDER_API,
|
|
74
|
-
models: foundationModels.map((m) =>
|
|
75
|
-
toPiModel(m, FOUNDATION_PROVIDER_API),
|
|
76
|
-
),
|
|
77
|
-
streamSimple: streamSapFoundation,
|
|
78
|
-
});
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
registerSapModelCommands(pi, registerProviders);
|
|
82
|
-
registerProviders();
|
|
9
|
+
const catalogController = createSapModelCatalogController();
|
|
10
|
+
const providers = createSapProviders(catalogController);
|
|
11
|
+
|
|
12
|
+
// coding-agent ships a nested exact pi-ai dependency, so strict resolvers can
|
|
13
|
+
// see two nominally distinct Provider type identities. The runtime contract is
|
|
14
|
+
// the same 0.81 object; isolate the package-boundary cast here.
|
|
15
|
+
const registerNativeProvider = pi.registerProvider.bind(pi) as unknown as (
|
|
16
|
+
provider: Provider,
|
|
17
|
+
) => void;
|
|
18
|
+
registerNativeProvider(providers.orchestration);
|
|
19
|
+
registerNativeProvider(providers.foundation);
|
|
20
|
+
registerSapModelCommands(pi, catalogController);
|
|
83
21
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sap-aicore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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)",
|
|
@@ -31,25 +31,26 @@
|
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"update-models": "node scripts/update-models.mjs",
|
|
34
|
-
"test": "node scripts/test-auth-storage.mjs && node scripts/test-vertex-tool-schema.mjs && node scripts/test-bedrock-tool-results.mjs && node scripts/test-empty-content-blocks.mjs",
|
|
34
|
+
"test": "node scripts/test-auth-storage.mjs && node scripts/test-native-providers.mjs && node scripts/test-model-refresh.mjs && node scripts/test-vertex-tool-schema.mjs && node scripts/test-bedrock-tool-results.mjs && node scripts/test-empty-content-blocks.mjs",
|
|
35
35
|
"validate:foundation": "node scripts/validate-foundation-executables.mjs",
|
|
36
36
|
"prepublishOnly": "tsc --noEmit && npm test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@sap-ai-sdk/ai-api": "^2.12.0",
|
|
40
|
+
"@sap-ai-sdk/core": "^2.12.0",
|
|
40
41
|
"@sap-ai-sdk/foundation-models": "^2.12.0",
|
|
41
42
|
"@sap-ai-sdk/orchestration": "^2.12.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
|
-
"@earendil-works/pi-ai": ">=0.
|
|
45
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
45
|
+
"@earendil-works/pi-ai": ">=0.81.0",
|
|
46
|
+
"@earendil-works/pi-coding-agent": ">=0.81.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-ai": "^0.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
49
|
+
"@earendil-works/pi-ai": "^0.81.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^0.81.0",
|
|
50
51
|
"typescript": "^7.0.2"
|
|
51
52
|
},
|
|
52
53
|
"engines": {
|
|
53
|
-
"node": ">=
|
|
54
|
+
"node": ">=22.19.0"
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Offline regression test for sharing the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Offline regression test for sharing the primary provider's stored service
|
|
3
|
+
// key with the foundation provider. Makes no network calls and uses an
|
|
4
|
+
// isolated temporary auth file.
|
|
5
5
|
|
|
6
6
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
@@ -10,7 +10,7 @@ import { pathToFileURL } from "node:url";
|
|
|
10
10
|
|
|
11
11
|
const ROOT = new URL("..", import.meta.url).pathname;
|
|
12
12
|
const { readSharedServiceKeyFromStore } = await import(
|
|
13
|
-
pathToFileURL(join(ROOT, "src/
|
|
13
|
+
pathToFileURL(join(ROOT, "src/auth.ts")).href
|
|
14
14
|
);
|
|
15
15
|
|
|
16
16
|
let failures = 0;
|
|
@@ -33,7 +33,7 @@ const tempDir = mkdtempSync(join(tmpdir(), "pi-sap-aicore-auth-"));
|
|
|
33
33
|
const authPath = join(tempDir, "auth.json");
|
|
34
34
|
|
|
35
35
|
try {
|
|
36
|
-
console.log("
|
|
36
|
+
console.log("Legacy OAuth credential lookup");
|
|
37
37
|
writeFileSync(
|
|
38
38
|
authPath,
|
|
39
39
|
JSON.stringify({
|
|
@@ -48,41 +48,41 @@ try {
|
|
|
48
48
|
);
|
|
49
49
|
check(
|
|
50
50
|
readSharedServiceKeyFromStore(authPath) === serviceKey,
|
|
51
|
-
"reads the sap-aicore
|
|
51
|
+
"reads the sap-aicore legacy credential verbatim",
|
|
52
52
|
);
|
|
53
53
|
check(
|
|
54
54
|
readSharedServiceKeyFromStore(authPath)?.includes("literal$secret"),
|
|
55
55
|
"preserves literal dollar signs",
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
console.log("Native API-key credential lookup");
|
|
59
|
+
writeFileSync(
|
|
60
|
+
authPath,
|
|
61
|
+
JSON.stringify({ "sap-aicore": { type: "api_key", key: serviceKey } }),
|
|
62
|
+
);
|
|
63
|
+
check(
|
|
64
|
+
readSharedServiceKeyFromStore(authPath) === serviceKey,
|
|
65
|
+
"reads the native Pi 0.81 API-key credential verbatim",
|
|
66
|
+
);
|
|
67
|
+
|
|
58
68
|
console.log("Provider isolation");
|
|
59
69
|
writeFileSync(
|
|
60
70
|
authPath,
|
|
61
|
-
JSON.stringify({
|
|
62
|
-
"another-provider": {
|
|
63
|
-
type: "oauth",
|
|
64
|
-
serviceKey,
|
|
65
|
-
access: "",
|
|
66
|
-
refresh: "",
|
|
67
|
-
expires: Number.MAX_SAFE_INTEGER,
|
|
68
|
-
},
|
|
69
|
-
}),
|
|
71
|
+
JSON.stringify({ "another-provider": { type: "api_key", key: serviceKey } }),
|
|
70
72
|
);
|
|
71
73
|
check(
|
|
72
74
|
readSharedServiceKeyFromStore(authPath) === undefined,
|
|
73
75
|
"does not borrow another provider's credential",
|
|
74
76
|
);
|
|
75
77
|
|
|
76
|
-
console.log("Credential
|
|
78
|
+
console.log("Credential validation");
|
|
77
79
|
writeFileSync(
|
|
78
80
|
authPath,
|
|
79
|
-
JSON.stringify({
|
|
80
|
-
"sap-aicore": { type: "api_key", key: serviceKey },
|
|
81
|
-
}),
|
|
81
|
+
JSON.stringify({ "sap-aicore": { type: "api_key", key: "not-json" } }),
|
|
82
82
|
);
|
|
83
83
|
check(
|
|
84
84
|
readSharedServiceKeyFromStore(authPath) === undefined,
|
|
85
|
-
"ignores
|
|
85
|
+
"ignores values that are not service-key JSON",
|
|
86
86
|
);
|
|
87
87
|
} finally {
|
|
88
88
|
rmSync(tempDir, { recursive: true, force: true });
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createSapModelCatalogController } from "../src/model-catalog-controller.ts";
|
|
4
|
+
import { shouldUseCachedSnapshot } from "../src/model-catalog.ts";
|
|
5
|
+
|
|
6
|
+
let failures = 0;
|
|
7
|
+
function check(condition, message) {
|
|
8
|
+
if (condition) {
|
|
9
|
+
console.log(` ✓ ${message}`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.error(` ❌ ${message}`);
|
|
13
|
+
failures++;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sapModel = (id) => ({
|
|
17
|
+
id,
|
|
18
|
+
name: id,
|
|
19
|
+
reasoning: false,
|
|
20
|
+
tool_call: true,
|
|
21
|
+
temperature: true,
|
|
22
|
+
modalities: { input: ["text"], output: ["text"] },
|
|
23
|
+
limit: { context: 128000, output: 16384 },
|
|
24
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
25
|
+
});
|
|
26
|
+
const loadedCatalog = (ids, cache) => ({
|
|
27
|
+
models: ids.map(sapModel),
|
|
28
|
+
foundationModelIds: new Set(ids.slice(0, 1)),
|
|
29
|
+
sources: { packaged: { models: ids.map(sapModel) }, cache },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
console.log("Snapshot ordering");
|
|
33
|
+
check(
|
|
34
|
+
!shouldUseCachedSnapshot(
|
|
35
|
+
{ fetchedAt: "2026-07-21T10:00:00.000Z", models: [sapModel("new")] },
|
|
36
|
+
{ fetchedAt: "2026-07-20T10:00:00.000Z", models: [sapModel("old")] },
|
|
37
|
+
),
|
|
38
|
+
"older persisted cache cannot override a newer bundled snapshot",
|
|
39
|
+
);
|
|
40
|
+
check(
|
|
41
|
+
shouldUseCachedSnapshot(
|
|
42
|
+
{ fetchedAt: "2026-07-20T10:00:00.000Z", models: [sapModel("old")] },
|
|
43
|
+
{ fetchedAt: "2026-07-21T10:00:00.000Z", models: [sapModel("new")] },
|
|
44
|
+
),
|
|
45
|
+
"newer cache overlays the bundled snapshot",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
console.log("Freshness and forced refresh");
|
|
49
|
+
const now = Date.parse("2026-07-21T12:00:00.000Z");
|
|
50
|
+
let disk = loadedCatalog(["cached"], {
|
|
51
|
+
fetchedAt: "2026-07-21T11:00:00.000Z",
|
|
52
|
+
models: [sapModel("cached")],
|
|
53
|
+
});
|
|
54
|
+
let fetchCount = 0;
|
|
55
|
+
const controller = createSapModelCatalogController({
|
|
56
|
+
loadCatalog: () => disk,
|
|
57
|
+
now: () => now,
|
|
58
|
+
fetchSnapshot: async () => {
|
|
59
|
+
fetchCount++;
|
|
60
|
+
return {
|
|
61
|
+
fetchedAt: "2026-07-21T12:00:00.000Z",
|
|
62
|
+
models: [sapModel("refreshed")],
|
|
63
|
+
count: 1,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
writeSnapshot: (snapshot) => {
|
|
67
|
+
disk = loadedCatalog(
|
|
68
|
+
(snapshot.models ?? []).map((entry) => entry.id),
|
|
69
|
+
snapshot,
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
await controller.refresh({ allowNetwork: true });
|
|
74
|
+
check(fetchCount === 0, "fresh cache suppresses automatic network refresh");
|
|
75
|
+
await controller.refresh({ allowNetwork: true, force: true });
|
|
76
|
+
check(fetchCount === 1, "forced refresh bypasses freshness gate");
|
|
77
|
+
check(controller.getCatalog().models[0]?.id === "refreshed", "successful refresh publishes new models");
|
|
78
|
+
|
|
79
|
+
console.log("Offline reload and cancellation");
|
|
80
|
+
disk = loadedCatalog(["overlay-edit"], disk.sources.cache);
|
|
81
|
+
await controller.refresh({ allowNetwork: false });
|
|
82
|
+
check(controller.getCatalog().models[0]?.id === "overlay-edit", "offline refresh reloads local policy");
|
|
83
|
+
const aborted = new AbortController();
|
|
84
|
+
aborted.abort();
|
|
85
|
+
await controller.refresh({ allowNetwork: true, force: true, signal: aborted.signal });
|
|
86
|
+
check(fetchCount === 1, "already-aborted refresh performs no network request");
|
|
87
|
+
|
|
88
|
+
console.log("Failure retention");
|
|
89
|
+
const stable = loadedCatalog(["stable"], undefined);
|
|
90
|
+
let failingDisk = stable;
|
|
91
|
+
const failing = createSapModelCatalogController({
|
|
92
|
+
loadCatalog: () => failingDisk,
|
|
93
|
+
fetchSnapshot: async () => {
|
|
94
|
+
throw new Error("network down");
|
|
95
|
+
},
|
|
96
|
+
writeSnapshot: () => {
|
|
97
|
+
throw new Error("must not write");
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
failingDisk = loadedCatalog(["unpublished-local-reload"], undefined);
|
|
101
|
+
try {
|
|
102
|
+
await failing.refresh({ allowNetwork: true, force: true });
|
|
103
|
+
check(false, "failed refresh rejects");
|
|
104
|
+
} catch {
|
|
105
|
+
check(true, "failed refresh rejects");
|
|
106
|
+
}
|
|
107
|
+
check(failing.getCatalog().models[0]?.id === "stable", "failed online refresh retains published models");
|
|
108
|
+
|
|
109
|
+
console.log("Incompatible cancellation signals");
|
|
110
|
+
let cancellationFetchCount = 0;
|
|
111
|
+
const cancelFirst = new AbortController();
|
|
112
|
+
const cancellationSafe = createSapModelCatalogController({
|
|
113
|
+
loadCatalog: () => stable,
|
|
114
|
+
fetchSnapshot: (signal) => {
|
|
115
|
+
cancellationFetchCount++;
|
|
116
|
+
if (!signal) {
|
|
117
|
+
return Promise.resolve({
|
|
118
|
+
fetchedAt: new Date(now).toISOString(),
|
|
119
|
+
models: [sapModel("retry-after-cancel")],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return new Promise((_resolve, reject) => {
|
|
123
|
+
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), {
|
|
124
|
+
once: true,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
writeSnapshot: () => {},
|
|
129
|
+
});
|
|
130
|
+
const cancelledRequest = cancellationSafe.refresh({
|
|
131
|
+
allowNetwork: true,
|
|
132
|
+
force: true,
|
|
133
|
+
signal: cancelFirst.signal,
|
|
134
|
+
});
|
|
135
|
+
const independentRequest = cancellationSafe.refresh({ allowNetwork: true, force: true });
|
|
136
|
+
cancelFirst.abort();
|
|
137
|
+
try {
|
|
138
|
+
await cancelledRequest;
|
|
139
|
+
} catch {}
|
|
140
|
+
await independentRequest;
|
|
141
|
+
check(cancellationFetchCount === 2, "differently signaled callers get independent refresh attempts");
|
|
142
|
+
|
|
143
|
+
console.log("Concurrent deduplication");
|
|
144
|
+
let releaseFetch;
|
|
145
|
+
let concurrentFetchCount = 0;
|
|
146
|
+
const deferred = new Promise((resolve) => {
|
|
147
|
+
releaseFetch = resolve;
|
|
148
|
+
});
|
|
149
|
+
const concurrent = createSapModelCatalogController({
|
|
150
|
+
loadCatalog: () => stable,
|
|
151
|
+
fetchSnapshot: async () => {
|
|
152
|
+
concurrentFetchCount++;
|
|
153
|
+
await deferred;
|
|
154
|
+
return { fetchedAt: new Date(now).toISOString(), models: [sapModel("deduped")] };
|
|
155
|
+
},
|
|
156
|
+
writeSnapshot: () => {},
|
|
157
|
+
});
|
|
158
|
+
const first = concurrent.refresh({ allowNetwork: true, force: true });
|
|
159
|
+
const second = concurrent.refresh({ allowNetwork: true, force: true });
|
|
160
|
+
releaseFetch();
|
|
161
|
+
await Promise.all([first, second]);
|
|
162
|
+
check(concurrentFetchCount === 1, "simultaneous provider refreshes share one request");
|
|
163
|
+
|
|
164
|
+
if (failures > 0) {
|
|
165
|
+
console.error(`\n${failures} check(s) failed`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
console.log("\nAll model refresh checks passed");
|