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 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
@@ -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