pi-workbuddy 0.0.0-stage → 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 +198 -2
- package/extensions/workbuddy.js +15 -0
- package/package.json +40 -4
- package/src/bridge-extension.js +45 -0
- package/src/bridge.js +63 -0
- package/src/client-identity.js +6 -0
- package/src/native-capabilities.js +84 -0
- package/src/native-client.js +183 -0
- package/src/native-errors.js +47 -0
- package/src/native-limits.js +43 -0
- package/src/native-models.js +63 -0
- package/src/native-provider.js +114 -0
- package/src/native-transport.js +115 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-workbuddy contributors
|
|
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
|
@@ -1,3 +1,199 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-workbuddy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A **WorkBuddy AI** provider for [Pi](https://pi.dev), with browser-based account login and direct model access—**no separate bridge required**.
|
|
4
|
+
|
|
5
|
+
> **Experimental and unofficial.** Built from static inspection of WorkBuddy AI 5.8.4. A user has successfully completed browser login and a basic live conversation with `deepseek-v4.1-flash`. Tool calling, image input, and long-running token refresh have not yet been verified against the live service. Automated tests use synthetic credentials and responses, including Pi's built-in streaming implementation.
|
|
6
|
+
|
|
7
|
+
## Getting started
|
|
8
|
+
|
|
9
|
+
Requires Node.js ≥22.19 and Pi with native provider support. Tested with Pi CLI **1.3.0**.
|
|
10
|
+
|
|
11
|
+
Install the npm package after the `pi-workbuddy` release is published:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pi install npm:pi-workbuddy
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Until the npm release is available, or to install directly from GitHub:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pi install git:github.com/lutfi238/pi-workbuddy
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then start `pi`. For local development, run `pi -e ./extensions/workbuddy.js` from the repository instead. Use only one installation source to avoid loading the provider twice.
|
|
24
|
+
|
|
25
|
+
Inside Pi:
|
|
26
|
+
|
|
27
|
+
1. Run `/login` and select **WorkBuddy AI**.
|
|
28
|
+
2. Complete the login yourself in the browser using the URL displayed by Pi.
|
|
29
|
+
3. Run `/model`; Pi refreshes model catalogs in the background. Wait for the refresh to finish, then select a model from the `workbuddy` provider.
|
|
30
|
+
4. If you need to force discovery or diagnose a failure, run `/workbuddy refresh`.
|
|
31
|
+
5. Send a prompt. Chat requests consume your WorkBuddy account quota.
|
|
32
|
+
|
|
33
|
+
Press Escape to cancel login. Polling is limited to five minutes. You do not need to share a password or token with the agent, extract desktop credentials, install a bridge, or open a local callback port.
|
|
34
|
+
|
|
35
|
+
For an optional persistent installation from a local checkout:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pi install .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Use `/reload` after updating a loaded extension. To sign out, run `/logout` and select WorkBuddy. This removes Pi's local credentials; it does not revoke every server-side account session or sign out the desktop application.
|
|
42
|
+
|
|
43
|
+
### Commands
|
|
44
|
+
|
|
45
|
+
| Command | Behavior |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `/login` → WorkBuddy AI | Browser login with credentials stored by Pi |
|
|
48
|
+
| `/logout` → WorkBuddy AI | Remove Pi's local WorkBuddy credentials |
|
|
49
|
+
| `/workbuddy status` or `/workbuddy doctor` | Show local configuration, catalog source, active model, and the last sanitized catalog error; no network requests |
|
|
50
|
+
| `/workbuddy refresh` | Fetch the authenticated catalog, refreshing the token if needed |
|
|
51
|
+
| `/model` | Refresh model catalogs in the background and select a model |
|
|
52
|
+
|
|
53
|
+
Before discovery, the provider includes conservative fallback metadata for `default-model`. Models are not listed as available until authentication is configured. If discovery fails, the last known catalog is retained **in memory**; it is not persisted across restarts. Pi may invoke discovery during its refresh lifecycle, but the extension factory does not make network requests when loaded.
|
|
54
|
+
|
|
55
|
+
### Automatic model discovery
|
|
56
|
+
|
|
57
|
+
There is no hardcoded allowlist of WorkBuddy model IDs. The provider fetches `/v3/config` with the signed-in account and projects compatible model entries into Pi. In Pi 1.3.0, opening `/model` refreshes provider catalogs in the background and updates the picker when discovery finishes. Pi also refreshes catalogs after login; `/workbuddy refresh` is the explicit manual override.
|
|
58
|
+
|
|
59
|
+
A newly advertised model can therefore appear without changing the extension or adding its ID manually. The model must pass the existing compatibility and security filters: disabled models, models explicitly lacking tool support, and custom endpoints/protocols/API keys are excluded. Catalog visibility does not guarantee that your account can run every listed model.
|
|
60
|
+
|
|
61
|
+
This is refresh-based discovery, not a realtime push subscription or continuous idle polling. Reopen `/model` to check for additions during a long session. Offline mode does not fetch new catalogs. Extension updates may still be necessary if WorkBuddy changes its API, authentication, metadata schema, or wire protocol.
|
|
62
|
+
|
|
63
|
+
### Login succeeds, but discovery fails or Codex remains selected
|
|
64
|
+
|
|
65
|
+
The message `Logged in ... Credentials saved` means Pi completed the login. The browser may still open the original WorkBuddy application through a server-provided redirect; this does not invalidate Pi's successful login. Signing in does not automatically change the active model.
|
|
66
|
+
|
|
67
|
+
To diagnose discovery without sending a chat request:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
/reload
|
|
71
|
+
/workbuddy refresh
|
|
72
|
+
/workbuddy doctor
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Refresh failures report safe categories such as `[http]` (HTTP status and business code), `[invalid-json]`, `[catalog-shape]`, `[catalog-empty]`, or `[catalog-filtered]` (counts by filter reason). Server messages and response bodies, model URLs, and tokens are not displayed. Do not sign in again or weaken endpoint restrictions before identifying the failure.
|
|
76
|
+
|
|
77
|
+
**HTTP 400 / 12403:** catalog probes with synthetic account metadata reproduced this error when the server could not parse the client version from the User-Agent. Version 0.2.1 adds the compatibility header `CLI/unknown WorkBuddy/5.8.4 pi-workbuddy/0.2.1` to discovery, token refresh, and chat transport. The WorkBuddy marker identifies the inspected protocol compatibility baseline; the extension still declares its own identity. Destination-origin restrictions remain unchanged, and no desktop credentials are used. Public catalog discovery alone does not prove account-specific model access. See the [12403 investigation](research/12403-findings.md).
|
|
78
|
+
|
|
79
|
+
To select the provider, run `/model` and search for `workbuddy`; selecting a model does not send a prompt. When troubleshooting with an agent, obtain explicit approval before sending quota-consuming inference requests. Never share `auth.json`, tokens, or login URLs.
|
|
80
|
+
|
|
81
|
+
## Implementation
|
|
82
|
+
|
|
83
|
+
- **Login:** a server-issued browser URL followed by polling `/v2/plugin/auth/token`. The UI uses Pi's native authentication mechanism; this is not a claim of an official public WorkBuddy OAuth integration.
|
|
84
|
+
- **Token refresh:** `/v2/plugin/auth/token/refresh`, with credential storage and refresh locking managed by Pi.
|
|
85
|
+
- **Catalog:** `GET https://www.workbuddy.ai/v3/config`.
|
|
86
|
+
- **Chat:** `POST https://www.workbuddy.ai/v2/chat/completions`.
|
|
87
|
+
- **Transport:** gzip requests, SSE framing and UTF-8 normalization, repeated or missing tool-call ID handling, usage tails, and cancellation.
|
|
88
|
+
- **Message conversion and streaming:** delegated to Pi's built-in OpenAI Chat Completions implementation. Payload, response, and provider-event hooks are forwarded.
|
|
89
|
+
- **Metadata:** live catalog metadata is supplemented with reviewed official vendor specifications for 24 named models. Context/output use the largest documented or advertised values, not smaller catalog defaults. Image support and reasoning efforts are additive. Routing aliases and newly discovered IDs remain catalog-driven; the reference table is not an allowlist. Disabled models, models without tool support, and custom model URLs, protocols, or API keys are excluded.
|
|
90
|
+
- **Pricing:** model prices are set to zero because USD token rates are unknown—**this does not mean usage is free**. WorkBuddy's `usage.credit` field is not included in Pi's cost accounting; token usage is still processed.
|
|
91
|
+
|
|
92
|
+
### Context and output budgets
|
|
93
|
+
|
|
94
|
+
The default policy is **documented maximum capabilities**, not a conservative gateway-default policy. Context uses the largest positive safe integer among the catalog's input limit, configured context default, supported context lengths, and reviewed vendor context reference. Output similarly uses the larger catalog/vendor maximum, bounded by the shared context budget. Smaller catalog defaults do not reduce these maxima, and a larger live value is never reduced by an older vendor reference.
|
|
95
|
+
|
|
96
|
+
For example, DeepSeek V4.1 Flash now advertises **1,048,576 context tokens**, **393,216 maximum output tokens**, image input, and **low/high/max** thinking. It no longer stays at the catalog's 300K default. Explicitly named regional/preview variants use their base-model references optimistically; identical gateway deployments are not guaranteed. Four routing aliases retain their own live metadata instead of inheriting an invented vendor identity.
|
|
97
|
+
|
|
98
|
+
Image support is enabled when either WorkBuddy or the reviewed vendor reference advertises it, even if gateway metadata disables multimodal input. Supported reasoning efforts combine both sources. Known mandatory-thinking models cannot turn thinking off; Kimi K2.6 uses a thinking toggle rather than an unsupported vendor `reasoning_effort` parameter. Pi exposes text/image input, not every vendor-native audio, video, or hosted-tool feature.
|
|
99
|
+
|
|
100
|
+
Fallbacks of 32,768 context / 4,096 output apply **only when neither source supplies the corresponding usable limit**. `maxAllowedSize` remains excluded because its units are unverified. Output is never added to input to invent a larger context. Rounded vendor notation such as 1M or 256K is normalized decimally where no exact integer is published; exact documented integers are used when available. Kimi completion ceilings share context with input; they are not independent additional output capacity. Grok's unspecified finite output cap and Kimi K2.8's unpublished output maximum retain the catalog value.
|
|
101
|
+
|
|
102
|
+
Smaller explicit output requests remain supported; sampling and payload hooks cannot exceed the resulting model maximum or bypass Pi's remaining-context adjustment. Normal Pi `streamSimple` requests additionally fit output to estimated remaining context, including Pi 1.3.0's 4,096-token safety margin. This is distinct from the default 16,384-token compaction reserve. Full API `stream` callers still own input/remaining-context budgeting.
|
|
103
|
+
|
|
104
|
+
**Optimistic does not mean live-verified:** vendor API limits/features may differ from WorkBuddy routing, account access, or gateway protocol support. OpenAI's native GPT-6 tool/endpoint restrictions, for example, do not establish whether WorkBuddy translates its own chat endpoint. Models are not disabled preemptively for this uncertainty. Actual errors should drive targeted compatibility fixes, rather than blanket reductions. There is no automatic downgrade, retry with another model, or quota-consuming capability probe.
|
|
105
|
+
|
|
106
|
+
See the current [capability policy](research/capability-policy.md), [vendor references](research/vendor-capability-comparison.md), and historical [context-limit audit](research/context-limit-audit.md). No global Pi compaction settings are changed by the extension.
|
|
107
|
+
|
|
108
|
+
### Native configuration
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
# Default mode:
|
|
112
|
+
$env:WORKBUDDY_MODE = "native"
|
|
113
|
+
|
|
114
|
+
# Only when testing plain-JSON transport compatibility:
|
|
115
|
+
$env:WORKBUDDY_GZIP = "0"
|
|
116
|
+
pi -e ./extensions/workbuddy.js
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Gzip is enabled by default. Bridge URLs and API keys are not used in native mode. The native origin cannot be changed through environment variables; an official endpoint change requires code review and testing.
|
|
120
|
+
|
|
121
|
+
The `.env.example` file is a reference only: Pi and this extension do **not** automatically load `.env` files. Set variables in the shell or process that starts Pi.
|
|
122
|
+
|
|
123
|
+
## Security and limitations
|
|
124
|
+
|
|
125
|
+
- Pi stores credentials in its agent profile (`auth.json`), not in the project. Protect that file; never commit or share it.
|
|
126
|
+
- The extension does not read the desktop application's credentials, databases, cookies, or sessions.
|
|
127
|
+
- Authentication, discovery, and chat requests target only the HTTPS origin `www.workbuddy.ai`; HTTP redirects are rejected. Recognized production WorkBuddy/CodeBuddy account domains are used only as header values, not destination URLs.
|
|
128
|
+
- Browser login URLs must use recognized production WorkBuddy/CodeBuddy origins. Login URLs can contain sensitive state; do not share them or screenshots containing them.
|
|
129
|
+
- Remote configuration cannot import prompts, tools, commands, credentials, or model URLs into this extension. Discovery does not send a list of repositories.
|
|
130
|
+
- `X-Private-Data: true` is a conservative privacy default, **not a guarantee** of the server's retention policy.
|
|
131
|
+
- Errors are sanitized. The extension does not log tokens or upstream error bodies. Pi's debugging features and other extensions remain within Pi's trust boundary.
|
|
132
|
+
- Catalog entries do not prove that a model is available to your account. Reasoning and image capabilities are not fully verified live; opaque `extra_fields` and model-specific behavior may require additional adapters.
|
|
133
|
+
- This version targets the inspected international production service, not the China edition, custom enterprise SSO, staging deployments, or arbitrary hosts.
|
|
134
|
+
- The service may reject third-party clients, change its protocol, or impose usage terms. This extension does not bypass authentication, account permissions, or quota limits.
|
|
135
|
+
- If login fails, do not paste tokens as a workaround. Record only the sanitized HTTP/business code and investigate compatibility.
|
|
136
|
+
|
|
137
|
+
## Legacy bridge mode (optional)
|
|
138
|
+
|
|
139
|
+
The original bridge integration is still available, but it does **not** provide native account login:
|
|
140
|
+
|
|
141
|
+
```powershell
|
|
142
|
+
$env:WORKBUDDY_MODE = "bridge"
|
|
143
|
+
$env:WORKBUDDY_BASE_URL = "http://127.0.0.1:8787/v1"
|
|
144
|
+
# Set this if the bridge validates a local API key:
|
|
145
|
+
$env:WORKBUDDY_API_KEY = "your-local-bridge-key"
|
|
146
|
+
pi -e ./extensions/workbuddy.js
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The bridge uses a separate provider ID, `workbuddy-bridge`, so native account credentials are not reused as a bridge API key. The default placeholder, `workbuddy-local`, is suitable only for bridges that do not validate API keys.
|
|
150
|
+
|
|
151
|
+
Third-party bridges identified during research:
|
|
152
|
+
|
|
153
|
+
- [workbuddy-connect-api](https://github.com/modersetech/workbuddy-connect-api), default port 8787.
|
|
154
|
+
- [workbuddy-bridge](https://github.com/hanhan1223/workbuddy-bridge), default port 8799.
|
|
155
|
+
|
|
156
|
+
Review their security and upstream instructions before use. The extension does not install or launch either bridge automatically.
|
|
157
|
+
|
|
158
|
+
Additional bridge variables:
|
|
159
|
+
|
|
160
|
+
| Variable | Default | Purpose |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `WORKBUDDY_MODELS` | `auto` | Comma-separated fallback model IDs |
|
|
163
|
+
| `WORKBUDDY_CONTEXT_WINDOW` | `32768` | Assumed context limit |
|
|
164
|
+
| `WORKBUDDY_MAX_TOKENS` | `4096` | Assumed output limit |
|
|
165
|
+
| `WORKBUDDY_TIMEOUT_MS` | `3000` | Discovery timeout in milliseconds |
|
|
166
|
+
|
|
167
|
+
## Development and tests
|
|
168
|
+
|
|
169
|
+
There are no runtime dependencies beyond the packages provided by Pi. Host packages are declared as peers, not bundled or installed again by the project.
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
npm test
|
|
173
|
+
npm run check
|
|
174
|
+
npm pack --dry-run
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Host integration tests are optional and skipped unless `PI_AI_TEST_PATH` is set. To use an existing Pi installation, point it to the `pi-ai/dist` directory:
|
|
178
|
+
|
|
179
|
+
```powershell
|
|
180
|
+
$env:PI_AI_TEST_PATH = "C:/path/to/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist"
|
|
181
|
+
npm test
|
|
182
|
+
node scripts/smoke-cli.mjs "C:/path/to/pi-coding-agent/dist/bundle/cli.js"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Host tests cover coordinated login, storage and refresh, tool-call/result round trips, reasoning, Unicode, usage tails, instrumentation, empty and truncated streams, errors, and cancellation. The CLI smoke test uses an offline temporary profile with synthetic credentials and removes it afterward. **None of these automated tests sends a prompt to the live WorkBuddy service.**
|
|
186
|
+
|
|
187
|
+
Local investigation notes:
|
|
188
|
+
[authentication](research/workbuddy-auth-findings.md) ·
|
|
189
|
+
[chat protocol](research/workbuddy-protocol-findings.md) ·
|
|
190
|
+
[error 12403](research/12403-findings.md) ·
|
|
191
|
+
[context-limit audit](research/context-limit-audit.md).
|
|
192
|
+
|
|
193
|
+
These notes are not included in the npm tarball. Raw third-party snapshots and generated evidence remain local and are excluded from Git; they are preserved verbatim rather than translated so their provenance and hashes remain valid. The authored documentation, code comments, user-facing messages, and test fixtures use English. Emoji in test fixtures intentionally exercise Unicode streaming.
|
|
194
|
+
|
|
195
|
+
Pi CLI setup instructions were checked against Context7 library `/earendil-works/pi/v1.3.0`.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
The extension code is licensed under MIT. Third-party application snapshots and generated evidence are not part of the distributed package.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import bridge from "../src/bridge-extension.js";
|
|
3
|
+
import { createWorkbuddyProvider, registerNativeCommands } from "../src/native-provider.js";
|
|
4
|
+
|
|
5
|
+
export default async function workbuddy(pi) {
|
|
6
|
+
const mode = process.env.WORKBUDDY_MODE || "native";
|
|
7
|
+
if (mode === "bridge") return bridge(pi);
|
|
8
|
+
if (mode !== "native") throw new Error("WORKBUDDY_MODE must be native or bridge.");
|
|
9
|
+
if (process.env.WORKBUDDY_GZIP && !["0", "1"].includes(process.env.WORKBUDDY_GZIP)) {
|
|
10
|
+
throw new Error("WORKBUDDY_GZIP must be 0 or 1.");
|
|
11
|
+
}
|
|
12
|
+
const provider = createWorkbuddyProvider({ api: openAICompletionsApi(), gzip: process.env.WORKBUDDY_GZIP !== "0" });
|
|
13
|
+
pi.registerProvider(provider);
|
|
14
|
+
registerNativeCommands(pi, provider);
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-workbuddy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Native WorkBuddy account login and direct model provider for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lutfi238/pi-workbuddy.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/lutfi238/pi-workbuddy#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/lutfi238/pi-workbuddy/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"workbuddy",
|
|
18
|
+
"pi-extension"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"extensions",
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions/workbuddy.js"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-ai": "*",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.19.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node --test",
|
|
40
|
+
"check": "node scripts/check.mjs"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { discoverModels, literalApiKey, modelDefinitions, readConfig } from "./bridge.js";
|
|
2
|
+
|
|
3
|
+
/** WorkBuddy's OpenAI-compatible bridge; no desktop credential access. */
|
|
4
|
+
export default async function workbuddy(pi) {
|
|
5
|
+
const config = readConfig();
|
|
6
|
+
let ids = config.fallbackIds;
|
|
7
|
+
let discoveryError;
|
|
8
|
+
const register = () => pi.registerProvider("workbuddy-bridge", {
|
|
9
|
+
baseUrl: config.baseUrl,
|
|
10
|
+
api: "openai-completions",
|
|
11
|
+
apiKey: literalApiKey(config.apiKey),
|
|
12
|
+
models: modelDefinitions(ids, config),
|
|
13
|
+
});
|
|
14
|
+
try { ids = await discoverModels(config); }
|
|
15
|
+
catch (error) { discoveryError = error.message; }
|
|
16
|
+
register();
|
|
17
|
+
|
|
18
|
+
pi.registerCommand("workbuddy", {
|
|
19
|
+
description: "WorkBuddy bridge: status or refresh model catalog",
|
|
20
|
+
handler: async (args, ctx) => {
|
|
21
|
+
const action = args.trim() || "status";
|
|
22
|
+
if (!["status", "refresh"].includes(action)) {
|
|
23
|
+
ctx.ui.notify("Usage: /workbuddy [status|refresh]", "warning");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const found = await discoverModels(config, ctx.signal);
|
|
28
|
+
if (action === "refresh") {
|
|
29
|
+
ids = found;
|
|
30
|
+
register();
|
|
31
|
+
discoveryError = undefined;
|
|
32
|
+
}
|
|
33
|
+
ctx.ui.notify(
|
|
34
|
+
"WorkBuddy bridge reachable: " + found.length + " advertised models. " +
|
|
35
|
+
(action === "refresh" ? "Catalog refreshed; select with /model." :
|
|
36
|
+
ids.length + " registered. Use /workbuddy refresh to update.") +
|
|
37
|
+
(discoveryError ? " Startup used fallback models." : ""),
|
|
38
|
+
"info",
|
|
39
|
+
);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
ctx.ui.notify(error.message + " Existing catalog retained.", "warning");
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function readConfig(env = process.env) {
|
|
2
|
+
const url = new URL(env.WORKBUDDY_BASE_URL || "http://127.0.0.1:8787/v1");
|
|
3
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
4
|
+
throw new Error("WORKBUDDY_BASE_URL must be an HTTP(S) URL without credentials, query, or fragment.");
|
|
5
|
+
}
|
|
6
|
+
const positiveInt = (name, fallback) => {
|
|
7
|
+
const value = env[name] === undefined ? fallback : Number(env[name]);
|
|
8
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(name + " must be a positive integer.");
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
const contextWindow = positiveInt("WORKBUDDY_CONTEXT_WINDOW", 32768);
|
|
12
|
+
const maxTokens = positiveInt("WORKBUDDY_MAX_TOKENS", 4096);
|
|
13
|
+
if (maxTokens > contextWindow) throw new Error("WORKBUDDY_MAX_TOKENS must not exceed WORKBUDDY_CONTEXT_WINDOW.");
|
|
14
|
+
const fallbackIds = [...new Set((env.WORKBUDDY_MODELS || "auto").split(",").map(s => s.trim()).filter(Boolean))];
|
|
15
|
+
if (!fallbackIds.length) throw new Error("WORKBUDDY_MODELS must contain at least one model ID.");
|
|
16
|
+
return {
|
|
17
|
+
baseUrl: url.href.replace(/\/+$/, ""),
|
|
18
|
+
apiKey: env.WORKBUDDY_API_KEY || "workbuddy-local",
|
|
19
|
+
timeoutMs: positiveInt("WORKBUDDY_TIMEOUT_MS", 3000),
|
|
20
|
+
fallbackIds, contextWindow, maxTokens,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ProviderConfig interprets $, ! prefixes. Escape literal bridge credentials.
|
|
25
|
+
export function literalApiKey(key) {
|
|
26
|
+
const escaped = key.replaceAll("$", () => "$$");
|
|
27
|
+
return escaped.startsWith("!") ? "$!" + escaped.slice(1) : escaped;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function modelDefinitions(ids, config) {
|
|
31
|
+
return ids.map(id => ({
|
|
32
|
+
id, name: "WorkBuddy · " + id,
|
|
33
|
+
reasoning: false, input: ["text"],
|
|
34
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
35
|
+
contextWindow: config.contextWindow, maxTokens: config.maxTokens,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function discoverModels(config, signal) {
|
|
40
|
+
const timeout = AbortSignal.timeout(config.timeoutMs);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetch(config.baseUrl + "/models", {
|
|
44
|
+
headers: { Authorization: "Bearer " + config.apiKey },
|
|
45
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
46
|
+
redirect: "error",
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("Bridge unreachable or request cancelled/timed out. Check its process, URL, and timeout.");
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
// Never echo upstream bodies: they can contain tokens or account details.
|
|
53
|
+
await response.body?.cancel();
|
|
54
|
+
throw new Error("Bridge model discovery failed (HTTP " + response.status + ").");
|
|
55
|
+
}
|
|
56
|
+
let body;
|
|
57
|
+
try { body = await response.json(); }
|
|
58
|
+
catch { throw new Error("Bridge returned invalid JSON."); }
|
|
59
|
+
if (!Array.isArray(body?.data)) throw new Error("Bridge must return an OpenAI model list: { data: [{ id }] }.");
|
|
60
|
+
const ids = [...new Set(body.data.map(item => item?.id).filter(id => typeof id === "string" && id.trim()))];
|
|
61
|
+
if (!ids.length) throw new Error("Bridge returned no usable model IDs.");
|
|
62
|
+
return ids;
|
|
63
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Compatibility baseline: the operator-supplied WorkBuddy AI 5.8.4 artifact.
|
|
2
|
+
// This is a protocol/version marker, not an assertion that Pi is the official desktop app.
|
|
3
|
+
export const EXTENSION_VERSION = "0.2.1";
|
|
4
|
+
export const WORKBUDDY_PROTOCOL_VERSION = "5.8.4";
|
|
5
|
+
export const USER_AGENT = "CLI/unknown WorkBuddy/" + WORKBUDDY_PROTOCOL_VERSION +
|
|
6
|
+
" pi-workbuddy/" + EXTENSION_VERSION;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Reviewed public vendor specifications supplement live WorkBuddy metadata.
|
|
2
|
+
// These are optimistic capabilities, not entitlement checks or a model-ID allowlist.
|
|
3
|
+
const profiles = new Map();
|
|
4
|
+
function add(ids, profile) {
|
|
5
|
+
for (const id of ids) profiles.set(id, Object.freeze({
|
|
6
|
+
...profile,
|
|
7
|
+
...(profile.efforts ? { efforts: Object.freeze([...profile.efforts]) } : {}),
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
const deepseek = {
|
|
11
|
+
contextWindow: 1048576, maxOutputTokens: 393216, images: true, reasoning: true,
|
|
12
|
+
efforts: ["low", "high", "max"],
|
|
13
|
+
source: "https://api-docs.deepseek.com/quick_start/pricing",
|
|
14
|
+
};
|
|
15
|
+
// Apply the named base model's documented abilities to the regional variant too.
|
|
16
|
+
// This is an optimistic name mapping, not proof of identical gateway deployments.
|
|
17
|
+
add(["deepseek-v4.1-flash", "deepseek-v4.1-flash-sg"], deepseek);
|
|
18
|
+
for (const id of ["gemini-3.5-flash", "gemini-3.8-flash"]) add([id], {
|
|
19
|
+
contextWindow: 1048576, maxOutputTokens: 65536, images: true, reasoning: true,
|
|
20
|
+
onlyReasoning: true,
|
|
21
|
+
efforts: [...(id === "gemini-3.5-flash" ? ["minimal"] : []), "low", "medium", "high"],
|
|
22
|
+
source: "https://ai.google.dev/gemini-api/docs/models/" + id,
|
|
23
|
+
});
|
|
24
|
+
for (const id of ["glm-5.2", "glm-5.3", "glm-5.3-flash"]) add([id], {
|
|
25
|
+
// Vendor docs publish rounded 1M; retain larger exact values advertised by WorkBuddy.
|
|
26
|
+
contextWindow: 1000000, maxOutputTokens: 131072, images: id === "glm-5.3-flash", reasoning: true,
|
|
27
|
+
onlyReasoning: id !== "glm-5.2",
|
|
28
|
+
efforts: id === "glm-5.2" ? ["minimal", "low", "medium", "high", "xhigh", "max"] : ["low", "high", "max"],
|
|
29
|
+
off: "none", source: "https://docs.z.ai/guides/llm/" + id,
|
|
30
|
+
});
|
|
31
|
+
for (const id of ["gpt-5.4", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra",
|
|
32
|
+
"gpt-6-astra", "gpt-6-luna", "gpt-6-sol", "gpt-6.1-sol"]) add([id], {
|
|
33
|
+
contextWindow: 1050000, maxOutputTokens: 128000, images: true, reasoning: true,
|
|
34
|
+
efforts: ["low", "medium", "high", "xhigh", ...(["gpt-5.4", "gpt-5.5"].includes(id) ? [] : ["max"])],
|
|
35
|
+
onlyReasoning: ["gpt-6-astra", "gpt-6.1-sol"].includes(id), off: "none",
|
|
36
|
+
source: "https://developers.openai.com/api/docs/models/" + id,
|
|
37
|
+
});
|
|
38
|
+
add(["grok-4.7"], {
|
|
39
|
+
contextWindow: 500000, images: true, reasoning: true, onlyReasoning: true,
|
|
40
|
+
// "No text output limit" is not a finite output number; keep WorkBuddy's numeric value.
|
|
41
|
+
efforts: ["low", "medium", "high", "xhigh"], source: "https://docs.x.ai/developers/grok-4-7",
|
|
42
|
+
});
|
|
43
|
+
add(["hy3"], {
|
|
44
|
+
contextWindow: 256000, maxOutputTokens: 128000, reasoning: true, efforts: ["low", "high"], off: "none",
|
|
45
|
+
source: "https://intl.cloud.tencent.com/ind/document/product/1300/80695",
|
|
46
|
+
});
|
|
47
|
+
add(["hy4-preview"], {
|
|
48
|
+
contextWindow: 1000000, maxOutputTokens: 64000, reasoning: true, efforts: ["high"], off: "none",
|
|
49
|
+
source: "https://intl.cloud.tencent.com/ind/document/product/1300/80695",
|
|
50
|
+
});
|
|
51
|
+
add(["kimi-k2.6"], {
|
|
52
|
+
contextWindow: 262144, maxOutputTokens: 262144, images: true, reasoning: true,
|
|
53
|
+
// K2.6 uses thinking.type, not reasoning_effort. Its output shares the context window.
|
|
54
|
+
thinkingFormat: "deepseek", supportsReasoningEffort: false,
|
|
55
|
+
source: "https://platform.kimi.ai/docs/api/models-overview",
|
|
56
|
+
});
|
|
57
|
+
add(["kimi-k2.8-preview"], {
|
|
58
|
+
// Official Kimi Code docs name K2.8 Preview but expose it as kimi-for-coding.
|
|
59
|
+
// Use that named-model reference optimistically; no published finite output cap.
|
|
60
|
+
contextWindow: 1048576, reasoning: true, efforts: ["low", "high", "max"],
|
|
61
|
+
source: "https://www.kimi.com/code/docs/en/kimi-code/models",
|
|
62
|
+
});
|
|
63
|
+
add(["kimi-k3"], {
|
|
64
|
+
contextWindow: 1048576, maxOutputTokens: 1048576, images: true, reasoning: true, onlyReasoning: true,
|
|
65
|
+
// This is the accepted completion-parameter ceiling, not additional independent context.
|
|
66
|
+
efforts: ["low", "high", "max"], source: "https://platform.kimi.ai/docs/guide/kimi-k3-quickstart",
|
|
67
|
+
});
|
|
68
|
+
add(["minimax-m3.1-flash"], {
|
|
69
|
+
contextWindow: 1000000, maxOutputTokens: 524288, images: true, reasoning: true, onlyReasoning: true,
|
|
70
|
+
efforts: ["low", "medium", "high", "xhigh", "max"],
|
|
71
|
+
source: "https://platform.minimax.io/docs/guides/models-intro",
|
|
72
|
+
});
|
|
73
|
+
add(["step-5-preview"], {
|
|
74
|
+
contextWindow: 1000000, maxOutputTokens: 64000, images: true, reasoning: true,
|
|
75
|
+
efforts: ["low", "medium", "high"], source: "https://platform.stepfun.ai/docs/en/guides/models/step-5-preview",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export function getVendorCapabilities(id) {
|
|
79
|
+
return profiles.get(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function vendorCapabilityIds() {
|
|
83
|
+
return [...profiles.keys()];
|
|
84
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { WorkbuddyError } from "./native-errors.js";
|
|
3
|
+
import { USER_AGENT } from "./client-identity.js";
|
|
4
|
+
|
|
5
|
+
export const ORIGIN = "https://www.workbuddy.ai";
|
|
6
|
+
export const BASE_URL = ORIGIN + "/v2";
|
|
7
|
+
const BROWSER_ORIGINS = new Set([ORIGIN, "https://workbuddy.ai", "https://www.codebuddy.ai"]);
|
|
8
|
+
const ACCOUNT_DOMAINS = new Set(["www.workbuddy.ai", "workbuddy.ai", "www.codebuddy.ai"]);
|
|
9
|
+
const LIMIT = 2 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export function safeHeader(value, name = "account metadata") {
|
|
12
|
+
if (typeof value !== "string" || !value || value.length > (name.includes("token") ? 16384 : 2048) || /[\r\n\0]/.test(value)) {
|
|
13
|
+
throw new WorkbuddyError("metadata", "Invalid WorkBuddy " + name + ".");
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function accountHeaders(credential) {
|
|
19
|
+
if (credential?.type !== "oauth" || credential.origin !== ORIGIN) {
|
|
20
|
+
throw new WorkbuddyError("credentials", "WorkBuddy credentials are missing or belong to a different endpoint. Run /login.");
|
|
21
|
+
}
|
|
22
|
+
validDomain({ domain: credential.domain });
|
|
23
|
+
const headers = {
|
|
24
|
+
Authorization: "Bearer " + safeHeader(credential.access, "access token"),
|
|
25
|
+
"X-User-Id": safeHeader(credential.userId, "user ID"),
|
|
26
|
+
"X-Domain": credential.domain || "www.workbuddy.ai",
|
|
27
|
+
"X-Product": "SaaS", "X-Requested-With": "XMLHttpRequest",
|
|
28
|
+
"User-Agent": USER_AGENT,
|
|
29
|
+
};
|
|
30
|
+
if (credential.enterpriseId) {
|
|
31
|
+
headers["X-Enterprise-Id"] = safeHeader(credential.enterpriseId);
|
|
32
|
+
headers["X-Tenant-Id"] = headers["X-Enterprise-Id"];
|
|
33
|
+
}
|
|
34
|
+
return headers;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function limitedJson(response, limit = LIMIT) {
|
|
38
|
+
if (!response.body) throw new WorkbuddyError("empty-response", "WorkBuddy returned an empty response.");
|
|
39
|
+
const reader = response.body.getReader();
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
try {
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) break;
|
|
46
|
+
size += value.byteLength;
|
|
47
|
+
if (size > limit) throw new WorkbuddyError("response-size", "WorkBuddy response exceeds the size limit.");
|
|
48
|
+
chunks.push(value);
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof WorkbuddyError) throw error;
|
|
52
|
+
throw new WorkbuddyError("response-read", "WorkBuddy response could not be read.");
|
|
53
|
+
} finally {
|
|
54
|
+
await reader.cancel().catch(() => {});
|
|
55
|
+
reader.releaseLock();
|
|
56
|
+
}
|
|
57
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
58
|
+
catch { throw new WorkbuddyError("invalid-json", "WorkBuddy returned invalid JSON."); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validDomain(token) {
|
|
62
|
+
if (token.domain && !ACCOUNT_DOMAINS.has(token.domain)) {
|
|
63
|
+
throw new WorkbuddyError("account-domain", "WorkBuddy returned an unsupported account domain.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tokenCredential(token, account, previous, now) {
|
|
68
|
+
if (!token || typeof token !== "object") throw new Error("WorkBuddy returned no token.");
|
|
69
|
+
validDomain(token);
|
|
70
|
+
const access = safeHeader(token.accessToken, "access token");
|
|
71
|
+
const refresh = token.refreshToken === undefined ? previous?.refresh || "" : token.refreshToken;
|
|
72
|
+
if (refresh !== "") safeHeader(refresh, "refresh token");
|
|
73
|
+
const expires = Number.isFinite(token.expiresAt) ? token.expiresAt :
|
|
74
|
+
Number.isFinite(token.expiresIn) ? now + token.expiresIn * 1000 : undefined;
|
|
75
|
+
if (!Number.isSafeInteger(expires) || expires <= now) {
|
|
76
|
+
throw new Error("WorkBuddy returned missing or expired token expiry. Please sign in again.");
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
type: "oauth", origin: ORIGIN, access, refresh, expires,
|
|
80
|
+
domain: token.domain || previous?.domain || "www.workbuddy.ai",
|
|
81
|
+
userId: safeHeader(String(account?.uid ?? previous?.userId ?? ""), "user ID"),
|
|
82
|
+
...(account?.enterpriseId || previous?.enterpriseId ?
|
|
83
|
+
{ enterpriseId: safeHeader(String(account?.enterpriseId ?? previous.enterpriseId)) } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createNativeClient({
|
|
88
|
+
fetch: fetchImpl = globalThis.fetch, requestTimeoutMs = 15000,
|
|
89
|
+
loginTimeoutMs = 300000, pollIntervalMs = 1000, now = Date.now,
|
|
90
|
+
} = {}) {
|
|
91
|
+
async function request(path, { method = "GET", credential, signal, pendingCode, headers = {} } = {}) {
|
|
92
|
+
const url = new URL(path, ORIGIN);
|
|
93
|
+
if (url.origin !== ORIGIN || url.username || url.password) throw new WorkbuddyError("endpoint", "Untrusted WorkBuddy endpoint.");
|
|
94
|
+
const timed = AbortSignal.timeout(requestTimeoutMs);
|
|
95
|
+
const activeSignal = signal ? AbortSignal.any([signal, timed]) : timed;
|
|
96
|
+
// Validate credential metadata before fetch so validation failures are not mislabeled as network errors.
|
|
97
|
+
const requestHeaders = {
|
|
98
|
+
"Content-Type": "application/json", "X-Domain": "www.workbuddy.ai",
|
|
99
|
+
"X-Product": "SaaS", "X-Requested-With": "XMLHttpRequest",
|
|
100
|
+
"User-Agent": USER_AGENT,
|
|
101
|
+
...(credential ? accountHeaders(credential) : {}), ...headers,
|
|
102
|
+
};
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await fetchImpl(url, {
|
|
106
|
+
method, redirect: "error", signal: activeSignal, headers: requestHeaders,
|
|
107
|
+
...(method === "POST" ? { body: "{}" } : {}),
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
if (signal?.aborted) throw new WorkbuddyError("cancelled", "WorkBuddy operation cancelled or timed out.");
|
|
111
|
+
if (timed.aborted) throw new WorkbuddyError("request-timeout", "WorkBuddy request timed out.");
|
|
112
|
+
throw new WorkbuddyError("network", "Cannot reach WorkBuddy. Check your connection or retry.");
|
|
113
|
+
}
|
|
114
|
+
let envelope;
|
|
115
|
+
try { envelope = await limitedJson(response); }
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (!response.ok) throw new WorkbuddyError("http", "WorkBuddy request failed (HTTP " + response.status + ").", { status: response.status });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (pendingCode !== undefined && envelope?.code === pendingCode) return undefined;
|
|
121
|
+
if (!response.ok || (envelope?.code !== undefined && envelope.code !== 0)) {
|
|
122
|
+
const code = typeof envelope?.code === "number" ? "; code " + envelope.code : "";
|
|
123
|
+
throw new WorkbuddyError("http", "WorkBuddy request failed (HTTP " + response.status + code + ").", {
|
|
124
|
+
status: response.status, businessCode: typeof envelope?.code === "number" ? envelope.code : undefined,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!envelope || typeof envelope !== "object" || envelope.data === undefined) {
|
|
128
|
+
throw new WorkbuddyError("envelope", "WorkBuddy returned an unexpected response envelope.");
|
|
129
|
+
}
|
|
130
|
+
return envelope.data;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async login(interaction) {
|
|
135
|
+
const deadline = AbortSignal.timeout(loginTimeoutMs);
|
|
136
|
+
const signal = interaction.signal ? AbortSignal.any([interaction.signal, deadline]) : deadline;
|
|
137
|
+
interaction.notify({ type: "progress", message: "Requesting WorkBuddy browser login…" });
|
|
138
|
+
const state = await request("/v2/plugin/auth/state?platform=workbuddy-ai", { method: "POST", signal });
|
|
139
|
+
if (typeof state?.state !== "string" || !state.state || state.state.length > 4096) {
|
|
140
|
+
throw new Error("WorkBuddy returned an invalid login state.");
|
|
141
|
+
}
|
|
142
|
+
let authUrl;
|
|
143
|
+
try { authUrl = new URL(state.authUrl); } catch { throw new Error("Invalid WorkBuddy login URL."); }
|
|
144
|
+
if (!BROWSER_ORIGINS.has(authUrl.origin) || authUrl.username || authUrl.password) {
|
|
145
|
+
throw new Error("WorkBuddy returned an untrusted login URL.");
|
|
146
|
+
}
|
|
147
|
+
interaction.notify({ type: "auth_url", url: authUrl.href, instructions: "Sign in with your WorkBuddy account in the browser. Pi will wait; Escape cancels." });
|
|
148
|
+
const query = encodeURIComponent(state.state);
|
|
149
|
+
let token;
|
|
150
|
+
while (!token) {
|
|
151
|
+
signal.throwIfAborted();
|
|
152
|
+
token = await request("/v2/plugin/auth/token?state=" + query, { signal, pendingCode: 11217 });
|
|
153
|
+
if (!token) await sleep(pollIntervalMs, undefined, { signal });
|
|
154
|
+
}
|
|
155
|
+
// Fetch account before creating/storing any Pi credential.
|
|
156
|
+
safeHeader(token.accessToken, "access token");
|
|
157
|
+
validDomain(token);
|
|
158
|
+
let account;
|
|
159
|
+
while (!account) {
|
|
160
|
+
signal.throwIfAborted();
|
|
161
|
+
account = await request("/v2/plugin/login/account?state=" + query, {
|
|
162
|
+
signal, pendingCode: 12151, headers: { Authorization: "Bearer " + token.accessToken },
|
|
163
|
+
});
|
|
164
|
+
if (!account) await sleep(pollIntervalMs, undefined, { signal });
|
|
165
|
+
}
|
|
166
|
+
const credential = tokenCredential(token, account, undefined, now());
|
|
167
|
+
interaction.notify({ type: "info", message: "WorkBuddy login completed. Run /workbuddy refresh, then select a model with /model." });
|
|
168
|
+
return credential;
|
|
169
|
+
},
|
|
170
|
+
async refresh(credential, signal) {
|
|
171
|
+
accountHeaders(credential); // Validate binding before any I/O.
|
|
172
|
+
if (!credential.refresh) throw new Error("WorkBuddy did not issue a refresh token. Run /login again.");
|
|
173
|
+
const token = await request("/v2/plugin/auth/token/refresh", {
|
|
174
|
+
method: "POST", credential, signal,
|
|
175
|
+
headers: { "X-Refresh-Token": safeHeader(credential.refresh), "X-Auth-Refresh-Source": "plugin" },
|
|
176
|
+
});
|
|
177
|
+
return tokenCredential(token, undefined, credential, now());
|
|
178
|
+
},
|
|
179
|
+
async catalog(credential, signal) {
|
|
180
|
+
return request("/v3/config", { credential, signal });
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Only explicitly allowlisted diagnostic fields may reach the UI. Never stringify upstream errors.
|
|
2
|
+
const REASONS = {
|
|
3
|
+
"credentials": "Credentials are missing or bound to a different endpoint; use /login.",
|
|
4
|
+
"account-domain": "The account domain is not supported by this provider.",
|
|
5
|
+
"metadata": "Account metadata or token has an invalid format.",
|
|
6
|
+
"endpoint": "An untrusted endpoint was rejected.",
|
|
7
|
+
"cancelled": "The operation was cancelled.",
|
|
8
|
+
"request-timeout": "The WorkBuddy request timed out.",
|
|
9
|
+
"network": "Cannot reach WorkBuddy; check connection or proxy settings.",
|
|
10
|
+
"http": "The server rejected the request.",
|
|
11
|
+
"empty-response": "The server returned an empty response.",
|
|
12
|
+
"response-size": "The response exceeded the size limit.",
|
|
13
|
+
"invalid-json": "The server response was not valid JSON.",
|
|
14
|
+
"response-read": "The response body could not be read.",
|
|
15
|
+
"envelope": "The JSON response envelope was unexpected.",
|
|
16
|
+
"catalog-shape": "The response did not contain a supported models array.",
|
|
17
|
+
"catalog-empty": "The server returned an empty model list.",
|
|
18
|
+
"catalog-filtered": "No model passed the provider's compatibility/security filters.",
|
|
19
|
+
};
|
|
20
|
+
export class WorkbuddyError extends Error {
|
|
21
|
+
constructor(code, message, details = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "WorkbuddyError";
|
|
24
|
+
this.diagnosticCode = code;
|
|
25
|
+
this.details = details;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function describeFailure(error) {
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (let current = error, depth = 0; current && depth < 6 && !seen.has(current); current = current.cause, depth++) {
|
|
32
|
+
seen.add(current);
|
|
33
|
+
if (!(current instanceof WorkbuddyError) || !Object.hasOwn(REASONS, current.diagnosticCode)) continue;
|
|
34
|
+
const { status, businessCode, total, accepted, endpoint, disabled, toolLess, unavailable, invalid, duplicate } = current.details;
|
|
35
|
+
const fields = [];
|
|
36
|
+
if (Number.isInteger(status) && status >= 100 && status <= 599) fields.push("HTTP " + status);
|
|
37
|
+
if (Number.isSafeInteger(businessCode)) fields.push("business code " + businessCode);
|
|
38
|
+
for (const [name, value] of Object.entries({ total, accepted, endpoint, disabled, toolLess, unavailable, invalid, duplicate })) {
|
|
39
|
+
if (Number.isSafeInteger(value) && value >= 0) fields.push(name + "=" + value);
|
|
40
|
+
}
|
|
41
|
+
const hint = current.diagnosticCode === "http" && businessCode === 12403 ?
|
|
42
|
+
" WorkBuddy could not parse the client version/User-Agent; reload the updated extension." : "";
|
|
43
|
+
return "[" + current.diagnosticCode + "] " + REASONS[current.diagnosticCode] +
|
|
44
|
+
(fields.length ? " (" + fields.join(", ") + ")" : "") + hint;
|
|
45
|
+
}
|
|
46
|
+
return "[unknown] Catalog refresh failed. No upstream error text was displayed to protect credentials.";
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const FALLBACK_CONTEXT = 32768;
|
|
2
|
+
const FALLBACK_OUTPUT = 4096;
|
|
3
|
+
export const isTokenLimit = value => Number.isSafeInteger(value) && value > 0;
|
|
4
|
+
|
|
5
|
+
/** Use the largest advertised/documented budget, not a smaller configured default. */
|
|
6
|
+
export function resolveTokenLimits(item, vendor = {}) {
|
|
7
|
+
const supported = Array.isArray(item.contextWindow?.supportedLengths) ? item.contextWindow.supportedLengths : [];
|
|
8
|
+
const contexts = [item.maxInputTokens, item.contextWindow?.defaultLength, ...supported, vendor.contextWindow].filter(isTokenLimit);
|
|
9
|
+
const contextWindow = contexts.reduce((maximum, value) => Math.max(maximum, value), 0) || FALLBACK_CONTEXT;
|
|
10
|
+
const outputs = [item.maxOutputTokens, vendor.maxOutputTokens].filter(isTokenLimit);
|
|
11
|
+
// Input and output still share Pi's context budget; never add them together.
|
|
12
|
+
const maxTokens = Math.min(outputs.length ? Math.max(...outputs) : FALLBACK_OUTPUT, contextWindow);
|
|
13
|
+
// maxAllowedSize remains excluded because its units are not established.
|
|
14
|
+
return { contextWindow, maxTokens };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveOutputLimit(model, requested) {
|
|
18
|
+
if (requested !== undefined && !isTokenLimit(requested)) {
|
|
19
|
+
throw new Error("WorkBuddy maxTokens must be a positive safe integer.");
|
|
20
|
+
}
|
|
21
|
+
if (!isTokenLimit(model.maxTokens) || !isTokenLimit(model.contextWindow)) {
|
|
22
|
+
throw new Error("WorkBuddy model token limits are invalid; refresh the model catalog.");
|
|
23
|
+
}
|
|
24
|
+
return Math.min(requested ?? model.maxTokens, model.maxTokens, model.contextWindow);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Apply after caller payload hooks so sampling parameters cannot silently bypass the output cap. */
|
|
28
|
+
export function capPayloadOutput(payload, outputLimit) {
|
|
29
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
30
|
+
throw new Error("WorkBuddy chat payload must be an object.");
|
|
31
|
+
}
|
|
32
|
+
const fields = ["max_tokens", "max_completion_tokens"];
|
|
33
|
+
let limit = outputLimit;
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
if (payload[field] === undefined) continue;
|
|
36
|
+
if (!isTokenLimit(payload[field])) throw new Error("WorkBuddy " + field + " must be a positive safe integer.");
|
|
37
|
+
limit = Math.min(limit, payload[field]);
|
|
38
|
+
}
|
|
39
|
+
// The inspected WorkBuddy wire format uses max_tokens, not max_completion_tokens.
|
|
40
|
+
const result = { ...payload, max_tokens: limit };
|
|
41
|
+
delete result.max_completion_tokens;
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BASE_URL } from "./native-client.js";
|
|
2
|
+
import { WorkbuddyError } from "./native-errors.js";
|
|
3
|
+
import { resolveTokenLimits } from "./native-limits.js";
|
|
4
|
+
import { getVendorCapabilities } from "./native-capabilities.js";
|
|
5
|
+
const COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
6
|
+
const LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
7
|
+
const validId = value => typeof value === "string" && value.length <= 256 && /^[\w./:-]+$/.test(value);
|
|
8
|
+
|
|
9
|
+
export function projectModels(config) {
|
|
10
|
+
if (!Array.isArray(config?.models) || config.models.length > 2000) {
|
|
11
|
+
throw new WorkbuddyError("catalog-shape", "Invalid WorkBuddy model catalog.");
|
|
12
|
+
}
|
|
13
|
+
const available = Array.isArray(config.availableModels) && config.availableModels.length ? new Set(config.availableModels) : undefined;
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const models = [];
|
|
16
|
+
const counts = { total: config.models.length, accepted: 0, invalid: 0, duplicate: 0,
|
|
17
|
+
disabled: 0, toolLess: 0, endpoint: 0, unavailable: 0 };
|
|
18
|
+
for (const item of config.models) {
|
|
19
|
+
// Report only counts, never model URLs, API keys, remote instructions, or full catalog bodies.
|
|
20
|
+
if (!validId(item?.id)) { counts.invalid++; continue; }
|
|
21
|
+
if (seen.has(item.id)) { counts.duplicate++; continue; }
|
|
22
|
+
if (item.disabled === true) { counts.disabled++; continue; }
|
|
23
|
+
if (item.supportsToolCall === false) { counts.toolLess++; continue; }
|
|
24
|
+
if (item.url || item.apiKey || item.useCustomProtocol) { counts.endpoint++; continue; }
|
|
25
|
+
if (available && !available.has(item.id)) { counts.unavailable++; continue; }
|
|
26
|
+
seen.add(item.id);
|
|
27
|
+
const vendor = getVendorCapabilities(item.id) ?? {};
|
|
28
|
+
const { contextWindow, maxTokens } = resolveTokenLimits(item, vendor);
|
|
29
|
+
const reasoning = item.supportsReasoning === true || vendor.reasoning === true;
|
|
30
|
+
const catalogEfforts = Array.isArray(item.reasoning?.supportedEfforts) ? item.reasoning.supportedEfforts : [];
|
|
31
|
+
const efforts = [...new Set([...catalogEfforts, ...(vendor.efforts ?? [])].filter(effort => LEVELS.includes(effort)))];
|
|
32
|
+
const onlyReasoning = item.onlyReasoning === true || item.reasoning?.canDisableThinking === false || vendor.onlyReasoning === true;
|
|
33
|
+
const thinkingLevelMap = reasoning ? {
|
|
34
|
+
...(onlyReasoning ? { off: null } : vendor.off ? { off: vendor.off } : {}),
|
|
35
|
+
...(efforts?.length ? Object.fromEntries(LEVELS.map(level => [level, efforts.includes(level) ? level : null])) : {}),
|
|
36
|
+
} : undefined;
|
|
37
|
+
models.push({
|
|
38
|
+
id: item.id, name: typeof item.name === "string" ? item.name.slice(0, 256) : item.id,
|
|
39
|
+
provider: "workbuddy", api: "openai-completions", baseUrl: BASE_URL,
|
|
40
|
+
// Prefer availability: retain gateway image support even when the vendor is text-only,
|
|
41
|
+
// and expose vendor-advertised image support even if gateway metadata omits/disables it.
|
|
42
|
+
reasoning, input: item.supportsImages === true || vendor.images === true ? ["text", "image"] : ["text"],
|
|
43
|
+
cost: { ...COST }, contextWindow, maxTokens,
|
|
44
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
45
|
+
samplingParams: {
|
|
46
|
+
...(Number.isFinite(item.temperature) ? { temperature: item.temperature } : {}),
|
|
47
|
+
...(Number.isFinite(item.top_p) ? { top_p: item.top_p } : {}),
|
|
48
|
+
},
|
|
49
|
+
// Observed in the shipped SDK: system role and max_tokens; never infer from model names.
|
|
50
|
+
compat: { supportsDeveloperRole: false, maxTokensField: "max_tokens", supportsStore: false,
|
|
51
|
+
supportsReasoningEffort: reasoning && (vendor.supportsReasoningEffort !== false || catalogEfforts.length > 0),
|
|
52
|
+
...(vendor.thinkingFormat ? { thinkingFormat: vendor.thinkingFormat } : {}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!models.length) throw new WorkbuddyError(config.models.length ? "catalog-filtered" : "catalog-empty",
|
|
57
|
+
"WorkBuddy returned no supported tool-capable models. Existing catalog retained.", counts);
|
|
58
|
+
return models;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fallbackModels() {
|
|
62
|
+
return projectModels({ models: [{ id: "default-model", name: "WorkBuddy Default (fallback)", maxInputTokens: 32768, maxOutputTokens: 4096 }] });
|
|
63
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createNativeClient, accountHeaders, BASE_URL } from "./native-client.js";
|
|
2
|
+
import { fallbackModels, projectModels } from "./native-models.js";
|
|
3
|
+
import { createChatFetch } from "./native-transport.js";
|
|
4
|
+
import { describeFailure } from "./native-errors.js";
|
|
5
|
+
import { resolveOutputLimit, capPayloadOutput } from "./native-limits.js";
|
|
6
|
+
|
|
7
|
+
export function createWorkbuddyProvider({ api, client = createNativeClient(), fetch: fetchImpl, gzip = true } = {}) {
|
|
8
|
+
if (!api?.stream || !api?.streamSimple) throw new Error("Pi OpenAI streaming API is required.");
|
|
9
|
+
let models = fallbackModels();
|
|
10
|
+
let catalogSource = "fallback";
|
|
11
|
+
let lastCatalogError;
|
|
12
|
+
function prepareRequest(model, options = {}) {
|
|
13
|
+
let outputLimit = resolveOutputLimit(model, options.maxTokens);
|
|
14
|
+
const modelSampling = model.samplingParams ? { ...model.samplingParams } : undefined;
|
|
15
|
+
const requestSampling = options.samplingParams ? { ...options.samplingParams } : undefined;
|
|
16
|
+
for (const params of [modelSampling, requestSampling]) {
|
|
17
|
+
if (!params) continue;
|
|
18
|
+
for (const key of ["max_tokens", "max_completion_tokens"]) {
|
|
19
|
+
if (params[key] !== undefined) outputLimit = resolveOutputLimit({ ...model, maxTokens: outputLimit }, params[key]);
|
|
20
|
+
delete params[key];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
model: { ...model, baseUrl: BASE_URL, samplingParams: modelSampling },
|
|
25
|
+
options: {
|
|
26
|
+
...options, maxTokens: outputLimit, samplingParams: requestSampling,
|
|
27
|
+
maxRetries: options.maxRetries ?? 0, timeoutMs: options.timeoutMs ?? 120000,
|
|
28
|
+
fetch: createChatFetch({ fetch: options.fetch ?? fetchImpl, gzip, sessionId: options.sessionId }),
|
|
29
|
+
async onPayload(payload, wireModel) {
|
|
30
|
+
// Preserve Pi streamSimple's remaining-context clamp before invoking caller hooks.
|
|
31
|
+
const bounded = capPayloadOutput(payload, outputLimit);
|
|
32
|
+
const ceiling = bounded.max_tokens;
|
|
33
|
+
const replacement = await options.onPayload?.(bounded, wireModel);
|
|
34
|
+
return capPayloadOutput(replacement === undefined ? bounded : replacement, ceiling);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
id: "workbuddy", name: "WorkBuddy AI (experimental native)", baseUrl: BASE_URL,
|
|
41
|
+
auth: {
|
|
42
|
+
oauth: {
|
|
43
|
+
name: "WorkBuddy AI", loginLabel: "Sign in with your WorkBuddy account", isSubscription: true,
|
|
44
|
+
login: interaction => client.login(interaction),
|
|
45
|
+
refresh: (credential, signal) => client.refresh(credential, signal),
|
|
46
|
+
toAuth: async credential => ({
|
|
47
|
+
apiKey: credential.access, headers: accountHeaders(credential), baseUrl: BASE_URL,
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
getModels: () => models,
|
|
52
|
+
getCatalogStatus: () => ({ source: catalogSource, lastError: lastCatalogError }),
|
|
53
|
+
async refreshModels(context) {
|
|
54
|
+
if (!context.allowNetwork || context.credential?.type !== "oauth") return;
|
|
55
|
+
try {
|
|
56
|
+
const next = projectModels(await client.catalog(context.credential, context.signal));
|
|
57
|
+
// Generation-checked publication owned by Pi. No auth or remote instructions persisted.
|
|
58
|
+
await context.publish({ update: () => {
|
|
59
|
+
models = next; catalogSource = "remote"; lastCatalogError = undefined;
|
|
60
|
+
} });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
lastCatalogError = describeFailure(error);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
stream(model, context, options) {
|
|
67
|
+
const request = prepareRequest(model, options);
|
|
68
|
+
return api.stream(request.model, context, request.options);
|
|
69
|
+
},
|
|
70
|
+
streamSimple(model, context, options) {
|
|
71
|
+
const request = prepareRequest(model, options);
|
|
72
|
+
return api.streamSimple(request.model, context, request.options);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function registerNativeCommands(pi, provider) {
|
|
78
|
+
pi.registerCommand("workbuddy", {
|
|
79
|
+
description: "Native WorkBuddy provider: status/doctor (local) or refresh model catalog",
|
|
80
|
+
handler: async (args, ctx) => {
|
|
81
|
+
const action = args.trim() || "status";
|
|
82
|
+
if (action === "status" || action === "doctor") {
|
|
83
|
+
const ready = ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0]);
|
|
84
|
+
const catalog = provider.getCatalogStatus?.();
|
|
85
|
+
const active = ctx.model ? ctx.model.provider + "/" + ctx.model.id : "unknown";
|
|
86
|
+
ctx.ui.notify("WorkBuddy native (experimental): " + (ready ? "account configured" : "not logged in; use /login") +
|
|
87
|
+
". " + provider.getModels().length + " known models; catalog=" + (catalog?.source ?? "unknown") +
|
|
88
|
+
". Active model: " + active + "." + (catalog?.lastError ? " Last catalog failure: " + catalog.lastError : "") +
|
|
89
|
+
" No chat request sent.", "info");
|
|
90
|
+
} else if (action === "refresh") {
|
|
91
|
+
try {
|
|
92
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0])) {
|
|
93
|
+
ctx.ui.notify("Sign in to WorkBuddy using /login first.", "warning"); return;
|
|
94
|
+
}
|
|
95
|
+
const result = await ctx.modelRegistry.refresh({ providers: ["workbuddy"], allowNetwork: true, force: true, signal: ctx.signal });
|
|
96
|
+
if (result.aborted) {
|
|
97
|
+
ctx.ui.notify("[cancelled] WorkBuddy catalog refresh cancelled. Existing catalog retained. No chat request sent.", "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (result.errors.has("workbuddy")) {
|
|
101
|
+
ctx.ui.notify("WorkBuddy catalog refresh failed: " + describeFailure(result.errors.get("workbuddy")) +
|
|
102
|
+
" Existing catalog retained. No chat request sent.", "warning");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
ctx.ui.notify("WorkBuddy catalog refreshed: " + provider.getModels().length + " models. Select with /model.", "info");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
ctx.ui.notify("WorkBuddy catalog refresh failed: " +
|
|
108
|
+
(ctx.signal?.aborted ? "[cancelled] Operation cancelled." : describeFailure(error)) +
|
|
109
|
+
" Existing catalog retained. No chat request sent.", "warning");
|
|
110
|
+
}
|
|
111
|
+
} else ctx.ui.notify("Usage: /workbuddy [status|doctor|refresh]. Sign in using /login.", "warning");
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
3
|
+
import { ORIGIN, limitedJson } from "./native-client.js";
|
|
4
|
+
import { USER_AGENT, EXTENSION_VERSION } from "./client-identity.js";
|
|
5
|
+
|
|
6
|
+
// Normalize complete SSE frames, never individual TCP/UTF-8 chunks.
|
|
7
|
+
export function normalizeSse(body) {
|
|
8
|
+
const decoder = new TextDecoder();
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
let pending = "";
|
|
11
|
+
const ids = new Set();
|
|
12
|
+
const indexes = new Map();
|
|
13
|
+
let nextIndex = 0;
|
|
14
|
+
function frame(text) {
|
|
15
|
+
const lines = text.split(/\r?\n/);
|
|
16
|
+
const data = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n");
|
|
17
|
+
if (!data || data.trim() === "[DONE]") return encoder.encode(text + "\n\n");
|
|
18
|
+
let value;
|
|
19
|
+
try { value = JSON.parse(data); } catch { throw new Error("WorkBuddy returned malformed SSE JSON."); }
|
|
20
|
+
if (value.error || (typeof value.code === "number" && value.code !== 0)) {
|
|
21
|
+
throw new Error("WorkBuddy reported a stream error.");
|
|
22
|
+
}
|
|
23
|
+
for (const choice of value.choices ?? []) {
|
|
24
|
+
for (const call of choice.delta?.tool_calls ?? []) {
|
|
25
|
+
if (!Number.isInteger(call.index)) {
|
|
26
|
+
if (call.id && indexes.has(call.id)) call.index = indexes.get(call.id);
|
|
27
|
+
else if (!call.id && nextIndex > 0) call.index = nextIndex - 1;
|
|
28
|
+
else { call.index = nextIndex++; if (call.id) indexes.set(call.id, call.index); }
|
|
29
|
+
} else {
|
|
30
|
+
nextIndex = Math.max(nextIndex, call.index + 1);
|
|
31
|
+
if (call.id) indexes.set(call.id, call.index);
|
|
32
|
+
}
|
|
33
|
+
if (call.id) {
|
|
34
|
+
if (ids.has(call.id)) delete call.id;
|
|
35
|
+
else ids.add(call.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return encoder.encode("data: " + JSON.stringify(value) + "\n\n");
|
|
40
|
+
}
|
|
41
|
+
function drain(controller, final = false) {
|
|
42
|
+
let match;
|
|
43
|
+
while ((match = /\r?\n\r?\n/.exec(pending))) {
|
|
44
|
+
const value = pending.slice(0, match.index);
|
|
45
|
+
if (value.length > 1024 * 1024) throw new Error("WorkBuddy SSE frame is too large.");
|
|
46
|
+
pending = pending.slice(match.index + match[0].length);
|
|
47
|
+
controller.enqueue(frame(value));
|
|
48
|
+
}
|
|
49
|
+
if (pending.length > 1024 * 1024) throw new Error("WorkBuddy SSE frame is too large.");
|
|
50
|
+
if (final && pending.trim()) { controller.enqueue(frame(pending)); pending = ""; }
|
|
51
|
+
}
|
|
52
|
+
return body.pipeThrough(new TransformStream({
|
|
53
|
+
transform(bytes, controller) { pending += decoder.decode(bytes, { stream: true }); drain(controller); },
|
|
54
|
+
flush(controller) { pending += decoder.decode(); drain(controller, true); },
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createChatFetch({ fetch: fetchImpl = globalThis.fetch, gzip = true, sessionId } = {}) {
|
|
59
|
+
const conversationId = sessionId || randomUUID();
|
|
60
|
+
return async (input, init) => {
|
|
61
|
+
const request = new Request(input, init);
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
if (url.origin !== ORIGIN || url.pathname !== "/v2/chat/completions" || url.search ||
|
|
64
|
+
url.username || url.password || request.method !== "POST") {
|
|
65
|
+
throw new Error("Refusing to send WorkBuddy credentials to an untrusted endpoint.");
|
|
66
|
+
}
|
|
67
|
+
const headers = new Headers(request.headers);
|
|
68
|
+
const requestId = randomUUID();
|
|
69
|
+
for (const [key, value] of Object.entries({
|
|
70
|
+
"X-Conversation-ID": conversationId, "X-Conversation-Request-ID": requestId,
|
|
71
|
+
"X-Conversation-Message-ID": requestId, "X-Request-ID": requestId,
|
|
72
|
+
"X-Agent-Intent": "craft", "X-IDE-Type": "pi", "X-IDE-Name": "pi-workbuddy",
|
|
73
|
+
"X-IDE-Version": EXTENSION_VERSION, "X-Product": "SaaS", "X-Requested-With": "XMLHttpRequest",
|
|
74
|
+
"User-Agent": USER_AGENT,
|
|
75
|
+
// Conservative privacy default: do not opt prompts into model optimization.
|
|
76
|
+
"X-Private-Data": "true", "Content-Type": "application/json", Accept: "text/event-stream",
|
|
77
|
+
})) headers.set(key, value);
|
|
78
|
+
const body = Buffer.from(await request.arrayBuffer());
|
|
79
|
+
if (gzip) headers.set("Content-Encoding", "gzip");
|
|
80
|
+
else headers.delete("Content-Encoding");
|
|
81
|
+
headers.delete("Content-Length");
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetchImpl(url, {
|
|
85
|
+
method: "POST", headers, body: gzip ? gzipSync(body) : body,
|
|
86
|
+
redirect: "error", signal: request.signal,
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(request.signal.aborted ? "WorkBuddy request cancelled." : "WorkBuddy connection failed.");
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
let code;
|
|
93
|
+
try { const value = await limitedJson(response); code = value?.error?.code ?? value?.code; } catch {}
|
|
94
|
+
// Keep status, safe business code, and retry metadata; never expose upstream bodies/tokens.
|
|
95
|
+
const contextOverflow = code === "context_length_exceeded";
|
|
96
|
+
const message = contextOverflow ? "context_length_exceeded" : "WorkBuddy request failed (HTTP " + response.status +
|
|
97
|
+
(typeof code === "number" ? "; code " + code : "") + ").";
|
|
98
|
+
const outHeaders = new Headers({ "Content-Type": "application/json" });
|
|
99
|
+
const retry = response.headers.get("retry-after");
|
|
100
|
+
if (retry) outHeaders.set("Retry-After", retry);
|
|
101
|
+
return new Response(JSON.stringify({ error: { message, ...(contextOverflow ? { code } : {}) } }), {
|
|
102
|
+
status: response.status, headers: outHeaders,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (!response.headers.get("content-type")?.includes("text/event-stream") || !response.body) {
|
|
106
|
+
await response.body?.cancel();
|
|
107
|
+
throw new Error("WorkBuddy did not return an SSE stream.");
|
|
108
|
+
}
|
|
109
|
+
const responseHeaders = new Headers(response.headers);
|
|
110
|
+
// The fetch implementation has already decoded transport compression.
|
|
111
|
+
responseHeaders.delete("content-encoding");
|
|
112
|
+
responseHeaders.delete("content-length");
|
|
113
|
+
return new Response(normalizeSse(response.body), { status: response.status, headers: responseHeaders });
|
|
114
|
+
};
|
|
115
|
+
}
|