pi-leo-bridge 0.1.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/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +16 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/SECURITY.md +23 -0
- package/bin/pi-leo +229 -0
- package/dist/src/check-model.d.ts +1 -0
- package/dist/src/check-model.js +47 -0
- package/dist/src/check-model.js.map +1 -0
- package/dist/src/config.d.ts +23 -0
- package/dist/src/config.js +81 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +60 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/list-models.d.ts +1 -0
- package/dist/src/list-models.js +54 -0
- package/dist/src/list-models.js.map +1 -0
- package/dist/src/openai.d.ts +47 -0
- package/dist/src/openai.js +210 -0
- package/dist/src/openai.js.map +1 -0
- package/dist/src/output-filter.d.ts +21 -0
- package/dist/src/output-filter.js +129 -0
- package/dist/src/output-filter.js.map +1 -0
- package/dist/src/pi-runner.d.ts +16 -0
- package/dist/src/pi-runner.js +220 -0
- package/dist/src/pi-runner.js.map +1 -0
- package/dist/src/server.d.ts +13 -0
- package/dist/src/server.js +342 -0
- package/dist/src/server.js.map +1 -0
- package/docs/RELEASING.md +28 -0
- package/package.json +63 -0
- package/scripts/configure-install.py +530 -0
- package/scripts/doctor.py +192 -0
- package/scripts/install.sh +406 -0
- package/scripts/remove-brave-models.py +112 -0
- package/scripts/set-brave-default.py +119 -0
- package/scripts/uninstall.sh +133 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.1] - 2026-09-03
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Reframed the README around bringing Pi models and authentication into Brave Leo, with protocol details kept in the implementation section.
|
|
12
|
+
|
|
13
|
+
## [0.1.0] - 2026-09-03
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Authenticated, IPv4-loopback-only OpenAI Chat Completions endpoint for Brave Leo BYOM.
|
|
18
|
+
- Isolated, tool-free Pi SDK session per request.
|
|
19
|
+
- Streaming and non-streaming responses, conversation history, embedded images, assistant prefixes, and stop sequences.
|
|
20
|
+
- Configurable Pi provider, model, context guardrail, and thinking-level picker profiles.
|
|
21
|
+
- macOS LaunchAgent installation with verified Brave Preferences backups.
|
|
22
|
+
- `pi-leo` install, uninstall, status, restart, doctor, model-listing, default-selection, log, and smoke-test commands.
|
|
23
|
+
- Request cancellation, concurrency and body-size limits, output filtering, and redacted metadata-only logs.
|
|
24
|
+
|
|
25
|
+
[Unreleased]: https://github.com/omaclaren/pi-leo-bridge/compare/v0.1.1...HEAD
|
|
26
|
+
[0.1.1]: https://github.com/omaclaren/pi-leo-bridge/compare/v0.1.0...v0.1.1
|
|
27
|
+
[0.1.0]: https://github.com/omaclaren/pi-leo-bridge/releases/tag/v0.1.0
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Issues and pull requests are welcome. Please keep the default security boundary intact: loopback-only binding, authenticated chat routes, tools disabled, no request-content logging, and verified browser backups.
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm ci
|
|
9
|
+
npm run check
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Use a disposable Brave profile for installer testing. Never commit real Preferences files, endpoint capabilities, Pi authentication files, logs, or page content.
|
|
13
|
+
|
|
14
|
+
Changes to Pi dependencies must use deliberate exact version updates and include a real installed smoke test. Changes to the Brave protocol adapter should add fixture-based tests for both streaming and non-streaming requests.
|
|
15
|
+
|
|
16
|
+
See [`docs/RELEASING.md`](docs/RELEASING.md) for the release checklist.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oliver Maclaren
|
|
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
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Pi Leo Bridge
|
|
2
|
+
|
|
3
|
+
**Pi Leo Bridge brings [Pi](https://github.com/earendil-works/pi) into [Brave Leo](https://support.brave.app/hc/en-us/articles/34070140231821-How-do-I-use-the-Bring-Your-Own-Model-BYOM-with-Brave-Leo).** It adds a Pi-configured model to Leo's model picker and uses Pi's existing provider authentication. Each request runs in a fresh, isolated Pi SDK session.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
Brave Leo -> authenticated local bridge -> isolated Pi SDK session -> selected provider/model
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The bridge runs locally on macOS and starts automatically with `launchd`. It supports streaming conversations, page context, images, titles, and rewrites through Leo's BYOM interface.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Uses Pi's model catalogue and existing provider authentication.
|
|
14
|
+
- Adds separate Leo picker entries for configurable Pi thinking levels.
|
|
15
|
+
- Supports Leo's BYOM chat flow, including streaming, history, embedded images, titles, rewrites, assistant prefixes, and stop sequences.
|
|
16
|
+
- Binds only to IPv4 loopback and authenticates a random capability URL.
|
|
17
|
+
- Runs every request in a fresh in-memory Pi session with a minimal, conversation-only runtime.
|
|
18
|
+
- Makes and verifies a timestamped backup before every Brave Preferences change.
|
|
19
|
+
- Installs an automatic per-user macOS LaunchAgent.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- macOS
|
|
24
|
+
- Brave with Leo BYOM support
|
|
25
|
+
- Node.js 22.19 or newer
|
|
26
|
+
- Pi authentication configured for the provider you want to use
|
|
27
|
+
- Python 3.10 or newer
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Install from npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install --global pi-leo-bridge
|
|
35
|
+
pi-leo install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For a source checkout:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/omaclaren/pi-leo-bridge.git
|
|
42
|
+
cd pi-leo-bridge
|
|
43
|
+
./scripts/install.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The source installer performs a locked dependency install, type check, and full test run before changing Brave.
|
|
47
|
+
|
|
48
|
+
A fresh interactive installation asks for the Pi provider, model, display name, and thinking levels. Defaults are:
|
|
49
|
+
|
|
50
|
+
- provider: `openai-codex`
|
|
51
|
+
- model: `gpt-5.6-sol`
|
|
52
|
+
- profiles: `low`, `medium`, and `high`
|
|
53
|
+
- primary profile: `medium`
|
|
54
|
+
- context cap advertised to Leo: 100,000 tokens
|
|
55
|
+
|
|
56
|
+
To install non-interactively or choose another model:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pi-leo install \
|
|
60
|
+
--provider openai-codex \
|
|
61
|
+
--model gpt-5.6-sol \
|
|
62
|
+
--name "GPT-5.6 Sol" \
|
|
63
|
+
--levels low,medium,high \
|
|
64
|
+
--primary-level medium \
|
|
65
|
+
--yes
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
List models available through configured Pi authentication:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pi-leo models
|
|
72
|
+
pi-leo models openai-codex
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If validation reports missing or expired authentication, open Pi, sign in to that provider again, and rerun `pi-leo install` or `pi-leo configure`. Model and authentication validation happens before the installer changes Brave.
|
|
76
|
+
|
|
77
|
+
On a fresh installation, the installer uses Brave's most recently used profile. Select another channel or profile explicitly when needed:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pi-leo install --channel beta --profile "Profile 1"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Supported channel names are `stable`, `beta`, and `nightly`. To move an existing bridge to another Brave profile, uninstall it first, ensuring the authenticated entries are removed from the old profile, then install it against the new target.
|
|
84
|
+
|
|
85
|
+
## Daily use
|
|
86
|
+
|
|
87
|
+
1. Open Leo.
|
|
88
|
+
2. Select one of the **Pi — … (Low/Medium/High)** entries in the model picker.
|
|
89
|
+
3. Attach current-page context when you want Pi to read the page.
|
|
90
|
+
4. Chat normally.
|
|
91
|
+
|
|
92
|
+
No terminal is required during normal use. Operational commands can be run from any terminal directory:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pi-leo status
|
|
96
|
+
pi-leo restart
|
|
97
|
+
pi-leo doctor
|
|
98
|
+
pi-leo logs
|
|
99
|
+
pi-leo smoke-test
|
|
100
|
+
pi-leo smoke-test low
|
|
101
|
+
pi-leo default medium
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`logs` follows both service logs until you press **Ctrl-C**. `smoke-test` makes one small real model request; the other commands do not invoke a model.
|
|
105
|
+
|
|
106
|
+
Re-run configuration at any time:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pi-leo configure --model OTHER_MODEL --name "Display name"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Set a managed profile—or the recorded pre-installation model—as the default for new Leo conversations:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pi-leo default medium
|
|
116
|
+
pi-leo default restore
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Rotate the local capability if its endpoint may have been exposed:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pi-leo configure --rotate-token
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Upgrade
|
|
126
|
+
|
|
127
|
+
Upgrade the npm package, re-run its idempotent configuration migration, and verify the result:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm install --global pi-leo-bridge@latest
|
|
131
|
+
pi-leo configure --yes
|
|
132
|
+
pi-leo doctor
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Run `pi-leo uninstall` before removing the npm package itself; otherwise the LaunchAgent would retain a path into the removed package.
|
|
136
|
+
|
|
137
|
+
## Uninstall
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pi-leo uninstall
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This stops and removes the LaunchAgent and removes only the Brave model entries managed by the bridge, after another Preferences backup. Workspace and logs are retained by default:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pi-leo uninstall --purge
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Uninstall the npm package separately if it was installed globally:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm uninstall --global pi-leo-bridge
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Security model
|
|
156
|
+
|
|
157
|
+
- The HTTP server rejects any configured host other than `127.0.0.1`.
|
|
158
|
+
- The endpoint contains a random 256-bit capability; only its SHA-256 hash is stored in bridge configuration.
|
|
159
|
+
- The unauthenticated health endpoint reports only service availability; detailed health checks require the capability.
|
|
160
|
+
- Request bodies, page content, and model output are never written to bridge logs.
|
|
161
|
+
- Remote image URLs are not fetched. Embedded PNG, JPEG, WebP, and GIF data images are accepted.
|
|
162
|
+
- Incoming tool definitions are rejected, and the Pi session is checked at runtime to contain zero tools.
|
|
163
|
+
- Webpage and document text is explicitly treated as untrusted reference material.
|
|
164
|
+
|
|
165
|
+
The capability URL is stored in the selected Brave profile's Preferences file. That file is protected by the user's macOS account; do not paste or publish the endpoint. See [SECURITY.md](SECURITY.md) for the full boundary and reporting guidance.
|
|
166
|
+
|
|
167
|
+
### Data flow
|
|
168
|
+
|
|
169
|
+
Only the Brave-to-bridge hop is local. Pi then sends the supplied conversation, page context, and images to the configured model provider. Provider retention policies, subscription limits, and charges apply. Brave's hosted-model proxy protections do not apply to BYOM.
|
|
170
|
+
|
|
171
|
+
## Scope
|
|
172
|
+
|
|
173
|
+
The initial release focuses on conversation. Leo sends text, page context, and images; the bridge returns responses from the selected Pi model.
|
|
174
|
+
|
|
175
|
+
### Protocol compatibility
|
|
176
|
+
|
|
177
|
+
Leo sends BYOM requests through an OpenAI-compatible Chat Completions interface. The bridge implements the fields Leo uses and translates them into Pi SDK calls. It ignores Brave's sampling field because Pi controls generation through the selected model and thinking level.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npm ci
|
|
183
|
+
npm run check
|
|
184
|
+
npm pack --dry-run
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The test suite includes protocol normalization, assistant-prefix and streaming stop filtering, authentication behavior, health metadata, SSE responses, tool rejection, and model allowlisting. Installer tests should always use a temporary home directory and fixture Preferences file.
|
|
188
|
+
|
|
189
|
+
Runtime dependencies on Pi are exact-pinned so SDK drift is caught before release. Update them deliberately, run the full test suite, and perform an installed smoke test before changing the pin.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
[MIT](LICENSE)
|
|
194
|
+
|
|
195
|
+
Pi Leo Bridge is an independent companion project maintained separately from Brave Software, OpenAI, and the Pi project.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please use [GitHub's private vulnerability reporting form](https://github.com/omaclaren/pi-leo-bridge/security/advisories/new). Do not include capability URLs, API keys, OAuth credentials, browser Preferences files, or private page content in a public issue.
|
|
6
|
+
|
|
7
|
+
## Security boundary
|
|
8
|
+
|
|
9
|
+
Pi Leo Bridge is intended for a single-user macOS account. It binds only to IPv4 loopback and authenticates chat requests with a random capability embedded in the Brave model endpoint. Only the capability's SHA-256 hash is stored in the bridge configuration.
|
|
10
|
+
|
|
11
|
+
Leo-facing Pi sessions intentionally have no tools, extensions, skills, project instructions, or persistent session state. Text and images supplied by webpages are treated as untrusted reference material. The bridge does not provide browser control or communication with other Pi sessions.
|
|
12
|
+
|
|
13
|
+
The capability URL is stored in the selected Brave profile's local Preferences file. Anyone able to read files as the same macOS user should be considered inside the local trust boundary. The public health route reports only availability; configuration details require the same capability. Installation also verifies that the listening process belongs to the installed LaunchAgent before accepting its health response.
|
|
14
|
+
|
|
15
|
+
## Data flow
|
|
16
|
+
|
|
17
|
+
The HTTP hop from Brave to the bridge stays on the local machine. Prompts, attached page content, and images are then sent by Pi to the configured model provider. Provider policies, retention, subscription limits, and charges still apply. Brave's hosted-model proxy protections do not apply to BYOM endpoints.
|
|
18
|
+
|
|
19
|
+
Request bodies and page contents are not written to bridge logs. Operational logs contain timestamps, generated request identifiers, selected public profile names, message counts, character counts, durations, and redacted error summaries.
|
|
20
|
+
|
|
21
|
+
## Supported versions
|
|
22
|
+
|
|
23
|
+
Until a later release is published, only the latest tagged release will receive security fixes. The initial release is macOS-only and requires Node.js 22.19 or newer.
|
package/bin/pi-leo
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SELF="$(node -e 'console.log(require("node:fs").realpathSync(process.argv[1]))' "$0")"
|
|
5
|
+
ROOT="$(cd "$(dirname "$SELF")/.." && pwd)"
|
|
6
|
+
LABEL="com.ojm.pi-leo-bridge"
|
|
7
|
+
DOMAIN="gui/${UID}/${LABEL}"
|
|
8
|
+
CONFIG="${HOME}/.config/pi-leo-bridge/config.json"
|
|
9
|
+
OUT_LOG="${HOME}/Library/Logs/pi-leo-bridge.log"
|
|
10
|
+
ERR_LOG="${HOME}/Library/Logs/pi-leo-bridge.error.log"
|
|
11
|
+
|
|
12
|
+
usage() {
|
|
13
|
+
cat <<'EOF'
|
|
14
|
+
Usage: pi-leo <command> [options]
|
|
15
|
+
|
|
16
|
+
Setup:
|
|
17
|
+
install Configure Brave and install/start the macOS LaunchAgent
|
|
18
|
+
configure Re-run installation with new model/profile options
|
|
19
|
+
uninstall Remove the service and its managed Brave models
|
|
20
|
+
models List models available through configured Pi authentication
|
|
21
|
+
default LEVEL Set the default for new Leo chats; LEVEL may also be restore
|
|
22
|
+
|
|
23
|
+
Operation:
|
|
24
|
+
status Show LaunchAgent and bridge health
|
|
25
|
+
restart Restart the bridge and wait for health
|
|
26
|
+
doctor Verify installation, security settings, and Brave configuration
|
|
27
|
+
logs [LINES] Follow service logs (default initial lines: 100)
|
|
28
|
+
smoke-test [PROFILE]
|
|
29
|
+
Make one real request; PROFILE is a level or public model id
|
|
30
|
+
version Show package version
|
|
31
|
+
help Show this help
|
|
32
|
+
|
|
33
|
+
Run `pi-leo install --help` for installation options.
|
|
34
|
+
EOF
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
require_config() {
|
|
38
|
+
if [[ ! -f "$CONFIG" ]]; then
|
|
39
|
+
echo "Pi Leo bridge is not configured. Run: pi-leo install" >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
config_value() {
|
|
45
|
+
python3 - "$CONFIG" "$1" <<'PY'
|
|
46
|
+
import json,sys
|
|
47
|
+
value=json.load(open(sys.argv[1])).get(sys.argv[2],"")
|
|
48
|
+
print(value if isinstance(value,(str,int)) else "")
|
|
49
|
+
PY
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
health_url() {
|
|
53
|
+
require_config
|
|
54
|
+
printf 'http://127.0.0.1:%s/healthz\n' "$(config_value port)"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
status() {
|
|
58
|
+
require_config
|
|
59
|
+
if launchctl print "$DOMAIN" >/dev/null 2>&1; then
|
|
60
|
+
echo "LaunchAgent: loaded"
|
|
61
|
+
else
|
|
62
|
+
echo "LaunchAgent: not loaded"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
if payload="$(curl --fail --silent --max-time 3 "$(health_url)")" \
|
|
66
|
+
&& printf '%s' "$payload" | python3 -c 'import json,sys; p=json.load(sys.stdin); assert p.get("service")=="pi-leo-bridge"' 2>/dev/null; then
|
|
67
|
+
echo "Bridge: healthy"
|
|
68
|
+
printf '%s' "$payload" | python3 -m json.tool
|
|
69
|
+
else
|
|
70
|
+
echo "Bridge: unavailable"
|
|
71
|
+
return 1
|
|
72
|
+
fi
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
restart() {
|
|
76
|
+
require_config
|
|
77
|
+
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
|
78
|
+
if launchctl print "$DOMAIN" >/dev/null 2>&1; then
|
|
79
|
+
launchctl kickstart -k "$DOMAIN"
|
|
80
|
+
elif [[ -f "$PLIST" ]]; then
|
|
81
|
+
launchctl bootstrap "gui/${UID}" "$PLIST"
|
|
82
|
+
else
|
|
83
|
+
echo "LaunchAgent is not installed. Run: pi-leo install" >&2
|
|
84
|
+
return 1
|
|
85
|
+
fi
|
|
86
|
+
URL="$(health_url)"
|
|
87
|
+
for _ in $(seq 1 60); do
|
|
88
|
+
if payload="$(curl --fail --silent --max-time 2 "$URL" 2>/dev/null)" \
|
|
89
|
+
&& printf '%s' "$payload" | python3 -c 'import json,sys; p=json.load(sys.stdin); assert p.get("service")=="pi-leo-bridge" and p.get("status")=="ok"' 2>/dev/null; then
|
|
90
|
+
echo "Bridge restarted and healthy."
|
|
91
|
+
return 0
|
|
92
|
+
fi
|
|
93
|
+
sleep 1
|
|
94
|
+
done
|
|
95
|
+
echo "Bridge did not become healthy; run: pi-leo logs" >&2
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
logs() {
|
|
100
|
+
local lines="${1:-100}"
|
|
101
|
+
if [[ ! "$lines" =~ ^[0-9]+$ ]]; then
|
|
102
|
+
echo "Log line count must be a non-negative integer." >&2
|
|
103
|
+
return 2
|
|
104
|
+
fi
|
|
105
|
+
touch "$OUT_LOG" "$ERR_LOG"
|
|
106
|
+
echo "Following logs; press Ctrl-C to stop."
|
|
107
|
+
tail -n "$lines" -F "$OUT_LOG" "$ERR_LOG"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
set_default() {
|
|
111
|
+
require_config
|
|
112
|
+
local selector="${1:-}"
|
|
113
|
+
if [[ -z "$selector" ]]; then
|
|
114
|
+
echo "Usage: pi-leo default {THINKING_LEVEL|restore}" >&2
|
|
115
|
+
return 2
|
|
116
|
+
fi
|
|
117
|
+
local app_name
|
|
118
|
+
app_name="$(config_value braveApplicationName)"
|
|
119
|
+
case "$app_name" in
|
|
120
|
+
"Brave Browser"|"Brave Browser Beta"|"Brave Browser Nightly") ;;
|
|
121
|
+
*) echo "Unsupported Brave application name: $app_name" >&2; return 1 ;;
|
|
122
|
+
esac
|
|
123
|
+
|
|
124
|
+
local was_running=false
|
|
125
|
+
local relaunched=false
|
|
126
|
+
if pgrep -x "$app_name" >/dev/null 2>&1; then
|
|
127
|
+
was_running=true
|
|
128
|
+
fi
|
|
129
|
+
reopen_brave() {
|
|
130
|
+
if [[ "$was_running" == true && "$relaunched" == false ]]; then
|
|
131
|
+
open -a "$app_name" >/dev/null 2>&1 || true
|
|
132
|
+
fi
|
|
133
|
+
}
|
|
134
|
+
trap reopen_brave EXIT
|
|
135
|
+
|
|
136
|
+
if [[ "$was_running" == true ]]; then
|
|
137
|
+
echo "Quitting $app_name briefly to update its default model..."
|
|
138
|
+
for _ in 1 2 3; do
|
|
139
|
+
osascript -e "tell application \"$app_name\" to quit" >/dev/null 2>&1 || true
|
|
140
|
+
for _ in $(seq 1 20); do
|
|
141
|
+
pgrep -x "$app_name" >/dev/null 2>&1 || break
|
|
142
|
+
sleep 0.5
|
|
143
|
+
done
|
|
144
|
+
pgrep -x "$app_name" >/dev/null 2>&1 || break
|
|
145
|
+
done
|
|
146
|
+
if pgrep -x "$app_name" >/dev/null 2>&1; then
|
|
147
|
+
echo "$app_name did not quit; no preferences were changed." >&2
|
|
148
|
+
return 1
|
|
149
|
+
fi
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
python3 "$ROOT/scripts/set-brave-default.py" --config "$CONFIG" "$selector"
|
|
153
|
+
if [[ "$was_running" == true ]]; then
|
|
154
|
+
open -a "$app_name"
|
|
155
|
+
relaunched=true
|
|
156
|
+
fi
|
|
157
|
+
trap - EXIT
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
smoke_test() {
|
|
161
|
+
require_config
|
|
162
|
+
python3 - "$CONFIG" "${1:-}" <<'PY'
|
|
163
|
+
import hashlib
|
|
164
|
+
import json
|
|
165
|
+
import re
|
|
166
|
+
import sys
|
|
167
|
+
import urllib.error
|
|
168
|
+
import urllib.request
|
|
169
|
+
from pathlib import Path
|
|
170
|
+
|
|
171
|
+
config=json.loads(Path(sys.argv[1]).read_text())
|
|
172
|
+
selector=sys.argv[2]
|
|
173
|
+
profiles=config.get('profiles',[])
|
|
174
|
+
if selector:
|
|
175
|
+
profile=next((p for p in profiles if p.get('thinkingLevel')==selector or p.get('publicModelId')==selector),None)
|
|
176
|
+
else:
|
|
177
|
+
profile=next((p for p in profiles if p.get('publicModelId')==config.get('publicModelId')),None)
|
|
178
|
+
if not profile:
|
|
179
|
+
choices=', '.join(str(p.get('thinkingLevel')) for p in profiles)
|
|
180
|
+
raise SystemExit(f"Unknown profile '{selector}'. Available levels: {choices}")
|
|
181
|
+
preferences_path=Path(config.get('bravePreferencesPath',Path.home()/"Library/Application Support/BraveSoftware/Brave-Browser/Default/Preferences"))
|
|
182
|
+
preferences=json.loads(preferences_path.read_text())
|
|
183
|
+
models=preferences.get('brave',{}).get('ai_chat',{}).get('custom_models',[])
|
|
184
|
+
model=next((m for m in models if m.get('model_request_name')==profile.get('publicModelId')),None)
|
|
185
|
+
if not model:
|
|
186
|
+
raise SystemExit('Selected Pi model is not configured in Brave')
|
|
187
|
+
endpoint=model.get('endpoint_url')
|
|
188
|
+
match=re.fullmatch(r'http://127\.0\.0\.1:\d+/auth/([^/]+)/v1/chat/completions',str(endpoint))
|
|
189
|
+
if not match or hashlib.sha256(match.group(1).encode()).hexdigest()!=config.get('tokenSha256'):
|
|
190
|
+
raise SystemExit('Pi model endpoint failed capability validation')
|
|
191
|
+
payload={
|
|
192
|
+
'model':profile['publicModelId'],
|
|
193
|
+
'messages':[{'role':'user','content':'Reply with exactly: Pi bridge OK'}],
|
|
194
|
+
'temperature':0.7,
|
|
195
|
+
'stream':False,
|
|
196
|
+
}
|
|
197
|
+
request=urllib.request.Request(endpoint,data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
|
|
198
|
+
try:
|
|
199
|
+
with urllib.request.urlopen(request,timeout=300) as response:
|
|
200
|
+
body=json.load(response)
|
|
201
|
+
except urllib.error.HTTPError as error:
|
|
202
|
+
try: detail=json.load(error).get('error',{}).get('message','request failed')
|
|
203
|
+
except Exception: detail='request failed'
|
|
204
|
+
raise SystemExit(f"Bridge smoke test failed (HTTP {error.code}): {detail}")
|
|
205
|
+
except Exception as error:
|
|
206
|
+
raise SystemExit(f"Bridge smoke test failed: {type(error).__name__}")
|
|
207
|
+
text=body['choices'][0]['message']['content']
|
|
208
|
+
print(text)
|
|
209
|
+
print('profile:',profile['thinkingLevel'])
|
|
210
|
+
print('usage:',body.get('usage',{}))
|
|
211
|
+
PY
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case "${1:-status}" in
|
|
215
|
+
install|configure) shift; exec "$ROOT/scripts/install.sh" "$@" ;;
|
|
216
|
+
uninstall) shift; exec "$ROOT/scripts/uninstall.sh" "$@" ;;
|
|
217
|
+
models) shift; exec node "$ROOT/dist/src/list-models.js" "$@" ;;
|
|
218
|
+
default) set_default "${2:-}" ;;
|
|
219
|
+
doctor) shift; exec python3 "$ROOT/scripts/doctor.py" "$@" ;;
|
|
220
|
+
status) status ;;
|
|
221
|
+
restart) restart ;;
|
|
222
|
+
logs) logs "${2:-100}" ;;
|
|
223
|
+
smoke-test|test) smoke_test "${2:-}" ;;
|
|
224
|
+
version)
|
|
225
|
+
node -e 'const fs=require("fs"),path=require("path"); console.log(JSON.parse(fs.readFileSync(path.join(process.argv[1],"package.json"),"utf8")).version)' "$ROOT"
|
|
226
|
+
;;
|
|
227
|
+
help|-h|--help) usage ;;
|
|
228
|
+
*) echo "Unknown command: $1" >&2; usage >&2; exit 2 ;;
|
|
229
|
+
esac
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
class ModelValidationError extends Error {
|
|
3
|
+
}
|
|
4
|
+
async function main() {
|
|
5
|
+
const [, , provider, modelId] = process.argv;
|
|
6
|
+
if (!provider || !modelId) {
|
|
7
|
+
console.error("Usage: check-model PROVIDER MODEL");
|
|
8
|
+
process.exitCode = 2;
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const runtime = await ModelRuntime.create({
|
|
12
|
+
allowModelNetwork: false,
|
|
13
|
+
refreshOnCreate: true,
|
|
14
|
+
signal: AbortSignal.timeout(30_000),
|
|
15
|
+
});
|
|
16
|
+
const model = runtime.getModel(provider, modelId);
|
|
17
|
+
if (!model) {
|
|
18
|
+
throw new ModelValidationError(`Pi model is not registered: ${provider}/${modelId}`);
|
|
19
|
+
}
|
|
20
|
+
if (!runtime.hasConfiguredAuth(provider)) {
|
|
21
|
+
throw new ModelValidationError(`Pi authentication is not configured for provider: ${provider}`);
|
|
22
|
+
}
|
|
23
|
+
const available = await runtime.getAvailable(provider, {
|
|
24
|
+
signal: AbortSignal.timeout(30_000),
|
|
25
|
+
});
|
|
26
|
+
if (!available.some((candidate) => candidate.id === modelId)) {
|
|
27
|
+
throw new ModelValidationError(`Pi model is not currently available: ${provider}/${modelId}`);
|
|
28
|
+
}
|
|
29
|
+
process.stdout.write(`${JSON.stringify({
|
|
30
|
+
provider,
|
|
31
|
+
modelId,
|
|
32
|
+
name: model.name,
|
|
33
|
+
contextWindow: model.contextWindow,
|
|
34
|
+
reasoning: model.reasoning,
|
|
35
|
+
vision: model.input.includes("image"),
|
|
36
|
+
})}\n`);
|
|
37
|
+
}
|
|
38
|
+
main().catch((error) => {
|
|
39
|
+
if (error instanceof ModelValidationError) {
|
|
40
|
+
console.error(error.message.replace(/[\r\n]+/g, " ").slice(0, 300));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.error("Pi model validation failed. Check provider authentication and network access.");
|
|
44
|
+
}
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
});
|
|
47
|
+
//# sourceMappingURL=check-model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"check-model.js","sourceRoot":"","sources":["../../src/check-model.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAE/D,MAAM,oBAAqB,SAAQ,KAAK;CAAG;AAE3C,KAAK,UAAU,IAAI;IACjB,MAAM,CAAC,EAAE,AAAD,EAAG,QAAQ,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAC7C,IAAI,CAAC,QAAQ,IAAI,CAAC,OAAO,EAAE,CAAC;QAC1B,OAAO,CAAC,KAAK,CAAC,mCAAmC,CAAC,CAAC;QACnD,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;QACrB,OAAO;IACT,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC;QACxC,iBAAiB,EAAE,KAAK;QACxB,eAAe,EAAE,IAAI;QACrB,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC;KACpC,CAAC,CAAC;IACH,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAClD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,oBAAoB,CAAC,+BAA+B,QAAQ,IAAI,OAAO,EAAE,CAAC,CAAC;IACvF,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,oBAAoB,CAC5B,qDAAqD,QAAQ,EAAE,CAChE,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,QAAQ,EAAE;QACrD,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC;KACpC,CAAC,CAAC;IACH,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,KAAK,OAAO,CAAC,EAAE,CAAC;QAC7D,MAAM,IAAI,oBAAoB,CAC5B,wCAAwC,QAAQ,IAAI,OAAO,EAAE,CAC9D,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,GAAG,IAAI,CAAC,SAAS,CAAC;QAChB,QAAQ;QACR,OAAO;QACP,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC;KACtC,CAAC,IAAI,CACP,CAAC;AACJ,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,IAAI,KAAK,YAAY,oBAAoB,EAAE,CAAC;QAC1C,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACtE,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,KAAK,CACX,+EAA+E,CAChF,CAAC;IACJ,CAAC;IACD,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const thinkingLevels: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
2
|
+
export type ThinkingLevel = (typeof thinkingLevels)[number];
|
|
3
|
+
export interface BridgeProfile {
|
|
4
|
+
publicModelId: string;
|
|
5
|
+
thinkingLevel: ThinkingLevel;
|
|
6
|
+
}
|
|
7
|
+
export interface BridgeConfig {
|
|
8
|
+
version: 1;
|
|
9
|
+
host: "127.0.0.1";
|
|
10
|
+
port: number;
|
|
11
|
+
tokenSha256: string;
|
|
12
|
+
publicModelId: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
modelId: string;
|
|
15
|
+
thinkingLevel: ThinkingLevel;
|
|
16
|
+
profiles: BridgeProfile[];
|
|
17
|
+
workspace: string;
|
|
18
|
+
agentDir: string;
|
|
19
|
+
maxBodyBytes: number;
|
|
20
|
+
maxConcurrentRequests: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function defaultConfigPath(): string;
|
|
23
|
+
export declare function loadConfig(path: string): Promise<BridgeConfig>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
export const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
5
|
+
function requireString(value, name) {
|
|
6
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
7
|
+
throw new Error(`Invalid configuration field: ${name}`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function requireInteger(value, name, minimum, maximum) {
|
|
12
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
13
|
+
throw new Error(`Invalid configuration field: ${name}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function defaultConfigPath() {
|
|
18
|
+
return resolve(homedir(), ".config", "pi-leo-bridge", "config.json");
|
|
19
|
+
}
|
|
20
|
+
function loadProfiles(value, fallbackModelId, fallbackThinkingLevel) {
|
|
21
|
+
if (value === undefined) {
|
|
22
|
+
return [{ publicModelId: fallbackModelId, thinkingLevel: fallbackThinkingLevel }];
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 16) {
|
|
25
|
+
throw new Error("Invalid configuration field: profiles");
|
|
26
|
+
}
|
|
27
|
+
const profiles = value.map((item, index) => {
|
|
28
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
29
|
+
throw new Error(`Invalid configuration profile at index ${index}`);
|
|
30
|
+
}
|
|
31
|
+
const profile = item;
|
|
32
|
+
const publicModelId = requireString(profile.publicModelId, `profiles[${index}].publicModelId`);
|
|
33
|
+
const level = requireString(profile.thinkingLevel, `profiles[${index}].thinkingLevel`);
|
|
34
|
+
if (!thinkingLevels.includes(level)) {
|
|
35
|
+
throw new Error(`Invalid configuration profile thinking level at index ${index}`);
|
|
36
|
+
}
|
|
37
|
+
return { publicModelId, thinkingLevel: level };
|
|
38
|
+
});
|
|
39
|
+
if (new Set(profiles.map((profile) => profile.publicModelId)).size !== profiles.length) {
|
|
40
|
+
throw new Error("Configuration profile model names must be unique");
|
|
41
|
+
}
|
|
42
|
+
return profiles;
|
|
43
|
+
}
|
|
44
|
+
export async function loadConfig(path) {
|
|
45
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
46
|
+
if (raw.version !== 1) {
|
|
47
|
+
throw new Error("Unsupported bridge configuration version");
|
|
48
|
+
}
|
|
49
|
+
if (raw.host !== "127.0.0.1") {
|
|
50
|
+
throw new Error("The bridge must bind to 127.0.0.1");
|
|
51
|
+
}
|
|
52
|
+
const tokenSha256 = requireString(raw.tokenSha256, "tokenSha256").toLowerCase();
|
|
53
|
+
if (!/^[a-f0-9]{64}$/.test(tokenSha256)) {
|
|
54
|
+
throw new Error("Invalid configuration field: tokenSha256");
|
|
55
|
+
}
|
|
56
|
+
const thinkingLevel = requireString(raw.thinkingLevel, "thinkingLevel");
|
|
57
|
+
if (!thinkingLevels.includes(thinkingLevel)) {
|
|
58
|
+
throw new Error("Invalid configuration field: thinkingLevel");
|
|
59
|
+
}
|
|
60
|
+
const publicModelId = requireString(raw.publicModelId, "publicModelId");
|
|
61
|
+
const profiles = loadProfiles(raw.profiles, publicModelId, thinkingLevel);
|
|
62
|
+
if (!profiles.some((profile) => profile.publicModelId === publicModelId)) {
|
|
63
|
+
throw new Error("The primary publicModelId must appear in profiles");
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
host: "127.0.0.1",
|
|
68
|
+
port: requireInteger(raw.port, "port", 1024, 65535),
|
|
69
|
+
tokenSha256,
|
|
70
|
+
publicModelId,
|
|
71
|
+
provider: requireString(raw.provider, "provider"),
|
|
72
|
+
modelId: requireString(raw.modelId, "modelId"),
|
|
73
|
+
thinkingLevel: thinkingLevel,
|
|
74
|
+
profiles,
|
|
75
|
+
workspace: resolve(requireString(raw.workspace, "workspace")),
|
|
76
|
+
agentDir: resolve(requireString(raw.agentDir, "agentDir")),
|
|
77
|
+
maxBodyBytes: requireInteger(raw.maxBodyBytes, "maxBodyBytes", 1024, 64 * 1024 * 1024),
|
|
78
|
+
maxConcurrentRequests: requireInteger(raw.maxConcurrentRequests, "maxConcurrentRequests", 1, 16),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=config.js.map
|