pennyrouter 0.3.46 → 0.3.48
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/README.md +57 -0
- package/package.json +2 -2
- package/src/cli.js +5 -0
- package/src/launch.js +10 -1
- package/src/remote.js +40 -0
- package/src/remote.test.js +37 -0
- package/standalone/penny-compare +2 -2
- package/standalone/rates.json +20 -1
- package/standalone/remote/rc_echo.py +298 -0
- package/standalone/remote/rc_local.py +110 -0
- package/standalone/remote/rc_penny.py +205 -0
- package/standalone/remote/rc_project.py +167 -0
- package/standalone/remote/rc_restart.py +145 -0
- package/standalone/remote/rc_tools.py +171 -0
- package/standalone/web-demo/claude-meter.mjs +18 -3
- package/standalone/web-demo/compare.html +26 -17
- package/standalone/web-demo/server.mjs +2 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Remote file-tool approval probe confined to a disposable test folder.
|
|
2
|
+
|
|
3
|
+
Only Read and Edit are available; every call asks the phone. No shell, MCP,
|
|
4
|
+
permission-mode changes, or arbitrary control commands are forwarded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import copy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from rc_echo import EchoWorker, read_login
|
|
16
|
+
from rc_penny import PennyClaude, PennyWorker, read_profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def confined_input(cwd, request, tool_input):
|
|
20
|
+
if request.get("tool_name") not in {"Read", "Edit"} or not isinstance(tool_input, dict):
|
|
21
|
+
return False
|
|
22
|
+
path = tool_input.get("file_path")
|
|
23
|
+
if not isinstance(path, str) or not path:
|
|
24
|
+
return False
|
|
25
|
+
root = Path(cwd).resolve()
|
|
26
|
+
target = Path(path)
|
|
27
|
+
if not target.is_absolute():
|
|
28
|
+
target = root / target
|
|
29
|
+
try:
|
|
30
|
+
return target.resolve().is_relative_to(root) and target.resolve() != root
|
|
31
|
+
except (OSError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolsWorker(PennyWorker):
|
|
36
|
+
def __init__(self, client, oauth, profile, model, cwd):
|
|
37
|
+
super().__init__(client, oauth, profile, model, cwd)
|
|
38
|
+
self.title = "Penny file approvals test"
|
|
39
|
+
self.cwd = cwd
|
|
40
|
+
self.tools = ["Read", "Edit"]
|
|
41
|
+
self.claude = PennyClaude(profile, model, self.local_session, cwd, self.permission)
|
|
42
|
+
self.pending_permissions = {}
|
|
43
|
+
self.permission_futures = {}
|
|
44
|
+
self.inbox = asyncio.Queue(maxsize=10)
|
|
45
|
+
self.queued_ids = set()
|
|
46
|
+
self.active_turn = False
|
|
47
|
+
self.disconnect_after_turns = 0
|
|
48
|
+
|
|
49
|
+
async def permission(self, event):
|
|
50
|
+
request = event.get("request", {})
|
|
51
|
+
denied = {"behavior": "deny", "message": "Only file operations inside this test folder are permitted."}
|
|
52
|
+
if request.get("subtype") != "can_use_tool" or not confined_input(self.cwd, request, request.get("input")):
|
|
53
|
+
return denied
|
|
54
|
+
request_id = event["request_id"]
|
|
55
|
+
future = asyncio.get_running_loop().create_future()
|
|
56
|
+
saved = copy.deepcopy(event)
|
|
57
|
+
self.pending_permissions[request_id] = saved
|
|
58
|
+
self.permission_futures[request_id] = future
|
|
59
|
+
print(f"Phone approval needed: {request['tool_name']}", flush=True)
|
|
60
|
+
try:
|
|
61
|
+
await self.events({**saved, "session_id": self.local_session, "uuid": self.payload("unused")["uuid"]})
|
|
62
|
+
async with asyncio.timeout(180):
|
|
63
|
+
decision = await future
|
|
64
|
+
if decision.get("behavior") == "allow":
|
|
65
|
+
tool_input = decision.get("updatedInput", request["input"])
|
|
66
|
+
if not confined_input(self.cwd, request, tool_input):
|
|
67
|
+
return denied
|
|
68
|
+
# Accept one tool decision, never persistent permission updates.
|
|
69
|
+
return {"behavior": "allow", "updatedInput": tool_input}
|
|
70
|
+
return {"behavior": "deny", "message": "The user denied this tool from the phone."}
|
|
71
|
+
except TimeoutError:
|
|
72
|
+
return {"behavior": "deny", "message": "Phone approval timed out."}
|
|
73
|
+
finally:
|
|
74
|
+
self.pending_permissions.pop(request_id, None)
|
|
75
|
+
self.permission_futures.pop(request_id, None)
|
|
76
|
+
|
|
77
|
+
async def handle(self, envelope):
|
|
78
|
+
event_id = envelope.get("event_id")
|
|
79
|
+
if not event_id or event_id in self.seen or event_id in self.queued_ids:
|
|
80
|
+
return
|
|
81
|
+
payload = envelope.get("payload", {})
|
|
82
|
+
kind = payload.get("type")
|
|
83
|
+
if kind == "user":
|
|
84
|
+
self.queued_ids.add(event_id)
|
|
85
|
+
await self.inbox.put(envelope)
|
|
86
|
+
return
|
|
87
|
+
if kind == "control_response":
|
|
88
|
+
await self.delivery(event_id, "received")
|
|
89
|
+
response = payload.get("response", {})
|
|
90
|
+
request_id = response.get("request_id")
|
|
91
|
+
future = self.permission_futures.get(request_id)
|
|
92
|
+
if future is not None and not future.done():
|
|
93
|
+
decision = response.get("response", {}) if response.get("subtype") == "success" else {}
|
|
94
|
+
future.set_result(decision)
|
|
95
|
+
print(f"Phone decision: {decision.get('behavior', 'deny')}", flush=True)
|
|
96
|
+
else:
|
|
97
|
+
print("Ignored unmatched permission response", flush=True)
|
|
98
|
+
await self.delivery(event_id, "processed")
|
|
99
|
+
self.seen.add(event_id)
|
|
100
|
+
return
|
|
101
|
+
if kind == "control_request" and payload.get("request", {}).get("subtype") == "interrupt":
|
|
102
|
+
await self.delivery(event_id, "received")
|
|
103
|
+
for future in self.permission_futures.values():
|
|
104
|
+
if not future.done():
|
|
105
|
+
future.set_result({"behavior": "deny"})
|
|
106
|
+
if self.active_turn:
|
|
107
|
+
await self.claude.interrupt(payload["request_id"])
|
|
108
|
+
else:
|
|
109
|
+
await self.events(self.payload("control_response", response={
|
|
110
|
+
"subtype": "success", "request_id": payload.get("request_id"), "response": {}}))
|
|
111
|
+
await self.delivery(event_id, "processed")
|
|
112
|
+
self.seen.add(event_id)
|
|
113
|
+
return
|
|
114
|
+
await EchoWorker.handle(self, envelope)
|
|
115
|
+
|
|
116
|
+
async def process_turns(self):
|
|
117
|
+
while not self.stopping:
|
|
118
|
+
envelope = await self.inbox.get()
|
|
119
|
+
self.active_turn = True
|
|
120
|
+
try:
|
|
121
|
+
await EchoWorker.handle(self, envelope)
|
|
122
|
+
if self.disconnect_after_turns and self.replies == self.disconnect_after_turns:
|
|
123
|
+
self.force_reconnect.set()
|
|
124
|
+
finally:
|
|
125
|
+
self.active_turn = False
|
|
126
|
+
self.queued_ids.discard(envelope["event_id"])
|
|
127
|
+
self.inbox.task_done()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def run(seconds, effort="low", disconnect_after_turns=0):
|
|
131
|
+
profile, model = read_profile()
|
|
132
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
133
|
+
"User-Agent": "penny-rc-file-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
134
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
135
|
+
if response.status_code != 200:
|
|
136
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
137
|
+
with tempfile.TemporaryDirectory(prefix="penny-rc-files-") as cwd:
|
|
138
|
+
(Path(cwd) / "note.txt").write_text("original test content\n")
|
|
139
|
+
worker = ToolsWorker(client, read_login(), profile, model, cwd)
|
|
140
|
+
worker.claude.effort = effort
|
|
141
|
+
worker.disconnect_after_turns = disconnect_after_turns
|
|
142
|
+
tasks = []
|
|
143
|
+
try:
|
|
144
|
+
await worker.start()
|
|
145
|
+
print("TEST FOLDER " + cwd, flush=True)
|
|
146
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
|
|
147
|
+
asyncio.create_task(worker.process_turns())]
|
|
148
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
149
|
+
for task in done:
|
|
150
|
+
task.result()
|
|
151
|
+
finally:
|
|
152
|
+
for task in tasks:
|
|
153
|
+
task.cancel()
|
|
154
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
155
|
+
await worker.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
160
|
+
parser.add_argument("--seconds", type=int, default=900)
|
|
161
|
+
parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
|
|
162
|
+
parser.add_argument("--disconnect-after-turns", type=int, default=0,
|
|
163
|
+
help="Simulate one three-second stream drop after this many completed turns")
|
|
164
|
+
args = parser.parse_args()
|
|
165
|
+
try:
|
|
166
|
+
asyncio.run(run(max(1, min(args.seconds, 1800)), args.effort, args.disconnect_after_turns))
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
pass
|
|
169
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError):
|
|
170
|
+
print("File approval probe stopped after a protocol, gateway, or timeout failure.")
|
|
171
|
+
raise SystemExit(1)
|
|
@@ -84,6 +84,10 @@ function emptyTotals() {
|
|
|
84
84
|
fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function emptySideCalls() {
|
|
88
|
+
return { count: 0, cost: 0, cache_read: 0, cache_write: 0, fresh_input: 0, output: 0, total: 0, models: {} };
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
export class ClaudeMeter {
|
|
88
92
|
constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard', options = {} }) {
|
|
89
93
|
this.sessionId = sessionId;
|
|
@@ -106,7 +110,7 @@ export class ClaudeMeter {
|
|
|
106
110
|
this.transcriptPrints = new Map();
|
|
107
111
|
this.chargedPrints = new Map();
|
|
108
112
|
this.telemetry = new Map();
|
|
109
|
-
this.sideCalls =
|
|
113
|
+
this.sideCalls = emptySideCalls();
|
|
110
114
|
this.localReplyIds = new Set();
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -162,6 +166,11 @@ export class ClaudeMeter {
|
|
|
162
166
|
byModel.calls += 1; byModel.tokens += total; byModel.cost += cost;
|
|
163
167
|
this.sideCalls.count += 1;
|
|
164
168
|
this.sideCalls.cost += cost;
|
|
169
|
+
this.sideCalls.cache_read += read;
|
|
170
|
+
this.sideCalls.cache_write += write;
|
|
171
|
+
this.sideCalls.fresh_input += input;
|
|
172
|
+
this.sideCalls.output += output;
|
|
173
|
+
this.sideCalls.total += total;
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
|
|
@@ -287,11 +296,17 @@ export class ClaudeMeter {
|
|
|
287
296
|
} finally { await handle.close(); }
|
|
288
297
|
}
|
|
289
298
|
|
|
299
|
+
// `totals` is everything billed; `main` leaves out the side calls.
|
|
290
300
|
snapshot() {
|
|
301
|
+
const side = this.sideCalls;
|
|
302
|
+
const main = { ...this.totals, cache_read: this.totals.cache_read - side.cache_read,
|
|
303
|
+
cache_write: this.totals.cache_write - side.cache_write, write_5m: this.totals.write_5m - side.cache_write,
|
|
304
|
+
fresh_input: this.totals.fresh_input - side.fresh_input, output: this.totals.output - side.output,
|
|
305
|
+
total: this.totals.total - side.total, cost: this.totals.cost - side.cost };
|
|
291
306
|
return { side: this.side, role: this.role, label: this.label, variant: this.variant, options: this.options,
|
|
292
307
|
harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
|
|
293
308
|
transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
|
|
294
|
-
totals: { ...this.totals }, local_replies: this.localReplyIds.size,
|
|
309
|
+
totals: { ...this.totals }, main, local_replies: this.localReplyIds.size,
|
|
295
310
|
side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
|
|
296
311
|
}
|
|
297
312
|
}
|
|
@@ -454,7 +469,7 @@ export function galleryReport(left, right, { environment, harnessVersions }) {
|
|
|
454
469
|
const penny = sides.find((side) => side.role === 'PENNY');
|
|
455
470
|
if (!native || !penny || native.harness !== penny.harness) return null;
|
|
456
471
|
const side = (snapshot) => ({ model: snapshot.model, call_count: snapshot.call_count, totals: snapshot.totals,
|
|
457
|
-
side_calls: snapshot.side_calls ||
|
|
472
|
+
side_calls: snapshot.side_calls || emptySideCalls() });
|
|
458
473
|
return JSON.stringify({ version: 1, harness: native.harness,
|
|
459
474
|
harness_version: harnessVersions?.[native.harness === 'codex' ? 'codex' : 'claude'] || '',
|
|
460
475
|
environment, native: side(native), penny: side(penny), finished_at: new Date().toISOString() });
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
.bar .sync-on { color: var(--accent); border-color: #547a50; background: var(--accent-wash); }
|
|
32
32
|
.bar .sync-off { color: #a0a0aa; border-color: #62626d; background: #292930; }
|
|
33
33
|
#delta { color: var(--accent); }
|
|
34
|
+
.pane header #delta { margin-left: auto; white-space: nowrap; }
|
|
34
35
|
#versions { margin-left: auto; color: #92929c; font-size: 12px; }
|
|
35
36
|
#status { color: #b8b8c3; }
|
|
36
37
|
@media (max-width: 760px) { .bar { overflow-x: auto; } #versions { display: none; } }
|
|
@@ -84,7 +85,7 @@
|
|
|
84
85
|
.meter .field.read strong { color: var(--accent); }
|
|
85
86
|
.meter .field.write strong { color: var(--warn); }
|
|
86
87
|
.meter .field.in strong, .meter .field.out strong { color: #75ec45; }
|
|
87
|
-
.meter .field.side { color: #77777e; }
|
|
88
|
+
.meter .field.main, .meter .field.side { color: #77777e; }
|
|
88
89
|
.meter .wait { display: none; color: #77777e; }
|
|
89
90
|
.meter.waiting .wait { display: block; }
|
|
90
91
|
.meter.waiting .field { display: none; }
|
|
@@ -214,7 +215,6 @@
|
|
|
214
215
|
<button id="finish" disabled>Finish comparison</button>
|
|
215
216
|
<button id="download-logs" hidden>Download logs</button>
|
|
216
217
|
<button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
|
|
217
|
-
<span id="delta" aria-live="polite"></span>
|
|
218
218
|
<span id="versions"></span>
|
|
219
219
|
<span id="status">Connecting…</span>
|
|
220
220
|
</div>
|
|
@@ -225,6 +225,7 @@
|
|
|
225
225
|
<div class="file-list" id="left-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
|
|
226
226
|
</header><div class="meter" id="left-meter"></div><div class="terminal" id="left"></div></section>
|
|
227
227
|
<section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong><span class="chip" id="right-chip" hidden></span>
|
|
228
|
+
<span id="delta" aria-live="polite"></span>
|
|
228
229
|
<div class="files" id="right-files"><button class="newest" id="right-newest" hidden></button>
|
|
229
230
|
<button class="count" id="right-files-count" aria-expanded="false" aria-controls="right-file-list">No files</button>
|
|
230
231
|
<div class="file-list" id="right-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
|
|
@@ -801,6 +802,9 @@
|
|
|
801
802
|
// Same abbreviation as hud.py: 1.2k, 3.45M.
|
|
802
803
|
const abbreviate = (value) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M`
|
|
803
804
|
: value >= 1_000 ? `${(value / 1_000).toFixed(1)}k` : String(value);
|
|
805
|
+
// Cents alone would round a cheap side call to $0.00.
|
|
806
|
+
const dollars = (value) => `$${value.toLocaleString('en-US',
|
|
807
|
+
{ minimumFractionDigits: value < 1 ? 4 : 2, maximumFractionDigits: value < 1 ? 4 : 2 })}`;
|
|
804
808
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
805
809
|
const meters = {};
|
|
806
810
|
const buildMeter = (side) => {
|
|
@@ -812,15 +816,16 @@
|
|
|
812
816
|
model.append(role, name);
|
|
813
817
|
root.append(model);
|
|
814
818
|
const amounts = {};
|
|
815
|
-
|
|
816
|
-
|
|
819
|
+
// Token rows count the main thread only. Background calls Claude Code makes outside the
|
|
820
|
+
// transcript (auto-mode reviewer, titles) show only as `side` cost; the last row is main + side.
|
|
821
|
+
for (const [key, label] of [['read', 'read'], ['write', 'write'], ['in', 'in'], ['out', 'out'],
|
|
822
|
+
['total', 'total'], ['main', 'main'], ['side', 'side'], ['cost', 'total']]) {
|
|
823
|
+
const field = document.createElement('span'); field.className = `field ${key}`;
|
|
817
824
|
field.append(document.createTextNode(`${label} `));
|
|
818
|
-
amounts[
|
|
819
|
-
field.append(amounts[
|
|
825
|
+
amounts[key] = document.createElement('strong');
|
|
826
|
+
field.append(amounts[key]);
|
|
820
827
|
root.append(field);
|
|
821
828
|
}
|
|
822
|
-
// Background calls (auto-mode reviewer, titles) that Claude Code makes outside the transcript.
|
|
823
|
-
amounts.side.parentElement.hidden = true;
|
|
824
829
|
// Zeros would read as a measurement; the pane has simply not seen a turn yet.
|
|
825
830
|
const wait = document.createElement('span'); wait.className = 'wait'; wait.textContent = 'waiting for first turn';
|
|
826
831
|
root.append(wait);
|
|
@@ -844,7 +849,7 @@
|
|
|
844
849
|
amounts.out.textContent = abbreviate(rolled.output);
|
|
845
850
|
amounts.total.textContent = abbreviate(rolled.cache_read + rolled.cache_write +
|
|
846
851
|
rolled.fresh_input + rolled.output);
|
|
847
|
-
amounts.cost.textContent =
|
|
852
|
+
amounts.cost.textContent = dollars(meter.cost);
|
|
848
853
|
if (Object.keys(rolled).some((key) => rolled[key] !== meter.targets[key])) settled = false;
|
|
849
854
|
}
|
|
850
855
|
if (settled) lastFrame = 0;
|
|
@@ -853,15 +858,15 @@
|
|
|
853
858
|
const showMeter = (snapshot) => {
|
|
854
859
|
const meter = meters[snapshot.side] ||= buildMeter(snapshot.side);
|
|
855
860
|
const { totals } = snapshot;
|
|
861
|
+
const main = snapshot.main || totals;
|
|
856
862
|
meter.role.textContent = snapshot.label || snapshot.role;
|
|
857
863
|
meter.name.textContent = (snapshot.model || 'waiting').split('/').pop();
|
|
858
|
-
meter.targets = { cache_read:
|
|
859
|
-
fresh_input:
|
|
864
|
+
meter.targets = { cache_read: main.cache_read, cache_write: main.cache_write,
|
|
865
|
+
fresh_input: main.fresh_input, output: main.output };
|
|
860
866
|
meter.cost = totals.cost;
|
|
861
867
|
const side = snapshot.side_calls;
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (side?.count) meter.amounts.side.textContent = `${side.count} · $${side.cost.toFixed(2)}`;
|
|
868
|
+
meter.amounts.main.textContent = dollars(main.cost);
|
|
869
|
+
meter.amounts.side.textContent = dollars(side?.cost || 0);
|
|
865
870
|
meter.root.classList.add('has-data');
|
|
866
871
|
meter.root.classList.toggle('waiting', !totals.total);
|
|
867
872
|
if (!frameRequest) frameRequest = requestAnimationFrame(paintMeters);
|
|
@@ -870,8 +875,10 @@
|
|
|
870
875
|
const native = meterSnapshots.left?.totals;
|
|
871
876
|
const penny = meterSnapshots.right?.totals;
|
|
872
877
|
if (!native || !penny) return;
|
|
878
|
+
const nativeMain = meterSnapshots.left.main || native;
|
|
879
|
+
const pennyMain = meterSnapshots.right.main || penny;
|
|
873
880
|
const cost = native.cost ? (native.cost - penny.cost) / native.cost * 100 : 0;
|
|
874
|
-
const tokens =
|
|
881
|
+
const tokens = nativeMain.total ? (nativeMain.total - pennyMain.total) / nativeMain.total * 100 : 0;
|
|
875
882
|
$('delta').textContent =
|
|
876
883
|
`${cost > 0 ? '-' : '+'}${Math.abs(cost).toFixed(0)}% cost ${tokens > 0 ? '-' : '+'}${Math.abs(tokens).toFixed(0)}% tok`;
|
|
877
884
|
};
|
|
@@ -1159,6 +1166,7 @@
|
|
|
1159
1166
|
button.disabled = true;
|
|
1160
1167
|
button.textContent = 'Submitted to gallery';
|
|
1161
1168
|
}
|
|
1169
|
+
$('publish').hidden = true;
|
|
1162
1170
|
};
|
|
1163
1171
|
|
|
1164
1172
|
// Messages common to both servers
|
|
@@ -1263,8 +1271,9 @@
|
|
|
1263
1271
|
// ids), so the alias is what is sent; the label names the model it currently resolves to.
|
|
1264
1272
|
claude: [['opus', 'Claude Opus 5.5'], ['sonnet', 'Claude Sonnet 5.5'], ['haiku', 'Claude Haiku 4.5'],
|
|
1265
1273
|
['fable', 'Claude Fable 5.1']],
|
|
1266
|
-
codex: [['gpt-
|
|
1267
|
-
['gpt-
|
|
1274
|
+
codex: [['gpt-6.1-sol', 'GPT-6.1 Sol'], ['gpt-6-astra', 'GPT-6 Astra'], ['gpt-6-sol', 'GPT-6 Sol'],
|
|
1275
|
+
['gpt-6-luna', 'GPT-6 Luna'], ['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'],
|
|
1276
|
+
['gpt-5.6-luna', 'GPT-5.6 Luna'], ['gpt-5.5', 'GPT-5.5']],
|
|
1268
1277
|
};
|
|
1269
1278
|
$('left-variant').value = config.defaultHarness;
|
|
1270
1279
|
$('right-variant').value = `penny-${config.defaultHarness}`;
|
|
@@ -170,7 +170,8 @@ function validateEnvironment(environment = {}) {
|
|
|
170
170
|
const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
|
|
171
171
|
|
|
172
172
|
async function validateConfig(config) {
|
|
173
|
-
const codexModels = ['gpt-
|
|
173
|
+
const codexModels = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra',
|
|
174
|
+
'gpt-5.6-luna', 'gpt-5.5'];
|
|
174
175
|
const claudeModels = ['opus', 'sonnet', 'haiku', 'fable'];
|
|
175
176
|
if (!config || !variants.has(config.variant) || typeof config.cwd !== 'string' ||
|
|
176
177
|
config.cwd.length > 4096 || typeof config.label !== 'string' || config.label.length > 80 ||
|