pi-bob 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +55 -17
  2. package/extensions/bob.ts +113 -13
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -4,12 +4,24 @@ Pi provider package for IBM Bob / IBM-approved enterprise model endpoints.
4
4
 
5
5
  The package registers Bob through Pi's built-in compatible provider APIs and discovers the currently exposed model catalog from Bob's authenticated `/inference/v1/model/info` endpoint. It does not scrape Bob, extract browser/session credentials, or bypass IBM-approved access paths.
6
6
 
7
+ > **✅ Works with IBM Bob Shell / Bob CLI 2.x (verified against `bobshell@2.0.1`, 2026-08-31).**
8
+ > SSO login, token refresh, and live model discovery all work against Bob 2.0 — 13 models discovered, including `premium`, `premium-ide`, `ultra`, `fast`, and `wxO-model` (1M ctx). One caveat: under 2.x, Bob only accepts the default **`openai-completions`** adapter; `anthropic-messages` and `openai-responses` are rejected with 403 by Bob-side entitlement. 1.x Bob Shell installations keep working — the parser accepts both payload shapes. See [Discovered local Bob Shell settings](#discovered-local-bob-shell-settings) for the full 2.x change list.
9
+
7
10
  ## Discovered local Bob Shell settings
8
11
 
9
- From the installed `bobshell@1.0.6` package and the local redacted Bob configuration:
12
+ Updated for the installed `bobshell@2.0.1` package (upgraded from 1.0.6; findings verified against the live endpoint on 2026-08-31):
13
+
14
+ - Bob Shell 2.x moved stored SSO tokens to `~/.bob/settings/auth-secrets.json` (key `bob.auth.tokens-<gateway>`); `~/.bob/settings.json` keeps only non-secret settings. This extension still reads only `ibm.instanceId`/`ibm.teamId` from there and still ignores Bob's secrets.
15
+ - Token endpoints are unchanged in practice: 2.x builds `${gateway}/authn` + `v1/auth/token|refresh`, which resolves to the same `/authn/v1/auth/token` and `/authn/v1/auth/refresh` routes used here. The web login URL is now assembled as `https://bob.ibm.com` + `/login`; the final URL is the same as the 1.x constant.
16
+ - **Breaking:** 2.x `model/info` entries dropped `litellm_params` (the backend identifier) and now carry `max_output_tokens` and `supports_prompt_caching` alongside `max_tokens`. The parser accepts both shapes and falls back to the route name when the backend is missing.
17
+ - **Breaking:** 2.x `model/info` requires `x-instance-id` (HTTP 400 without it); `x-team-id` stays optional. The extension already sends both from Bob settings or the JWT `instances` claim.
18
+ - Model tiers were renamed: 1.x `premium`/`pro`/`flash`/`flash-lite` became 2.x tiers `fast`/`premium`/`ultra` (`explorer` exists but is hidden). Live catalog also exposes `premium-ide`, `premium-shell`, `sonnet-4.5`, `wxO-model` (1M ctx / 64k out), `gpt-oss-20b`, and granite/rnj code models. The `premium` alias still exists, so the fallback catalog stays valid.
19
+ - **Breaking (entitlement):** under 2.0, the `anthropic-messages` and `openai-responses` routes return `403 Access denied` with a valid token (routes exist; access is denied for this instance). Only the default `openai-completions` adapter works; treat the alternates as unavailable until IBM grants access.
10
20
 
11
- - Bob Shell CLI: `/opt/homebrew/bin/bob`
12
- - Bob Shell package: `/opt/homebrew/lib/node_modules/bobshell`
21
+ From the installed `bobshell@1.0.6` package and the local redacted Bob configuration (historical):
22
+
23
+ - Bob Shell CLI: `bob`
24
+ - Bob Shell package: `bobshell`
13
25
  - Installed auth method: `sso`
14
26
  - Default Bob API host: `https://api.us-east.bob.ibm.com`
15
27
  - OpenAI-compatible chat base URL: `https://api.us-east.bob.ibm.com/inference/v1`
@@ -36,6 +48,8 @@ Set `IBM_BOB_API` to one of Pi's compatible API adapters:
36
48
  - `openai-responses` — OpenAI Responses-compatible routes.
37
49
  - `anthropic-messages` — Anthropic Messages-compatible routes.
38
50
 
51
+ Under Bob Shell 2.x, Bob currently accepts only the default `openai-completions` adapter; the other two return `403 Access denied` regardless of extension settings (verified 2026-08-31).
52
+
39
53
  The extension registers provider id `ibm-bob`. It uses an isolated `ibm-bob-compatible` Pi API adapter internally, then delegates serialization and streaming to the selected built-in adapter. This prevents Bob-specific authentication rules from affecting other providers.
40
54
 
41
55
  ## Dynamic model discovery
@@ -43,7 +57,7 @@ The extension registers provider id `ibm-bob`. It uses an isolated `ibm-bob-comp
43
57
  Model discovery is enabled by default:
44
58
 
45
59
  - With `IBM_BOB_API_KEY` or `IBM_BOB_KEY`, the extension fetches `/inference/v1/model/info` during Pi's async extension startup. This makes current models available to `--list-models` and `/model` immediately.
46
- - With `/login ibm-bob`, the extension fetches the catalog after login and on each token refresh. A sanitized, non-secret copy is cached with Pi's OAuth credentials so the models can be restored on later startups.
60
+ - With `/login ibm-bob`, the extension fetches the catalog after login, and on token refresh only when no catalog is already cached (reusing a cached catalog avoids holding Pi's global auth-store lock across a network call). A sanitized, non-secret copy is cached with Pi's OAuth credentials so the models can be restored on later startups.
47
61
  - Entries returned by the authenticated catalog are registered when `model_info.exposed` is omitted or `true`. Routes explicitly marked `exposed: false` are ignored.
48
62
  - HTTP failures, timeouts, malformed responses, empty catalogs, and catalogs with no visible models retain the previous SSO catalog or fall back to `IBM_BOB_MODELS`. Bob still enforces route access during inference.
49
63
 
@@ -54,7 +68,7 @@ Discovered context limits, output limits, vision support, reasoning support, bac
54
68
  Use Bob SSO through Pi:
55
69
 
56
70
  ```bash
57
- pi -e /Users/larry.song/work/hashicorp/pi-bob
71
+ pi -e .
58
72
  # inside Pi:
59
73
  # /login ibm-bob
60
74
  # /model ibm-bob/premium
@@ -67,8 +81,8 @@ For non-interactive use with an approved Bob API key, run:
67
81
  ```bash
68
82
  export IBM_BOB_API_KEY="..." # IBM_BOB_KEY is also accepted; do not commit either
69
83
 
70
- pi -e /Users/larry.song/work/hashicorp/pi-bob --list-models
71
- pi -e /Users/larry.song/work/hashicorp/pi-bob --model ibm-bob/premium
84
+ pi -e . --list-models
85
+ pi -e . --model ibm-bob/premium
72
86
  ```
73
87
 
74
88
  API keys use Bob's `Authorization: Apikey ...` scheme by default. If your approved credential is instead a bearer token, set `IBM_BOB_AUTH_SCHEME=Bearer`. Pi resolves credentials in this order: runtime `--api-key`, a stored credential (including SSO), then the provider's environment-key fallback. The model catalog follows stored SSO metadata when SSO remains configured. Run `/logout ibm-bob` before switching from SSO to either `IBM_BOB_API_KEY` or runtime `--api-key`; runtime-only keys cannot drive startup discovery.
@@ -109,7 +123,7 @@ For SSO, do **not** copy a token out of Bob's local credential store unless IBM
109
123
  | `IBM_BOB_READ_BOBSHELL_SETTINGS` | `true` | Read non-secret `instanceId`/`teamId` from `~/.bob/settings.json`. |
110
124
  | `IBM_BOB_INSTANCE_ID` | Bob setting | Override `x-instance-id`. |
111
125
  | `IBM_BOB_TEAM_ID` | Bob setting | Override `x-team-id`. |
112
- | `IBM_BOB_USER_AGENT` | `pi-bob/0.1.0` | User-Agent header sent to Bob endpoint. |
126
+ | `IBM_BOB_USER_AGENT` | `pi-bob/0.2.2` | User-Agent header sent to Bob endpoint. |
113
127
 
114
128
  ### Auth headers
115
129
 
@@ -141,21 +155,24 @@ export IBM_BOB_MAX_TOKENS_FIELD=max_tokens
141
155
  ```
142
156
 
143
157
  Bob's OpenAI-compatible route currently rejects `tools[].function.strict`, so `IBM_BOB_SUPPORTS_STRICT_MODE=false` is the default.
158
+ It also rejects OpenAI-specific `store` and `prompt_cache_key` request properties with a bare `422`, so both are always disabled for the OpenAI adapters.
144
159
 
145
160
  ## Validation performed
146
161
 
147
- Bob Shell SSO itself works locally:
162
+ ### Verified against Bob Shell 2.0.1 (2026-08-31)
148
163
 
149
- ```bash
150
- bob --auth-method sso -m premium -p 'Reply with exactly: bob-ok' --hide-intermediary-output --output-format json
151
- ```
164
+ - Live discovery through Pi: `pi --list-models` shows all 13 catalog models (`premium`, `premium-ide`, `premium-shell`, `ultra`, `fast`, `explorer`, `sonnet-4.5`, `wxO-model`, `gpt-oss-20b`, granite/rnj) from any directory, using stored SSO credentials.
165
+ - End-to-end inference through `openai-completions`: `premium`, `ultra`, and `premium-shell` all responded correctly to exact-reply smokes.
166
+ - SSO refresh on `/authn/v1/auth/refresh` returned `200` with a rotated refresh token; the refreshed token immediately passed `model/info`.
167
+ - `anthropic-messages` and `openai-responses` routes: `403 Access denied` with a valid token — Bob-side entitlement change, not an extension defect.
168
+ - `bun test`: 33 tests pass, including a fixture built from an observed 2.x `model/info` payload (no `litellm_params`, `max_output_tokens`, `supports_prompt_caching`).
152
169
 
153
- The response included `bob-ok` and successful usage stats for model `premium`.
170
+ ### Earlier validation (Bob Shell 1.x era)
154
171
 
155
172
  The unauthenticated Bob model-info endpoint responds as expected when called with a normal User-Agent:
156
173
 
157
174
  ```bash
158
- curl -H 'User-Agent: pi-bob/0.1.0' \
175
+ curl -H 'User-Agent: pi-bob/0.2.2' \
159
176
  https://api.us-east.bob.ibm.com/inference/v1/model/info
160
177
  ```
161
178
 
@@ -197,24 +214,45 @@ Result:
197
214
  pi-bob-ok
198
215
  ```
199
216
 
217
+ ## Troubleshooting
218
+
219
+ **`/login ibm-bob` freezes after the browser returns to Pi.** Pi refreshes
220
+ model catalogs for built-in providers after every login by fetching
221
+ `https://pi.dev/api/models/providers/<id>` (the remote-catalog provider). If
222
+ pi.dev's API is unreachable or hangs from your network, that post-login fetch
223
+ blocks with no timeout for roughly 5 minutes, and Ctrl-C cannot cancel it.
224
+ Run Pi with `PI_OFFLINE=1` to skip Pi's network catalog refresh entirely; the
225
+ login then returns to the prompt immediately after SSO, and ibm-bob's own
226
+ model discovery still runs.
227
+
228
+ **`/model` shows only `premium` under `ibm-bob`.** The stored credentials carry no cached model catalog — typical for credentials created before this version, or after Bob changes its catalog shape. Run `/login ibm-bob` once: login always rediscovers the catalog and re-attaches it to the stored credentials, and every later startup reuses it. Do **not** hand-edit `~/.pi/agent/auth.json` to add the catalog; Pi's auth store manages that file and may silently drop hand-written fields.
229
+
230
+ **Debugging the login flow.** Run with `IBM_BOB_DEBUG=1` and stderr
231
+ redirected to a file to see per-step timing for the SSO callback, token
232
+ exchange, and model discovery:
233
+
234
+ ```bash
235
+ IBM_BOB_DEBUG=1 pi 2>/tmp/pi-bob-debug.log
236
+ ```
237
+
200
238
  ## Install options
201
239
 
202
240
  Temporary test load:
203
241
 
204
242
  ```bash
205
- pi -e /Users/larry.song/work/hashicorp/pi-bob
243
+ pi -e .
206
244
  ```
207
245
 
208
246
  Install as a local Pi package:
209
247
 
210
248
  ```bash
211
- pi install /Users/larry.song/work/hashicorp/pi-bob
249
+ pi install npm:pi-bob
212
250
  ```
213
251
 
214
252
  Remove later:
215
253
 
216
254
  ```bash
217
- pi remove /Users/larry.song/work/hashicorp/pi-bob
255
+ pi remove npm:pi-bob
218
256
  ```
219
257
 
220
258
  ## Next steps
package/extensions/bob.ts CHANGED
@@ -239,7 +239,7 @@ function parseJsonHeaders(): Record<string, string> | undefined {
239
239
 
240
240
  function buildHeaders(settings?: BobShellSettings): Record<string, string> | undefined {
241
241
  const headers: Record<string, string> = {
242
- "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0",
242
+ "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.2",
243
243
  };
244
244
 
245
245
  const instanceId = env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId;
@@ -282,6 +282,10 @@ function buildOpenAiCompat(api: CompatibleApi): Record<string, unknown> | undefi
282
282
  supportsReasoningEffort: envBool("IBM_BOB_SUPPORTS_REASONING_EFFORT", false),
283
283
  supportsUsageInStreaming: envBool("IBM_BOB_SUPPORTS_USAGE_IN_STREAMING", true),
284
284
  supportsStrictMode: envBool("IBM_BOB_SUPPORTS_STRICT_MODE", false),
285
+ // pi-ai auto-detect defaults these to true for unknown endpoints; Bob's
286
+ // strict validator rejects `store` and `prompt_cache_key` with a bare 422.
287
+ supportsStore: false,
288
+ supportsLongCacheRetention: false,
285
289
  };
286
290
 
287
291
  const maxTokensField = env("IBM_BOB_MAX_TOKENS_FIELD") as MaxTokensField | undefined;
@@ -338,10 +342,12 @@ export function parseBobModelCatalog(payload: unknown): BobDiscoveredModel[] {
338
342
  const seen = new Set<string>();
339
343
  let structurallyValidEntries = 0;
340
344
  for (const entry of payload.data) {
341
- if (!isRecord(entry) || !isRecord(entry.model_info) || !isRecord(entry.litellm_params)) continue;
345
+ if (!isRecord(entry) || !isRecord(entry.model_info)) continue;
342
346
  const id = safeCatalogLabel(entry.model_name);
343
- const backend = safeCatalogLabel(entry.litellm_params.model);
344
- if (!id || !backend) continue;
347
+ if (!id) continue;
348
+ // bobshell 2.x model/info entries dropped litellm_params; fall back to the
349
+ // route name itself so discovery still works against 2.x payloads.
350
+ const backend = safeCatalogLabel((entry.litellm_params as Record<string, unknown> | undefined)?.model) || id;
345
351
  const exposed = entry.model_info.exposed;
346
352
  if (exposed !== undefined && typeof exposed !== "boolean") continue;
347
353
  structurallyValidEntries++;
@@ -359,7 +365,10 @@ export function parseBobModelCatalog(payload: unknown): BobDiscoveredModel[] {
359
365
  entry.model_info.supports_thinking === true,
360
366
  supportsVision: entry.model_info.supports_vision === true,
361
367
  contextWindow: positiveNumber(entry.model_info.max_input_tokens),
362
- maxTokens: positiveNumber(entry.model_info.max_tokens),
368
+ // bobshell 2.x adds max_output_tokens; keep the conservative 1.x
369
+ // max_tokens precedence and only use it as a fallback.
370
+ maxTokens:
371
+ positiveNumber(entry.model_info.max_tokens) ?? positiveNumber(entry.model_info.max_output_tokens),
363
372
  cost: {
364
373
  input: perMillionCost(entry.model_info.input_cost_per_token),
365
374
  output: perMillionCost(entry.model_info.output_cost_per_token),
@@ -497,18 +506,23 @@ async function fetchBobModelCatalog(
497
506
  authScheme: string,
498
507
  settings?: BobShellSettings,
499
508
  ): Promise<BobDiscoveredModel[]> {
509
+ const startedAt = Date.now();
500
510
  const controller = new AbortController();
501
511
  const timeoutMs = envInt("IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS", DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS);
502
512
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
503
513
  const headers: Record<string, string> = {
504
514
  Accept: "application/json",
505
515
  Authorization: `${authScheme} ${accessToken}`,
506
- "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0",
516
+ "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.2",
507
517
  };
508
- const instanceId = env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId;
518
+ const instanceId =
519
+ env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId ?? readInstanceFromJwt(accessToken);
509
520
  const teamId = env("IBM_BOB_TEAM_ID") ?? settings?.ibm?.teamId;
510
521
  if (instanceId) headers["x-instance-id"] = instanceId;
511
522
  if (teamId) headers["x-team-id"] = teamId;
523
+ bobLog(
524
+ `model discovery → ${providerBaseUrl().replace(/\/+$/, "")}/model/info (instance=${instanceId ?? "none"}, timeout=${timeoutMs}ms)`,
525
+ );
512
526
 
513
527
  try {
514
528
  const response = await fetch(`${providerBaseUrl().replace(/\/+$/, "")}/model/info`, {
@@ -521,11 +535,16 @@ async function fetchBobModelCatalog(
521
535
  `IBM Bob model discovery failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`.trim(),
522
536
  );
523
537
  }
524
- return parseBobModelCatalog(await readJsonResponse(response));
538
+ const catalog = parseBobModelCatalog(await readJsonResponse(response));
539
+ bobLog(`model discovery ok: ${catalog.length} models in ${Date.now() - startedAt}ms`);
540
+ return catalog;
525
541
  } catch (error) {
526
542
  if (controller.signal.aborted) {
527
543
  throw new Error(`IBM Bob model discovery timed out after ${timeoutMs}ms.`);
528
544
  }
545
+ bobLog(
546
+ `model discovery failed in ${Date.now() - startedAt}ms: ${error instanceof Error ? error.message : String(error)}`,
547
+ );
529
548
  throw error;
530
549
  } finally {
531
550
  clearTimeout(timeout);
@@ -572,6 +591,21 @@ function jwtExpiry(accessToken: string): number | undefined {
572
591
  return typeof exp === "number" ? exp * 1000 - 5 * 60 * 1000 : undefined;
573
592
  }
574
593
 
594
+ function readInstanceFromJwt(accessToken: string | undefined): string | undefined {
595
+ if (!accessToken) return undefined;
596
+ const instances = jwtPayload(accessToken)?.instances;
597
+ if (!Array.isArray(instances) || instances.length === 0) return undefined;
598
+ for (const entry of instances) {
599
+ if (isRecord(entry) && typeof entry.id === "string" && entry.id) return entry.id;
600
+ }
601
+ return undefined;
602
+ }
603
+
604
+ function bobLog(message: string): void {
605
+ if (!envBool("IBM_BOB_DEBUG", false)) return;
606
+ console.warn(`[pi-bob] ${new Date().toISOString()} ${message}`);
607
+ }
608
+
575
609
  export function applyBobAuthHeaders(
576
610
  headers: Record<string, string | null | undefined>,
577
611
  resolvedCredential?: string,
@@ -666,6 +700,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
666
700
 
667
701
  const code = new Promise<string>((resolve, reject) => {
668
702
  server.on("request", (req, res) => {
703
+ bobLog(`login: callback request received: ${(req.url ?? "").split("?")[0] || "(no path)"}`);
669
704
  if (!req.url?.startsWith("/bob-callback")) {
670
705
  res.writeHead(404, { "Content-Type": "text/plain" });
671
706
  res.end("Not Found");
@@ -679,6 +714,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
679
714
  const error = url.searchParams.get("error");
680
715
 
681
716
  if (returnedState !== state) {
717
+ bobLog(`login: callback state mismatch (expected ${state}, got ${returnedState})`);
682
718
  res.writeHead(400, { "Content-Type": "text/plain" });
683
719
  res.end("Invalid state parameter");
684
720
  reject(new Error("Invalid state parameter from IBM Bob SSO callback."));
@@ -686,6 +722,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
686
722
  }
687
723
 
688
724
  if (error) {
725
+ bobLog(`login: callback auth error: ${error}`);
689
726
  res.writeHead(400, { "Content-Type": "text/plain" });
690
727
  res.end("Authentication failed");
691
728
  reject(new Error(`IBM Bob SSO failed: ${error}`));
@@ -693,6 +730,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
693
730
  }
694
731
 
695
732
  if (!authCode) {
733
+ bobLog("login: callback missing authorization code");
696
734
  res.writeHead(400, { "Content-Type": "text/plain" });
697
735
  res.end("Missing authorization code");
698
736
  reject(new Error("IBM Bob SSO callback did not include an authorization code."));
@@ -712,7 +750,10 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
712
750
  reject(error);
713
751
  }
714
752
  });
715
- server.on("error", reject);
753
+ server.on("error", (error) => {
754
+ bobLog(`login: callback server error: ${error instanceof Error ? error.message : String(error)}`);
755
+ reject(error);
756
+ });
716
757
  });
717
758
 
718
759
  await new Promise<void>((resolve, reject) => {
@@ -733,13 +774,15 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
733
774
  }
734
775
 
735
776
  async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh", body: unknown): Promise<BobTokenResponse> {
777
+ const startedAt = Date.now();
736
778
  const controller = new AbortController();
737
779
  const timeoutMs = envInt("IBM_BOB_TOKEN_REQUEST_TIMEOUT_MS", DEFAULT_TOKEN_REQUEST_TIMEOUT_MS);
738
780
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
781
+ bobLog(`token request → ${bobOrigin()}${path} (timeout=${timeoutMs}ms)`);
739
782
  try {
740
783
  const response = await fetch(`${bobOrigin()}${path}`, {
741
784
  method: "POST",
742
- headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0" },
785
+ headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.2" },
743
786
  body: JSON.stringify(body),
744
787
  signal: controller.signal,
745
788
  });
@@ -750,6 +793,7 @@ async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh"
750
793
  );
751
794
  }
752
795
 
796
+ bobLog(`token request ok in ${Date.now() - startedAt}ms`);
753
797
  return (await readJsonResponse(response)) as BobTokenResponse;
754
798
  } catch (error) {
755
799
  if (controller.signal.aborted) throw new Error(`IBM Bob SSO token request timed out after ${timeoutMs}ms.`);
@@ -760,26 +804,73 @@ async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh"
760
804
  }
761
805
 
762
806
  async function loginBob(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
807
+ const startedAt = Date.now();
808
+ const elapsed = () => `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
809
+ bobLog("login: starting IBM Bob SSO");
810
+
763
811
  const state = randomBytes(16).toString("hex");
764
812
  const callbackServer = await startCallbackServer(state);
813
+ bobLog(`login: callback server listening at ${callbackServer.callbackUri} (+${elapsed()})`);
814
+
765
815
  const loginUrl = new URL(bobWebLoginUrl());
766
816
  loginUrl.searchParams.set("callback_uri", callbackServer.callbackUri);
767
817
  loginUrl.searchParams.set("state", state);
768
818
 
819
+ const signal = callbacks.signal;
769
820
  let loginTimeout: ReturnType<typeof setTimeout> | undefined;
821
+ let progressInterval: ReturnType<typeof setInterval> | undefined;
822
+ let onAbort: (() => void) | undefined;
770
823
  try {
771
824
  callbacks.onAuth({ url: loginUrl.toString() });
825
+ bobLog(`login: browser opened for SSO (+${elapsed()})`);
772
826
 
773
827
  const timeoutMs = envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000);
774
828
  const timeout = new Promise<never>((_resolve, reject) => {
775
- loginTimeout = setTimeout(() => reject(new Error("IBM Bob SSO timed out.")), timeoutMs);
829
+ loginTimeout = setTimeout(() => {
830
+ bobLog(`login: SSO timed out after ${timeoutMs}ms (+${elapsed()})`);
831
+ reject(new Error("IBM Bob SSO timed out."));
832
+ }, timeoutMs);
776
833
  });
777
834
 
778
- const code = await Promise.race([callbackServer.code, timeout]);
835
+ const abort =
836
+ signal === undefined
837
+ ? undefined
838
+ : new Promise<never>((_resolve, reject) => {
839
+ if (signal.aborted) {
840
+ bobLog("login: cancelled (abort signal already fired)");
841
+ reject(new Error("Login cancelled"));
842
+ return;
843
+ }
844
+ onAbort = () => {
845
+ bobLog(`login: cancelled via abort signal (+${elapsed()})`);
846
+ reject(new Error("Login cancelled"));
847
+ };
848
+ signal.addEventListener("abort", onAbort, { once: true });
849
+ });
850
+
851
+ progressInterval = setInterval(() => {
852
+ bobLog(`login: still waiting for SSO browser callback (+${elapsed()})`);
853
+ }, Math.min(15_000, Math.max(5_000, Math.floor(timeoutMs / 12))));
854
+
855
+ const races: Array<Promise<string | never>> = [callbackServer.code, timeout];
856
+ if (abort) races.push(abort);
857
+ const code = await Promise.race(races);
858
+ bobLog(`login: SSO callback received (+${elapsed()})`);
859
+
779
860
  const credentials = credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
780
- return attachBobModelCatalog(credentials);
861
+ bobLog(`login: access token exchanged (+${elapsed()})`);
862
+
863
+ const withCatalog = await attachBobModelCatalog(credentials);
864
+ bobLog(`login: model catalog attached (+${elapsed()})`);
865
+
866
+ return withCatalog;
867
+ } catch (error) {
868
+ bobLog(`login: failed (+${elapsed()}): ${error instanceof Error ? error.message : String(error)}`);
869
+ throw error;
781
870
  } finally {
782
871
  if (loginTimeout) clearTimeout(loginTimeout);
872
+ if (progressInterval) clearInterval(progressInterval);
873
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
783
874
  callbackServer.close();
784
875
  }
785
876
  }
@@ -790,6 +881,15 @@ async function refreshBobToken(credentials: OAuthCredentials): Promise<OAuthCred
790
881
  await postToken("/authn/v1/auth/refresh", { refresh_token: credentials.refresh }),
791
882
  credentials.refresh,
792
883
  );
884
+ // Token refresh runs inside pi-ai's locked credentials.modify (auth.json lock).
885
+ // Avoid holding that global lock across a model-discovery network call when a
886
+ // catalog is already cached; rediscovery still happens on login and whenever
887
+ // no cached catalog exists.
888
+ const existingCatalog = cachedCatalog(credentials);
889
+ if (existingCatalog) {
890
+ bobLog(`refresh: reusing cached catalog (${existingCatalog.length} models); skipping rediscovery`);
891
+ return { ...refreshed, bobModelCatalog: existingCatalog };
892
+ }
793
893
  return attachBobModelCatalog(refreshed, credentials);
794
894
  }
795
895
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bob",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Pi provider extension for IBM Bob / IBM-approved compatible model endpoints.",
6
6
  "keywords": [
@@ -24,7 +24,7 @@
24
24
  "license": "MIT",
25
25
  "repository": {
26
26
  "type": "git",
27
- "url": "https://github.com/songlining/pi-bob.git"
27
+ "url": "git+https://github.com/songlining/pi-bob.git"
28
28
  },
29
29
  "bugs": {
30
30
  "url": "https://github.com/songlining/pi-bob/issues"