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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import plistlib
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
LABEL = "com.ojm.pi-leo-bridge"
|
|
20
|
+
ENDPOINT_RE = re.compile(
|
|
21
|
+
r"^http://127\.0\.0\.1:(\d+)/auth/([A-Za-z0-9_-]{32,})/v1/chat/completions$"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def file_mode(path: Path) -> int | None:
|
|
26
|
+
return stat.S_IMODE(path.stat().st_mode) if path.exists() else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
parser = argparse.ArgumentParser()
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--config",
|
|
33
|
+
type=Path,
|
|
34
|
+
default=Path.home() / ".config" / "pi-leo-bridge" / "config.json",
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
checks: list[tuple[bool, str]] = []
|
|
39
|
+
|
|
40
|
+
def check(condition: bool, message: str) -> None:
|
|
41
|
+
checks.append((condition, message))
|
|
42
|
+
print(f"{'PASS' if condition else 'FAIL'} {message}")
|
|
43
|
+
|
|
44
|
+
config_path = args.config
|
|
45
|
+
check(config_path.is_file(), f"Configuration exists: {config_path}")
|
|
46
|
+
if not config_path.is_file():
|
|
47
|
+
raise SystemExit(1)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
config: dict[str, Any] = json.loads(config_path.read_text())
|
|
51
|
+
check(isinstance(config, dict), "Configuration is valid JSON")
|
|
52
|
+
except Exception:
|
|
53
|
+
check(False, "Configuration is valid JSON")
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
|
|
56
|
+
check(file_mode(config_path) == 0o600, "Configuration permissions are 0600")
|
|
57
|
+
check(config.get("host") == "127.0.0.1", "Bridge is configured for IPv4 loopback only")
|
|
58
|
+
port = config.get("port")
|
|
59
|
+
check(isinstance(port, int) and 1024 <= port <= 65535, "Bridge port is valid")
|
|
60
|
+
|
|
61
|
+
plist_path = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
62
|
+
check(plist_path.is_file(), f"LaunchAgent exists: {plist_path}")
|
|
63
|
+
check(file_mode(plist_path) == 0o600, "LaunchAgent permissions are 0600")
|
|
64
|
+
if plist_path.is_file():
|
|
65
|
+
try:
|
|
66
|
+
plist = plistlib.loads(plist_path.read_bytes())
|
|
67
|
+
arguments = plist.get("ProgramArguments", [])
|
|
68
|
+
check(
|
|
69
|
+
isinstance(arguments, list)
|
|
70
|
+
and len(arguments) >= 2
|
|
71
|
+
and all(Path(value).exists() for value in arguments[:2]),
|
|
72
|
+
"LaunchAgent runtime paths exist",
|
|
73
|
+
)
|
|
74
|
+
except Exception:
|
|
75
|
+
check(False, "LaunchAgent is valid")
|
|
76
|
+
|
|
77
|
+
domain = f"gui/{os.getuid()}/{LABEL}"
|
|
78
|
+
loaded = subprocess.run(
|
|
79
|
+
["launchctl", "print", domain],
|
|
80
|
+
stdout=subprocess.DEVNULL,
|
|
81
|
+
stderr=subprocess.DEVNULL,
|
|
82
|
+
check=False,
|
|
83
|
+
).returncode == 0
|
|
84
|
+
check(loaded, "LaunchAgent is loaded")
|
|
85
|
+
|
|
86
|
+
configured_profiles = config.get("profiles", [])
|
|
87
|
+
check(
|
|
88
|
+
isinstance(configured_profiles, list) and bool(configured_profiles),
|
|
89
|
+
"At least one thinking profile is configured",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
preferences_value = config.get("bravePreferencesPath")
|
|
93
|
+
preferences_path = (
|
|
94
|
+
Path(preferences_value)
|
|
95
|
+
if isinstance(preferences_value, str)
|
|
96
|
+
else Path.home()
|
|
97
|
+
/ "Library/Application Support/BraveSoftware/Brave-Browser/Default/Preferences"
|
|
98
|
+
)
|
|
99
|
+
check(preferences_path.is_file(), f"Brave Preferences exists: {preferences_path}")
|
|
100
|
+
health_url: str | None = None
|
|
101
|
+
if preferences_path.is_file():
|
|
102
|
+
try:
|
|
103
|
+
preferences = json.loads(preferences_path.read_text())
|
|
104
|
+
models = preferences.get("brave", {}).get("ai_chat", {}).get("custom_models", [])
|
|
105
|
+
managed_keys = config.get("braveModelKeys", [])
|
|
106
|
+
expected_hash = config.get("tokenSha256")
|
|
107
|
+
profile_ok = (
|
|
108
|
+
isinstance(configured_profiles, list)
|
|
109
|
+
and isinstance(managed_keys, list)
|
|
110
|
+
and len(managed_keys) == len(configured_profiles)
|
|
111
|
+
)
|
|
112
|
+
for index, profile in enumerate(configured_profiles):
|
|
113
|
+
if not isinstance(profile, dict) or index >= len(managed_keys):
|
|
114
|
+
profile_ok = False
|
|
115
|
+
continue
|
|
116
|
+
model = next(
|
|
117
|
+
(
|
|
118
|
+
candidate
|
|
119
|
+
for candidate in models
|
|
120
|
+
if isinstance(candidate, dict)
|
|
121
|
+
and candidate.get("key") == managed_keys[index]
|
|
122
|
+
and candidate.get("model_request_name") == profile.get("publicModelId")
|
|
123
|
+
),
|
|
124
|
+
None,
|
|
125
|
+
)
|
|
126
|
+
match = ENDPOINT_RE.match(str(model.get("endpoint_url", ""))) if model else None
|
|
127
|
+
current_ok = bool(
|
|
128
|
+
model
|
|
129
|
+
and match
|
|
130
|
+
and int(match.group(1)) == port
|
|
131
|
+
and hashlib.sha256(match.group(2).encode()).hexdigest() == expected_hash
|
|
132
|
+
and model.get("api_key") == ""
|
|
133
|
+
and model.get("supports_tools") is False
|
|
134
|
+
)
|
|
135
|
+
profile_ok = profile_ok and current_ok
|
|
136
|
+
if current_ok and health_url is None and match is not None:
|
|
137
|
+
health_url = (
|
|
138
|
+
f"http://127.0.0.1:{match.group(1)}/auth/{match.group(2)}/healthz"
|
|
139
|
+
)
|
|
140
|
+
check(profile_ok, "Brave models match the authenticated no-tools configuration")
|
|
141
|
+
except Exception:
|
|
142
|
+
check(False, "Brave model configuration is valid")
|
|
143
|
+
|
|
144
|
+
health: dict[str, Any] = {}
|
|
145
|
+
if health_url is not None:
|
|
146
|
+
try:
|
|
147
|
+
with urllib.request.urlopen(health_url, timeout=3) as response:
|
|
148
|
+
health = json.load(response)
|
|
149
|
+
check(
|
|
150
|
+
health.get("status") == "ok"
|
|
151
|
+
and health.get("service") == "pi-leo-bridge",
|
|
152
|
+
"Authenticated bridge health endpoint identifies the expected service",
|
|
153
|
+
)
|
|
154
|
+
except (OSError, urllib.error.URLError, json.JSONDecodeError):
|
|
155
|
+
check(False, "Authenticated bridge health endpoint is reachable")
|
|
156
|
+
else:
|
|
157
|
+
check(False, "Authenticated bridge health endpoint can be derived")
|
|
158
|
+
|
|
159
|
+
if health:
|
|
160
|
+
check(health.get("profiles") == configured_profiles, "Running profiles match configuration")
|
|
161
|
+
check(health.get("tools") == "disabled", "Running bridge reports tools disabled")
|
|
162
|
+
|
|
163
|
+
if isinstance(port, int):
|
|
164
|
+
listener_is_local = False
|
|
165
|
+
try:
|
|
166
|
+
output = subprocess.run(
|
|
167
|
+
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"],
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
check=False,
|
|
171
|
+
timeout=5,
|
|
172
|
+
).stdout
|
|
173
|
+
lines = [line for line in output.splitlines()[1:] if line.strip()]
|
|
174
|
+
listener_is_local = bool(lines) and all(
|
|
175
|
+
"127.0.0.1:" in line and "*:" not in line for line in lines
|
|
176
|
+
)
|
|
177
|
+
except (OSError, subprocess.SubprocessError):
|
|
178
|
+
try:
|
|
179
|
+
with socket.create_connection(("127.0.0.1", port), timeout=2):
|
|
180
|
+
listener_is_local = config.get("host") == "127.0.0.1"
|
|
181
|
+
except OSError:
|
|
182
|
+
listener_is_local = False
|
|
183
|
+
check(listener_is_local, "Listening socket is restricted to IPv4 loopback")
|
|
184
|
+
|
|
185
|
+
passed = sum(1 for ok, _ in checks if ok)
|
|
186
|
+
print(f"\n{passed}/{len(checks)} checks passed.")
|
|
187
|
+
if passed != len(checks):
|
|
188
|
+
raise SystemExit(1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
5
|
+
LABEL="com.ojm.pi-leo-bridge"
|
|
6
|
+
CONFIG="${HOME}/.config/pi-leo-bridge/config.json"
|
|
7
|
+
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
|
8
|
+
CHANNEL="stable"
|
|
9
|
+
PROFILE=""
|
|
10
|
+
PROVIDER=""
|
|
11
|
+
MODEL=""
|
|
12
|
+
DISPLAY_NAME=""
|
|
13
|
+
LEVELS=""
|
|
14
|
+
PRIMARY_LEVEL=""
|
|
15
|
+
PORT=""
|
|
16
|
+
CONTEXT_SIZE=""
|
|
17
|
+
ASSUME_YES=false
|
|
18
|
+
SKIP_VERIFY=false
|
|
19
|
+
ROTATE_TOKEN=false
|
|
20
|
+
CHANNEL_SET=false
|
|
21
|
+
PROFILE_SET=false
|
|
22
|
+
|
|
23
|
+
usage() {
|
|
24
|
+
cat <<'EOF'
|
|
25
|
+
Usage: pi-leo install [options]
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--provider ID Pi provider (default: openai-codex)
|
|
29
|
+
--model ID Pi model (default: gpt-5.6-sol)
|
|
30
|
+
--name NAME Display name used in Brave
|
|
31
|
+
--levels LIST Comma-separated levels (default: low,medium,high)
|
|
32
|
+
--primary-level LEVEL Unsuffixed/default profile (default: medium)
|
|
33
|
+
--context-size TOKENS Context cap advertised to Leo (default: 100000)
|
|
34
|
+
--port PORT Loopback port (default: 43127)
|
|
35
|
+
--channel CHANNEL stable, beta, or nightly
|
|
36
|
+
--profile DIRECTORY Brave profile directory, e.g. Default or "Profile 1"
|
|
37
|
+
--yes Accept defaults without interactive questions
|
|
38
|
+
--rotate-token Replace the local capability token
|
|
39
|
+
--skip-verify Skip source-checkout dependency and test step
|
|
40
|
+
-h, --help Show this help
|
|
41
|
+
EOF
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
while [[ $# -gt 0 ]]; do
|
|
45
|
+
case "$1" in
|
|
46
|
+
--provider) PROVIDER="${2:?Missing provider}"; shift 2 ;;
|
|
47
|
+
--model) MODEL="${2:?Missing model}"; shift 2 ;;
|
|
48
|
+
--name) DISPLAY_NAME="${2:?Missing display name}"; shift 2 ;;
|
|
49
|
+
--levels) LEVELS="${2:?Missing levels}"; shift 2 ;;
|
|
50
|
+
--primary-level) PRIMARY_LEVEL="${2:?Missing primary level}"; shift 2 ;;
|
|
51
|
+
--context-size) CONTEXT_SIZE="${2:?Missing context size}"; shift 2 ;;
|
|
52
|
+
--port) PORT="${2:?Missing port}"; shift 2 ;;
|
|
53
|
+
--channel) CHANNEL="${2:?Missing channel}"; CHANNEL_SET=true; shift 2 ;;
|
|
54
|
+
--profile) PROFILE="${2:?Missing profile}"; PROFILE_SET=true; shift 2 ;;
|
|
55
|
+
--yes) ASSUME_YES=true; shift ;;
|
|
56
|
+
--rotate-token) ROTATE_TOKEN=true; shift ;;
|
|
57
|
+
--skip-verify) SKIP_VERIFY=true; shift ;;
|
|
58
|
+
-h|--help) usage; exit 0 ;;
|
|
59
|
+
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
|
|
63
|
+
if [[ "$(uname -s)" != "Darwin" ]]; then
|
|
64
|
+
echo "This release currently supports macOS only." >&2
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
for command in node npm python3 curl launchctl osascript lsof; do
|
|
68
|
+
if ! command -v "$command" >/dev/null 2>&1; then
|
|
69
|
+
echo "Required command not found: $command" >&2
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
done
|
|
73
|
+
NODE_BIN="$(command -v node)"
|
|
74
|
+
node -e 'const [major,minor]=process.versions.node.split(".").map(Number); if(major<22||(major===22&&minor<19)){console.error("Node.js 22.19 or newer is required"); process.exit(1)}'
|
|
75
|
+
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else "Python 3.10 or newer is required")'
|
|
76
|
+
|
|
77
|
+
if [[ -f "$ROOT/src/index.ts" && "$SKIP_VERIFY" != true ]]; then
|
|
78
|
+
echo "Verifying source checkout..."
|
|
79
|
+
(cd "$ROOT" && npm ci && npm run typecheck && npm test)
|
|
80
|
+
elif [[ ! -f "$ROOT/dist/src/index.js" ]]; then
|
|
81
|
+
echo "Packaged runtime is missing: $ROOT/dist/src/index.js" >&2
|
|
82
|
+
exit 1
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Reuse the browser target chosen by an existing installation unless overridden.
|
|
86
|
+
PREFERENCES=""
|
|
87
|
+
APP_NAME=""
|
|
88
|
+
if [[ -f "$CONFIG" && "$CHANNEL_SET" != true && "$PROFILE_SET" != true ]]; then
|
|
89
|
+
EXISTING_TARGET="$(python3 - "$CONFIG" <<'PY'
|
|
90
|
+
import json,sys
|
|
91
|
+
c=json.load(open(sys.argv[1]))
|
|
92
|
+
print(c.get('bravePreferencesPath',''))
|
|
93
|
+
print(c.get('braveApplicationName',''))
|
|
94
|
+
PY
|
|
95
|
+
)"
|
|
96
|
+
PREFERENCES="$(printf '%s\n' "$EXISTING_TARGET" | sed -n '1p')"
|
|
97
|
+
APP_NAME="$(printf '%s\n' "$EXISTING_TARGET" | sed -n '2p')"
|
|
98
|
+
fi
|
|
99
|
+
if [[ -z "$PREFERENCES" || -z "$APP_NAME" ]]; then
|
|
100
|
+
case "$CHANNEL" in
|
|
101
|
+
stable)
|
|
102
|
+
USER_DATA="${HOME}/Library/Application Support/BraveSoftware/Brave-Browser"
|
|
103
|
+
APP_NAME="Brave Browser"
|
|
104
|
+
;;
|
|
105
|
+
beta)
|
|
106
|
+
USER_DATA="${HOME}/Library/Application Support/BraveSoftware/Brave-Browser-Beta"
|
|
107
|
+
APP_NAME="Brave Browser Beta"
|
|
108
|
+
;;
|
|
109
|
+
nightly)
|
|
110
|
+
USER_DATA="${HOME}/Library/Application Support/BraveSoftware/Brave-Browser-Nightly"
|
|
111
|
+
APP_NAME="Brave Browser Nightly"
|
|
112
|
+
;;
|
|
113
|
+
*) echo "Unsupported Brave channel: $CHANNEL" >&2; exit 2 ;;
|
|
114
|
+
esac
|
|
115
|
+
if [[ -z "$PROFILE" ]]; then
|
|
116
|
+
PROFILE="$(python3 - "$USER_DATA" <<'PY'
|
|
117
|
+
import json,sys
|
|
118
|
+
from pathlib import Path
|
|
119
|
+
root=Path(sys.argv[1])
|
|
120
|
+
try:
|
|
121
|
+
state=json.loads((root/'Local State').read_text())
|
|
122
|
+
value=state.get('profile',{}).get('last_used','Default')
|
|
123
|
+
print(value if isinstance(value,str) and value else 'Default')
|
|
124
|
+
except Exception:
|
|
125
|
+
print('Default')
|
|
126
|
+
PY
|
|
127
|
+
)"
|
|
128
|
+
fi
|
|
129
|
+
if [[ "$PROFILE" == *"/"* || "$PROFILE" == "." || "$PROFILE" == ".." ]]; then
|
|
130
|
+
echo "Invalid Brave profile directory: $PROFILE" >&2
|
|
131
|
+
exit 2
|
|
132
|
+
fi
|
|
133
|
+
PREFERENCES="$USER_DATA/$PROFILE/Preferences"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
if [[ -f "$CONFIG" ]]; then
|
|
137
|
+
INSTALLED_PREFERENCES="$(python3 - "$CONFIG" <<'PY'
|
|
138
|
+
import json,sys
|
|
139
|
+
print(json.load(open(sys.argv[1])).get('bravePreferencesPath',''))
|
|
140
|
+
PY
|
|
141
|
+
)"
|
|
142
|
+
if [[ -n "$INSTALLED_PREFERENCES" && "$INSTALLED_PREFERENCES" != "$PREFERENCES" ]]; then
|
|
143
|
+
echo "Changing Brave channels or profiles in place could leave an authenticated model in the old profile." >&2
|
|
144
|
+
echo "Run 'pi-leo uninstall' first, then install again with the new --channel/--profile." >&2
|
|
145
|
+
exit 1
|
|
146
|
+
fi
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
case "$APP_NAME" in
|
|
150
|
+
"Brave Browser"|"Brave Browser Beta"|"Brave Browser Nightly") ;;
|
|
151
|
+
*) echo "Unsupported Brave application name in configuration: $APP_NAME" >&2; exit 1 ;;
|
|
152
|
+
esac
|
|
153
|
+
if [[ -L "$(dirname "$PREFERENCES")" || -L "$PREFERENCES" ]]; then
|
|
154
|
+
echo "Refusing to modify symbolic-linked Brave profile data." >&2
|
|
155
|
+
exit 1
|
|
156
|
+
fi
|
|
157
|
+
if [[ ! -f "$PREFERENCES" ]]; then
|
|
158
|
+
echo "Brave Preferences not found: $PREFERENCES" >&2
|
|
159
|
+
echo "Use --channel and --profile to select the correct Brave profile." >&2
|
|
160
|
+
exit 1
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
if [[ ! -f "$CONFIG" && "$ASSUME_YES" != true && -t 0 && -t 1 ]]; then
|
|
164
|
+
echo "Configure the Pi model used by Leo. Run 'pi-leo models' in another terminal to list available models."
|
|
165
|
+
read -r -p "Pi provider [openai-codex]: " answer
|
|
166
|
+
PROVIDER="${PROVIDER:-${answer:-openai-codex}}"
|
|
167
|
+
read -r -p "Pi model [gpt-5.6-sol]: " answer
|
|
168
|
+
MODEL="${MODEL:-${answer:-gpt-5.6-sol}}"
|
|
169
|
+
read -r -p "Brave display name [automatic]: " answer
|
|
170
|
+
DISPLAY_NAME="${DISPLAY_NAME:-$answer}"
|
|
171
|
+
read -r -p "Thinking levels [low,medium,high]: " answer
|
|
172
|
+
LEVELS="${LEVELS:-${answer:-low,medium,high}}"
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
EXISTING_MODEL_INFO=""
|
|
176
|
+
if [[ -f "$CONFIG" ]]; then
|
|
177
|
+
EXISTING_MODEL_INFO="$(python3 - "$CONFIG" <<'PY'
|
|
178
|
+
import json,sys
|
|
179
|
+
c=json.load(open(sys.argv[1]))
|
|
180
|
+
print(c.get('provider',''))
|
|
181
|
+
print(c.get('modelId',''))
|
|
182
|
+
print(c.get('displayName',''))
|
|
183
|
+
print(c.get('contextSize',''))
|
|
184
|
+
print(c.get('port',''))
|
|
185
|
+
PY
|
|
186
|
+
)"
|
|
187
|
+
fi
|
|
188
|
+
EXISTING_PROVIDER="$(printf '%s\n' "$EXISTING_MODEL_INFO" | sed -n '1p')"
|
|
189
|
+
EXISTING_MODEL="$(printf '%s\n' "$EXISTING_MODEL_INFO" | sed -n '2p')"
|
|
190
|
+
EXISTING_DISPLAY_NAME="$(printf '%s\n' "$EXISTING_MODEL_INFO" | sed -n '3p')"
|
|
191
|
+
EXISTING_CONTEXT_SIZE="$(printf '%s\n' "$EXISTING_MODEL_INFO" | sed -n '4p')"
|
|
192
|
+
EXISTING_PORT="$(printf '%s\n' "$EXISTING_MODEL_INFO" | sed -n '5p')"
|
|
193
|
+
if [[ -n "$PROVIDER" && -z "$MODEL" && -n "$EXISTING_PROVIDER" && "$PROVIDER" != "$EXISTING_PROVIDER" ]]; then
|
|
194
|
+
echo "--model is required when changing --provider." >&2
|
|
195
|
+
exit 2
|
|
196
|
+
fi
|
|
197
|
+
EFFECTIVE_PROVIDER="${PROVIDER:-${EXISTING_PROVIDER:-openai-codex}}"
|
|
198
|
+
EFFECTIVE_MODEL="${MODEL:-${EXISTING_MODEL:-gpt-5.6-sol}}"
|
|
199
|
+
EFFECTIVE_PORT="${PORT:-${EXISTING_PORT:-43127}}"
|
|
200
|
+
if [[ ! "$EFFECTIVE_PORT" =~ ^[0-9]+$ ]] || (( EFFECTIVE_PORT < 1024 || EFFECTIVE_PORT > 65535 )); then
|
|
201
|
+
echo "Port must be an integer between 1024 and 65535." >&2
|
|
202
|
+
exit 2
|
|
203
|
+
fi
|
|
204
|
+
MODEL_INFO="$(node "$ROOT/dist/src/check-model.js" "$EFFECTIVE_PROVIDER" "$EFFECTIVE_MODEL")"
|
|
205
|
+
MODEL_FIELDS="$(printf '%s' "$MODEL_INFO" | python3 -c 'import json,sys; m=json.load(sys.stdin); print(m["name"]); print(m["contextWindow"]); print(str(m["reasoning"]).lower()); print(str(m["vision"]).lower())')"
|
|
206
|
+
MODEL_DISPLAY_NAME="$(printf '%s\n' "$MODEL_FIELDS" | sed -n '1p')"
|
|
207
|
+
MODEL_CONTEXT_WINDOW="$(printf '%s\n' "$MODEL_FIELDS" | sed -n '2p')"
|
|
208
|
+
MODEL_REASONING="$(printf '%s\n' "$MODEL_FIELDS" | sed -n '3p')"
|
|
209
|
+
MODEL_VISION="$(printf '%s\n' "$MODEL_FIELDS" | sed -n '4p')"
|
|
210
|
+
echo "Validated Pi model: $EFFECTIVE_PROVIDER/$EFFECTIVE_MODEL (context $MODEL_CONTEXT_WINDOW)"
|
|
211
|
+
PROVIDER="$EFFECTIVE_PROVIDER"
|
|
212
|
+
MODEL="$EFFECTIVE_MODEL"
|
|
213
|
+
PORT="$EFFECTIVE_PORT"
|
|
214
|
+
if [[ -z "$DISPLAY_NAME" && ( -z "$EXISTING_MODEL" || "$MODEL" != "$EXISTING_MODEL" || -z "$EXISTING_DISPLAY_NAME" ) ]]; then
|
|
215
|
+
DISPLAY_NAME="$MODEL_DISPLAY_NAME"
|
|
216
|
+
fi
|
|
217
|
+
if [[ -z "$CONTEXT_SIZE" ]]; then
|
|
218
|
+
CONTEXT_SIZE="${EXISTING_CONTEXT_SIZE:-100000}"
|
|
219
|
+
fi
|
|
220
|
+
if [[ ! "$CONTEXT_SIZE" =~ ^[0-9]+$ || ! "$MODEL_CONTEXT_WINDOW" =~ ^[0-9]+$ ]]; then
|
|
221
|
+
echo "Context sizes must be integers." >&2
|
|
222
|
+
exit 2
|
|
223
|
+
fi
|
|
224
|
+
if (( CONTEXT_SIZE > MODEL_CONTEXT_WINDOW )); then
|
|
225
|
+
CONTEXT_SIZE="$MODEL_CONTEXT_WINDOW"
|
|
226
|
+
fi
|
|
227
|
+
if [[ "$MODEL_REASONING" != true && -z "$LEVELS" ]]; then
|
|
228
|
+
LEVELS="off"
|
|
229
|
+
PRIMARY_LEVEL="off"
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
CONFIGURE_ARGS=(
|
|
233
|
+
--project "$ROOT"
|
|
234
|
+
--node "$NODE_BIN"
|
|
235
|
+
--preferences "$PREFERENCES"
|
|
236
|
+
--app-name "$APP_NAME"
|
|
237
|
+
--vision-support "$MODEL_VISION"
|
|
238
|
+
)
|
|
239
|
+
[[ -n "$PROVIDER" ]] && CONFIGURE_ARGS+=(--provider "$PROVIDER")
|
|
240
|
+
[[ -n "$MODEL" ]] && CONFIGURE_ARGS+=(--model "$MODEL")
|
|
241
|
+
[[ -n "$DISPLAY_NAME" ]] && CONFIGURE_ARGS+=(--display-name "$DISPLAY_NAME")
|
|
242
|
+
[[ -n "$LEVELS" ]] && CONFIGURE_ARGS+=(--levels "$LEVELS")
|
|
243
|
+
[[ -n "$PRIMARY_LEVEL" ]] && CONFIGURE_ARGS+=(--primary-level "$PRIMARY_LEVEL")
|
|
244
|
+
[[ -n "$PORT" ]] && CONFIGURE_ARGS+=(--port "$PORT")
|
|
245
|
+
[[ -n "$CONTEXT_SIZE" ]] && CONFIGURE_ARGS+=(--context-size "$CONTEXT_SIZE")
|
|
246
|
+
[[ "$ROTATE_TOKEN" == true ]] && CONFIGURE_ARGS+=(--rotate-token)
|
|
247
|
+
|
|
248
|
+
WAS_RUNNING=false
|
|
249
|
+
if pgrep -x "$APP_NAME" >/dev/null 2>&1; then
|
|
250
|
+
WAS_RUNNING=true
|
|
251
|
+
fi
|
|
252
|
+
ROLLBACK_DIR=""
|
|
253
|
+
CHANGES_STARTED=false
|
|
254
|
+
INSTALL_SUCCEEDED=false
|
|
255
|
+
HAD_CONFIG=false
|
|
256
|
+
HAD_PLIST=false
|
|
257
|
+
HAD_BIN_LINK=false
|
|
258
|
+
HAD_LOADED=false
|
|
259
|
+
BIN_LINK="${HOME}/.local/bin/pi-leo"
|
|
260
|
+
if [[ -e "$BIN_LINK" && ! -L "$BIN_LINK" ]]; then
|
|
261
|
+
echo "Refusing to replace non-symlink command: $BIN_LINK" >&2
|
|
262
|
+
exit 1
|
|
263
|
+
fi
|
|
264
|
+
if launchctl print "gui/${UID}/${LABEL}" >/dev/null 2>&1; then
|
|
265
|
+
HAD_LOADED=true
|
|
266
|
+
fi
|
|
267
|
+
|
|
268
|
+
finish_install() {
|
|
269
|
+
local status=$?
|
|
270
|
+
trap - EXIT
|
|
271
|
+
if [[ "$CHANGES_STARTED" == true && "$INSTALL_SUCCEEDED" != true && -n "$ROLLBACK_DIR" ]]; then
|
|
272
|
+
echo "Installation failed; restoring the previous bridge and Brave configuration." >&2
|
|
273
|
+
launchctl bootout "gui/${UID}/${LABEL}" >/dev/null 2>&1 || true
|
|
274
|
+
cp -p "$ROLLBACK_DIR/Preferences" "$PREFERENCES" || true
|
|
275
|
+
if [[ "$HAD_CONFIG" == true ]]; then
|
|
276
|
+
mkdir -p "$(dirname "$CONFIG")"
|
|
277
|
+
cp -p "$ROLLBACK_DIR/config.json" "$CONFIG" || true
|
|
278
|
+
else
|
|
279
|
+
rm -f "$CONFIG"
|
|
280
|
+
fi
|
|
281
|
+
if [[ "$HAD_PLIST" == true ]]; then
|
|
282
|
+
mkdir -p "$(dirname "$PLIST")"
|
|
283
|
+
cp -p "$ROLLBACK_DIR/launch-agent.plist" "$PLIST" || true
|
|
284
|
+
else
|
|
285
|
+
rm -f "$PLIST"
|
|
286
|
+
fi
|
|
287
|
+
rm -f "$BIN_LINK"
|
|
288
|
+
if [[ "$HAD_BIN_LINK" == true ]]; then
|
|
289
|
+
cp -P "$ROLLBACK_DIR/pi-leo-link" "$BIN_LINK" || true
|
|
290
|
+
fi
|
|
291
|
+
if [[ "$HAD_LOADED" == true && "$HAD_PLIST" == true ]]; then
|
|
292
|
+
launchctl bootstrap "gui/${UID}" "$PLIST" >/dev/null 2>&1 || true
|
|
293
|
+
launchctl enable "gui/${UID}/${LABEL}" >/dev/null 2>&1 || true
|
|
294
|
+
fi
|
|
295
|
+
fi
|
|
296
|
+
[[ -z "$ROLLBACK_DIR" ]] || rm -rf "$ROLLBACK_DIR"
|
|
297
|
+
if [[ "$WAS_RUNNING" == true ]]; then
|
|
298
|
+
open -a "$APP_NAME" >/dev/null 2>&1 || true
|
|
299
|
+
fi
|
|
300
|
+
exit "$status"
|
|
301
|
+
}
|
|
302
|
+
trap finish_install EXIT
|
|
303
|
+
|
|
304
|
+
if [[ "$WAS_RUNNING" == true ]]; then
|
|
305
|
+
echo "Quitting $APP_NAME briefly to update its model list..."
|
|
306
|
+
for _ in 1 2 3; do
|
|
307
|
+
osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true
|
|
308
|
+
for _ in $(seq 1 20); do
|
|
309
|
+
pgrep -x "$APP_NAME" >/dev/null 2>&1 || break
|
|
310
|
+
sleep 0.5
|
|
311
|
+
done
|
|
312
|
+
pgrep -x "$APP_NAME" >/dev/null 2>&1 || break
|
|
313
|
+
done
|
|
314
|
+
if pgrep -x "$APP_NAME" >/dev/null 2>&1; then
|
|
315
|
+
echo "$APP_NAME did not quit within 30 seconds; no preferences were changed." >&2
|
|
316
|
+
exit 1
|
|
317
|
+
fi
|
|
318
|
+
fi
|
|
319
|
+
|
|
320
|
+
ROLLBACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pi-leo-install.XXXXXX")"
|
|
321
|
+
cp -p "$PREFERENCES" "$ROLLBACK_DIR/Preferences"
|
|
322
|
+
cmp -s "$PREFERENCES" "$ROLLBACK_DIR/Preferences"
|
|
323
|
+
if [[ -f "$CONFIG" ]]; then
|
|
324
|
+
HAD_CONFIG=true
|
|
325
|
+
cp -p "$CONFIG" "$ROLLBACK_DIR/config.json"
|
|
326
|
+
fi
|
|
327
|
+
if [[ -f "$PLIST" ]]; then
|
|
328
|
+
HAD_PLIST=true
|
|
329
|
+
cp -p "$PLIST" "$ROLLBACK_DIR/launch-agent.plist"
|
|
330
|
+
fi
|
|
331
|
+
if [[ -L "$BIN_LINK" ]]; then
|
|
332
|
+
HAD_BIN_LINK=true
|
|
333
|
+
cp -P "$BIN_LINK" "$ROLLBACK_DIR/pi-leo-link"
|
|
334
|
+
fi
|
|
335
|
+
CHANGES_STARTED=true
|
|
336
|
+
|
|
337
|
+
# Stop the prior managed process before trusting the port. A listener that
|
|
338
|
+
# remains cannot masquerade as this installation's public health endpoint.
|
|
339
|
+
launchctl bootout "gui/${UID}/${LABEL}" >/dev/null 2>&1 || true
|
|
340
|
+
for _ in $(seq 1 50); do
|
|
341
|
+
if ! lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
|
342
|
+
break
|
|
343
|
+
fi
|
|
344
|
+
sleep 0.1
|
|
345
|
+
done
|
|
346
|
+
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
|
347
|
+
echo "Port $PORT is already used by another service." >&2
|
|
348
|
+
exit 1
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
python3 "$ROOT/scripts/configure-install.py" "${CONFIGURE_ARGS[@]}"
|
|
352
|
+
|
|
353
|
+
mkdir -p "${HOME}/.local/bin"
|
|
354
|
+
ln -sfn "$ROOT/bin/pi-leo" "$BIN_LINK"
|
|
355
|
+
|
|
356
|
+
launchctl bootstrap "gui/${UID}" "$PLIST"
|
|
357
|
+
launchctl enable "gui/${UID}/${LABEL}"
|
|
358
|
+
|
|
359
|
+
HEALTH_URL="$(python3 - "$CONFIG" "$PREFERENCES" <<'PY'
|
|
360
|
+
import hashlib,json,re,sys
|
|
361
|
+
config=json.load(open(sys.argv[1]))
|
|
362
|
+
preferences=json.load(open(sys.argv[2]))
|
|
363
|
+
keys=set(config.get('braveModelKeys',[]))
|
|
364
|
+
models=preferences.get('brave',{}).get('ai_chat',{}).get('custom_models',[])
|
|
365
|
+
pattern=re.compile(r'^(http://127\.0\.0\.1:\d+/auth/([A-Za-z0-9_-]{32,}))/v1/chat/completions$')
|
|
366
|
+
for model in models:
|
|
367
|
+
if not isinstance(model,dict) or model.get('key') not in keys:
|
|
368
|
+
continue
|
|
369
|
+
match=pattern.match(str(model.get('endpoint_url','')))
|
|
370
|
+
if match and hashlib.sha256(match.group(2).encode()).hexdigest()==config.get('tokenSha256'):
|
|
371
|
+
print(f"{match.group(1)}/healthz")
|
|
372
|
+
break
|
|
373
|
+
else:
|
|
374
|
+
raise SystemExit('Could not recover the managed health capability')
|
|
375
|
+
PY
|
|
376
|
+
)"
|
|
377
|
+
managed_listener_matches() {
|
|
378
|
+
local service_pid
|
|
379
|
+
service_pid="$(launchctl print "gui/${UID}/${LABEL}" 2>/dev/null \
|
|
380
|
+
| awk '$1 == "pid" && $2 == "=" { print $3; exit }')"
|
|
381
|
+
[[ "$service_pid" =~ ^[0-9]+$ ]] || return 1
|
|
382
|
+
lsof -t -nP -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null | grep -qx "$service_pid"
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
healthy=false
|
|
386
|
+
for _ in $(seq 1 90); do
|
|
387
|
+
if managed_listener_matches \
|
|
388
|
+
&& payload="$(curl --fail --silent --max-time 2 "$HEALTH_URL" 2>/dev/null)" \
|
|
389
|
+
&& 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
|
|
390
|
+
healthy=true
|
|
391
|
+
break
|
|
392
|
+
fi
|
|
393
|
+
sleep 1
|
|
394
|
+
done
|
|
395
|
+
if [[ "$healthy" != true ]]; then
|
|
396
|
+
echo "The bridge did not become healthy on its LaunchAgent-owned socket. Recent errors:" >&2
|
|
397
|
+
tail -n 30 "${HOME}/Library/Logs/pi-leo-bridge.error.log" 2>/dev/null || true
|
|
398
|
+
exit 1
|
|
399
|
+
fi
|
|
400
|
+
|
|
401
|
+
INSTALL_SUCCEEDED=true
|
|
402
|
+
|
|
403
|
+
echo
|
|
404
|
+
echo "Pi Leo bridge installed and healthy."
|
|
405
|
+
echo "Choose one of the new Pi profiles in Leo's model picker."
|
|
406
|
+
echo "Commands: pi-leo status | restart | doctor | logs | smoke-test | uninstall"
|