pi-bob 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Larry Song
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  Pi provider package for IBM Bob / IBM-approved enterprise model endpoints.
4
4
 
5
- This first pass implements **Option 1** from `PLAN.md`: register Bob as a Pi provider using Pi's built-in compatible provider APIs. It does not scrape Bob, extract browser/session credentials, or bypass IBM-approved access paths.
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
7
  ## Discovered local Bob Shell settings
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`
@@ -36,14 +36,25 @@ Set `IBM_BOB_API` to one of Pi's compatible API adapters:
36
36
  - `openai-responses` — OpenAI Responses-compatible routes.
37
37
  - `anthropic-messages` — Anthropic Messages-compatible routes.
38
38
 
39
- The extension registers provider id `ibm-bob`.
39
+ 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
+
41
+ ## Dynamic model discovery
42
+
43
+ Model discovery is enabled by default:
44
+
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 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
+ - Entries returned by the authenticated catalog are registered when `model_info.exposed` is omitted or `true`. Routes explicitly marked `exposed: false` are ignored.
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
+
50
+ Discovered context limits, output limits, vision support, reasoning support, backend identifiers, and token prices are mapped into Pi model definitions. Bob reports prices per token; Pi displays prices per million tokens, so the extension performs that conversion.
40
51
 
41
52
  ## Quick start for the discovered Bob endpoint
42
53
 
43
54
  Use Bob SSO through Pi:
44
55
 
45
56
  ```bash
46
- pi -e /Users/larry.song/work/hashicorp/pi-bob
57
+ pi -e .
47
58
  # inside Pi:
48
59
  # /login ibm-bob
49
60
  # /model ibm-bob/premium
@@ -51,30 +62,28 @@ pi -e /Users/larry.song/work/hashicorp/pi-bob
51
62
 
52
63
  Pi stores the resulting Bob SSO access/refresh tokens in Pi's normal auth store (`~/.pi/agent/auth.json`). This extension obtains those tokens only through Bob's browser SSO endpoints; it does not read Bob Shell's stored SSO secrets.
53
64
 
54
- For non-interactive/API-key use, if you have an approved Bob bearer token/API token available outside Bob Shell, run:
65
+ For non-interactive use with an approved Bob API key, run:
55
66
 
56
67
  ```bash
57
- export IBM_BOB_API_KEY="..." # do not commit this
68
+ export IBM_BOB_API_KEY="..." # IBM_BOB_KEY is also accepted; do not commit either
58
69
 
59
- pi -e /Users/larry.song/work/hashicorp/pi-bob --list-models
60
- 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
61
72
  ```
62
73
 
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.
75
+
63
76
  Defaults are already set to:
64
77
 
65
78
  ```bash
66
79
  IBM_BOB_BASE_URL="https://api.us-east.bob.ibm.com/inference/v1"
67
80
  IBM_BOB_API="openai-completions"
68
- IBM_BOB_MODELS="premium"
69
- IBM_BOB_CONTEXT_WINDOW="200000"
70
- IBM_BOB_MAX_TOKENS="8192"
81
+ IBM_BOB_DISCOVER_MODELS="true"
82
+ IBM_BOB_MODELS="premium" # fallback catalog
83
+ IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS="5000"
71
84
  ```
72
85
 
73
- For IBM Bob API keys that require `Authorization: Apikey ...` rather than `Authorization: Bearer ...`:
74
-
75
- ```bash
76
- export IBM_BOB_AUTH_SCHEME="Apikey"
77
- ```
86
+ Discovered metadata is used unless a corresponding metadata override is set. Without a discovered catalog, fallback models use a 200,000-token context window and 8,192-token output limit.
78
87
 
79
88
  For SSO, do **not** copy a token out of Bob's local credential store unless IBM policy explicitly permits it. Use `/login ibm-bob` instead.
80
89
 
@@ -84,10 +93,14 @@ For SSO, do **not** copy a token out of Bob's local credential store unless IBM
84
93
 
85
94
  | Variable | Default | Description |
86
95
  | --- | --- | --- |
87
- | `IBM_BOB_BASE_URL` | `https://api.us-east.bob.ibm.com/inference/v1` | Approved Bob/IBM endpoint base URL. |
88
- | `IBM_BOB_MODELS` | `premium` | Comma-separated model IDs exposed by the endpoint. |
96
+ | `IBM_BOB_BASE_URL` | `https://api.us-east.bob.ibm.com/inference/v1` | Approved Bob/IBM endpoint base URL. For `anthropic-messages`, a trailing `/inference/v1` is normalized so the adapter sends requests to `/inference/v1/messages` rather than `/inference/v1/v1/messages`. |
97
+ | `IBM_BOB_MODELS` | `premium` | Comma-separated fallback model IDs used when discovery is unavailable. |
89
98
  | `IBM_BOB_API_KEY` | unset | Approved API key/token. Keep it out of repo files. |
99
+ | `IBM_BOB_KEY` | unset | Alias for `IBM_BOB_API_KEY`, matching Bob/OpenCode configuration. |
90
100
  | `IBM_BOB_API` | `openai-completions` | One of `openai-completions`, `openai-responses`, `anthropic-messages`. |
101
+ | `IBM_BOB_DISCOVER_MODELS` | `true` | Discover visible models from `/model/info`; entries explicitly marked `exposed: false` are excluded. |
102
+ | `IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS` | `5000` | Startup/login discovery timeout in milliseconds. |
103
+ | `IBM_BOB_TOKEN_REQUEST_TIMEOUT_MS` | `10000` | SSO token exchange and refresh timeout in milliseconds. |
91
104
 
92
105
  ### Bob routing headers
93
106
 
@@ -96,26 +109,26 @@ For SSO, do **not** copy a token out of Bob's local credential store unless IBM
96
109
  | `IBM_BOB_READ_BOBSHELL_SETTINGS` | `true` | Read non-secret `instanceId`/`teamId` from `~/.bob/settings.json`. |
97
110
  | `IBM_BOB_INSTANCE_ID` | Bob setting | Override `x-instance-id`. |
98
111
  | `IBM_BOB_TEAM_ID` | Bob setting | Override `x-team-id`. |
99
- | `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. |
100
113
 
101
114
  ### Auth headers
102
115
 
103
116
  | Variable | Default | Description |
104
117
  | --- | --- | --- |
105
- | `IBM_BOB_AUTH_SCHEME` | unset/Bearer via Pi's OpenAI adapter | Set to `Apikey` when the Bob token must be sent as `Authorization: Apikey $IBM_BOB_API_KEY`. Do not set this for `/login ibm-bob` SSO. |
118
+ | `IBM_BOB_AUTH_SCHEME` | `Apikey` for environment API keys | Override with `Bearer` when the environment credential is a bearer token. SSO always uses Bearer automatically. |
106
119
  | `IBM_BOB_HEADERS_JSON` | unset | JSON object of extra headers. Values may use Pi env interpolation such as `"$IBM_BOB_API_KEY"`. |
107
120
 
108
121
  ### Model metadata
109
122
 
110
123
  | Variable | Default | Description |
111
124
  | --- | --- | --- |
112
- | `IBM_BOB_CONTEXT_WINDOW` | `200000` | Context window Pi should assume. Keep this at or below Bob's backend max input tokens so Pi compacts before Bob rejects the request. |
113
- | `IBM_BOB_MAX_TOKENS` | `8192` | Max output tokens Pi should request/allow. |
114
- | `IBM_BOB_INPUT` | `text` | Comma-separated input types: `text` or `text,image`. |
115
- | `IBM_BOB_REASONING` | `false` | Mark all configured models as reasoning-capable. |
116
- | `IBM_BOB_REASONING_MODELS` | empty | Comma-separated subset of model IDs that support reasoning. |
125
+ | `IBM_BOB_CONTEXT_WINDOW` | discovered; fallback `200000` | Override the context window for every Bob model. Keep it at or below the backend limit so Pi compacts before Bob rejects the request. |
126
+ | `IBM_BOB_MAX_TOKENS` | discovered; fallback `8192` | Override maximum output tokens for every Bob model. |
127
+ | `IBM_BOB_INPUT` | discovered; fallback `text` | Override input types with `text` or `text,image`. |
128
+ | `IBM_BOB_REASONING` | discovered; fallback `false` | Explicitly enable or disable reasoning for all models. |
129
+ | `IBM_BOB_REASONING_MODELS` | empty | Comma-separated model IDs to mark as reasoning-capable. |
117
130
 
118
- Pricing is set to zero until IBM-specific metering details are known.
131
+ Discovered pricing comes from Bob's model-info response and is converted to Pi's per-million-token units. Fallback models retain zero pricing.
119
132
 
120
133
  ### OpenAI compatibility toggles
121
134
 
@@ -128,6 +141,7 @@ export IBM_BOB_MAX_TOKENS_FIELD=max_tokens
128
141
  ```
129
142
 
130
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.
131
145
 
132
146
  ## Validation performed
133
147
 
@@ -150,16 +164,17 @@ Result: HTTP `401` with `Authentication required`, confirming the discovered rou
150
164
 
151
165
  The Bob SSO endpoint flow was smoke-tested independently: browser SSO callback succeeded, token exchange succeeded, and `GET /inference/v1/model/info` returned HTTP `200` using the fresh SSO token.
152
166
 
153
- The Pi provider registers with default settings when an env token is present:
167
+ The Pi provider registers with fallback settings when no authenticated catalog is available:
154
168
 
155
169
  ```bash
156
- IBM_BOB_API_KEY=dummy pi -e . --list-models | grep ibm-bob
170
+ pi -e . --list-models | grep ibm-bob
157
171
  ```
158
172
 
159
- Result:
173
+ Automated tests cover LiteLLM response validation, hidden-model filtering, per-million cost conversion, API-key discovery, `IBM_BOB_KEY` compatibility, fallback behavior, SSO refresh discovery, routing headers, cached catalog replacement, and final authentication headers/routes through all three advertised adapters:
160
174
 
161
- ```text
162
- ibm-bob premium 200K 8.2K no no
175
+ ```bash
176
+ bun test
177
+ npm run check
163
178
  ```
164
179
 
165
180
  A dummy-token Pi request reaches the Bob endpoint and fails with the expected auth error:
@@ -183,30 +198,48 @@ Result:
183
198
  pi-bob-ok
184
199
  ```
185
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
+
186
220
  ## Install options
187
221
 
188
222
  Temporary test load:
189
223
 
190
224
  ```bash
191
- pi -e /Users/larry.song/work/hashicorp/pi-bob
225
+ pi -e .
192
226
  ```
193
227
 
194
228
  Install as a local Pi package:
195
229
 
196
230
  ```bash
197
- pi install /Users/larry.song/work/hashicorp/pi-bob
231
+ pi install npm:pi-bob
198
232
  ```
199
233
 
200
234
  Remove later:
201
235
 
202
236
  ```bash
203
- pi remove /Users/larry.song/work/hashicorp/pi-bob
237
+ pi remove npm:pi-bob
204
238
  ```
205
239
 
206
240
  ## Next steps
207
241
 
208
- Option 1 is wired to the discovered Bob OpenAI-compatible route and `/login ibm-bob` is implemented. Next useful improvements:
242
+ The compatible provider, `/login ibm-bob`, and dynamic model discovery are implemented. Next useful improvements:
209
243
 
210
244
  1. Add a `/bob-status` command that checks auth, model-info, selected instance, and selected team without printing secrets.
211
- 2. Add optional dynamic model discovery from `/inference/v1/model/info` after login.
212
- 3. Add tests around Bob's SSO callback and token refresh helpers.
245
+ 2. Run periodic compatibility smoke tests against representative Claude, GPT, and Gemini routes exposed by Bob.
package/extensions/bob.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
2
+ import type {
3
+ Api,
4
+ AssistantMessageEventStream,
5
+ Context,
6
+ Model,
7
+ OAuthCredentials,
8
+ OAuthLoginCallbacks,
9
+ SimpleStreamOptions,
10
+ } from "@earendil-works/pi-ai";
11
+ import { anthropicMessagesApi, openAICompletionsApi, openAIResponsesApi } from "@earendil-works/pi-ai/compat";
3
12
  import { randomBytes } from "node:crypto";
4
13
  import { readFileSync } from "node:fs";
5
- import { createServer, type Server } from "node:http";
14
+ import { createServer } from "node:http";
6
15
  import { createServer as createNetServer } from "node:net";
7
16
  import { homedir } from "node:os";
8
17
  import { join } from "node:path";
@@ -27,6 +36,20 @@ interface BobModelConfig {
27
36
  compat?: Record<string, unknown>;
28
37
  }
29
38
 
39
+ export interface BobDiscoveredModel {
40
+ id: string;
41
+ backend: string;
42
+ reasoning: boolean;
43
+ supportsVision: boolean;
44
+ contextWindow?: number;
45
+ maxTokens?: number;
46
+ cost: BobModelConfig["cost"];
47
+ }
48
+
49
+ interface BobOAuthCredentials extends OAuthCredentials {
50
+ bobModelCatalog?: BobDiscoveredModel[];
51
+ }
52
+
30
53
  interface BobShellSettings {
31
54
  ibm?: {
32
55
  instanceId?: string;
@@ -42,19 +65,26 @@ interface BobTokenResponse {
42
65
  }
43
66
 
44
67
  interface CallbackServer {
45
- port: number;
46
68
  callbackUri: string;
47
69
  code: Promise<string>;
48
70
  close(): void;
49
71
  }
50
72
 
51
73
  const PROVIDER_ID = "ibm-bob";
74
+ const BOB_API = "ibm-bob-compatible" as Api;
52
75
  const DEFAULT_BOB_ORIGIN = "https://api.us-east.bob.ibm.com";
53
76
  const DEFAULT_BASE_URL = `${DEFAULT_BOB_ORIGIN}/inference/v1`;
54
77
  const DEFAULT_WEB_LOGIN_URL = "https://bob.ibm.com/login";
55
78
  const DEFAULT_MODELS = ["premium"];
56
79
  const DEFAULT_CONTEXT_WINDOW = 200_000;
57
80
  const DEFAULT_MAX_TOKENS = 8192;
81
+ const DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS = 5000;
82
+ const DEFAULT_TOKEN_REQUEST_TIMEOUT_MS = 10_000;
83
+ const MAX_RESPONSE_BYTES = 1_048_576;
84
+ const MAX_CATALOG_ENTRIES = 500;
85
+ const MAX_CATALOG_LABEL_LENGTH = 512;
86
+ const CATALOG_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
87
+ const CATALOG_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
58
88
  const SUPPORTED_APIS = new Set<CompatibleApi>(["openai-completions", "openai-responses", "anthropic-messages"]);
59
89
 
60
90
  function env(name: string): string | undefined {
@@ -63,10 +93,14 @@ function env(name: string): string | undefined {
63
93
  }
64
94
 
65
95
  function envInt(name: string, fallback: number): number {
96
+ return envOptionalInt(name) ?? fallback;
97
+ }
98
+
99
+ function envOptionalInt(name: string): number | undefined {
66
100
  const value = env(name);
67
- if (!value) return fallback;
101
+ if (!value) return undefined;
68
102
  const parsed = Number.parseInt(value, 10);
69
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
103
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
70
104
  }
71
105
 
72
106
  function envBool(name: string, fallback: boolean): boolean {
@@ -77,6 +111,14 @@ function envBool(name: string, fallback: boolean): boolean {
77
111
  return fallback;
78
112
  }
79
113
 
114
+ function envOptionalBool(name: string): boolean | undefined {
115
+ const value = env(name);
116
+ if (!value) return undefined;
117
+ if (["1", "true", "yes", "y", "on"].includes(value.toLowerCase())) return true;
118
+ if (["0", "false", "no", "n", "off"].includes(value.toLowerCase())) return false;
119
+ return undefined;
120
+ }
121
+
80
122
  function envCsv(name: string, fallback: string[] = []): string[] {
81
123
  const value = env(name);
82
124
  if (!value) return fallback;
@@ -110,6 +152,26 @@ function providerBaseUrl(): string {
110
152
  return env("IBM_BOB_BASE_URL") ?? DEFAULT_BASE_URL;
111
153
  }
112
154
 
155
+ function providerRequestBaseUrl(api: CompatibleApi): string {
156
+ const baseUrl = providerBaseUrl().replace(/\/+$/, "");
157
+ return api === "anthropic-messages" ? baseUrl.replace(/\/inference\/v1$/, "/inference") : baseUrl;
158
+ }
159
+
160
+ function configuredApiKey(): { envName: "IBM_BOB_API_KEY" | "IBM_BOB_KEY"; value: string } | undefined {
161
+ const primary = env("IBM_BOB_API_KEY");
162
+ if (primary) return { envName: "IBM_BOB_API_KEY", value: primary };
163
+ const alias = env("IBM_BOB_KEY");
164
+ return alias ? { envName: "IBM_BOB_KEY", value: alias } : undefined;
165
+ }
166
+
167
+ function providerApiKeyReference(): string {
168
+ return `$${configuredApiKey()?.envName ?? "IBM_BOB_API_KEY"}`;
169
+ }
170
+
171
+ function apiKeyAuthScheme(): string {
172
+ return env("IBM_BOB_AUTH_SCHEME") ?? "Apikey";
173
+ }
174
+
113
175
  function bobOrigin(): string {
114
176
  const explicit = env("IBM_BOB_AUTH_BASE_URL");
115
177
  if (explicit) return explicit.replace(/\/$/, "");
@@ -177,7 +239,7 @@ function parseJsonHeaders(): Record<string, string> | undefined {
177
239
 
178
240
  function buildHeaders(settings?: BobShellSettings): Record<string, string> | undefined {
179
241
  const headers: Record<string, string> = {
180
- "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",
181
243
  };
182
244
 
183
245
  const instanceId = env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId;
@@ -185,11 +247,6 @@ function buildHeaders(settings?: BobShellSettings): Record<string, string> | und
185
247
  if (instanceId) headers["x-instance-id"] = instanceId;
186
248
  if (teamId) headers["x-team-id"] = teamId;
187
249
 
188
- const authScheme = env("IBM_BOB_AUTH_SCHEME");
189
- if (authScheme && authScheme.toLowerCase() !== "bearer") {
190
- headers.Authorization = `${authScheme} $IBM_BOB_API_KEY`;
191
- }
192
-
193
250
  const jsonHeaders = parseJsonHeaders();
194
251
  if (jsonHeaders) Object.assign(headers, jsonHeaders);
195
252
 
@@ -225,6 +282,10 @@ function buildOpenAiCompat(api: CompatibleApi): Record<string, unknown> | undefi
225
282
  supportsReasoningEffort: envBool("IBM_BOB_SUPPORTS_REASONING_EFFORT", false),
226
283
  supportsUsageInStreaming: envBool("IBM_BOB_SUPPORTS_USAGE_IN_STREAMING", true),
227
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,
228
289
  };
229
290
 
230
291
  const maxTokensField = env("IBM_BOB_MAX_TOKENS_FIELD") as MaxTokensField | undefined;
@@ -249,19 +310,143 @@ function buildAnthropicCompat(api: CompatibleApi): Record<string, unknown> | und
249
310
  };
250
311
  }
251
312
 
252
- function buildModels(api: CompatibleApi): BobModelConfig[] {
253
- const ids = envCsv("IBM_BOB_MODELS", DEFAULT_MODELS);
313
+ function isRecord(value: unknown): value is Record<string, unknown> {
314
+ return typeof value === "object" && value !== null && !Array.isArray(value);
315
+ }
316
+
317
+ function positiveNumber(value: unknown): number | undefined {
318
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
319
+ }
320
+
321
+ function perMillionCost(value: unknown): number {
322
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
323
+ const scaled = value * 1_000_000;
324
+ return Number.isFinite(scaled) ? scaled : 0;
325
+ }
326
+
327
+ function safeCatalogLabel(value: unknown): string {
328
+ if (typeof value !== "string") return "";
329
+ const label = value.trim();
330
+ return label && label.length <= MAX_CATALOG_LABEL_LENGTH && !CATALOG_CONTROL_CHARACTER.test(label) ? label : "";
331
+ }
332
+
333
+ export function parseBobModelCatalog(payload: unknown): BobDiscoveredModel[] {
334
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
335
+ throw new Error("IBM Bob model-info response did not contain a data array.");
336
+ }
337
+ if (payload.data.length > MAX_CATALOG_ENTRIES) {
338
+ throw new Error(`IBM Bob model-info response exceeded ${MAX_CATALOG_ENTRIES} entries.`);
339
+ }
340
+
341
+ const models: BobDiscoveredModel[] = [];
342
+ const seen = new Set<string>();
343
+ let structurallyValidEntries = 0;
344
+ for (const entry of payload.data) {
345
+ if (!isRecord(entry) || !isRecord(entry.model_info) || !isRecord(entry.litellm_params)) continue;
346
+ const id = safeCatalogLabel(entry.model_name);
347
+ const backend = safeCatalogLabel(entry.litellm_params.model);
348
+ if (!id || !backend) continue;
349
+ const exposed = entry.model_info.exposed;
350
+ if (exposed !== undefined && typeof exposed !== "boolean") continue;
351
+ structurallyValidEntries++;
352
+
353
+ const visible = exposed === undefined || exposed === true;
354
+ if (!visible || seen.has(id)) continue;
355
+
356
+ seen.add(id);
357
+ models.push({
358
+ id,
359
+ backend,
360
+ reasoning:
361
+ entry.model_info.supports_reasoning === true ||
362
+ entry.model_info.supports_reasoning_effort === true ||
363
+ entry.model_info.supports_thinking === true,
364
+ supportsVision: entry.model_info.supports_vision === true,
365
+ contextWindow: positiveNumber(entry.model_info.max_input_tokens),
366
+ maxTokens: positiveNumber(entry.model_info.max_tokens),
367
+ cost: {
368
+ input: perMillionCost(entry.model_info.input_cost_per_token),
369
+ output: perMillionCost(entry.model_info.output_cost_per_token),
370
+ cacheRead: perMillionCost(entry.model_info.cache_read_input_token_cost),
371
+ cacheWrite: perMillionCost(entry.model_info.cache_creation_input_token_cost),
372
+ },
373
+ });
374
+ }
375
+
376
+ if (structurallyValidEntries === 0) {
377
+ throw new Error("IBM Bob model-info response contained no structurally valid model entries.");
378
+ }
379
+ if (models.length === 0) {
380
+ throw new Error("IBM Bob model-info response contained no visible models.");
381
+ }
382
+ return models;
383
+ }
384
+
385
+ function sanitizeCachedCatalog(value: unknown): BobDiscoveredModel[] | undefined {
386
+ if (!Array.isArray(value)) return undefined;
387
+ const models: BobDiscoveredModel[] = [];
388
+ const seen = new Set<string>();
389
+ for (const entry of value) {
390
+ if (!isRecord(entry) || !isRecord(entry.cost)) continue;
391
+ const id = safeCatalogLabel(entry.id);
392
+ const backend = safeCatalogLabel(entry.backend);
393
+ if (!id || !backend || seen.has(id)) continue;
394
+ seen.add(id);
395
+ models.push({
396
+ id,
397
+ backend,
398
+ reasoning: entry.reasoning === true,
399
+ supportsVision: entry.supportsVision === true,
400
+ contextWindow: positiveNumber(entry.contextWindow),
401
+ maxTokens: positiveNumber(entry.maxTokens),
402
+ cost: {
403
+ input: positiveNumber(entry.cost.input) ?? 0,
404
+ output: positiveNumber(entry.cost.output) ?? 0,
405
+ cacheRead: positiveNumber(entry.cost.cacheRead) ?? 0,
406
+ cacheWrite: positiveNumber(entry.cost.cacheWrite) ?? 0,
407
+ },
408
+ });
409
+ }
410
+ return models.length > 0 ? models : undefined;
411
+ }
412
+
413
+ function cachedCatalog(credentials: OAuthCredentials | undefined): BobDiscoveredModel[] | undefined {
414
+ return sanitizeCachedCatalog((credentials as BobOAuthCredentials | undefined)?.bobModelCatalog);
415
+ }
416
+
417
+ export function buildModels(api: CompatibleApi, discovered?: BobDiscoveredModel[]): BobModelConfig[] {
254
418
  const reasoningModels = parseReasoningModels();
255
- const allReasoning = envBool("IBM_BOB_REASONING", false);
256
- const input = parseInputTypes();
257
- const contextWindow = envInt("IBM_BOB_CONTEXT_WINDOW", DEFAULT_CONTEXT_WINDOW);
258
- const maxTokens = envInt("IBM_BOB_MAX_TOKENS", DEFAULT_MAX_TOKENS);
419
+ const reasoningOverride = envOptionalBool("IBM_BOB_REASONING");
420
+ const inputOverride = env("IBM_BOB_INPUT") ? parseInputTypes() : undefined;
421
+ const contextOverride = envOptionalInt("IBM_BOB_CONTEXT_WINDOW");
422
+ const maxTokensOverride = envOptionalInt("IBM_BOB_MAX_TOKENS");
259
423
  const compat = buildOpenAiCompat(api) ?? buildAnthropicCompat(api);
260
424
 
425
+ if (discovered && discovered.length > 0) {
426
+ return discovered.map((model) => ({
427
+ id: model.id,
428
+ name: model.backend === model.id ? modelName(model.id) : `${modelName(model.id)} — ${model.backend}`,
429
+ reasoning:
430
+ reasoningOverride === true ||
431
+ reasoningModels.has(model.id) ||
432
+ (reasoningOverride === undefined && model.reasoning),
433
+ input: inputOverride ?? (model.supportsVision ? ["text", "image"] : ["text"]),
434
+ contextWindow: contextOverride ?? model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
435
+ maxTokens: maxTokensOverride ?? model.maxTokens ?? DEFAULT_MAX_TOKENS,
436
+ cost: model.cost,
437
+ ...(compat ? { compat } : {}),
438
+ }));
439
+ }
440
+
441
+ const configuredIds = envCsv("IBM_BOB_MODELS", DEFAULT_MODELS);
442
+ const ids = configuredIds.length > 0 ? configuredIds : DEFAULT_MODELS;
443
+ const input = inputOverride ?? parseInputTypes();
444
+ const contextWindow = contextOverride ?? DEFAULT_CONTEXT_WINDOW;
445
+ const maxTokens = maxTokensOverride ?? DEFAULT_MAX_TOKENS;
261
446
  return ids.map((id) => ({
262
447
  id,
263
448
  name: modelName(id),
264
- reasoning: allReasoning || reasoningModels.has(id),
449
+ reasoning: reasoningOverride === true || reasoningModels.has(id),
265
450
  input,
266
451
  contextWindow,
267
452
  maxTokens,
@@ -270,19 +455,212 @@ function buildModels(api: CompatibleApi): BobModelConfig[] {
270
455
  }));
271
456
  }
272
457
 
273
- function jwtExpiry(accessToken: string): number | undefined {
458
+ async function readBoundedResponseBody(response: Response): Promise<string> {
459
+ const declaredLength = Number(response.headers.get("content-length"));
460
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
461
+ throw new Error(`IBM Bob response exceeded ${MAX_RESPONSE_BYTES} bytes.`);
462
+ }
463
+ if (!response.body) return "";
464
+
465
+ const reader = response.body.getReader();
466
+ const decoder = new TextDecoder();
467
+ let bytesRead = 0;
468
+ let body = "";
274
469
  try {
275
- const [, payload] = accessToken.split(".");
276
- if (!payload) return undefined;
277
- const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
278
- const parsed = JSON.parse(json) as { exp?: unknown };
279
- if (typeof parsed.exp !== "number") return undefined;
280
- return parsed.exp * 1000 - 5 * 60 * 1000;
470
+ while (true) {
471
+ const { done, value } = await reader.read();
472
+ if (done) break;
473
+ bytesRead += value.byteLength;
474
+ if (bytesRead > MAX_RESPONSE_BYTES) {
475
+ await reader.cancel();
476
+ throw new Error(`IBM Bob response exceeded ${MAX_RESPONSE_BYTES} bytes.`);
477
+ }
478
+ body += decoder.decode(value, { stream: true });
479
+ }
480
+ return body + decoder.decode();
481
+ } finally {
482
+ reader.releaseLock();
483
+ }
484
+ }
485
+
486
+ async function readJsonResponse(response: Response): Promise<unknown> {
487
+ const body = await readBoundedResponseBody(response);
488
+ return JSON.parse(body);
489
+ }
490
+
491
+ function truncateHttpBody(body: string): string {
492
+ const trimmed = body.trim();
493
+ const truncated = trimmed.length > 512 ? `${trimmed.slice(0, 512)}…` : trimmed;
494
+ return truncated.replace(CATALOG_CONTROL_CHARACTERS, (character) =>
495
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
496
+ );
497
+ }
498
+
499
+ async function fetchBobModelCatalog(
500
+ accessToken: string,
501
+ authScheme: string,
502
+ settings?: BobShellSettings,
503
+ ): Promise<BobDiscoveredModel[]> {
504
+ const startedAt = Date.now();
505
+ const controller = new AbortController();
506
+ const timeoutMs = envInt("IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS", DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS);
507
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
508
+ const headers: Record<string, string> = {
509
+ Accept: "application/json",
510
+ Authorization: `${authScheme} ${accessToken}`,
511
+ "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0",
512
+ };
513
+ const instanceId =
514
+ env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId ?? readInstanceFromJwt(accessToken);
515
+ const teamId = env("IBM_BOB_TEAM_ID") ?? settings?.ibm?.teamId;
516
+ if (instanceId) headers["x-instance-id"] = instanceId;
517
+ if (teamId) headers["x-team-id"] = teamId;
518
+ bobLog(
519
+ `model discovery → ${providerBaseUrl().replace(/\/+$/, "")}/model/info (instance=${instanceId ?? "none"}, timeout=${timeoutMs}ms)`,
520
+ );
521
+
522
+ try {
523
+ const response = await fetch(`${providerBaseUrl().replace(/\/+$/, "")}/model/info`, {
524
+ method: "GET",
525
+ headers,
526
+ signal: controller.signal,
527
+ });
528
+ if (!response.ok) {
529
+ throw new Error(
530
+ `IBM Bob model discovery failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`.trim(),
531
+ );
532
+ }
533
+ const catalog = parseBobModelCatalog(await readJsonResponse(response));
534
+ bobLog(`model discovery ok: ${catalog.length} models in ${Date.now() - startedAt}ms`);
535
+ return catalog;
536
+ } catch (error) {
537
+ if (controller.signal.aborted) {
538
+ throw new Error(`IBM Bob model discovery timed out after ${timeoutMs}ms.`);
539
+ }
540
+ bobLog(
541
+ `model discovery failed in ${Date.now() - startedAt}ms: ${error instanceof Error ? error.message : String(error)}`,
542
+ );
543
+ throw error;
544
+ } finally {
545
+ clearTimeout(timeout);
546
+ }
547
+ }
548
+
549
+ async function attachBobModelCatalog(
550
+ credentials: OAuthCredentials,
551
+ previous?: OAuthCredentials,
552
+ ): Promise<BobOAuthCredentials> {
553
+ if (!envBool("IBM_BOB_DISCOVER_MODELS", true)) {
554
+ const previousCatalog = cachedCatalog(previous);
555
+ return previousCatalog ? { ...credentials, bobModelCatalog: previousCatalog } : credentials;
556
+ }
557
+
558
+ try {
559
+ const catalog = await fetchBobModelCatalog(credentials.access, "Bearer", readBobShellSettings());
560
+ return { ...credentials, bobModelCatalog: catalog };
561
+ } catch (error) {
562
+ const previousCatalog = cachedCatalog(previous);
563
+ console.warn(
564
+ `pi-bob: ${error instanceof Error ? error.message : String(error)} Using ${
565
+ previousCatalog ? "the previously cached catalog" : "configured fallback models"
566
+ }.`,
567
+ );
568
+ return previousCatalog ? { ...credentials, bobModelCatalog: previousCatalog } : credentials;
569
+ }
570
+ }
571
+
572
+ function jwtPayload(accessToken: string): Record<string, unknown> | undefined {
573
+ try {
574
+ const parts = accessToken.split(".");
575
+ if (parts.length !== 3 || !parts[1]) return undefined;
576
+ const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
577
+ const parsed = JSON.parse(json) as unknown;
578
+ return isRecord(parsed) ? parsed : undefined;
281
579
  } catch {
282
580
  return undefined;
283
581
  }
284
582
  }
285
583
 
584
+ function jwtExpiry(accessToken: string): number | undefined {
585
+ const exp = jwtPayload(accessToken)?.exp;
586
+ return typeof exp === "number" ? exp * 1000 - 5 * 60 * 1000 : undefined;
587
+ }
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
+
604
+ export function applyBobAuthHeaders(
605
+ headers: Record<string, string | null | undefined>,
606
+ resolvedCredential?: string,
607
+ ): void {
608
+ let token = resolvedCredential;
609
+ if (!token) {
610
+ for (const [name, value] of Object.entries(headers)) {
611
+ if (typeof value !== "string") continue;
612
+ const lowerName = name.toLowerCase();
613
+ if (lowerName === "authorization") {
614
+ const match = value.match(/^\s*(?:Bearer|Apikey)\s+(.+)\s*$/iu);
615
+ if (match?.[1]) token = match[1];
616
+ } else if (lowerName === "x-api-key" && value.trim()) {
617
+ token ??= value.trim();
618
+ }
619
+ }
620
+ }
621
+ if (!token) return;
622
+
623
+ for (const name of Object.keys(headers)) {
624
+ const lowerName = name.toLowerCase();
625
+ if (lowerName === "authorization" || lowerName === "x-api-key") headers[name] = null;
626
+ }
627
+ const scheme = jwtPayload(token) ? "Bearer" : apiKeyAuthScheme();
628
+ headers.Authorization = `${scheme} ${token}`;
629
+ }
630
+
631
+ function streamBob(
632
+ adapter: CompatibleApi,
633
+ model: Model<Api>,
634
+ context: Context,
635
+ options?: SimpleStreamOptions,
636
+ ): AssistantMessageEventStream {
637
+ const mutableHeaders: Record<string, string | null | undefined> = { ...options?.headers };
638
+ applyBobAuthHeaders(mutableHeaders, options?.apiKey);
639
+ const headers = Object.fromEntries(
640
+ Object.entries(mutableHeaders).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
641
+ );
642
+ const hasHeaderAuth = Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
643
+ const forwardedOptions: SimpleStreamOptions = { ...options, headers };
644
+
645
+ switch (adapter) {
646
+ case "anthropic-messages":
647
+ return anthropicMessagesApi().streamSimple(model as Model<"anthropic-messages">, context, {
648
+ ...forwardedOptions,
649
+ apiKey: hasHeaderAuth ? undefined : options?.apiKey,
650
+ });
651
+ case "openai-responses":
652
+ return openAIResponsesApi().streamSimple(model as Model<"openai-responses">, context, {
653
+ ...forwardedOptions,
654
+ apiKey: hasHeaderAuth ? "pi-bob-header-auth" : options?.apiKey,
655
+ });
656
+ default:
657
+ return openAICompletionsApi().streamSimple(model as Model<"openai-completions">, context, {
658
+ ...forwardedOptions,
659
+ apiKey: hasHeaderAuth ? "pi-bob-header-auth" : options?.apiKey,
660
+ });
661
+ }
662
+ }
663
+
286
664
  function credentialsFromTokenResponse(response: BobTokenResponse, previousRefresh?: string): OAuthCredentials {
287
665
  if (!response.token) throw new Error("IBM Bob SSO response did not include an access token.");
288
666
  return {
@@ -317,6 +695,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
317
695
 
318
696
  const code = new Promise<string>((resolve, reject) => {
319
697
  server.on("request", (req, res) => {
698
+ bobLog(`login: callback request received: ${(req.url ?? "").split("?")[0] || "(no path)"}`);
320
699
  if (!req.url?.startsWith("/bob-callback")) {
321
700
  res.writeHead(404, { "Content-Type": "text/plain" });
322
701
  res.end("Not Found");
@@ -330,6 +709,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
330
709
  const error = url.searchParams.get("error");
331
710
 
332
711
  if (returnedState !== state) {
712
+ bobLog(`login: callback state mismatch (expected ${state}, got ${returnedState})`);
333
713
  res.writeHead(400, { "Content-Type": "text/plain" });
334
714
  res.end("Invalid state parameter");
335
715
  reject(new Error("Invalid state parameter from IBM Bob SSO callback."));
@@ -337,6 +717,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
337
717
  }
338
718
 
339
719
  if (error) {
720
+ bobLog(`login: callback auth error: ${error}`);
340
721
  res.writeHead(400, { "Content-Type": "text/plain" });
341
722
  res.end("Authentication failed");
342
723
  reject(new Error(`IBM Bob SSO failed: ${error}`));
@@ -344,6 +725,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
344
725
  }
345
726
 
346
727
  if (!authCode) {
728
+ bobLog("login: callback missing authorization code");
347
729
  res.writeHead(400, { "Content-Type": "text/plain" });
348
730
  res.end("Missing authorization code");
349
731
  reject(new Error("IBM Bob SSO callback did not include an authorization code."));
@@ -363,7 +745,10 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
363
745
  reject(error);
364
746
  }
365
747
  });
366
- 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
+ });
367
752
  });
368
753
 
369
754
  await new Promise<void>((resolve, reject) => {
@@ -375,7 +760,6 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
375
760
  });
376
761
 
377
762
  return {
378
- port,
379
763
  callbackUri: `http://localhost:${port}/bob-callback`,
380
764
  code,
381
765
  close() {
@@ -385,71 +769,175 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
385
769
  }
386
770
 
387
771
  async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh", body: unknown): Promise<BobTokenResponse> {
388
- const response = await fetch(`${bobOrigin()}${path}`, {
389
- method: "POST",
390
- headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0" },
391
- body: JSON.stringify(body),
392
- });
772
+ const startedAt = Date.now();
773
+ const controller = new AbortController();
774
+ const timeoutMs = envInt("IBM_BOB_TOKEN_REQUEST_TIMEOUT_MS", DEFAULT_TOKEN_REQUEST_TIMEOUT_MS);
775
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
776
+ bobLog(`token request → ${bobOrigin()}${path} (timeout=${timeoutMs}ms)`);
777
+ try {
778
+ const response = await fetch(`${bobOrigin()}${path}`, {
779
+ method: "POST",
780
+ headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0" },
781
+ body: JSON.stringify(body),
782
+ signal: controller.signal,
783
+ });
393
784
 
394
- if (!response.ok) {
395
- throw new Error(`IBM Bob SSO token request failed: ${response.status} ${await response.text()}`);
396
- }
785
+ if (!response.ok) {
786
+ throw new Error(
787
+ `IBM Bob SSO token request failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`,
788
+ );
789
+ }
397
790
 
398
- return (await response.json()) as BobTokenResponse;
791
+ bobLog(`token request ok in ${Date.now() - startedAt}ms`);
792
+ return (await readJsonResponse(response)) as BobTokenResponse;
793
+ } catch (error) {
794
+ if (controller.signal.aborted) throw new Error(`IBM Bob SSO token request timed out after ${timeoutMs}ms.`);
795
+ throw error;
796
+ } finally {
797
+ clearTimeout(timeout);
798
+ }
399
799
  }
400
800
 
401
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
+
402
806
  const state = randomBytes(16).toString("hex");
403
807
  const callbackServer = await startCallbackServer(state);
808
+ bobLog(`login: callback server listening at ${callbackServer.callbackUri} (+${elapsed()})`);
809
+
404
810
  const loginUrl = new URL(bobWebLoginUrl());
405
811
  loginUrl.searchParams.set("callback_uri", callbackServer.callbackUri);
406
812
  loginUrl.searchParams.set("state", state);
407
813
 
814
+ const signal = callbacks.signal;
815
+ let loginTimeout: ReturnType<typeof setTimeout> | undefined;
816
+ let progressInterval: ReturnType<typeof setInterval> | undefined;
817
+ let onAbort: (() => void) | undefined;
408
818
  try {
409
819
  callbacks.onAuth({ url: loginUrl.toString() });
410
- callbacks.onDeviceCode?.({
411
- userCode: "Browser SSO",
412
- verificationUri: loginUrl.toString(),
413
- expiresInSeconds: Math.floor(envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000) / 1000),
414
- });
820
+ bobLog(`login: browser opened for SSO (+${elapsed()})`);
415
821
 
416
822
  const timeoutMs = envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000);
417
823
  const timeout = new Promise<never>((_resolve, reject) => {
418
- 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);
419
828
  });
420
829
 
421
- const code = await Promise.race([callbackServer.code, timeout]);
422
- return credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
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
+
855
+ const credentials = credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
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;
423
865
  } finally {
866
+ if (loginTimeout) clearTimeout(loginTimeout);
867
+ if (progressInterval) clearInterval(progressInterval);
868
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
424
869
  callbackServer.close();
425
870
  }
426
871
  }
427
872
 
428
873
  async function refreshBobToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
429
874
  if (!credentials.refresh) throw new Error("No IBM Bob refresh token is available. Run /login ibm-bob again.");
430
- return credentialsFromTokenResponse(
875
+ const refreshed = credentialsFromTokenResponse(
431
876
  await postToken("/authn/v1/auth/refresh", { refresh_token: credentials.refresh }),
432
877
  credentials.refresh,
433
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
+ }
888
+ return attachBobModelCatalog(refreshed, credentials);
889
+ }
890
+
891
+ function modifyModelsFromCachedCatalog(
892
+ models: Model<Api>[],
893
+ credentials: OAuthCredentials,
894
+ api: CompatibleApi,
895
+ ): Model<Api>[] {
896
+ if (!envBool("IBM_BOB_DISCOVER_MODELS", true)) return models;
897
+ const catalog = cachedCatalog(credentials);
898
+ const otherProviders = models.filter((model) => model.provider !== PROVIDER_ID);
899
+ const discovered = buildModels(api, catalog).map(
900
+ (model) =>
901
+ ({
902
+ ...model,
903
+ api: BOB_API,
904
+ provider: PROVIDER_ID,
905
+ baseUrl: providerRequestBaseUrl(api),
906
+ }) as Model<Api>,
907
+ );
908
+ return [...otherProviders, ...discovered];
434
909
  }
435
910
 
436
- export default function (pi: ExtensionAPI) {
911
+ export default async function (pi: ExtensionAPI) {
437
912
  const api = parseApi();
438
913
  const settings = readBobShellSettings();
439
914
  const headers = buildHeaders(settings);
915
+ let catalog: BobDiscoveredModel[] | undefined;
916
+ const apiKey = configuredApiKey();
917
+ if (apiKey && envBool("IBM_BOB_DISCOVER_MODELS", true)) {
918
+ try {
919
+ catalog = await fetchBobModelCatalog(apiKey.value, apiKeyAuthScheme(), settings);
920
+ } catch (error) {
921
+ console.warn(
922
+ `pi-bob: ${error instanceof Error ? error.message : String(error)} Using configured fallback models.`,
923
+ );
924
+ }
925
+ }
440
926
 
441
927
  pi.registerProvider(PROVIDER_ID, {
442
928
  name: "IBM Bob",
443
- baseUrl: providerBaseUrl(),
444
- apiKey: "$IBM_BOB_API_KEY",
445
- api,
929
+ baseUrl: providerRequestBaseUrl(api),
930
+ apiKey: providerApiKeyReference(),
931
+ api: BOB_API,
446
932
  ...(headers ? { headers } : {}),
447
- models: buildModels(api),
933
+ models: buildModels(api, catalog),
934
+ streamSimple: (model, context, options) => streamBob(api, model, context, options),
448
935
  oauth: {
449
936
  name: "IBM Bob SSO",
450
937
  login: loginBob,
451
938
  refreshToken: refreshBobToken,
452
939
  getApiKey: (credentials) => credentials.access,
940
+ modifyModels: (models, credentials) => modifyModelsFromCachedCatalog(models, credentials, api),
453
941
  },
454
942
  });
455
943
  }
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "pi-bob",
3
- "version": "0.1.0",
4
-
3
+ "version": "0.2.1",
5
4
  "type": "module",
6
5
  "description": "Pi provider extension for IBM Bob / IBM-approved compatible model endpoints.",
7
6
  "keywords": [
@@ -16,11 +15,16 @@
16
15
  "./extensions/bob.ts"
17
16
  ]
18
17
  },
18
+ "files": [
19
+ "extensions/bob.ts",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
19
23
  "author": "Larry Song",
20
24
  "license": "MIT",
21
25
  "repository": {
22
26
  "type": "git",
23
- "url": "https://github.com/songlining/pi-bob.git"
27
+ "url": "git+https://github.com/songlining/pi-bob.git"
24
28
  },
25
29
  "bugs": {
26
30
  "url": "https://github.com/songlining/pi-bob/issues"
@@ -28,10 +32,16 @@
28
32
  "homepage": "https://github.com/songlining/pi-bob#readme",
29
33
  "scripts": {
30
34
  "check": "pi -e . --list-models >/dev/null",
35
+ "test": "bun test",
31
36
  "clean": "echo 'nothing to clean'",
32
37
  "build": "echo 'nothing to build'"
33
38
  },
34
39
  "peerDependencies": {
40
+ "@earendil-works/pi-ai": "*",
35
41
  "@earendil-works/pi-coding-agent": "*"
42
+ },
43
+ "devDependencies": {
44
+ "@earendil-works/pi-ai": "0.80.7",
45
+ "@earendil-works/pi-coding-agent": "0.80.7"
36
46
  }
37
47
  }
package/PLAN.md DELETED
@@ -1,96 +0,0 @@
1
- # IBM Bob provider plan
2
-
3
- ## Safety boundaries
4
-
5
- - Use only IBM-approved Bob/model endpoints and credentials the user is authorised to use.
6
- - Do not scrape, extract, copy, or reuse Bob browser/session credentials.
7
- - Do not store secrets in this repository or in chat transcripts.
8
- - Prefer environment variables, password-manager commands, formal Pi `/login` support, or approved local auth helpers for credentials.
9
-
10
- ## Implementation strategy
11
-
12
- ### Option 1 — compatible provider first
13
-
14
- Implement a Pi package that registers IBM Bob as a normal Pi provider using Pi's built-in compatible API adapters:
15
-
16
- - `openai-completions` for OpenAI Chat Completions-compatible Bob routes.
17
- - `openai-responses` for OpenAI Responses-compatible Bob routes.
18
- - `anthropic-messages` for Anthropic Messages-compatible Bob routes.
19
-
20
- This avoids custom streaming code and keeps the integration transparent: Pi serializes messages, tools, streaming, and usage exactly as it already does for compatible providers.
21
-
22
- ### Option 2 — approved SSO/auth wrapper
23
-
24
- If Bob uses short-lived IBM SSO/IAM/direct-access tokens but still exposes an OpenAI/Anthropic-compatible model API, add a Pi `/login ibm-bob` flow or an approved auth-helper integration that obtains tokens, injects headers, and delegates to Pi's built-in streamers.
25
-
26
- Implemented: `/login ibm-bob` uses Bob's browser SSO endpoints (`/authn/v1/auth/token` and `/authn/v1/auth/refresh`) and Pi's standard OAuth credential store. It does not extract Bob Shell's stored token from `~/.bob/settings.json` or Bob's credential store.
27
-
28
- ### Option 3 — native Bob/watsonx adapter
29
-
30
- Only if Bob exposes a non-compatible native API, implement a full `streamSimple` adapter that maps Pi context and tool calls to Bob requests and Bob streaming events back to Pi assistant-message events.
31
-
32
- ## Discovered local Bob Shell facts
33
-
34
- From `bobshell@1.0.6` installed at `/opt/homebrew/lib/node_modules/bobshell`:
35
-
36
- - CLI path: `/opt/homebrew/bin/bob`
37
- - Local auth method: `sso`
38
- - Default Bob API host: `https://api.us-east.bob.ibm.com`
39
- - OpenAI-compatible base URL: `https://api.us-east.bob.ibm.com/inference/v1`
40
- - Chat route: `/inference/v1/chat/completions`
41
- - Model-info route: `/inference/v1/model/info`
42
- - Default model alias: `premium`
43
- - Other visible aliases/constants: `pro`, `flash`, `flash-lite`, `bob-3-pro-preview`
44
- - Installed Bob Shell contains a broad ~1M context-window default, but the observed `premium` backend route maps to Claude Sonnet 4.5 with `Max Input Tokens=200000`.
45
- - Provider default context window for Pi: `200000`, so Pi compacts before Bob rejects oversized requests.
46
- - Default max output token constant: `8192`
47
- - Non-secret routing headers: `x-instance-id`, `x-team-id`
48
- - SSO browser login URL: `https://bob.ibm.com/login?callback_uri=...&state=...`
49
- - SSO token endpoint: `/authn/v1/auth/token`
50
- - SSO refresh endpoint: `/authn/v1/auth/refresh`
51
-
52
- ## Option 1 execution
53
-
54
- This repository now contains a local Pi package:
55
-
56
- - `package.json` declares a Pi package.
57
- - `extensions/bob.ts` registers provider `ibm-bob` and Pi `/login ibm-bob` SSO.
58
- - `README.md` documents discovered settings, setup, and validation.
59
-
60
- Defaults now target the discovered Bob Shell route:
61
-
62
- - `IBM_BOB_BASE_URL=https://api.us-east.bob.ibm.com/inference/v1`
63
- - `IBM_BOB_API=openai-completions`
64
- - `IBM_BOB_MODELS=premium`
65
- - `IBM_BOB_CONTEXT_WINDOW=200000`
66
- - `IBM_BOB_MAX_TOKENS=8192`
67
-
68
- The extension reads only non-secret `ibm.instanceId` and `ibm.teamId` from `~/.bob/settings.json` unless `IBM_BOB_READ_BOBSHELL_SETTINGS=false`.
69
-
70
- Secret configuration remains external:
71
-
72
- - Pi `/login ibm-bob`, which stores fresh SSO credentials in `~/.pi/agent/auth.json`, or
73
- - `IBM_BOB_API_KEY`, or
74
- - `IBM_BOB_HEADERS_JSON` with environment-variable placeholders, or
75
- - future approved local auth-helper support.
76
-
77
- ## Validation completed
78
-
79
- - Bob Shell SSO test succeeded with `bob --auth-method sso -m premium`.
80
- - Direct unauthenticated model-info call reached the discovered endpoint and returned expected `401 Authentication required`.
81
- - Pi provider lists `ibm-bob/premium` with dummy token.
82
- - Pi request with dummy token reaches Bob and returns expected `401 unauthorized`.
83
- - Independent Bob SSO smoke test succeeded: callback received, token exchange succeeded, and authenticated model-info returned HTTP `200`.
84
- - Direct minimal chat completion returned `direct-bob-ok`.
85
- - Direct tool payload test showed Bob rejects `tools[].function.strict`; provider default now sets `supportsStrictMode=false`.
86
- - Pi end-to-end with fresh SSO token succeeded: `pi-bob-ok`.
87
- - After a real context overflow from Bob (`Max Input Tokens=200000, Got=236458`), the provider default context window was lowered from `1048576` to `200000` so Pi's own compaction/overflow handling can engage before Bob rejects the request.
88
-
89
- ## Remaining work
90
-
91
- The provider works end-to-end for a basic Pi prompt through Bob's OpenAI-compatible route. Useful next steps:
92
-
93
- 1. Run interactive `/login ibm-bob` once inside Pi to persist SSO credentials in Pi's auth store.
94
- 2. Add `/bob-status` for no-secret health checks.
95
- 3. Add dynamic model discovery from `/inference/v1/model/info`.
96
- 4. Add regression tests for SSO callback, refresh, and strict-tool compatibility.