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.
- package/README.md +55 -17
- package/extensions/bob.ts +113 -13
- 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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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
|
|
71
|
-
pi -e
|
|
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.
|
|
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
|
|
162
|
+
### Verified against Bob Shell 2.0.1 (2026-08-31)
|
|
148
163
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
243
|
+
pi -e .
|
|
206
244
|
```
|
|
207
245
|
|
|
208
246
|
Install as a local Pi package:
|
|
209
247
|
|
|
210
248
|
```bash
|
|
211
|
-
pi install
|
|
249
|
+
pi install npm:pi-bob
|
|
212
250
|
```
|
|
213
251
|
|
|
214
252
|
Remove later:
|
|
215
253
|
|
|
216
254
|
```bash
|
|
217
|
-
pi remove
|
|
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.
|
|
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)
|
|
345
|
+
if (!isRecord(entry) || !isRecord(entry.model_info)) continue;
|
|
342
346
|
const id = safeCatalogLabel(entry.model_name);
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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.
|
|
516
|
+
"User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.2",
|
|
507
517
|
};
|
|
508
|
-
const 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
|
-
|
|
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",
|
|
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.
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|