pi-bob 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -38
- package/extensions/bob.ts +538 -50
- package/package.json +13 -3
- 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,14 +2,14 @@
|
|
|
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
|
|
|
9
9
|
From the installed `bobshell@1.0.6` package and the local redacted Bob configuration:
|
|
10
10
|
|
|
11
|
-
- Bob Shell CLI:
|
|
12
|
-
- Bob Shell package:
|
|
11
|
+
- Bob Shell CLI: `bob`
|
|
12
|
+
- Bob Shell package: `bobshell`
|
|
13
13
|
- Installed auth method: `sso`
|
|
14
14
|
- Default Bob API host: `https://api.us-east.bob.ibm.com`
|
|
15
15
|
- OpenAI-compatible chat base URL: `https://api.us-east.bob.ibm.com/inference/v1`
|
|
@@ -36,14 +36,25 @@ Set `IBM_BOB_API` to one of Pi's compatible API adapters:
|
|
|
36
36
|
- `openai-responses` — OpenAI Responses-compatible routes.
|
|
37
37
|
- `anthropic-messages` — Anthropic Messages-compatible routes.
|
|
38
38
|
|
|
39
|
-
The extension registers provider id `ibm-bob`.
|
|
39
|
+
The extension registers provider id `ibm-bob`. It uses an isolated `ibm-bob-compatible` Pi API adapter internally, then delegates serialization and streaming to the selected built-in adapter. This prevents Bob-specific authentication rules from affecting other providers.
|
|
40
|
+
|
|
41
|
+
## Dynamic model discovery
|
|
42
|
+
|
|
43
|
+
Model discovery is enabled by default:
|
|
44
|
+
|
|
45
|
+
- With `IBM_BOB_API_KEY` or `IBM_BOB_KEY`, the extension fetches `/inference/v1/model/info` during Pi's async extension startup. This makes current models available to `--list-models` and `/model` immediately.
|
|
46
|
+
- With `/login ibm-bob`, the extension fetches the catalog after login, and on token refresh only when no catalog is already cached (reusing a cached catalog avoids holding Pi's global auth-store lock across a network call). A sanitized, non-secret copy is cached with Pi's OAuth credentials so the models can be restored on later startups.
|
|
47
|
+
- Entries returned by the authenticated catalog are registered when `model_info.exposed` is omitted or `true`. Routes explicitly marked `exposed: false` are ignored.
|
|
48
|
+
- HTTP failures, timeouts, malformed responses, empty catalogs, and catalogs with no visible models retain the previous SSO catalog or fall back to `IBM_BOB_MODELS`. Bob still enforces route access during inference.
|
|
49
|
+
|
|
50
|
+
Discovered context limits, output limits, vision support, reasoning support, backend identifiers, and token prices are mapped into Pi model definitions. Bob reports prices per token; Pi displays prices per million tokens, so the extension performs that conversion.
|
|
40
51
|
|
|
41
52
|
## Quick start for the discovered Bob endpoint
|
|
42
53
|
|
|
43
54
|
Use Bob SSO through Pi:
|
|
44
55
|
|
|
45
56
|
```bash
|
|
46
|
-
pi -e
|
|
57
|
+
pi -e .
|
|
47
58
|
# inside Pi:
|
|
48
59
|
# /login ibm-bob
|
|
49
60
|
# /model ibm-bob/premium
|
|
@@ -51,30 +62,28 @@ pi -e /Users/larry.song/work/hashicorp/pi-bob
|
|
|
51
62
|
|
|
52
63
|
Pi stores the resulting Bob SSO access/refresh tokens in Pi's normal auth store (`~/.pi/agent/auth.json`). This extension obtains those tokens only through Bob's browser SSO endpoints; it does not read Bob Shell's stored SSO secrets.
|
|
53
64
|
|
|
54
|
-
For non-interactive
|
|
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
|
-
pi -e
|
|
60
|
-
pi -e
|
|
70
|
+
pi -e . --list-models
|
|
71
|
+
pi -e . --model ibm-bob/premium
|
|
61
72
|
```
|
|
62
73
|
|
|
74
|
+
API keys use Bob's `Authorization: Apikey ...` scheme by default. If your approved credential is instead a bearer token, set `IBM_BOB_AUTH_SCHEME=Bearer`. Pi resolves credentials in this order: runtime `--api-key`, a stored credential (including SSO), then the provider's environment-key fallback. The model catalog follows stored SSO metadata when SSO remains configured. Run `/logout ibm-bob` before switching from SSO to either `IBM_BOB_API_KEY` or runtime `--api-key`; runtime-only keys cannot drive startup discovery.
|
|
75
|
+
|
|
63
76
|
Defaults are already set to:
|
|
64
77
|
|
|
65
78
|
```bash
|
|
66
79
|
IBM_BOB_BASE_URL="https://api.us-east.bob.ibm.com/inference/v1"
|
|
67
80
|
IBM_BOB_API="openai-completions"
|
|
68
|
-
|
|
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
|
|
|
@@ -96,26 +109,26 @@ For SSO, do **not** copy a token out of Bob's local credential store unless IBM
|
|
|
96
109
|
| `IBM_BOB_READ_BOBSHELL_SETTINGS` | `true` | Read non-secret `instanceId`/`teamId` from `~/.bob/settings.json`. |
|
|
97
110
|
| `IBM_BOB_INSTANCE_ID` | Bob setting | Override `x-instance-id`. |
|
|
98
111
|
| `IBM_BOB_TEAM_ID` | Bob setting | Override `x-team-id`. |
|
|
99
|
-
| `IBM_BOB_USER_AGENT` | `pi-bob/0.
|
|
112
|
+
| `IBM_BOB_USER_AGENT` | `pi-bob/0.2.0` | User-Agent header sent to Bob endpoint. |
|
|
100
113
|
|
|
101
114
|
### Auth headers
|
|
102
115
|
|
|
103
116
|
| Variable | Default | Description |
|
|
104
117
|
| --- | --- | --- |
|
|
105
|
-
| `IBM_BOB_AUTH_SCHEME` |
|
|
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
|
|
|
@@ -128,6 +141,7 @@ export IBM_BOB_MAX_TOKENS_FIELD=max_tokens
|
|
|
128
141
|
```
|
|
129
142
|
|
|
130
143
|
Bob's OpenAI-compatible route currently rejects `tools[].function.strict`, so `IBM_BOB_SUPPORTS_STRICT_MODE=false` is the default.
|
|
144
|
+
It also rejects OpenAI-specific `store` and `prompt_cache_key` request properties with a bare `422`, so both are always disabled for the OpenAI adapters.
|
|
131
145
|
|
|
132
146
|
## Validation performed
|
|
133
147
|
|
|
@@ -150,16 +164,17 @@ Result: HTTP `401` with `Authentication required`, confirming the discovered rou
|
|
|
150
164
|
|
|
151
165
|
The Bob SSO endpoint flow was smoke-tested independently: browser SSO callback succeeded, token exchange succeeded, and `GET /inference/v1/model/info` returned HTTP `200` using the fresh SSO token.
|
|
152
166
|
|
|
153
|
-
The Pi provider registers with
|
|
167
|
+
The Pi provider registers with fallback settings when no authenticated catalog is available:
|
|
154
168
|
|
|
155
169
|
```bash
|
|
156
|
-
|
|
170
|
+
pi -e . --list-models | grep ibm-bob
|
|
157
171
|
```
|
|
158
172
|
|
|
159
|
-
|
|
173
|
+
Automated tests cover LiteLLM response validation, hidden-model filtering, per-million cost conversion, API-key discovery, `IBM_BOB_KEY` compatibility, fallback behavior, SSO refresh discovery, routing headers, cached catalog replacement, and final authentication headers/routes through all three advertised adapters:
|
|
160
174
|
|
|
161
|
-
```
|
|
162
|
-
|
|
175
|
+
```bash
|
|
176
|
+
bun test
|
|
177
|
+
npm run check
|
|
163
178
|
```
|
|
164
179
|
|
|
165
180
|
A dummy-token Pi request reaches the Bob endpoint and fails with the expected auth error:
|
|
@@ -183,30 +198,48 @@ Result:
|
|
|
183
198
|
pi-bob-ok
|
|
184
199
|
```
|
|
185
200
|
|
|
201
|
+
## Troubleshooting
|
|
202
|
+
|
|
203
|
+
**`/login ibm-bob` freezes after the browser returns to Pi.** Pi refreshes
|
|
204
|
+
model catalogs for built-in providers after every login by fetching
|
|
205
|
+
`https://pi.dev/api/models/providers/<id>` (the remote-catalog provider). If
|
|
206
|
+
pi.dev's API is unreachable or hangs from your network, that post-login fetch
|
|
207
|
+
blocks with no timeout for roughly 5 minutes, and Ctrl-C cannot cancel it.
|
|
208
|
+
Run Pi with `PI_OFFLINE=1` to skip Pi's network catalog refresh entirely; the
|
|
209
|
+
login then returns to the prompt immediately after SSO, and ibm-bob's own
|
|
210
|
+
model discovery still runs.
|
|
211
|
+
|
|
212
|
+
**Debugging the login flow.** Run with `IBM_BOB_DEBUG=1` and stderr
|
|
213
|
+
redirected to a file to see per-step timing for the SSO callback, token
|
|
214
|
+
exchange, and model discovery:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
IBM_BOB_DEBUG=1 pi 2>/tmp/pi-bob-debug.log
|
|
218
|
+
```
|
|
219
|
+
|
|
186
220
|
## Install options
|
|
187
221
|
|
|
188
222
|
Temporary test load:
|
|
189
223
|
|
|
190
224
|
```bash
|
|
191
|
-
pi -e
|
|
225
|
+
pi -e .
|
|
192
226
|
```
|
|
193
227
|
|
|
194
228
|
Install as a local Pi package:
|
|
195
229
|
|
|
196
230
|
```bash
|
|
197
|
-
pi install
|
|
231
|
+
pi install npm:pi-bob
|
|
198
232
|
```
|
|
199
233
|
|
|
200
234
|
Remove later:
|
|
201
235
|
|
|
202
236
|
```bash
|
|
203
|
-
pi remove
|
|
237
|
+
pi remove npm:pi-bob
|
|
204
238
|
```
|
|
205
239
|
|
|
206
240
|
## Next steps
|
|
207
241
|
|
|
208
|
-
|
|
242
|
+
The compatible provider, `/login ibm-bob`, and dynamic model discovery are implemented. Next useful improvements:
|
|
209
243
|
|
|
210
244
|
1. Add a `/bob-status` command that checks auth, model-info, selected instance, and selected team without printing secrets.
|
|
211
|
-
2.
|
|
212
|
-
3. Add tests around Bob's SSO callback and token refresh helpers.
|
|
245
|
+
2. Run periodic compatibility smoke tests against representative Claude, GPT, and Gemini routes exposed by Bob.
|
package/extensions/bob.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
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(/\/$/, "");
|
|
@@ -177,7 +239,7 @@ function parseJsonHeaders(): Record<string, string> | undefined {
|
|
|
177
239
|
|
|
178
240
|
function buildHeaders(settings?: BobShellSettings): Record<string, string> | undefined {
|
|
179
241
|
const headers: Record<string, string> = {
|
|
180
|
-
"User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.
|
|
242
|
+
"User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0",
|
|
181
243
|
};
|
|
182
244
|
|
|
183
245
|
const instanceId = env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId;
|
|
@@ -185,11 +247,6 @@ function buildHeaders(settings?: BobShellSettings): Record<string, string> | und
|
|
|
185
247
|
if (instanceId) headers["x-instance-id"] = instanceId;
|
|
186
248
|
if (teamId) headers["x-team-id"] = teamId;
|
|
187
249
|
|
|
188
|
-
const authScheme = env("IBM_BOB_AUTH_SCHEME");
|
|
189
|
-
if (authScheme && authScheme.toLowerCase() !== "bearer") {
|
|
190
|
-
headers.Authorization = `${authScheme} $IBM_BOB_API_KEY`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
250
|
const jsonHeaders = parseJsonHeaders();
|
|
194
251
|
if (jsonHeaders) Object.assign(headers, jsonHeaders);
|
|
195
252
|
|
|
@@ -225,6 +282,10 @@ function buildOpenAiCompat(api: CompatibleApi): Record<string, unknown> | undefi
|
|
|
225
282
|
supportsReasoningEffort: envBool("IBM_BOB_SUPPORTS_REASONING_EFFORT", false),
|
|
226
283
|
supportsUsageInStreaming: envBool("IBM_BOB_SUPPORTS_USAGE_IN_STREAMING", true),
|
|
227
284
|
supportsStrictMode: envBool("IBM_BOB_SUPPORTS_STRICT_MODE", false),
|
|
285
|
+
// pi-ai auto-detect defaults these to true for unknown endpoints; Bob's
|
|
286
|
+
// strict validator rejects `store` and `prompt_cache_key` with a bare 422.
|
|
287
|
+
supportsStore: false,
|
|
288
|
+
supportsLongCacheRetention: false,
|
|
228
289
|
};
|
|
229
290
|
|
|
230
291
|
const maxTokensField = env("IBM_BOB_MAX_TOKENS_FIELD") as MaxTokensField | undefined;
|
|
@@ -249,19 +310,143 @@ function buildAnthropicCompat(api: CompatibleApi): Record<string, unknown> | und
|
|
|
249
310
|
};
|
|
250
311
|
}
|
|
251
312
|
|
|
252
|
-
function
|
|
253
|
-
|
|
313
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
314
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function positiveNumber(value: unknown): number | undefined {
|
|
318
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function perMillionCost(value: unknown): number {
|
|
322
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
|
|
323
|
+
const scaled = value * 1_000_000;
|
|
324
|
+
return Number.isFinite(scaled) ? scaled : 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function safeCatalogLabel(value: unknown): string {
|
|
328
|
+
if (typeof value !== "string") return "";
|
|
329
|
+
const label = value.trim();
|
|
330
|
+
return label && label.length <= MAX_CATALOG_LABEL_LENGTH && !CATALOG_CONTROL_CHARACTER.test(label) ? label : "";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function parseBobModelCatalog(payload: unknown): BobDiscoveredModel[] {
|
|
334
|
+
if (!isRecord(payload) || !Array.isArray(payload.data)) {
|
|
335
|
+
throw new Error("IBM Bob model-info response did not contain a data array.");
|
|
336
|
+
}
|
|
337
|
+
if (payload.data.length > MAX_CATALOG_ENTRIES) {
|
|
338
|
+
throw new Error(`IBM Bob model-info response exceeded ${MAX_CATALOG_ENTRIES} entries.`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const models: BobDiscoveredModel[] = [];
|
|
342
|
+
const seen = new Set<string>();
|
|
343
|
+
let structurallyValidEntries = 0;
|
|
344
|
+
for (const entry of payload.data) {
|
|
345
|
+
if (!isRecord(entry) || !isRecord(entry.model_info) || !isRecord(entry.litellm_params)) continue;
|
|
346
|
+
const id = safeCatalogLabel(entry.model_name);
|
|
347
|
+
const backend = safeCatalogLabel(entry.litellm_params.model);
|
|
348
|
+
if (!id || !backend) continue;
|
|
349
|
+
const exposed = entry.model_info.exposed;
|
|
350
|
+
if (exposed !== undefined && typeof exposed !== "boolean") continue;
|
|
351
|
+
structurallyValidEntries++;
|
|
352
|
+
|
|
353
|
+
const visible = exposed === undefined || exposed === true;
|
|
354
|
+
if (!visible || seen.has(id)) continue;
|
|
355
|
+
|
|
356
|
+
seen.add(id);
|
|
357
|
+
models.push({
|
|
358
|
+
id,
|
|
359
|
+
backend,
|
|
360
|
+
reasoning:
|
|
361
|
+
entry.model_info.supports_reasoning === true ||
|
|
362
|
+
entry.model_info.supports_reasoning_effort === true ||
|
|
363
|
+
entry.model_info.supports_thinking === true,
|
|
364
|
+
supportsVision: entry.model_info.supports_vision === true,
|
|
365
|
+
contextWindow: positiveNumber(entry.model_info.max_input_tokens),
|
|
366
|
+
maxTokens: positiveNumber(entry.model_info.max_tokens),
|
|
367
|
+
cost: {
|
|
368
|
+
input: perMillionCost(entry.model_info.input_cost_per_token),
|
|
369
|
+
output: perMillionCost(entry.model_info.output_cost_per_token),
|
|
370
|
+
cacheRead: perMillionCost(entry.model_info.cache_read_input_token_cost),
|
|
371
|
+
cacheWrite: perMillionCost(entry.model_info.cache_creation_input_token_cost),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (structurallyValidEntries === 0) {
|
|
377
|
+
throw new Error("IBM Bob model-info response contained no structurally valid model entries.");
|
|
378
|
+
}
|
|
379
|
+
if (models.length === 0) {
|
|
380
|
+
throw new Error("IBM Bob model-info response contained no visible models.");
|
|
381
|
+
}
|
|
382
|
+
return models;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function sanitizeCachedCatalog(value: unknown): BobDiscoveredModel[] | undefined {
|
|
386
|
+
if (!Array.isArray(value)) return undefined;
|
|
387
|
+
const models: BobDiscoveredModel[] = [];
|
|
388
|
+
const seen = new Set<string>();
|
|
389
|
+
for (const entry of value) {
|
|
390
|
+
if (!isRecord(entry) || !isRecord(entry.cost)) continue;
|
|
391
|
+
const id = safeCatalogLabel(entry.id);
|
|
392
|
+
const backend = safeCatalogLabel(entry.backend);
|
|
393
|
+
if (!id || !backend || seen.has(id)) continue;
|
|
394
|
+
seen.add(id);
|
|
395
|
+
models.push({
|
|
396
|
+
id,
|
|
397
|
+
backend,
|
|
398
|
+
reasoning: entry.reasoning === true,
|
|
399
|
+
supportsVision: entry.supportsVision === true,
|
|
400
|
+
contextWindow: positiveNumber(entry.contextWindow),
|
|
401
|
+
maxTokens: positiveNumber(entry.maxTokens),
|
|
402
|
+
cost: {
|
|
403
|
+
input: positiveNumber(entry.cost.input) ?? 0,
|
|
404
|
+
output: positiveNumber(entry.cost.output) ?? 0,
|
|
405
|
+
cacheRead: positiveNumber(entry.cost.cacheRead) ?? 0,
|
|
406
|
+
cacheWrite: positiveNumber(entry.cost.cacheWrite) ?? 0,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return models.length > 0 ? models : undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function cachedCatalog(credentials: OAuthCredentials | undefined): BobDiscoveredModel[] | undefined {
|
|
414
|
+
return sanitizeCachedCatalog((credentials as BobOAuthCredentials | undefined)?.bobModelCatalog);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function buildModels(api: CompatibleApi, discovered?: BobDiscoveredModel[]): BobModelConfig[] {
|
|
254
418
|
const reasoningModels = parseReasoningModels();
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
419
|
+
const reasoningOverride = envOptionalBool("IBM_BOB_REASONING");
|
|
420
|
+
const inputOverride = env("IBM_BOB_INPUT") ? parseInputTypes() : undefined;
|
|
421
|
+
const contextOverride = envOptionalInt("IBM_BOB_CONTEXT_WINDOW");
|
|
422
|
+
const maxTokensOverride = envOptionalInt("IBM_BOB_MAX_TOKENS");
|
|
259
423
|
const compat = buildOpenAiCompat(api) ?? buildAnthropicCompat(api);
|
|
260
424
|
|
|
425
|
+
if (discovered && discovered.length > 0) {
|
|
426
|
+
return discovered.map((model) => ({
|
|
427
|
+
id: model.id,
|
|
428
|
+
name: model.backend === model.id ? modelName(model.id) : `${modelName(model.id)} — ${model.backend}`,
|
|
429
|
+
reasoning:
|
|
430
|
+
reasoningOverride === true ||
|
|
431
|
+
reasoningModels.has(model.id) ||
|
|
432
|
+
(reasoningOverride === undefined && model.reasoning),
|
|
433
|
+
input: inputOverride ?? (model.supportsVision ? ["text", "image"] : ["text"]),
|
|
434
|
+
contextWindow: contextOverride ?? model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
435
|
+
maxTokens: maxTokensOverride ?? model.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
436
|
+
cost: model.cost,
|
|
437
|
+
...(compat ? { compat } : {}),
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const configuredIds = envCsv("IBM_BOB_MODELS", DEFAULT_MODELS);
|
|
442
|
+
const ids = configuredIds.length > 0 ? configuredIds : DEFAULT_MODELS;
|
|
443
|
+
const input = inputOverride ?? parseInputTypes();
|
|
444
|
+
const contextWindow = contextOverride ?? DEFAULT_CONTEXT_WINDOW;
|
|
445
|
+
const maxTokens = maxTokensOverride ?? DEFAULT_MAX_TOKENS;
|
|
261
446
|
return ids.map((id) => ({
|
|
262
447
|
id,
|
|
263
448
|
name: modelName(id),
|
|
264
|
-
reasoning:
|
|
449
|
+
reasoning: reasoningOverride === true || reasoningModels.has(id),
|
|
265
450
|
input,
|
|
266
451
|
contextWindow,
|
|
267
452
|
maxTokens,
|
|
@@ -270,19 +455,212 @@ function buildModels(api: CompatibleApi): BobModelConfig[] {
|
|
|
270
455
|
}));
|
|
271
456
|
}
|
|
272
457
|
|
|
273
|
-
function
|
|
458
|
+
async function readBoundedResponseBody(response: Response): Promise<string> {
|
|
459
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
460
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
|
461
|
+
throw new Error(`IBM Bob response exceeded ${MAX_RESPONSE_BYTES} bytes.`);
|
|
462
|
+
}
|
|
463
|
+
if (!response.body) return "";
|
|
464
|
+
|
|
465
|
+
const reader = response.body.getReader();
|
|
466
|
+
const decoder = new TextDecoder();
|
|
467
|
+
let bytesRead = 0;
|
|
468
|
+
let body = "";
|
|
274
469
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
470
|
+
while (true) {
|
|
471
|
+
const { done, value } = await reader.read();
|
|
472
|
+
if (done) break;
|
|
473
|
+
bytesRead += value.byteLength;
|
|
474
|
+
if (bytesRead > MAX_RESPONSE_BYTES) {
|
|
475
|
+
await reader.cancel();
|
|
476
|
+
throw new Error(`IBM Bob response exceeded ${MAX_RESPONSE_BYTES} bytes.`);
|
|
477
|
+
}
|
|
478
|
+
body += decoder.decode(value, { stream: true });
|
|
479
|
+
}
|
|
480
|
+
return body + decoder.decode();
|
|
481
|
+
} finally {
|
|
482
|
+
reader.releaseLock();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function readJsonResponse(response: Response): Promise<unknown> {
|
|
487
|
+
const body = await readBoundedResponseBody(response);
|
|
488
|
+
return JSON.parse(body);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function truncateHttpBody(body: string): string {
|
|
492
|
+
const trimmed = body.trim();
|
|
493
|
+
const truncated = trimmed.length > 512 ? `${trimmed.slice(0, 512)}…` : trimmed;
|
|
494
|
+
return truncated.replace(CATALOG_CONTROL_CHARACTERS, (character) =>
|
|
495
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function fetchBobModelCatalog(
|
|
500
|
+
accessToken: string,
|
|
501
|
+
authScheme: string,
|
|
502
|
+
settings?: BobShellSettings,
|
|
503
|
+
): Promise<BobDiscoveredModel[]> {
|
|
504
|
+
const startedAt = Date.now();
|
|
505
|
+
const controller = new AbortController();
|
|
506
|
+
const timeoutMs = envInt("IBM_BOB_MODEL_DISCOVERY_TIMEOUT_MS", DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS);
|
|
507
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
508
|
+
const headers: Record<string, string> = {
|
|
509
|
+
Accept: "application/json",
|
|
510
|
+
Authorization: `${authScheme} ${accessToken}`,
|
|
511
|
+
"User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0",
|
|
512
|
+
};
|
|
513
|
+
const instanceId =
|
|
514
|
+
env("IBM_BOB_INSTANCE_ID") ?? settings?.ibm?.instanceId ?? readInstanceFromJwt(accessToken);
|
|
515
|
+
const teamId = env("IBM_BOB_TEAM_ID") ?? settings?.ibm?.teamId;
|
|
516
|
+
if (instanceId) headers["x-instance-id"] = instanceId;
|
|
517
|
+
if (teamId) headers["x-team-id"] = teamId;
|
|
518
|
+
bobLog(
|
|
519
|
+
`model discovery → ${providerBaseUrl().replace(/\/+$/, "")}/model/info (instance=${instanceId ?? "none"}, timeout=${timeoutMs}ms)`,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const response = await fetch(`${providerBaseUrl().replace(/\/+$/, "")}/model/info`, {
|
|
524
|
+
method: "GET",
|
|
525
|
+
headers,
|
|
526
|
+
signal: controller.signal,
|
|
527
|
+
});
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`IBM Bob model discovery failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`.trim(),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const catalog = parseBobModelCatalog(await readJsonResponse(response));
|
|
534
|
+
bobLog(`model discovery ok: ${catalog.length} models in ${Date.now() - startedAt}ms`);
|
|
535
|
+
return catalog;
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (controller.signal.aborted) {
|
|
538
|
+
throw new Error(`IBM Bob model discovery timed out after ${timeoutMs}ms.`);
|
|
539
|
+
}
|
|
540
|
+
bobLog(
|
|
541
|
+
`model discovery failed in ${Date.now() - startedAt}ms: ${error instanceof Error ? error.message : String(error)}`,
|
|
542
|
+
);
|
|
543
|
+
throw error;
|
|
544
|
+
} finally {
|
|
545
|
+
clearTimeout(timeout);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function attachBobModelCatalog(
|
|
550
|
+
credentials: OAuthCredentials,
|
|
551
|
+
previous?: OAuthCredentials,
|
|
552
|
+
): Promise<BobOAuthCredentials> {
|
|
553
|
+
if (!envBool("IBM_BOB_DISCOVER_MODELS", true)) {
|
|
554
|
+
const previousCatalog = cachedCatalog(previous);
|
|
555
|
+
return previousCatalog ? { ...credentials, bobModelCatalog: previousCatalog } : credentials;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const catalog = await fetchBobModelCatalog(credentials.access, "Bearer", readBobShellSettings());
|
|
560
|
+
return { ...credentials, bobModelCatalog: catalog };
|
|
561
|
+
} catch (error) {
|
|
562
|
+
const previousCatalog = cachedCatalog(previous);
|
|
563
|
+
console.warn(
|
|
564
|
+
`pi-bob: ${error instanceof Error ? error.message : String(error)} Using ${
|
|
565
|
+
previousCatalog ? "the previously cached catalog" : "configured fallback models"
|
|
566
|
+
}.`,
|
|
567
|
+
);
|
|
568
|
+
return previousCatalog ? { ...credentials, bobModelCatalog: previousCatalog } : credentials;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function jwtPayload(accessToken: string): Record<string, unknown> | undefined {
|
|
573
|
+
try {
|
|
574
|
+
const parts = accessToken.split(".");
|
|
575
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
576
|
+
const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
577
|
+
const parsed = JSON.parse(json) as unknown;
|
|
578
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
281
579
|
} catch {
|
|
282
580
|
return undefined;
|
|
283
581
|
}
|
|
284
582
|
}
|
|
285
583
|
|
|
584
|
+
function jwtExpiry(accessToken: string): number | undefined {
|
|
585
|
+
const exp = jwtPayload(accessToken)?.exp;
|
|
586
|
+
return typeof exp === "number" ? exp * 1000 - 5 * 60 * 1000 : undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function readInstanceFromJwt(accessToken: string | undefined): string | undefined {
|
|
590
|
+
if (!accessToken) return undefined;
|
|
591
|
+
const instances = jwtPayload(accessToken)?.instances;
|
|
592
|
+
if (!Array.isArray(instances) || instances.length === 0) return undefined;
|
|
593
|
+
for (const entry of instances) {
|
|
594
|
+
if (isRecord(entry) && typeof entry.id === "string" && entry.id) return entry.id;
|
|
595
|
+
}
|
|
596
|
+
return undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function bobLog(message: string): void {
|
|
600
|
+
if (!envBool("IBM_BOB_DEBUG", false)) return;
|
|
601
|
+
console.warn(`[pi-bob] ${new Date().toISOString()} ${message}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function applyBobAuthHeaders(
|
|
605
|
+
headers: Record<string, string | null | undefined>,
|
|
606
|
+
resolvedCredential?: string,
|
|
607
|
+
): void {
|
|
608
|
+
let token = resolvedCredential;
|
|
609
|
+
if (!token) {
|
|
610
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
611
|
+
if (typeof value !== "string") continue;
|
|
612
|
+
const lowerName = name.toLowerCase();
|
|
613
|
+
if (lowerName === "authorization") {
|
|
614
|
+
const match = value.match(/^\s*(?:Bearer|Apikey)\s+(.+)\s*$/iu);
|
|
615
|
+
if (match?.[1]) token = match[1];
|
|
616
|
+
} else if (lowerName === "x-api-key" && value.trim()) {
|
|
617
|
+
token ??= value.trim();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (!token) return;
|
|
622
|
+
|
|
623
|
+
for (const name of Object.keys(headers)) {
|
|
624
|
+
const lowerName = name.toLowerCase();
|
|
625
|
+
if (lowerName === "authorization" || lowerName === "x-api-key") headers[name] = null;
|
|
626
|
+
}
|
|
627
|
+
const scheme = jwtPayload(token) ? "Bearer" : apiKeyAuthScheme();
|
|
628
|
+
headers.Authorization = `${scheme} ${token}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function streamBob(
|
|
632
|
+
adapter: CompatibleApi,
|
|
633
|
+
model: Model<Api>,
|
|
634
|
+
context: Context,
|
|
635
|
+
options?: SimpleStreamOptions,
|
|
636
|
+
): AssistantMessageEventStream {
|
|
637
|
+
const mutableHeaders: Record<string, string | null | undefined> = { ...options?.headers };
|
|
638
|
+
applyBobAuthHeaders(mutableHeaders, options?.apiKey);
|
|
639
|
+
const headers = Object.fromEntries(
|
|
640
|
+
Object.entries(mutableHeaders).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
641
|
+
);
|
|
642
|
+
const hasHeaderAuth = Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
|
|
643
|
+
const forwardedOptions: SimpleStreamOptions = { ...options, headers };
|
|
644
|
+
|
|
645
|
+
switch (adapter) {
|
|
646
|
+
case "anthropic-messages":
|
|
647
|
+
return anthropicMessagesApi().streamSimple(model as Model<"anthropic-messages">, context, {
|
|
648
|
+
...forwardedOptions,
|
|
649
|
+
apiKey: hasHeaderAuth ? undefined : options?.apiKey,
|
|
650
|
+
});
|
|
651
|
+
case "openai-responses":
|
|
652
|
+
return openAIResponsesApi().streamSimple(model as Model<"openai-responses">, context, {
|
|
653
|
+
...forwardedOptions,
|
|
654
|
+
apiKey: hasHeaderAuth ? "pi-bob-header-auth" : options?.apiKey,
|
|
655
|
+
});
|
|
656
|
+
default:
|
|
657
|
+
return openAICompletionsApi().streamSimple(model as Model<"openai-completions">, context, {
|
|
658
|
+
...forwardedOptions,
|
|
659
|
+
apiKey: hasHeaderAuth ? "pi-bob-header-auth" : options?.apiKey,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
286
664
|
function credentialsFromTokenResponse(response: BobTokenResponse, previousRefresh?: string): OAuthCredentials {
|
|
287
665
|
if (!response.token) throw new Error("IBM Bob SSO response did not include an access token.");
|
|
288
666
|
return {
|
|
@@ -317,6 +695,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
317
695
|
|
|
318
696
|
const code = new Promise<string>((resolve, reject) => {
|
|
319
697
|
server.on("request", (req, res) => {
|
|
698
|
+
bobLog(`login: callback request received: ${(req.url ?? "").split("?")[0] || "(no path)"}`);
|
|
320
699
|
if (!req.url?.startsWith("/bob-callback")) {
|
|
321
700
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
322
701
|
res.end("Not Found");
|
|
@@ -330,6 +709,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
330
709
|
const error = url.searchParams.get("error");
|
|
331
710
|
|
|
332
711
|
if (returnedState !== state) {
|
|
712
|
+
bobLog(`login: callback state mismatch (expected ${state}, got ${returnedState})`);
|
|
333
713
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
334
714
|
res.end("Invalid state parameter");
|
|
335
715
|
reject(new Error("Invalid state parameter from IBM Bob SSO callback."));
|
|
@@ -337,6 +717,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
337
717
|
}
|
|
338
718
|
|
|
339
719
|
if (error) {
|
|
720
|
+
bobLog(`login: callback auth error: ${error}`);
|
|
340
721
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
341
722
|
res.end("Authentication failed");
|
|
342
723
|
reject(new Error(`IBM Bob SSO failed: ${error}`));
|
|
@@ -344,6 +725,7 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
344
725
|
}
|
|
345
726
|
|
|
346
727
|
if (!authCode) {
|
|
728
|
+
bobLog("login: callback missing authorization code");
|
|
347
729
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
348
730
|
res.end("Missing authorization code");
|
|
349
731
|
reject(new Error("IBM Bob SSO callback did not include an authorization code."));
|
|
@@ -363,7 +745,10 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
363
745
|
reject(error);
|
|
364
746
|
}
|
|
365
747
|
});
|
|
366
|
-
server.on("error",
|
|
748
|
+
server.on("error", (error) => {
|
|
749
|
+
bobLog(`login: callback server error: ${error instanceof Error ? error.message : String(error)}`);
|
|
750
|
+
reject(error);
|
|
751
|
+
});
|
|
367
752
|
});
|
|
368
753
|
|
|
369
754
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -375,7 +760,6 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
375
760
|
});
|
|
376
761
|
|
|
377
762
|
return {
|
|
378
|
-
port,
|
|
379
763
|
callbackUri: `http://localhost:${port}/bob-callback`,
|
|
380
764
|
code,
|
|
381
765
|
close() {
|
|
@@ -385,71 +769,175 @@ async function startCallbackServer(state: string): Promise<CallbackServer> {
|
|
|
385
769
|
}
|
|
386
770
|
|
|
387
771
|
async function postToken(path: "/authn/v1/auth/token" | "/authn/v1/auth/refresh", body: unknown): Promise<BobTokenResponse> {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
});
|
|
772
|
+
const startedAt = Date.now();
|
|
773
|
+
const controller = new AbortController();
|
|
774
|
+
const timeoutMs = envInt("IBM_BOB_TOKEN_REQUEST_TIMEOUT_MS", DEFAULT_TOKEN_REQUEST_TIMEOUT_MS);
|
|
775
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
776
|
+
bobLog(`token request → ${bobOrigin()}${path} (timeout=${timeoutMs}ms)`);
|
|
777
|
+
try {
|
|
778
|
+
const response = await fetch(`${bobOrigin()}${path}`, {
|
|
779
|
+
method: "POST",
|
|
780
|
+
headers: { "Content-Type": "application/json", "User-Agent": env("IBM_BOB_USER_AGENT") ?? "pi-bob/0.2.0" },
|
|
781
|
+
body: JSON.stringify(body),
|
|
782
|
+
signal: controller.signal,
|
|
783
|
+
});
|
|
393
784
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
785
|
+
if (!response.ok) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`IBM Bob SSO token request failed: ${response.status} ${truncateHttpBody(await readBoundedResponseBody(response))}`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
397
790
|
|
|
398
|
-
|
|
791
|
+
bobLog(`token request ok in ${Date.now() - startedAt}ms`);
|
|
792
|
+
return (await readJsonResponse(response)) as BobTokenResponse;
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (controller.signal.aborted) throw new Error(`IBM Bob SSO token request timed out after ${timeoutMs}ms.`);
|
|
795
|
+
throw error;
|
|
796
|
+
} finally {
|
|
797
|
+
clearTimeout(timeout);
|
|
798
|
+
}
|
|
399
799
|
}
|
|
400
800
|
|
|
401
801
|
async function loginBob(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
802
|
+
const startedAt = Date.now();
|
|
803
|
+
const elapsed = () => `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
804
|
+
bobLog("login: starting IBM Bob SSO");
|
|
805
|
+
|
|
402
806
|
const state = randomBytes(16).toString("hex");
|
|
403
807
|
const callbackServer = await startCallbackServer(state);
|
|
808
|
+
bobLog(`login: callback server listening at ${callbackServer.callbackUri} (+${elapsed()})`);
|
|
809
|
+
|
|
404
810
|
const loginUrl = new URL(bobWebLoginUrl());
|
|
405
811
|
loginUrl.searchParams.set("callback_uri", callbackServer.callbackUri);
|
|
406
812
|
loginUrl.searchParams.set("state", state);
|
|
407
813
|
|
|
814
|
+
const signal = callbacks.signal;
|
|
815
|
+
let loginTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
816
|
+
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
|
817
|
+
let onAbort: (() => void) | undefined;
|
|
408
818
|
try {
|
|
409
819
|
callbacks.onAuth({ url: loginUrl.toString() });
|
|
410
|
-
|
|
411
|
-
userCode: "Browser SSO",
|
|
412
|
-
verificationUri: loginUrl.toString(),
|
|
413
|
-
expiresInSeconds: Math.floor(envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000) / 1000),
|
|
414
|
-
});
|
|
820
|
+
bobLog(`login: browser opened for SSO (+${elapsed()})`);
|
|
415
821
|
|
|
416
822
|
const timeoutMs = envInt("IBM_BOB_LOGIN_TIMEOUT_MS", 180_000);
|
|
417
823
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
418
|
-
setTimeout(() =>
|
|
824
|
+
loginTimeout = setTimeout(() => {
|
|
825
|
+
bobLog(`login: SSO timed out after ${timeoutMs}ms (+${elapsed()})`);
|
|
826
|
+
reject(new Error("IBM Bob SSO timed out."));
|
|
827
|
+
}, timeoutMs);
|
|
419
828
|
});
|
|
420
829
|
|
|
421
|
-
const
|
|
422
|
-
|
|
830
|
+
const abort =
|
|
831
|
+
signal === undefined
|
|
832
|
+
? undefined
|
|
833
|
+
: new Promise<never>((_resolve, reject) => {
|
|
834
|
+
if (signal.aborted) {
|
|
835
|
+
bobLog("login: cancelled (abort signal already fired)");
|
|
836
|
+
reject(new Error("Login cancelled"));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
onAbort = () => {
|
|
840
|
+
bobLog(`login: cancelled via abort signal (+${elapsed()})`);
|
|
841
|
+
reject(new Error("Login cancelled"));
|
|
842
|
+
};
|
|
843
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
progressInterval = setInterval(() => {
|
|
847
|
+
bobLog(`login: still waiting for SSO browser callback (+${elapsed()})`);
|
|
848
|
+
}, Math.min(15_000, Math.max(5_000, Math.floor(timeoutMs / 12))));
|
|
849
|
+
|
|
850
|
+
const races: Array<Promise<string | never>> = [callbackServer.code, timeout];
|
|
851
|
+
if (abort) races.push(abort);
|
|
852
|
+
const code = await Promise.race(races);
|
|
853
|
+
bobLog(`login: SSO callback received (+${elapsed()})`);
|
|
854
|
+
|
|
855
|
+
const credentials = credentialsFromTokenResponse(await postToken("/authn/v1/auth/token", { code }));
|
|
856
|
+
bobLog(`login: access token exchanged (+${elapsed()})`);
|
|
857
|
+
|
|
858
|
+
const withCatalog = await attachBobModelCatalog(credentials);
|
|
859
|
+
bobLog(`login: model catalog attached (+${elapsed()})`);
|
|
860
|
+
|
|
861
|
+
return withCatalog;
|
|
862
|
+
} catch (error) {
|
|
863
|
+
bobLog(`login: failed (+${elapsed()}): ${error instanceof Error ? error.message : String(error)}`);
|
|
864
|
+
throw error;
|
|
423
865
|
} finally {
|
|
866
|
+
if (loginTimeout) clearTimeout(loginTimeout);
|
|
867
|
+
if (progressInterval) clearInterval(progressInterval);
|
|
868
|
+
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
424
869
|
callbackServer.close();
|
|
425
870
|
}
|
|
426
871
|
}
|
|
427
872
|
|
|
428
873
|
async function refreshBobToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
429
874
|
if (!credentials.refresh) throw new Error("No IBM Bob refresh token is available. Run /login ibm-bob again.");
|
|
430
|
-
|
|
875
|
+
const refreshed = credentialsFromTokenResponse(
|
|
431
876
|
await postToken("/authn/v1/auth/refresh", { refresh_token: credentials.refresh }),
|
|
432
877
|
credentials.refresh,
|
|
433
878
|
);
|
|
879
|
+
// Token refresh runs inside pi-ai's locked credentials.modify (auth.json lock).
|
|
880
|
+
// Avoid holding that global lock across a model-discovery network call when a
|
|
881
|
+
// catalog is already cached; rediscovery still happens on login and whenever
|
|
882
|
+
// no cached catalog exists.
|
|
883
|
+
const existingCatalog = cachedCatalog(credentials);
|
|
884
|
+
if (existingCatalog) {
|
|
885
|
+
bobLog(`refresh: reusing cached catalog (${existingCatalog.length} models); skipping rediscovery`);
|
|
886
|
+
return { ...refreshed, bobModelCatalog: existingCatalog };
|
|
887
|
+
}
|
|
888
|
+
return attachBobModelCatalog(refreshed, credentials);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function modifyModelsFromCachedCatalog(
|
|
892
|
+
models: Model<Api>[],
|
|
893
|
+
credentials: OAuthCredentials,
|
|
894
|
+
api: CompatibleApi,
|
|
895
|
+
): Model<Api>[] {
|
|
896
|
+
if (!envBool("IBM_BOB_DISCOVER_MODELS", true)) return models;
|
|
897
|
+
const catalog = cachedCatalog(credentials);
|
|
898
|
+
const otherProviders = models.filter((model) => model.provider !== PROVIDER_ID);
|
|
899
|
+
const discovered = buildModels(api, catalog).map(
|
|
900
|
+
(model) =>
|
|
901
|
+
({
|
|
902
|
+
...model,
|
|
903
|
+
api: BOB_API,
|
|
904
|
+
provider: PROVIDER_ID,
|
|
905
|
+
baseUrl: providerRequestBaseUrl(api),
|
|
906
|
+
}) as Model<Api>,
|
|
907
|
+
);
|
|
908
|
+
return [...otherProviders, ...discovered];
|
|
434
909
|
}
|
|
435
910
|
|
|
436
|
-
export default function (pi: ExtensionAPI) {
|
|
911
|
+
export default async function (pi: ExtensionAPI) {
|
|
437
912
|
const api = parseApi();
|
|
438
913
|
const settings = readBobShellSettings();
|
|
439
914
|
const headers = buildHeaders(settings);
|
|
915
|
+
let catalog: BobDiscoveredModel[] | undefined;
|
|
916
|
+
const apiKey = configuredApiKey();
|
|
917
|
+
if (apiKey && envBool("IBM_BOB_DISCOVER_MODELS", true)) {
|
|
918
|
+
try {
|
|
919
|
+
catalog = await fetchBobModelCatalog(apiKey.value, apiKeyAuthScheme(), settings);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
console.warn(
|
|
922
|
+
`pi-bob: ${error instanceof Error ? error.message : String(error)} Using configured fallback models.`,
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
440
926
|
|
|
441
927
|
pi.registerProvider(PROVIDER_ID, {
|
|
442
928
|
name: "IBM Bob",
|
|
443
|
-
baseUrl:
|
|
444
|
-
apiKey:
|
|
445
|
-
api,
|
|
929
|
+
baseUrl: providerRequestBaseUrl(api),
|
|
930
|
+
apiKey: providerApiKeyReference(),
|
|
931
|
+
api: BOB_API,
|
|
446
932
|
...(headers ? { headers } : {}),
|
|
447
|
-
models: buildModels(api),
|
|
933
|
+
models: buildModels(api, catalog),
|
|
934
|
+
streamSimple: (model, context, options) => streamBob(api, model, context, options),
|
|
448
935
|
oauth: {
|
|
449
936
|
name: "IBM Bob SSO",
|
|
450
937
|
login: loginBob,
|
|
451
938
|
refreshToken: refreshBobToken,
|
|
452
939
|
getApiKey: (credentials) => credentials.access,
|
|
940
|
+
modifyModels: (models, credentials) => modifyModelsFromCachedCatalog(models, credentials, api),
|
|
453
941
|
},
|
|
454
942
|
});
|
|
455
943
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-bob",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
5
4
|
"type": "module",
|
|
6
5
|
"description": "Pi provider extension for IBM Bob / IBM-approved compatible model endpoints.",
|
|
7
6
|
"keywords": [
|
|
@@ -16,11 +15,16 @@
|
|
|
16
15
|
"./extensions/bob.ts"
|
|
17
16
|
]
|
|
18
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"extensions/bob.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
19
23
|
"author": "Larry Song",
|
|
20
24
|
"license": "MIT",
|
|
21
25
|
"repository": {
|
|
22
26
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/songlining/pi-bob.git"
|
|
27
|
+
"url": "git+https://github.com/songlining/pi-bob.git"
|
|
24
28
|
},
|
|
25
29
|
"bugs": {
|
|
26
30
|
"url": "https://github.com/songlining/pi-bob/issues"
|
|
@@ -28,10 +32,16 @@
|
|
|
28
32
|
"homepage": "https://github.com/songlining/pi-bob#readme",
|
|
29
33
|
"scripts": {
|
|
30
34
|
"check": "pi -e . --list-models >/dev/null",
|
|
35
|
+
"test": "bun test",
|
|
31
36
|
"clean": "echo 'nothing to clean'",
|
|
32
37
|
"build": "echo 'nothing to build'"
|
|
33
38
|
},
|
|
34
39
|
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "*",
|
|
35
41
|
"@earendil-works/pi-coding-agent": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@earendil-works/pi-ai": "0.80.7",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "0.80.7"
|
|
36
46
|
}
|
|
37
47
|
}
|
package/PLAN.md
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# IBM Bob provider plan
|
|
2
|
-
|
|
3
|
-
## Safety boundaries
|
|
4
|
-
|
|
5
|
-
- Use only IBM-approved Bob/model endpoints and credentials the user is authorised to use.
|
|
6
|
-
- Do not scrape, extract, copy, or reuse Bob browser/session credentials.
|
|
7
|
-
- Do not store secrets in this repository or in chat transcripts.
|
|
8
|
-
- Prefer environment variables, password-manager commands, formal Pi `/login` support, or approved local auth helpers for credentials.
|
|
9
|
-
|
|
10
|
-
## Implementation strategy
|
|
11
|
-
|
|
12
|
-
### Option 1 — compatible provider first
|
|
13
|
-
|
|
14
|
-
Implement a Pi package that registers IBM Bob as a normal Pi provider using Pi's built-in compatible API adapters:
|
|
15
|
-
|
|
16
|
-
- `openai-completions` for OpenAI Chat Completions-compatible Bob routes.
|
|
17
|
-
- `openai-responses` for OpenAI Responses-compatible Bob routes.
|
|
18
|
-
- `anthropic-messages` for Anthropic Messages-compatible Bob routes.
|
|
19
|
-
|
|
20
|
-
This avoids custom streaming code and keeps the integration transparent: Pi serializes messages, tools, streaming, and usage exactly as it already does for compatible providers.
|
|
21
|
-
|
|
22
|
-
### Option 2 — approved SSO/auth wrapper
|
|
23
|
-
|
|
24
|
-
If Bob uses short-lived IBM SSO/IAM/direct-access tokens but still exposes an OpenAI/Anthropic-compatible model API, add a Pi `/login ibm-bob` flow or an approved auth-helper integration that obtains tokens, injects headers, and delegates to Pi's built-in streamers.
|
|
25
|
-
|
|
26
|
-
Implemented: `/login ibm-bob` uses Bob's browser SSO endpoints (`/authn/v1/auth/token` and `/authn/v1/auth/refresh`) and Pi's standard OAuth credential store. It does not extract Bob Shell's stored token from `~/.bob/settings.json` or Bob's credential store.
|
|
27
|
-
|
|
28
|
-
### Option 3 — native Bob/watsonx adapter
|
|
29
|
-
|
|
30
|
-
Only if Bob exposes a non-compatible native API, implement a full `streamSimple` adapter that maps Pi context and tool calls to Bob requests and Bob streaming events back to Pi assistant-message events.
|
|
31
|
-
|
|
32
|
-
## Discovered local Bob Shell facts
|
|
33
|
-
|
|
34
|
-
From `bobshell@1.0.6` installed at `/opt/homebrew/lib/node_modules/bobshell`:
|
|
35
|
-
|
|
36
|
-
- CLI path: `/opt/homebrew/bin/bob`
|
|
37
|
-
- Local auth method: `sso`
|
|
38
|
-
- Default Bob API host: `https://api.us-east.bob.ibm.com`
|
|
39
|
-
- OpenAI-compatible base URL: `https://api.us-east.bob.ibm.com/inference/v1`
|
|
40
|
-
- Chat route: `/inference/v1/chat/completions`
|
|
41
|
-
- Model-info route: `/inference/v1/model/info`
|
|
42
|
-
- Default model alias: `premium`
|
|
43
|
-
- Other visible aliases/constants: `pro`, `flash`, `flash-lite`, `bob-3-pro-preview`
|
|
44
|
-
- Installed Bob Shell contains a broad ~1M context-window default, but the observed `premium` backend route maps to Claude Sonnet 4.5 with `Max Input Tokens=200000`.
|
|
45
|
-
- Provider default context window for Pi: `200000`, so Pi compacts before Bob rejects oversized requests.
|
|
46
|
-
- Default max output token constant: `8192`
|
|
47
|
-
- Non-secret routing headers: `x-instance-id`, `x-team-id`
|
|
48
|
-
- SSO browser login URL: `https://bob.ibm.com/login?callback_uri=...&state=...`
|
|
49
|
-
- SSO token endpoint: `/authn/v1/auth/token`
|
|
50
|
-
- SSO refresh endpoint: `/authn/v1/auth/refresh`
|
|
51
|
-
|
|
52
|
-
## Option 1 execution
|
|
53
|
-
|
|
54
|
-
This repository now contains a local Pi package:
|
|
55
|
-
|
|
56
|
-
- `package.json` declares a Pi package.
|
|
57
|
-
- `extensions/bob.ts` registers provider `ibm-bob` and Pi `/login ibm-bob` SSO.
|
|
58
|
-
- `README.md` documents discovered settings, setup, and validation.
|
|
59
|
-
|
|
60
|
-
Defaults now target the discovered Bob Shell route:
|
|
61
|
-
|
|
62
|
-
- `IBM_BOB_BASE_URL=https://api.us-east.bob.ibm.com/inference/v1`
|
|
63
|
-
- `IBM_BOB_API=openai-completions`
|
|
64
|
-
- `IBM_BOB_MODELS=premium`
|
|
65
|
-
- `IBM_BOB_CONTEXT_WINDOW=200000`
|
|
66
|
-
- `IBM_BOB_MAX_TOKENS=8192`
|
|
67
|
-
|
|
68
|
-
The extension reads only non-secret `ibm.instanceId` and `ibm.teamId` from `~/.bob/settings.json` unless `IBM_BOB_READ_BOBSHELL_SETTINGS=false`.
|
|
69
|
-
|
|
70
|
-
Secret configuration remains external:
|
|
71
|
-
|
|
72
|
-
- Pi `/login ibm-bob`, which stores fresh SSO credentials in `~/.pi/agent/auth.json`, or
|
|
73
|
-
- `IBM_BOB_API_KEY`, or
|
|
74
|
-
- `IBM_BOB_HEADERS_JSON` with environment-variable placeholders, or
|
|
75
|
-
- future approved local auth-helper support.
|
|
76
|
-
|
|
77
|
-
## Validation completed
|
|
78
|
-
|
|
79
|
-
- Bob Shell SSO test succeeded with `bob --auth-method sso -m premium`.
|
|
80
|
-
- Direct unauthenticated model-info call reached the discovered endpoint and returned expected `401 Authentication required`.
|
|
81
|
-
- Pi provider lists `ibm-bob/premium` with dummy token.
|
|
82
|
-
- Pi request with dummy token reaches Bob and returns expected `401 unauthorized`.
|
|
83
|
-
- Independent Bob SSO smoke test succeeded: callback received, token exchange succeeded, and authenticated model-info returned HTTP `200`.
|
|
84
|
-
- Direct minimal chat completion returned `direct-bob-ok`.
|
|
85
|
-
- Direct tool payload test showed Bob rejects `tools[].function.strict`; provider default now sets `supportsStrictMode=false`.
|
|
86
|
-
- Pi end-to-end with fresh SSO token succeeded: `pi-bob-ok`.
|
|
87
|
-
- After a real context overflow from Bob (`Max Input Tokens=200000, Got=236458`), the provider default context window was lowered from `1048576` to `200000` so Pi's own compaction/overflow handling can engage before Bob rejects the request.
|
|
88
|
-
|
|
89
|
-
## Remaining work
|
|
90
|
-
|
|
91
|
-
The provider works end-to-end for a basic Pi prompt through Bob's OpenAI-compatible route. Useful next steps:
|
|
92
|
-
|
|
93
|
-
1. Run interactive `/login ibm-bob` once inside Pi to persist SSO credentials in Pi's auth store.
|
|
94
|
-
2. Add `/bob-status` for no-secret health checks.
|
|
95
|
-
3. Add dynamic model discovery from `/inference/v1/model/info`.
|
|
96
|
-
4. Add regression tests for SSO callback, refresh, and strict-tool compatibility.
|