pi-bob 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,7 +2,7 @@
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
 
@@ -36,7 +36,18 @@ 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 each token refresh. 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
 
@@ -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
70
  pi -e /Users/larry.song/work/hashicorp/pi-bob --list-models
60
71
  pi -e /Users/larry.song/work/hashicorp/pi-bob --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
 
@@ -102,20 +115,20 @@ For SSO, do **not** copy a token out of Bob's local credential store unless IBM
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
 
@@ -150,16 +163,17 @@ Result: HTTP `401` with `Authentication required`, confirming the discovered rou
150
163
 
151
164
  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
165
 
153
- The Pi provider registers with default settings when an env token is present:
166
+ The Pi provider registers with fallback settings when no authenticated catalog is available:
154
167
 
155
168
  ```bash
156
- IBM_BOB_API_KEY=dummy pi -e . --list-models | grep ibm-bob
169
+ pi -e . --list-models | grep ibm-bob
157
170
  ```
158
171
 
159
- Result:
172
+ 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
173
 
161
- ```text
162
- ibm-bob premium 200K 8.2K no no
174
+ ```bash
175
+ bun test
176
+ npm run check
163
177
  ```
164
178
 
165
179
  A dummy-token Pi request reaches the Bob endpoint and fails with the expected auth error:
@@ -205,8 +219,7 @@ pi remove /Users/larry.song/work/hashicorp/pi-bob
205
219
 
206
220
  ## Next steps
207
221
 
208
- Option 1 is wired to the discovered Bob OpenAI-compatible route and `/login ibm-bob` is implemented. Next useful improvements:
222
+ The compatible provider, `/login ibm-bob`, and dynamic model discovery are implemented. Next useful improvements:
209
223
 
210
224
  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.
225
+ 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(/\/$/, "");
@@ -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
 
@@ -249,19 +306,143 @@ function buildAnthropicCompat(api: CompatibleApi): Record<string, unknown> | und
249
306
  };
250
307
  }
251
308
 
252
- function buildModels(api: CompatibleApi): BobModelConfig[] {
253
- const ids = envCsv("IBM_BOB_MODELS", DEFAULT_MODELS);
309
+ function isRecord(value: unknown): value is Record<string, unknown> {
310
+ return typeof value === "object" && value !== null && !Array.isArray(value);
311
+ }
312
+
313
+ function positiveNumber(value: unknown): number | undefined {
314
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
315
+ }
316
+
317
+ function perMillionCost(value: unknown): number {
318
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
319
+ const scaled = value * 1_000_000;
320
+ return Number.isFinite(scaled) ? scaled : 0;
321
+ }
322
+
323
+ function safeCatalogLabel(value: unknown): string {
324
+ if (typeof value !== "string") return "";
325
+ const label = value.trim();
326
+ return label && label.length <= MAX_CATALOG_LABEL_LENGTH && !CATALOG_CONTROL_CHARACTER.test(label) ? label : "";
327
+ }
328
+
329
+ export function parseBobModelCatalog(payload: unknown): BobDiscoveredModel[] {
330
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
331
+ throw new Error("IBM Bob model-info response did not contain a data array.");
332
+ }
333
+ if (payload.data.length > MAX_CATALOG_ENTRIES) {
334
+ throw new Error(`IBM Bob model-info response exceeded ${MAX_CATALOG_ENTRIES} entries.`);
335
+ }
336
+
337
+ const models: BobDiscoveredModel[] = [];
338
+ const seen = new Set<string>();
339
+ let structurallyValidEntries = 0;
340
+ for (const entry of payload.data) {
341
+ if (!isRecord(entry) || !isRecord(entry.model_info) || !isRecord(entry.litellm_params)) continue;
342
+ const id = safeCatalogLabel(entry.model_name);
343
+ const backend = safeCatalogLabel(entry.litellm_params.model);
344
+ if (!id || !backend) continue;
345
+ const exposed = entry.model_info.exposed;
346
+ if (exposed !== undefined && typeof exposed !== "boolean") continue;
347
+ structurallyValidEntries++;
348
+
349
+ const visible = exposed === undefined || exposed === true;
350
+ if (!visible || seen.has(id)) continue;
351
+
352
+ seen.add(id);
353
+ models.push({
354
+ id,
355
+ backend,
356
+ reasoning:
357
+ entry.model_info.supports_reasoning === true ||
358
+ entry.model_info.supports_reasoning_effort === true ||
359
+ entry.model_info.supports_thinking === true,
360
+ supportsVision: entry.model_info.supports_vision === true,
361
+ contextWindow: positiveNumber(entry.model_info.max_input_tokens),
362
+ maxTokens: positiveNumber(entry.model_info.max_tokens),
363
+ cost: {
364
+ input: perMillionCost(entry.model_info.input_cost_per_token),
365
+ output: perMillionCost(entry.model_info.output_cost_per_token),
366
+ cacheRead: perMillionCost(entry.model_info.cache_read_input_token_cost),
367
+ cacheWrite: perMillionCost(entry.model_info.cache_creation_input_token_cost),
368
+ },
369
+ });
370
+ }
371
+
372
+ if (structurallyValidEntries === 0) {
373
+ throw new Error("IBM Bob model-info response contained no structurally valid model entries.");
374
+ }
375
+ if (models.length === 0) {
376
+ throw new Error("IBM Bob model-info response contained no visible models.");
377
+ }
378
+ return models;
379
+ }
380
+
381
+ function sanitizeCachedCatalog(value: unknown): BobDiscoveredModel[] | undefined {
382
+ if (!Array.isArray(value)) return undefined;
383
+ const models: BobDiscoveredModel[] = [];
384
+ const seen = new Set<string>();
385
+ for (const entry of value) {
386
+ if (!isRecord(entry) || !isRecord(entry.cost)) continue;
387
+ const id = safeCatalogLabel(entry.id);
388
+ const backend = safeCatalogLabel(entry.backend);
389
+ if (!id || !backend || seen.has(id)) continue;
390
+ seen.add(id);
391
+ models.push({
392
+ id,
393
+ backend,
394
+ reasoning: entry.reasoning === true,
395
+ supportsVision: entry.supportsVision === true,
396
+ contextWindow: positiveNumber(entry.contextWindow),
397
+ maxTokens: positiveNumber(entry.maxTokens),
398
+ cost: {
399
+ input: positiveNumber(entry.cost.input) ?? 0,
400
+ output: positiveNumber(entry.cost.output) ?? 0,
401
+ cacheRead: positiveNumber(entry.cost.cacheRead) ?? 0,
402
+ cacheWrite: positiveNumber(entry.cost.cacheWrite) ?? 0,
403
+ },
404
+ });
405
+ }
406
+ return models.length > 0 ? models : undefined;
407
+ }
408
+
409
+ function cachedCatalog(credentials: OAuthCredentials | undefined): BobDiscoveredModel[] | undefined {
410
+ return sanitizeCachedCatalog((credentials as BobOAuthCredentials | undefined)?.bobModelCatalog);
411
+ }
412
+
413
+ export function buildModels(api: CompatibleApi, discovered?: BobDiscoveredModel[]): BobModelConfig[] {
254
414
  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);
415
+ const reasoningOverride = envOptionalBool("IBM_BOB_REASONING");
416
+ const inputOverride = env("IBM_BOB_INPUT") ? parseInputTypes() : undefined;
417
+ const contextOverride = envOptionalInt("IBM_BOB_CONTEXT_WINDOW");
418
+ const maxTokensOverride = envOptionalInt("IBM_BOB_MAX_TOKENS");
259
419
  const compat = buildOpenAiCompat(api) ?? buildAnthropicCompat(api);
260
420
 
421
+ if (discovered && discovered.length > 0) {
422
+ return discovered.map((model) => ({
423
+ id: model.id,
424
+ name: model.backend === model.id ? modelName(model.id) : `${modelName(model.id)} — ${model.backend}`,
425
+ reasoning:
426
+ reasoningOverride === true ||
427
+ reasoningModels.has(model.id) ||
428
+ (reasoningOverride === undefined && model.reasoning),
429
+ input: inputOverride ?? (model.supportsVision ? ["text", "image"] : ["text"]),
430
+ contextWindow: contextOverride ?? model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
431
+ maxTokens: maxTokensOverride ?? model.maxTokens ?? DEFAULT_MAX_TOKENS,
432
+ cost: model.cost,
433
+ ...(compat ? { compat } : {}),
434
+ }));
435
+ }
436
+
437
+ const configuredIds = envCsv("IBM_BOB_MODELS", DEFAULT_MODELS);
438
+ const ids = configuredIds.length > 0 ? configuredIds : DEFAULT_MODELS;
439
+ const input = inputOverride ?? parseInputTypes();
440
+ const contextWindow = contextOverride ?? DEFAULT_CONTEXT_WINDOW;
441
+ const maxTokens = maxTokensOverride ?? DEFAULT_MAX_TOKENS;
261
442
  return ids.map((id) => ({
262
443
  id,
263
444
  name: modelName(id),
264
- reasoning: allReasoning || reasoningModels.has(id),
445
+ reasoning: reasoningOverride === true || reasoningModels.has(id),
265
446
  input,
266
447
  contextWindow,
267
448
  maxTokens,
@@ -270,19 +451,187 @@ function buildModels(api: CompatibleApi): BobModelConfig[] {
270
451
  }));
271
452
  }
272
453
 
273
- function jwtExpiry(accessToken: string): number | undefined {
454
+ async function readBoundedResponseBody(response: Response): Promise<string> {
455
+ const declaredLength = Number(response.headers.get("content-length"));
456
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
457
+ throw new Error(`IBM Bob response exceeded ${MAX_RESPONSE_BYTES} bytes.`);
458
+ }
459
+ if (!response.body) return "";
460
+
461
+ const reader = response.body.getReader();
462
+ const decoder = new TextDecoder();
463
+ let bytesRead = 0;
464
+ let body = "";
465
+ try {
466
+ while (true) {
467
+ const { done, value } = await reader.read();
468
+ if (done) break;
469
+ bytesRead += value.byteLength;
470
+ if (bytesRead > MAX_RESPONSE_BYTES) {
471
+ await reader.cancel();
472
+ throw new Error(`IBM Bob response exceeded ${MAX_RESPONSE_BYTES} bytes.`);
473
+ }
474
+ body += decoder.decode(value, { stream: true });
475
+ }
476
+ return body + decoder.decode();
477
+ } finally {
478
+ reader.releaseLock();
479
+ }
480
+ }
481
+
482
+ async function readJsonResponse(response: Response): Promise<unknown> {
483
+ const body = await readBoundedResponseBody(response);
484
+ return JSON.parse(body);
485
+ }
486
+
487
+ function truncateHttpBody(body: string): string {
488
+ const trimmed = body.trim();
489
+ const truncated = trimmed.length > 512 ? `${trimmed.slice(0, 512)}…` : trimmed;
490
+ return truncated.replace(CATALOG_CONTROL_CHARACTERS, (character) =>
491
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
492
+ );
493
+ }
494
+
495
+ async function fetchBobModelCatalog(
496
+ accessToken: string,
497
+ authScheme: string,
498
+ settings?: BobShellSettings,
499
+ ): Promise<BobDiscoveredModel[]> {
500
+ const controller = new AbortController();
501
+ const timeoutMs = envInt("IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS", DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS);
502
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
503
+ const headers: Record<string, string> = {
504
+ Accept: "application/json",
505
+ Authorization: `${authScheme} ${accessToken}`,
506
+ "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0",
507
+ };
508
+ const instanceId = env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId;
509
+ const teamId = env("IBM_BOB_TEAM_ID") ?? settings?.ibm?.teamId;
510
+ if (instanceId) headers["x-instance-id"] = instanceId;
511
+ if (teamId) headers["x-team-id"] = teamId;
512
+
274
513
  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;
514
+ const response = await fetch(`${providerBaseUrl().replace(/\/+$/, "")}/model/info`, {
515
+ method: "GET",
516
+ headers,
517
+ signal: controller.signal,
518
+ });
519
+ if (!response.ok) {
520
+ throw new Error(
521
+ `IBM Bob model discovery failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`.trim(),
522
+ );
523
+ }
524
+ return parseBobModelCatalog(await readJsonResponse(response));
525
+ } catch (error) {
526
+ if (controller.signal.aborted) {
527
+ throw new Error(`IBM Bob model discovery timed out after ${timeoutMs}ms.`);
528
+ }
529
+ throw error;
530
+ } finally {
531
+ clearTimeout(timeout);
532
+ }
533
+ }
534
+
535
+ async function attachBobModelCatalog(
536
+ credentials: OAuthCredentials,
537
+ previous?: OAuthCredentials,
538
+ ): Promise<BobOAuthCredentials> {
539
+ if (!envBool("IBM_BOB_DISCOVER_MODELS", true)) {
540
+ const previousCatalog = cachedCatalog(previous);
541
+ return previousCatalog ? { ...credentials, bobModelCatalog: previousCatalog } : credentials;
542
+ }
543
+
544
+ try {
545
+ const catalog = await fetchBobModelCatalog(credentials.access, "Bearer", readBobShellSettings());
546
+ return { ...credentials, bobModelCatalog: catalog };
547
+ } catch (error) {
548
+ const previousCatalog = cachedCatalog(previous);
549
+ console.warn(
550
+ `pi-bob: ${error instanceof Error ? error.message : String(error)} Using ${
551
+ previousCatalog ? "the previously cached catalog" : "configured fallback models"
552
+ }.`,
553
+ );
554
+ return previousCatalog ? { ...credentials, bobModelCatalog: previousCatalog } : credentials;
555
+ }
556
+ }
557
+
558
+ function jwtPayload(accessToken: string): Record<string, unknown> | undefined {
559
+ try {
560
+ const parts = accessToken.split(".");
561
+ if (parts.length !== 3 || !parts[1]) return undefined;
562
+ const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
563
+ const parsed = JSON.parse(json) as unknown;
564
+ return isRecord(parsed) ? parsed : undefined;
281
565
  } catch {
282
566
  return undefined;
283
567
  }
284
568
  }
285
569
 
570
+ function jwtExpiry(accessToken: string): number | undefined {
571
+ const exp = jwtPayload(accessToken)?.exp;
572
+ return typeof exp === "number" ? exp * 1000 - 5 * 60 * 1000 : undefined;
573
+ }
574
+
575
+ export function applyBobAuthHeaders(
576
+ headers: Record<string, string | null | undefined>,
577
+ resolvedCredential?: string,
578
+ ): void {
579
+ let token = resolvedCredential;
580
+ if (!token) {
581
+ for (const [name, value] of Object.entries(headers)) {
582
+ if (typeof value !== "string") continue;
583
+ const lowerName = name.toLowerCase();
584
+ if (lowerName === "authorization") {
585
+ const match = value.match(/^\s*(?:Bearer|Apikey)\s+(.+)\s*$/iu);
586
+ if (match?.[1]) token = match[1];
587
+ } else if (lowerName === "x-api-key" && value.trim()) {
588
+ token ??= value.trim();
589
+ }
590
+ }
591
+ }
592
+ if (!token) return;
593
+
594
+ for (const name of Object.keys(headers)) {
595
+ const lowerName = name.toLowerCase();
596
+ if (lowerName === "authorization" || lowerName === "x-api-key") headers[name] = null;
597
+ }
598
+ const scheme = jwtPayload(token) ? "Bearer" : apiKeyAuthScheme();
599
+ headers.Authorization = `${scheme} ${token}`;
600
+ }
601
+
602
+ function streamBob(
603
+ adapter: CompatibleApi,
604
+ model: Model<Api>,
605
+ context: Context,
606
+ options?: SimpleStreamOptions,
607
+ ): AssistantMessageEventStream {
608
+ const mutableHeaders: Record<string, string | null | undefined> = { ...options?.headers };
609
+ applyBobAuthHeaders(mutableHeaders, options?.apiKey);
610
+ const headers = Object.fromEntries(
611
+ Object.entries(mutableHeaders).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
612
+ );
613
+ const hasHeaderAuth = Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
614
+ const forwardedOptions: SimpleStreamOptions = { ...options, headers };
615
+
616
+ switch (adapter) {
617
+ case "anthropic-messages":
618
+ return anthropicMessagesApi().streamSimple(model as Model<"anthropic-messages">, context, {
619
+ ...forwardedOptions,
620
+ apiKey: hasHeaderAuth ? undefined : options?.apiKey,
621
+ });
622
+ case "openai-responses":
623
+ return openAIResponsesApi().streamSimple(model as Model<"openai-responses">, context, {
624
+ ...forwardedOptions,
625
+ apiKey: hasHeaderAuth ? "pi-bob-header-auth" : options?.apiKey,
626
+ });
627
+ default:
628
+ return openAICompletionsApi().streamSimple(model as Model<"openai-completions">, context, {
629
+ ...forwardedOptions,
630
+ apiKey: hasHeaderAuth ? "pi-bob-header-auth" : options?.apiKey,
631
+ });
632
+ }
633
+ }
634
+
286
635
  function credentialsFromTokenResponse(response: BobTokenResponse, previousRefresh?: string): OAuthCredentials {
287
636
  if (!response.token) throw new Error("IBM Bob SSO response did not include an access token.");
288
637
  return {
@@ -375,7 +724,6 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
375
724
  });
376
725
 
377
726
  return {
378
- port,
379
727
  callbackUri: `http://localhost:${port}/bob-callback`,
380
728
  code,
381
729
  close() {
@@ -385,17 +733,30 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
385
733
  }
386
734
 
387
735
  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
- });
736
+ const controller = new AbortController();
737
+ const timeoutMs = envInt("IBM_BOB_TOKEN_REQUEST_TIMEOUT_MS", DEFAULT_TOKEN_REQUEST_TIMEOUT_MS);
738
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
739
+ try {
740
+ const response = await fetch(`${bobOrigin()}${path}`, {
741
+ method: "POST",
742
+ headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.1.0" },
743
+ body: JSON.stringify(body),
744
+ signal: controller.signal,
745
+ });
393
746
 
394
- if (!response.ok) {
395
- throw new Error(`IBM Bob SSO token request failed: ${response.status} ${await response.text()}`);
396
- }
747
+ if (!response.ok) {
748
+ throw new Error(
749
+ `IBM Bob SSO token request failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`,
750
+ );
751
+ }
397
752
 
398
- return (await response.json()) as BobTokenResponse;
753
+ return (await readJsonResponse(response)) as BobTokenResponse;
754
+ } catch (error) {
755
+ if (controller.signal.aborted) throw new Error(`IBM Bob SSO token request timed out after ${timeoutMs}ms.`);
756
+ throw error;
757
+ } finally {
758
+ clearTimeout(timeout);
759
+ }
399
760
  }
400
761
 
401
762
  async function loginBob(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
@@ -405,51 +766,83 @@ async function loginBob(callbacks: OAuthLoginCallbacks): Promise<OAuthCredential
405
766
  loginUrl.searchParams.set("callback_uri", callbackServer.callbackUri);
406
767
  loginUrl.searchParams.set("state", state);
407
768
 
769
+ let loginTimeout: ReturnType<typeof setTimeout> | undefined;
408
770
  try {
409
771
  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
- });
415
772
 
416
773
  const timeoutMs = envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000);
417
774
  const timeout = new Promise<never>((_resolve, reject) => {
418
- setTimeout(() => reject(new Error("IBM Bob SSO timed out.")), timeoutMs);
775
+ loginTimeout = setTimeout(() => reject(new Error("IBM Bob SSO timed out.")), timeoutMs);
419
776
  });
420
777
 
421
778
  const code = await Promise.race([callbackServer.code, timeout]);
422
- return credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
779
+ const credentials = credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
780
+ return attachBobModelCatalog(credentials);
423
781
  } finally {
782
+ if (loginTimeout) clearTimeout(loginTimeout);
424
783
  callbackServer.close();
425
784
  }
426
785
  }
427
786
 
428
787
  async function refreshBobToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
429
788
  if (!credentials.refresh) throw new Error("No IBM Bob refresh token is available. Run /login ibm-bob again.");
430
- return credentialsFromTokenResponse(
789
+ const refreshed = credentialsFromTokenResponse(
431
790
  await postToken("/authn/v1/auth/refresh", { refresh_token: credentials.refresh }),
432
791
  credentials.refresh,
433
792
  );
793
+ return attachBobModelCatalog(refreshed, credentials);
434
794
  }
435
795
 
436
- export default function (pi: ExtensionAPI) {
796
+ function modifyModelsFromCachedCatalog(
797
+ models: Model<Api>[],
798
+ credentials: OAuthCredentials,
799
+ api: CompatibleApi,
800
+ ): Model<Api>[] {
801
+ if (!envBool("IBM_BOB_DISCOVER_MODELS", true)) return models;
802
+ const catalog = cachedCatalog(credentials);
803
+ const otherProviders = models.filter((model) => model.provider !== PROVIDER_ID);
804
+ const discovered = buildModels(api, catalog).map(
805
+ (model) =>
806
+ ({
807
+ ...model,
808
+ api: BOB_API,
809
+ provider: PROVIDER_ID,
810
+ baseUrl: providerRequestBaseUrl(api),
811
+ }) as Model<Api>,
812
+ );
813
+ return [...otherProviders, ...discovered];
814
+ }
815
+
816
+ export default async function (pi: ExtensionAPI) {
437
817
  const api = parseApi();
438
818
  const settings = readBobShellSettings();
439
819
  const headers = buildHeaders(settings);
820
+ let catalog: BobDiscoveredModel[] | undefined;
821
+ const apiKey = configuredApiKey();
822
+ if (apiKey && envBool("IBM_BOB_DISCOVER_MODELS", true)) {
823
+ try {
824
+ catalog = await fetchBobModelCatalog(apiKey.value, apiKeyAuthScheme(), settings);
825
+ } catch (error) {
826
+ console.warn(
827
+ `pi-bob: ${error instanceof Error ? error.message : String(error)} Using configured fallback models.`,
828
+ );
829
+ }
830
+ }
440
831
 
441
832
  pi.registerProvider(PROVIDER_ID, {
442
833
  name: "IBM Bob",
443
- baseUrl: providerBaseUrl(),
444
- apiKey: "$IBM_BOB_API_KEY",
445
- api,
834
+ baseUrl: providerRequestBaseUrl(api),
835
+ apiKey: providerApiKeyReference(),
836
+ api: BOB_API,
446
837
  ...(headers ? { headers } : {}),
447
- models: buildModels(api),
838
+ models: buildModels(api, catalog),
839
+ streamSimple: (model, context, options) => streamBob(api, model, context, options),
448
840
  oauth: {
449
841
  name: "IBM Bob SSO",
450
842
  login: loginBob,
451
843
  refreshToken: refreshBobToken,
452
844
  getApiKey: (credentials) => credentials.access,
845
+ modifyModels: (models, credentials) => modifyModelsFromCachedCatalog(models, credentials, api),
453
846
  },
454
847
  });
455
848
  }
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.0",
5
4
  "type": "module",
6
5
  "description": "Pi provider extension for IBM Bob / IBM-approved compatible model endpoints.",
7
6
  "keywords": [
@@ -16,6 +15,11 @@
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": {
@@ -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.