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 +21 -0
- package/README.md +42 -29
- package/extensions/bob.ts +440 -47
- package/package.json +12 -2
- package/PLAN.md +0 -96
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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` |
|
|
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` |
|
|
113
|
-
| `IBM_BOB_MAX_TOKENS` | `8192` |
|
|
114
|
-
| `IBM_BOB_INPUT` | `text` |
|
|
115
|
-
| `IBM_BOB_REASONING` | `false` |
|
|
116
|
-
| `IBM_BOB_REASONING_MODELS` | empty | Comma-separated
|
|
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
|
-
|
|
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
|
|
166
|
+
The Pi provider registers with fallback settings when no authenticated catalog is available:
|
|
154
167
|
|
|
155
168
|
```bash
|
|
156
|
-
|
|
169
|
+
pi -e . --list-models | grep ibm-bob
|
|
157
170
|
```
|
|
158
171
|
|
|
159
|
-
|
|
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
|
-
```
|
|
162
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
101
|
+
if (!value) return undefined;
|
|
68
102
|
const parsed = Number.parseInt(value, 10);
|
|
69
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed :
|
|
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
|
|
253
|
-
|
|
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
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
444
|
-
apiKey:
|
|
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.
|
|
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.
|