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 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.3.7...HEAD
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.78.0 or newer** installed (`npm install -g @earendil-works/pi-coding-agent`)
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
- 1. Pick **Use a subscription**.
54
+
55
+ 1. Pick **Sign in with an API key**.
45
56
  2. Pick **SAP AI Core**.
46
- 3. At the prompt, paste your BTP service-key JSON as a single line and hit enter.
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 in `~/.pi/agent/auth.json` for future sessions.
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
- > **Why "Use a subscription" and not "Use an API key"?** SAP service keys contain
56
- > a `$` in their `clientsecret`. Since pi 0.77, keys stored via "Use an API key"
57
- > are run through a `$`-interpolating template resolver that mangles them. The
58
- > extension registers credentials through pi's `oauth` mechanism instead, which
59
- > stores and returns the key verbatim. It's not real OAuth it's just the path
60
- > that keeps your key intact. (See [pi issue #5095](https://github.com/earendil-works/pi/issues/5095).)
61
-
62
- > **Upgrading from an older install?** If you previously logged in via
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. Users refresh it inside pi with:
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 command re-registers the SAP providers for the current session;
204
- restart pi or `/reload` if another session should pick it up.
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` | 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. |
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 + registerProvider calls (both providers)
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 # service-key validation + pi oauth registration
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
- ├── models-config.ts # exposes merged MODELS and FOUNDATION_MODELS
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's ProviderModelConfig mapper
484
- ├── stream.ts # orchestration streamSimple adapter + shared helpers (auth, usage, errors)
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 { Api } from "@earendil-works/pi-ai";
1
+ import type { Provider } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
- import { sapAiCoreOAuth } from "./src/auth.ts";
5
- import { loadModelCatalog } from "./src/model-catalog.ts";
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 registerProviders = () => {
30
- const catalog = loadModelCatalog();
31
- const models = catalog.models;
32
- const foundationModels = models.filter((m) =>
33
- catalog.foundationModelIds.has(m.id),
34
- );
35
-
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 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.7",
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.80.9",
45
- "@earendil-works/pi-coding-agent": ">=0.80.9"
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.80.9",
49
- "@earendil-works/pi-coding-agent": "^0.80.9",
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": ">=20"
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 orchestration provider's stored
3
- // subscription credential with the foundation provider. This makes no network
4
- // calls and uses an isolated temporary auth file.
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/stream.ts")).href
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("Shared OAuth credential lookup");
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 OAuth credential verbatim",
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 type validation");
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 non-OAuth credentials",
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");