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/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-leo-bridge",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Bring Pi-configured models and authentication into Brave Leo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"brave",
|
|
10
|
+
"brave-leo",
|
|
11
|
+
"byom",
|
|
12
|
+
"openai-compatible",
|
|
13
|
+
"macos"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/omaclaren/pi-leo-bridge#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/omaclaren/pi-leo-bridge/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/omaclaren/pi-leo-bridge.git"
|
|
22
|
+
},
|
|
23
|
+
"os": [
|
|
24
|
+
"darwin"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"pi-leo": "./bin/pi-leo",
|
|
31
|
+
"pi-leo-bridge": "./bin/pi-leo"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/src",
|
|
35
|
+
"bin",
|
|
36
|
+
"scripts/*.sh",
|
|
37
|
+
"scripts/*.py",
|
|
38
|
+
"docs",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CONTRIBUTING.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"SECURITY.md"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
47
|
+
"build": "tsc -p tsconfig.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
|
+
"check:scripts": "bash -n scripts/install.sh scripts/uninstall.sh bin/pi-leo && python3 -c \"from pathlib import Path; [compile(path.read_text(), str(path), 'exec') for path in map(Path, ['scripts/configure-install.py','scripts/remove-brave-models.py','scripts/set-brave-default.py','scripts/doctor.py'])]\"",
|
|
50
|
+
"test": "npm run build && node --test dist/test/*.test.js",
|
|
51
|
+
"check": "npm run typecheck && npm run test && npm run check:scripts",
|
|
52
|
+
"prepack": "npm run clean && npm run check"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@earendil-works/pi-agent-core": "0.84.4",
|
|
56
|
+
"@earendil-works/pi-ai": "0.84.4",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "0.84.4"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "22.20.1",
|
|
61
|
+
"typescript": "5.9.2"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,530 @@
|
|
|
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 secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
LABEL = "com.ojm.pi-leo-bridge"
|
|
19
|
+
DEFAULT_PROVIDER = "openai-codex"
|
|
20
|
+
DEFAULT_MODEL = "gpt-5.6-sol"
|
|
21
|
+
DEFAULT_PORT = 43127
|
|
22
|
+
DEFAULT_LEVELS = ("low", "medium", "high")
|
|
23
|
+
VALID_LEVELS = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
24
|
+
ENDPOINT_RE = re.compile(
|
|
25
|
+
r"^http://127\.0\.0\.1:(\d+)/auth/([A-Za-z0-9_-]{32,})/v1/chat/completions$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def atomic_bytes(path: Path, payload: bytes, mode: int) -> None:
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
32
|
+
try:
|
|
33
|
+
with os.fdopen(fd, "wb") as stream:
|
|
34
|
+
stream.write(payload)
|
|
35
|
+
stream.flush()
|
|
36
|
+
os.fsync(stream.fileno())
|
|
37
|
+
os.chmod(temporary, mode)
|
|
38
|
+
os.replace(temporary, path)
|
|
39
|
+
finally:
|
|
40
|
+
if os.path.exists(temporary):
|
|
41
|
+
os.unlink(temporary)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_object(path: Path) -> dict[str, Any]:
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
return {}
|
|
47
|
+
value = json.loads(path.read_text())
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
raise RuntimeError(f"Expected a JSON object: {path}")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_levels(raw: str | None, existing: dict[str, Any]) -> list[str]:
|
|
54
|
+
if raw is None:
|
|
55
|
+
existing_profiles = existing.get("profiles")
|
|
56
|
+
if isinstance(existing_profiles, list):
|
|
57
|
+
values = [
|
|
58
|
+
profile.get("thinkingLevel")
|
|
59
|
+
for profile in existing_profiles
|
|
60
|
+
if isinstance(profile, dict)
|
|
61
|
+
]
|
|
62
|
+
if values and all(isinstance(value, str) for value in values):
|
|
63
|
+
raw_values = values
|
|
64
|
+
else:
|
|
65
|
+
raw_values = list(DEFAULT_LEVELS)
|
|
66
|
+
else:
|
|
67
|
+
raw_values = list(DEFAULT_LEVELS)
|
|
68
|
+
else:
|
|
69
|
+
raw_values = [part.strip().lower() for part in raw.split(",") if part.strip()]
|
|
70
|
+
|
|
71
|
+
if not raw_values:
|
|
72
|
+
raise RuntimeError("At least one thinking level is required")
|
|
73
|
+
if len(set(raw_values)) != len(raw_values):
|
|
74
|
+
raise RuntimeError("Thinking levels must be unique")
|
|
75
|
+
invalid = [value for value in raw_values if value not in VALID_LEVELS]
|
|
76
|
+
if invalid:
|
|
77
|
+
raise RuntimeError(f"Unsupported thinking level(s): {', '.join(invalid)}")
|
|
78
|
+
return list(raw_values)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def title_model(model_id: str) -> str:
|
|
82
|
+
words = re.split(r"[-_/]+", model_id)
|
|
83
|
+
titled: list[str] = []
|
|
84
|
+
start = 0
|
|
85
|
+
if len(words) >= 2 and words[0].lower() == "gpt":
|
|
86
|
+
titled.append(f"GPT-{words[1]}")
|
|
87
|
+
start = 2
|
|
88
|
+
for word in words[start:]:
|
|
89
|
+
lower = word.lower()
|
|
90
|
+
if lower in {"gpt", "llm", "ai"}:
|
|
91
|
+
titled.append(lower.upper())
|
|
92
|
+
elif lower == "codex":
|
|
93
|
+
titled.append("Codex")
|
|
94
|
+
else:
|
|
95
|
+
titled.append(word[:1].upper() + word[1:])
|
|
96
|
+
return " ".join(titled)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def public_base_id(provider: str, model_id: str) -> str:
|
|
100
|
+
model_slug = re.sub(r"[^A-Za-z0-9._-]+", "-", model_id).strip("-._").lower()
|
|
101
|
+
provider_slug = re.sub(r"[^A-Za-z0-9._-]+", "-", provider).strip("-._").lower()
|
|
102
|
+
if not model_slug:
|
|
103
|
+
raise RuntimeError("The model id cannot be converted into a Leo model name")
|
|
104
|
+
prefix = "pi" if provider == DEFAULT_PROVIDER else f"pi-{provider_slug}"
|
|
105
|
+
return f"{prefix}-{model_slug}"[:120]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_profiles(
|
|
109
|
+
provider: str,
|
|
110
|
+
model_id: str,
|
|
111
|
+
display_name: str,
|
|
112
|
+
levels: list[str],
|
|
113
|
+
primary_level: str,
|
|
114
|
+
existing: dict[str, Any],
|
|
115
|
+
) -> list[dict[str, str]]:
|
|
116
|
+
existing_ids: dict[str, str] = {}
|
|
117
|
+
if existing.get("provider") == provider and existing.get("modelId") == model_id:
|
|
118
|
+
for profile in existing.get("profiles", []):
|
|
119
|
+
if isinstance(profile, dict):
|
|
120
|
+
level = profile.get("thinkingLevel")
|
|
121
|
+
public_id = profile.get("publicModelId")
|
|
122
|
+
if isinstance(level, str) and isinstance(public_id, str):
|
|
123
|
+
existing_ids[level] = public_id
|
|
124
|
+
|
|
125
|
+
base_id = public_base_id(provider, model_id)
|
|
126
|
+
profiles = []
|
|
127
|
+
for level in levels:
|
|
128
|
+
generated_id = base_id if level == primary_level else f"{base_id}-{level}"
|
|
129
|
+
profiles.append(
|
|
130
|
+
{
|
|
131
|
+
"public_model_id": existing_ids.get(level, generated_id),
|
|
132
|
+
"label": f"Pi — {display_name} ({level.title()})",
|
|
133
|
+
"thinking_level": level,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
if len({profile["public_model_id"] for profile in profiles}) != len(profiles):
|
|
137
|
+
raise RuntimeError("Generated Leo model names are not unique")
|
|
138
|
+
return profiles
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_bridge_model(
|
|
142
|
+
model: dict[str, Any],
|
|
143
|
+
owned_keys: set[str],
|
|
144
|
+
owned_ids: set[str],
|
|
145
|
+
) -> bool:
|
|
146
|
+
key = str(model.get("key", ""))
|
|
147
|
+
request_name = str(model.get("model_request_name", ""))
|
|
148
|
+
# Ownership comes only from the installed manifest. Never infer ownership
|
|
149
|
+
# from a localhost port or a user-chosen model-name prefix.
|
|
150
|
+
return key in owned_keys or request_name in owned_ids
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def new_model_key(existing_keys: set[str]) -> str:
|
|
154
|
+
key = f"custom:{secrets.token_hex(4)}"
|
|
155
|
+
while key in existing_keys:
|
|
156
|
+
key = f"custom:{secrets.token_hex(4)}"
|
|
157
|
+
existing_keys.add(key)
|
|
158
|
+
return key
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def configure_preferences(
|
|
162
|
+
path: Path,
|
|
163
|
+
*,
|
|
164
|
+
port: int,
|
|
165
|
+
profiles: list[dict[str, str]],
|
|
166
|
+
existing_config: dict[str, Any],
|
|
167
|
+
context_size: int,
|
|
168
|
+
vision_support: bool,
|
|
169
|
+
rotate_token: bool,
|
|
170
|
+
) -> tuple[str, Path, list[str], str | None]:
|
|
171
|
+
raw = path.read_bytes()
|
|
172
|
+
preferences = json.loads(raw)
|
|
173
|
+
ai_chat = preferences.setdefault("brave", {}).setdefault("ai_chat", {})
|
|
174
|
+
models = ai_chat.setdefault("custom_models", [])
|
|
175
|
+
if not isinstance(models, list) or not all(isinstance(model, dict) for model in models):
|
|
176
|
+
raise RuntimeError("Unexpected Brave custom-model preference structure")
|
|
177
|
+
|
|
178
|
+
owned_keys = {
|
|
179
|
+
str(key)
|
|
180
|
+
for key in existing_config.get("braveModelKeys", [])
|
|
181
|
+
if isinstance(key, str)
|
|
182
|
+
}
|
|
183
|
+
owned_ids: set[str] = set()
|
|
184
|
+
# Current installations identify entries by their random Brave keys. Model
|
|
185
|
+
# ids are used only to migrate the original private build, which predated
|
|
186
|
+
# the managed-key manifest.
|
|
187
|
+
if not owned_keys:
|
|
188
|
+
owned_ids.update(
|
|
189
|
+
str(profile.get("publicModelId"))
|
|
190
|
+
for profile in existing_config.get("profiles", [])
|
|
191
|
+
if isinstance(profile, dict) and isinstance(profile.get("publicModelId"), str)
|
|
192
|
+
)
|
|
193
|
+
if (
|
|
194
|
+
existing_config.get("provider") == DEFAULT_PROVIDER
|
|
195
|
+
and existing_config.get("modelId") == DEFAULT_MODEL
|
|
196
|
+
):
|
|
197
|
+
owned_ids.update(
|
|
198
|
+
{
|
|
199
|
+
"pi-gpt-5.6-sol-low",
|
|
200
|
+
"pi-gpt-5.6-sol",
|
|
201
|
+
"pi-gpt-5.6-sol-high",
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
bridge_models = [
|
|
206
|
+
model for model in models if is_bridge_model(model, owned_keys, owned_ids)
|
|
207
|
+
]
|
|
208
|
+
old_default_key = ai_chat.get("default_model_key")
|
|
209
|
+
old_default_profile_id: str | None = None
|
|
210
|
+
old_default_level: str | None = None
|
|
211
|
+
for model in bridge_models:
|
|
212
|
+
if model.get("key") == old_default_key:
|
|
213
|
+
value = model.get("model_request_name")
|
|
214
|
+
old_default_profile_id = value if isinstance(value, str) else None
|
|
215
|
+
break
|
|
216
|
+
if old_default_profile_id is not None:
|
|
217
|
+
for profile in existing_config.get("profiles", []):
|
|
218
|
+
if (
|
|
219
|
+
isinstance(profile, dict)
|
|
220
|
+
and profile.get("publicModelId") == old_default_profile_id
|
|
221
|
+
and isinstance(profile.get("thinkingLevel"), str)
|
|
222
|
+
):
|
|
223
|
+
old_default_level = profile["thinkingLevel"]
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
token: str | None = None
|
|
227
|
+
if not rotate_token:
|
|
228
|
+
for bridge_model in bridge_models:
|
|
229
|
+
match = ENDPOINT_RE.match(str(bridge_model.get("endpoint_url", "")))
|
|
230
|
+
if match:
|
|
231
|
+
token = match.group(2)
|
|
232
|
+
break
|
|
233
|
+
if token is None:
|
|
234
|
+
token = secrets.token_urlsafe(32)
|
|
235
|
+
|
|
236
|
+
existing_keys = {str(model.get("key")) for model in models}
|
|
237
|
+
existing_by_request = {
|
|
238
|
+
str(model.get("model_request_name")): model for model in bridge_models
|
|
239
|
+
}
|
|
240
|
+
old_primary_id = existing_config.get("publicModelId")
|
|
241
|
+
legacy_primary = next(
|
|
242
|
+
(
|
|
243
|
+
model
|
|
244
|
+
for model in bridge_models
|
|
245
|
+
if model.get("model_request_name") == old_primary_id and model.get("key")
|
|
246
|
+
),
|
|
247
|
+
next((model for model in bridge_models if model.get("key")), None),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
endpoint = f"http://127.0.0.1:{port}/auth/{token}/v1/chat/completions"
|
|
251
|
+
replacements: list[dict[str, Any]] = []
|
|
252
|
+
model_keys: list[str] = []
|
|
253
|
+
for profile in profiles:
|
|
254
|
+
old = existing_by_request.get(profile["public_model_id"])
|
|
255
|
+
if old is None and profile["thinking_level"] == existing_config.get("thinkingLevel"):
|
|
256
|
+
old = legacy_primary
|
|
257
|
+
model_key = (
|
|
258
|
+
str(old["key"])
|
|
259
|
+
if old is not None and old.get("key")
|
|
260
|
+
else new_model_key(existing_keys)
|
|
261
|
+
)
|
|
262
|
+
model_keys.append(model_key)
|
|
263
|
+
replacements.append(
|
|
264
|
+
{
|
|
265
|
+
"api_key": "",
|
|
266
|
+
"context_size": context_size,
|
|
267
|
+
"endpoint_url": endpoint,
|
|
268
|
+
"key": model_key,
|
|
269
|
+
"label": profile["label"],
|
|
270
|
+
"model_request_name": profile["public_model_id"],
|
|
271
|
+
"model_system_prompt": (
|
|
272
|
+
"Be concise unless asked for detail. Use attached page context when relevant."
|
|
273
|
+
),
|
|
274
|
+
"supports_tools": False,
|
|
275
|
+
"vision_support": vision_support,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if bridge_models:
|
|
280
|
+
first_index = next(
|
|
281
|
+
index
|
|
282
|
+
for index, model in enumerate(models)
|
|
283
|
+
if is_bridge_model(model, owned_keys, owned_ids)
|
|
284
|
+
)
|
|
285
|
+
insertion_index = sum(
|
|
286
|
+
1
|
|
287
|
+
for model in models[:first_index]
|
|
288
|
+
if not is_bridge_model(model, owned_keys, owned_ids)
|
|
289
|
+
)
|
|
290
|
+
retained = [
|
|
291
|
+
model for model in models if not is_bridge_model(model, owned_keys, owned_ids)
|
|
292
|
+
]
|
|
293
|
+
models[:] = retained[:insertion_index] + replacements + retained[insertion_index:]
|
|
294
|
+
else:
|
|
295
|
+
models.extend(replacements)
|
|
296
|
+
|
|
297
|
+
if old_default_profile_id is not None:
|
|
298
|
+
replacement_by_id = {
|
|
299
|
+
model["model_request_name"]: model["key"] for model in replacements
|
|
300
|
+
}
|
|
301
|
+
replacement_by_level = {
|
|
302
|
+
profile["thinking_level"]: model["key"]
|
|
303
|
+
for profile, model in zip(profiles, replacements, strict=True)
|
|
304
|
+
}
|
|
305
|
+
ai_chat["default_model_key"] = replacement_by_id.get(
|
|
306
|
+
old_default_profile_id,
|
|
307
|
+
replacement_by_level.get(old_default_level, replacements[0]["key"]),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
previous_default = existing_config.get("previousDefaultModelKey")
|
|
311
|
+
if not isinstance(previous_default, str):
|
|
312
|
+
removed_keys = {str(model.get("key")) for model in bridge_models}
|
|
313
|
+
previous_default = (
|
|
314
|
+
str(old_default_key)
|
|
315
|
+
if isinstance(old_default_key, str) and old_default_key not in removed_keys
|
|
316
|
+
else None
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
320
|
+
backup = path.with_name(f"Preferences.backup-{stamp}-before-pi-leo-bridge")
|
|
321
|
+
shutil.copy2(path, backup)
|
|
322
|
+
if backup.read_bytes() != raw:
|
|
323
|
+
raise RuntimeError("Brave Preferences backup verification failed")
|
|
324
|
+
|
|
325
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
326
|
+
encoded = json.dumps(preferences, ensure_ascii=False, separators=(",", ":")).encode()
|
|
327
|
+
check = json.loads(encoded)
|
|
328
|
+
if not isinstance(check, dict):
|
|
329
|
+
raise RuntimeError("Updated Brave Preferences failed validation")
|
|
330
|
+
atomic_bytes(path, encoded, mode)
|
|
331
|
+
return token, backup, model_keys, previous_default
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def configure_runtime(
|
|
335
|
+
*,
|
|
336
|
+
home: Path,
|
|
337
|
+
project: Path,
|
|
338
|
+
node: Path,
|
|
339
|
+
token: str,
|
|
340
|
+
preferences: Path,
|
|
341
|
+
app_name: str,
|
|
342
|
+
provider: str,
|
|
343
|
+
model_id: str,
|
|
344
|
+
display_name: str,
|
|
345
|
+
profiles: list[dict[str, str]],
|
|
346
|
+
primary_level: str,
|
|
347
|
+
port: int,
|
|
348
|
+
context_size: int,
|
|
349
|
+
vision_support: bool,
|
|
350
|
+
model_keys: list[str],
|
|
351
|
+
previous_default: str | None,
|
|
352
|
+
) -> tuple[Path, Path]:
|
|
353
|
+
config_dir = home / ".config" / "pi-leo-bridge"
|
|
354
|
+
workspace = home / ".local" / "share" / "pi-leo-bridge" / "workspace"
|
|
355
|
+
logs = home / "Library" / "Logs"
|
|
356
|
+
config_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
357
|
+
workspace.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
358
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
os.chmod(config_dir, 0o700)
|
|
360
|
+
os.chmod(workspace, 0o700)
|
|
361
|
+
|
|
362
|
+
primary = next(
|
|
363
|
+
profile for profile in profiles if profile["thinking_level"] == primary_level
|
|
364
|
+
)
|
|
365
|
+
config_path = config_dir / "config.json"
|
|
366
|
+
config: dict[str, Any] = {
|
|
367
|
+
"version": 1,
|
|
368
|
+
"host": "127.0.0.1",
|
|
369
|
+
"port": port,
|
|
370
|
+
"tokenSha256": hashlib.sha256(token.encode()).hexdigest(),
|
|
371
|
+
"publicModelId": primary["public_model_id"],
|
|
372
|
+
"provider": provider,
|
|
373
|
+
"modelId": model_id,
|
|
374
|
+
"displayName": display_name,
|
|
375
|
+
"thinkingLevel": primary_level,
|
|
376
|
+
"profiles": [
|
|
377
|
+
{
|
|
378
|
+
"publicModelId": profile["public_model_id"],
|
|
379
|
+
"thinkingLevel": profile["thinking_level"],
|
|
380
|
+
}
|
|
381
|
+
for profile in profiles
|
|
382
|
+
],
|
|
383
|
+
"workspace": str(workspace),
|
|
384
|
+
"agentDir": str(home / ".pi" / "agent"),
|
|
385
|
+
"maxBodyBytes": 12 * 1024 * 1024,
|
|
386
|
+
"maxConcurrentRequests": 2,
|
|
387
|
+
"contextSize": context_size,
|
|
388
|
+
"visionSupport": vision_support,
|
|
389
|
+
"bravePreferencesPath": str(preferences),
|
|
390
|
+
"braveApplicationName": app_name,
|
|
391
|
+
"braveModelKeys": model_keys,
|
|
392
|
+
"previousDefaultModelKey": previous_default,
|
|
393
|
+
}
|
|
394
|
+
atomic_bytes(config_path, (json.dumps(config, indent=2) + "\n").encode(), 0o600)
|
|
395
|
+
|
|
396
|
+
plist_path = home / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
397
|
+
plist = {
|
|
398
|
+
"Label": LABEL,
|
|
399
|
+
"ProgramArguments": [
|
|
400
|
+
str(node),
|
|
401
|
+
str(project / "dist" / "src" / "index.js"),
|
|
402
|
+
"--config",
|
|
403
|
+
str(config_path),
|
|
404
|
+
],
|
|
405
|
+
"WorkingDirectory": str(project),
|
|
406
|
+
"RunAtLoad": True,
|
|
407
|
+
"KeepAlive": True,
|
|
408
|
+
"ThrottleInterval": 10,
|
|
409
|
+
"ProcessType": "Background",
|
|
410
|
+
"Umask": 0o077,
|
|
411
|
+
"StandardOutPath": str(logs / "pi-leo-bridge.log"),
|
|
412
|
+
"StandardErrorPath": str(logs / "pi-leo-bridge.error.log"),
|
|
413
|
+
"EnvironmentVariables": {
|
|
414
|
+
"HOME": str(home),
|
|
415
|
+
"NODE_ENV": "production",
|
|
416
|
+
"PATH": f"{node.parent}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
payload = plistlib.dumps(plist, fmt=plistlib.FMT_XML, sort_keys=True)
|
|
421
|
+
atomic_bytes(plist_path, payload, 0o600)
|
|
422
|
+
return config_path, plist_path
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def main() -> None:
|
|
426
|
+
parser = argparse.ArgumentParser()
|
|
427
|
+
parser.add_argument("--project", type=Path, required=True)
|
|
428
|
+
parser.add_argument("--node", type=Path, required=True)
|
|
429
|
+
parser.add_argument("--preferences", type=Path, required=True)
|
|
430
|
+
parser.add_argument("--app-name", default="Brave Browser")
|
|
431
|
+
parser.add_argument("--provider")
|
|
432
|
+
parser.add_argument("--model")
|
|
433
|
+
parser.add_argument("--display-name")
|
|
434
|
+
parser.add_argument("--levels", help="Comma-separated Pi thinking levels")
|
|
435
|
+
parser.add_argument("--primary-level")
|
|
436
|
+
parser.add_argument("--port", type=int)
|
|
437
|
+
parser.add_argument("--context-size", type=int)
|
|
438
|
+
parser.add_argument("--vision-support", choices=("true", "false"))
|
|
439
|
+
parser.add_argument("--rotate-token", action="store_true")
|
|
440
|
+
args = parser.parse_args()
|
|
441
|
+
|
|
442
|
+
home = Path.home().resolve()
|
|
443
|
+
project = args.project.resolve()
|
|
444
|
+
node = args.node.resolve()
|
|
445
|
+
preferences = args.preferences.resolve()
|
|
446
|
+
config_path = home / ".config" / "pi-leo-bridge" / "config.json"
|
|
447
|
+
existing = read_object(config_path)
|
|
448
|
+
|
|
449
|
+
provider = args.provider or str(existing.get("provider") or DEFAULT_PROVIDER)
|
|
450
|
+
model_id = args.model or str(existing.get("modelId") or DEFAULT_MODEL)
|
|
451
|
+
display_name = args.display_name or str(existing.get("displayName") or title_model(model_id))
|
|
452
|
+
levels = parse_levels(args.levels, existing)
|
|
453
|
+
primary_level = args.primary_level or str(existing.get("thinkingLevel") or "medium")
|
|
454
|
+
if primary_level not in levels:
|
|
455
|
+
primary_level = "medium" if "medium" in levels else levels[0]
|
|
456
|
+
port = args.port if args.port is not None else int(existing.get("port") or DEFAULT_PORT)
|
|
457
|
+
context_size = (
|
|
458
|
+
args.context_size
|
|
459
|
+
if args.context_size is not None
|
|
460
|
+
else int(existing.get("contextSize") or 100_000)
|
|
461
|
+
)
|
|
462
|
+
vision_support = (
|
|
463
|
+
args.vision_support == "true"
|
|
464
|
+
if args.vision_support is not None
|
|
465
|
+
else bool(existing.get("visionSupport", True))
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if not display_name.strip() or len(display_name) > 120 or any(
|
|
469
|
+
ord(character) < 32 for character in display_name
|
|
470
|
+
):
|
|
471
|
+
raise RuntimeError("Display name must be 1-120 printable characters")
|
|
472
|
+
if not 1024 <= port <= 65535:
|
|
473
|
+
raise RuntimeError("Port must be between 1024 and 65535")
|
|
474
|
+
if not 1_024 <= context_size <= 2_000_000:
|
|
475
|
+
raise RuntimeError("Context size must be between 1,024 and 2,000,000")
|
|
476
|
+
if not node.is_file():
|
|
477
|
+
raise RuntimeError(f"Node executable not found: {node}")
|
|
478
|
+
if not (project / "dist" / "src" / "index.js").is_file():
|
|
479
|
+
raise RuntimeError("Build output is missing")
|
|
480
|
+
if not preferences.is_file():
|
|
481
|
+
raise RuntimeError(f"Brave Preferences not found: {preferences}")
|
|
482
|
+
|
|
483
|
+
profiles = build_profiles(
|
|
484
|
+
provider,
|
|
485
|
+
model_id,
|
|
486
|
+
display_name,
|
|
487
|
+
levels,
|
|
488
|
+
primary_level,
|
|
489
|
+
existing,
|
|
490
|
+
)
|
|
491
|
+
token, backup, model_keys, previous_default = configure_preferences(
|
|
492
|
+
preferences,
|
|
493
|
+
port=port,
|
|
494
|
+
profiles=profiles,
|
|
495
|
+
existing_config=existing,
|
|
496
|
+
context_size=context_size,
|
|
497
|
+
vision_support=vision_support,
|
|
498
|
+
rotate_token=args.rotate_token,
|
|
499
|
+
)
|
|
500
|
+
config_path, plist_path = configure_runtime(
|
|
501
|
+
home=home,
|
|
502
|
+
project=project,
|
|
503
|
+
node=node,
|
|
504
|
+
token=token,
|
|
505
|
+
preferences=preferences,
|
|
506
|
+
app_name=args.app_name,
|
|
507
|
+
provider=provider,
|
|
508
|
+
model_id=model_id,
|
|
509
|
+
display_name=display_name,
|
|
510
|
+
profiles=profiles,
|
|
511
|
+
primary_level=primary_level,
|
|
512
|
+
port=port,
|
|
513
|
+
context_size=context_size,
|
|
514
|
+
vision_support=vision_support,
|
|
515
|
+
model_keys=model_keys,
|
|
516
|
+
previous_default=previous_default,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Never print the capability token or endpoint URL.
|
|
520
|
+
print(f"Brave backup: {backup}")
|
|
521
|
+
print("Brave models:")
|
|
522
|
+
for profile in profiles:
|
|
523
|
+
print(f" {profile['label']}: {profile['thinking_level']}")
|
|
524
|
+
print(f"Bridge model: {provider}/{model_id}")
|
|
525
|
+
print(f"Bridge config: {config_path}")
|
|
526
|
+
print(f"LaunchAgent: {plist_path}")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
main()
|