pi-bob 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -8,8 +8,8 @@ The package registers Bob through Pi's built-in compatible provider APIs and dis
8
8
 
9
9
  From the installed `bobshell@1.0.6` package and the local redacted Bob configuration:
10
10
 
11
- - Bob Shell CLI: `/opt/homebrew/bin/bob`
12
- - Bob Shell package: `/opt/homebrew/lib/node_modules/bobshell`
11
+ - Bob Shell CLI: `bob`
12
+ - Bob Shell package: `bobshell`
13
13
  - Installed auth method: `sso`
14
14
  - Default Bob API host: `https://api.us-east.bob.ibm.com`
15
15
  - OpenAI-compatible chat base URL: `https://api.us-east.bob.ibm.com/inference/v1`
@@ -43,7 +43,7 @@ The extension registers provider id `ibm-bob`. It uses an isolated `ibm-bob-comp
43
43
  Model discovery is enabled by default:
44
44
 
45
45
  - 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.
46
+ - 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
47
  - Entries returned by the authenticated catalog are registered when `model_info.exposed` is omitted or `true`. Routes explicitly marked `exposed: false` are ignored.
48
48
  - 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
49
 
@@ -54,7 +54,7 @@ Discovered context limits, output limits, vision support, reasoning support, bac
54
54
  Use Bob SSO through Pi:
55
55
 
56
56
  ```bash
57
- pi -e /Users/larry.song/work/hashicorp/pi-bob
57
+ pi -e .
58
58
  # inside Pi:
59
59
  # /login ibm-bob
60
60
  # /model ibm-bob/premium
@@ -67,8 +67,8 @@ For non-interactive use with an approved Bob API key, run:
67
67
  ```bash
68
68
  export IBM_BOB_API_KEY="..." # IBM_BOB_KEY is also accepted; do not commit either
69
69
 
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
70
+ pi -e . --list-models
71
+ pi -e . --model ibm-bob/premium
72
72
  ```
73
73
 
74
74
  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 +109,7 @@ For SSO, do **not** copy a token out of Bob's local credential store unless IBM
109
109
  | `IBM_BOB_READ_BOBSHELL_SETTINGS` | `true` | Read non-secret `instanceId`/`teamId` from `~/.bob/settings.json`. |
110
110
  | `IBM_BOB_INSTANCE_ID` | Bob setting | Override `x-instance-id`. |
111
111
  | `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. |
112
+ | `IBM_BOB_USER_AGENT` | `pi-bob/0.2.0` | User-Agent header sent to Bob endpoint. |
113
113
 
114
114
  ### Auth headers
115
115
 
@@ -141,6 +141,7 @@ export IBM_BOB_MAX_TOKENS_FIELD=max_tokens
141
141
  ```
142
142
 
143
143
  Bob's OpenAI-compatible route currently rejects `tools[].function.strict`, so `IBM_BOB_SUPPORTS_STRICT_MODE=false` is the default.
144
+ 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
145
 
145
146
  ## Validation performed
146
147
 
@@ -197,24 +198,43 @@ Result:
197
198
  pi-bob-ok
198
199
  ```
199
200
 
201
+ ## Troubleshooting
202
+
203
+ **`/login ibm-bob` freezes after the browser returns to Pi.** Pi refreshes
204
+ model catalogs for built-in providers after every login by fetching
205
+ `https://pi.dev/api/models/providers/<id>` (the remote-catalog provider). If
206
+ pi.dev's API is unreachable or hangs from your network, that post-login fetch
207
+ blocks with no timeout for roughly 5 minutes, and Ctrl-C cannot cancel it.
208
+ Run Pi with `PI_OFFLINE=1` to skip Pi's network catalog refresh entirely; the
209
+ login then returns to the prompt immediately after SSO, and ibm-bob's own
210
+ model discovery still runs.
211
+
212
+ **Debugging the login flow.** Run with `IBM_BOB_DEBUG=1` and stderr
213
+ redirected to a file to see per-step timing for the SSO callback, token
214
+ exchange, and model discovery:
215
+
216
+ ```bash
217
+ IBM_BOB_DEBUG=1 pi 2>/tmp/pi-bob-debug.log
218
+ ```
219
+
200
220
  ## Install options
201
221
 
202
222
  Temporary test load:
203
223
 
204
224
  ```bash
205
- pi -e /Users/larry.song/work/hashicorp/pi-bob
225
+ pi -e .
206
226
  ```
207
227
 
208
228
  Install as a local Pi package:
209
229
 
210
230
  ```bash
211
- pi install /Users/larry.song/work/hashicorp/pi-bob
231
+ pi install npm:pi-bob
212
232
  ```
213
233
 
214
234
  Remove later:
215
235
 
216
236
  ```bash
217
- pi remove /Users/larry.song/work/hashicorp/pi-bob
237
+ pi remove npm:pi-bob
218
238
  ```
219
239
 
220
240
  ## 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.0",
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;
@@ -497,18 +501,23 @@ async function fetchBobModelCatalog(
497
501
  authScheme: string,
498
502
  settings?: BobShellSettings,
499
503
  ): Promise<BobDiscoveredModel[]> {
504
+ const startedAt = Date.now();
500
505
  const controller = new AbortController();
501
506
  const timeoutMs = envInt("IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS", DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS);
502
507
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
503
508
  const headers: Record<string, string> = {
504
509
  Accept: "application/json",
505
510
  Authorization: `${authScheme} ${accessToken}`,
506
- "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0",
511
+ "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0",
507
512
  };
508
- const instanceId = env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId;
513
+ const instanceId =
514
+ env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId ?? readInstanceFromJwt(accessToken);
509
515
  const teamId = env("IBM_BOB_TEAM_ID") ?? settings?.ibm?.teamId;
510
516
  if (instanceId) headers["x-instance-id"] = instanceId;
511
517
  if (teamId) headers["x-team-id"] = teamId;
518
+ bobLog(
519
+ `model discovery → ${providerBaseUrl().replace(/\/+$/, "")}/model/info (instance=${instanceId ?? "none"}, timeout=${timeoutMs}ms)`,
520
+ );
512
521
 
513
522
  try {
514
523
  const response = await fetch(`${providerBaseUrl().replace(/\/+$/, "")}/model/info`, {
@@ -521,11 +530,16 @@ async function fetchBobModelCatalog(
521
530
  `IBM Bob model discovery failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`.trim(),
522
531
  );
523
532
  }
524
- return parseBobModelCatalog(await readJsonResponse(response));
533
+ const catalog = parseBobModelCatalog(await readJsonResponse(response));
534
+ bobLog(`model discovery ok: ${catalog.length} models in ${Date.now() - startedAt}ms`);
535
+ return catalog;
525
536
  } catch (error) {
526
537
  if (controller.signal.aborted) {
527
538
  throw new Error(`IBM Bob model discovery timed out after ${timeoutMs}ms.`);
528
539
  }
540
+ bobLog(
541
+ `model discovery failed in ${Date.now() - startedAt}ms: ${error instanceof Error ? error.message : String(error)}`,
542
+ );
529
543
  throw error;
530
544
  } finally {
531
545
  clearTimeout(timeout);
@@ -572,6 +586,21 @@ function jwtExpiry(accessToken: string): number | undefined {
572
586
  return typeof exp === "number" ? exp * 1000 - 5 * 60 * 1000 : undefined;
573
587
  }
574
588
 
589
+ function readInstanceFromJwt(accessToken: string | undefined): string | undefined {
590
+ if (!accessToken) return undefined;
591
+ const instances = jwtPayload(accessToken)?.instances;
592
+ if (!Array.isArray(instances) || instances.length === 0) return undefined;
593
+ for (const entry of instances) {
594
+ if (isRecord(entry) && typeof entry.id === "string" && entry.id) return entry.id;
595
+ }
596
+ return undefined;
597
+ }
598
+
599
+ function bobLog(message: string): void {
600
+ if (!envBool("IBM_BOB_DEBUG", false)) return;
601
+ console.warn(`[pi-bob] ${new Date().toISOString()} ${message}`);
602
+ }
603
+
575
604
  export function applyBobAuthHeaders(
576
605
  headers: Record<string, string | null | undefined>,
577
606
  resolvedCredential?: string,
@@ -666,6 +695,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
666
695
 
667
696
  const code = new Promise<string>((resolve, reject) => {
668
697
  server.on("request", (req, res) => {
698
+ bobLog(`login: callback request received: ${(req.url ?? "").split("?")[0] || "(no path)"}`);
669
699
  if (!req.url?.startsWith("/bob-callback")) {
670
700
  res.writeHead(404, { "Content-Type": "text/plain" });
671
701
  res.end("Not Found");
@@ -679,6 +709,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
679
709
  const error = url.searchParams.get("error");
680
710
 
681
711
  if (returnedState !== state) {
712
+ bobLog(`login: callback state mismatch (expected ${state}, got ${returnedState})`);
682
713
  res.writeHead(400, { "Content-Type": "text/plain" });
683
714
  res.end("Invalid state parameter");
684
715
  reject(new Error("Invalid state parameter from IBM Bob SSO callback."));
@@ -686,6 +717,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
686
717
  }
687
718
 
688
719
  if (error) {
720
+ bobLog(`login: callback auth error: ${error}`);
689
721
  res.writeHead(400, { "Content-Type": "text/plain" });
690
722
  res.end("Authentication failed");
691
723
  reject(new Error(`IBM Bob SSO failed: ${error}`));
@@ -693,6 +725,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
693
725
  }
694
726
 
695
727
  if (!authCode) {
728
+ bobLog("login: callback missing authorization code");
696
729
  res.writeHead(400, { "Content-Type": "text/plain" });
697
730
  res.end("Missing authorization code");
698
731
  reject(new Error("IBM Bob SSO callback did not include an authorization code."));
@@ -712,7 +745,10 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
712
745
  reject(error);
713
746
  }
714
747
  });
715
- server.on("error", reject);
748
+ server.on("error", (error) => {
749
+ bobLog(`login: callback server error: ${error instanceof Error ? error.message : String(error)}`);
750
+ reject(error);
751
+ });
716
752
  });
717
753
 
718
754
  await new Promise<void>((resolve, reject) => {
@@ -733,13 +769,15 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
733
769
  }
734
770
 
735
771
  async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh", body: unknown): Promise<BobTokenResponse> {
772
+ const startedAt = Date.now();
736
773
  const controller = new AbortController();
737
774
  const timeoutMs = envInt("IBM_BOB_TOKEN_REQUEST_TIMEOUT_MS", DEFAULT_TOKEN_REQUEST_TIMEOUT_MS);
738
775
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
776
+ bobLog(`token request → ${bobOrigin()}${path} (timeout=${timeoutMs}ms)`);
739
777
  try {
740
778
  const response = await fetch(`${bobOrigin()}${path}`, {
741
779
  method: "POST",
742
- headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0" },
780
+ headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0" },
743
781
  body: JSON.stringify(body),
744
782
  signal: controller.signal,
745
783
  });
@@ -750,6 +788,7 @@ async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh"
750
788
  );
751
789
  }
752
790
 
791
+ bobLog(`token request ok in ${Date.now() - startedAt}ms`);
753
792
  return (await readJsonResponse(response)) as BobTokenResponse;
754
793
  } catch (error) {
755
794
  if (controller.signal.aborted) throw new Error(`IBM Bob SSO token request timed out after ${timeoutMs}ms.`);
@@ -760,26 +799,73 @@ async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh"
760
799
  }
761
800
 
762
801
  async function loginBob(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
802
+ const startedAt = Date.now();
803
+ const elapsed = () => `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
804
+ bobLog("login: starting IBM Bob SSO");
805
+
763
806
  const state = randomBytes(16).toString("hex");
764
807
  const callbackServer = await startCallbackServer(state);
808
+ bobLog(`login: callback server listening at ${callbackServer.callbackUri} (+${elapsed()})`);
809
+
765
810
  const loginUrl = new URL(bobWebLoginUrl());
766
811
  loginUrl.searchParams.set("callback_uri", callbackServer.callbackUri);
767
812
  loginUrl.searchParams.set("state", state);
768
813
 
814
+ const signal = callbacks.signal;
769
815
  let loginTimeout: ReturnType<typeof setTimeout> | undefined;
816
+ let progressInterval: ReturnType<typeof setInterval> | undefined;
817
+ let onAbort: (() => void) | undefined;
770
818
  try {
771
819
  callbacks.onAuth({ url: loginUrl.toString() });
820
+ bobLog(`login: browser opened for SSO (+${elapsed()})`);
772
821
 
773
822
  const timeoutMs = envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000);
774
823
  const timeout = new Promise<never>((_resolve, reject) => {
775
- loginTimeout = setTimeout(() => reject(new Error("IBM Bob SSO timed out.")), timeoutMs);
824
+ loginTimeout = setTimeout(() => {
825
+ bobLog(`login: SSO timed out after ${timeoutMs}ms (+${elapsed()})`);
826
+ reject(new Error("IBM Bob SSO timed out."));
827
+ }, timeoutMs);
776
828
  });
777
829
 
778
- const code = await Promise.race([callbackServer.code, timeout]);
830
+ const abort =
831
+ signal === undefined
832
+ ? undefined
833
+ : new Promise<never>((_resolve, reject) => {
834
+ if (signal.aborted) {
835
+ bobLog("login: cancelled (abort signal already fired)");
836
+ reject(new Error("Login cancelled"));
837
+ return;
838
+ }
839
+ onAbort = () => {
840
+ bobLog(`login: cancelled via abort signal (+${elapsed()})`);
841
+ reject(new Error("Login cancelled"));
842
+ };
843
+ signal.addEventListener("abort", onAbort, { once: true });
844
+ });
845
+
846
+ progressInterval = setInterval(() => {
847
+ bobLog(`login: still waiting for SSO browser callback (+${elapsed()})`);
848
+ }, Math.min(15_000, Math.max(5_000, Math.floor(timeoutMs / 12))));
849
+
850
+ const races: Array<Promise<string | never>> = [callbackServer.code, timeout];
851
+ if (abort) races.push(abort);
852
+ const code = await Promise.race(races);
853
+ bobLog(`login: SSO callback received (+${elapsed()})`);
854
+
779
855
  const credentials = credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
780
- return attachBobModelCatalog(credentials);
856
+ bobLog(`login: access token exchanged (+${elapsed()})`);
857
+
858
+ const withCatalog = await attachBobModelCatalog(credentials);
859
+ bobLog(`login: model catalog attached (+${elapsed()})`);
860
+
861
+ return withCatalog;
862
+ } catch (error) {
863
+ bobLog(`login: failed (+${elapsed()}): ${error instanceof Error ? error.message : String(error)}`);
864
+ throw error;
781
865
  } finally {
782
866
  if (loginTimeout) clearTimeout(loginTimeout);
867
+ if (progressInterval) clearInterval(progressInterval);
868
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
783
869
  callbackServer.close();
784
870
  }
785
871
  }
@@ -790,6 +876,15 @@ async function refreshBobToken(credentials: OAuthCredentials): Promise<OAuthCred
790
876
  await postToken("/authn/v1/auth/refresh", { refresh_token: credentials.refresh }),
791
877
  credentials.refresh,
792
878
  );
879
+ // Token refresh runs inside pi-ai's locked credentials.modify (auth.json lock).
880
+ // Avoid holding that global lock across a model-discovery network call when a
881
+ // catalog is already cached; rediscovery still happens on login and whenever
882
+ // no cached catalog exists.
883
+ const existingCatalog = cachedCatalog(credentials);
884
+ if (existingCatalog) {
885
+ bobLog(`refresh: reusing cached catalog (${existingCatalog.length} models); skipping rediscovery`);
886
+ return { ...refreshed, bobModelCatalog: existingCatalog };
887
+ }
793
888
  return attachBobModelCatalog(refreshed, credentials);
794
889
  }
795
890
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bob",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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"