-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbyteask
More file actions
executable file
·2186 lines (2090 loc) · 104 KB
/
Copy pathbyteask
File metadata and controls
executable file
·2186 lines (2090 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/sh
# byteask — ByteAsk AI coding agent CLI.
# Thin wrapper over the engine so the client surface is fully ByteAsk-branded.
set -eu
VERSION="0.1.11"
DEFAULT_GATEWAY="https://code.byteask.ai"
export CODEX_HOME="${BYTEASK_HOME:-$HOME/.byteask}" # engine's config/home dir
export CODEX_BRAND="${BYTEASK_BRAND:-ByteAsk}" # in-app banner brand
export BYTEASK_CLIENT_VERSION="$VERSION" # engine displays THIS (not its crate ver)
SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
ENGINE="$SELF_DIR/byteask-engine"
# minimal JSON string-field extractor (no jq dependency on clients)
_json() { grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/"; }
resolve_gateway() {
if [ -n "${BYTEASK_GATEWAY:-}" ]; then echo "$BYTEASK_GATEWAY"
elif [ -f "$CODEX_HOME/gateway" ]; then cat "$CODEX_HOME/gateway"
else echo "$DEFAULT_GATEWAY"; fi
}
# ---- auto-update check: cached ~hourly, fail-open, never blocks launch ----
# State file holds: last_check=<epoch> latest=<ver>
# No "dismissed" memory: a declined update is re-offered on every launch, so a
# freshly published version keeps nudging until the user takes it.
UPDATE_STATE="$CODEX_HOME/update-check"
# version_gt A B -> returns 0 if A > B (numeric, dot-separated), else 1.
# POSIX component compare (macOS `sort` has no -V, so we cannot use it).
version_gt() {
_a="$1"; _b="$2"
if [ "$_a" = "$_b" ]; then return 1; fi
while [ -n "$_a" ] || [ -n "$_b" ]; do
_ia=${_a%%.*}; _ib=${_b%%.*}
case "$_a" in *.*) _a=${_a#*.};; *) _a="";; esac
case "$_b" in *.*) _b=${_b#*.};; *) _b="";; esac
case "$_ia" in ''|*[!0-9]*) _ia=0;; esac
case "$_ib" in ''|*[!0-9]*) _ib=0;; esac
if [ "$_ia" -gt "$_ib" ]; then return 0; fi
if [ "$_ia" -lt "$_ib" ]; then return 1; fi
done
return 1
}
# Check the server for a newer version and (on a TTY) offer to update. Entirely
# best-effort: any failure (offline, no /version, bad data) is swallowed so the
# engine always launches. Opt out with BYTEASK_NO_UPDATE_CHECK=1.
check_for_update() {
if [ -n "${BYTEASK_NO_UPDATE_CHECK:-}" ]; then return 0; fi
_uc_latest=""; _uc_last=0
if [ -f "$UPDATE_STATE" ]; then
while IFS='=' read -r _k _v; do
case "$_k" in
last_check) _uc_last=$_v;;
latest) _uc_latest=$_v;;
esac
done < "$UPDATE_STATE"
fi
case "$_uc_last" in ''|*[!0-9]*) _uc_last=0;; esac
_uc_now=$(date +%s 2>/dev/null || echo 0)
# Hit the network at most once per hour; cache the result. Fail-open. The
# short TTL means a new release is noticed within the hour on any launch.
if [ -z "$_uc_latest" ] || [ $(( _uc_now - _uc_last )) -ge 3600 ]; then
_uc_gw="$(resolve_gateway)"; _uc_gw="${_uc_gw%/}"
_uc_fetched=$(curl -fsS --max-time 2 "$_uc_gw/version" 2>/dev/null | head -n1 | tr -d '[:space:]') || _uc_fetched=""
if printf '%s' "$_uc_fetched" | grep -qE '^[0-9]+(\.[0-9]+)+$'; then
_uc_latest="$_uc_fetched"; _uc_last="$_uc_now"
mkdir -p "$CODEX_HOME" 2>/dev/null || true
printf 'last_check=%s\nlatest=%s\n' "$_uc_last" "$_uc_latest" > "$UPDATE_STATE" 2>/dev/null || true
fi
fi
if [ -z "$_uc_latest" ]; then return 0; fi
if ! version_gt "$_uc_latest" "$VERSION"; then return 0; fi # not newer than installed
if [ -t 0 ] && [ -t 1 ]; then
printf 'ByteAsk %s is available (you have %s). Update now? [Y/n] ' "$_uc_latest" "$VERSION" >&2
read -r _uc_ans </dev/tty 2>/dev/null || _uc_ans=""
case "$_uc_ans" in
""|[yY]*) # Enter (default) or y -> update
_uc_gw="$(resolve_gateway)"; _uc_gw="${_uc_gw%/}"
printf 'Updating ByteAsk to %s ...\n' "$_uc_latest" >&2
# Install in place (over the running wrapper), then relaunch the fresh
# wrapper with the original args. The relaunch carries a no-recheck guard
# so a same-session update can never loop, even if the install no-ops.
if curl -fsSL "$_uc_gw/install.sh" | PREFIX="$SELF_DIR" sh; then
exec env BYTEASK_NO_UPDATE_CHECK=1 "$SELF_DIR/byteask" "$@"
fi
printf 'Update failed; continuing on %s.\n' "$VERSION" >&2
;;
*) : ;; # declined: continue now, re-offer next launch (no dismiss memory)
esac
else
printf 'ByteAsk %s is available (you have %s). Run: byteask --update\n' "$_uc_latest" "$VERSION" >&2
fi
return 0
}
# ============================ BYOK (bring your own key) =====================
# A user's own OpenAI/Anthropic/Gemini key (or ChatGPT subscription) so their
# requests bill to THEIR account and never route through us. Keys + the managed
# JWT live in ~/.byteask/byok-config.json (0600) which a local Python "sidecar"
# (byok_sidecar.py, loopback only) reads; the engine points at the sidecar, which
# routes per model: own-key -> provider direct; no key -> managed gateway (billed
# as today). The engine holds NO secret in BYOK mode (JWT migrated to the sidecar
# store), so a repo re-pointing base_url can't exfiltrate anything. Requires python3.
BYOK_PORT="${BYOK_SIDECAR_PORT:-8799}"
BYOK_CFG="$CODEX_HOME/byok-config.json"
BYOK_SIDECAR="$CODEX_HOME/byok_sidecar.py"
BYOK_MODELS="$CODEX_HOME/byteask_models.py" # shared self-hosted-models helper
_models_ready() {
[ -f "$BYOK_MODELS" ] || { echo "byteask: self-hosted models need the helper; run 'byteask --update'." >&2; return 1; }
command -v python3 >/dev/null 2>&1 || { echo "byteask: self-hosted models need python3 (not found on PATH)." >&2; return 1; }
}
# Re-merge the registry's self/* endpoints into the model catalog (idempotent +
# atomic; never drops a base cloud model). Called at launch + after add/remove so
# /model always reflects the registry, and a `byteask --update` (which re-fetches a
# base catalog) can never permanently drop custom rows.
_models_merge() {
[ -f "$BYOK_MODELS" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
[ -f "$CODEX_HOME/models-catalog.json" ] || return 0
python3 "$BYOK_MODELS" merge-catalog "$BYOK_CFG" "$CODEX_HOME/models-catalog.json" 2>/dev/null || true
}
# True when at least one self-hosted endpoint is registered (cheap grep guard so the
# merge/python spawn never touches the launch path for the 99% who have none).
_has_self_endpoints() { [ -f "$BYOK_CFG" ] && grep -q '"endpoints"' "$BYOK_CFG" 2>/dev/null; }
# Warn when the ACTIVE model is a self/<alias> that failed `byteask models test`'s
# tool round-trip. Such a model can hold a conversation but never emits a structured
# tool call, so every file edit / shell command / search silently no-ops — a failure
# mode with nothing on screen explaining it. The verdict is recorded by models_test
# (tools_ok); absent (never tested) stays silent rather than nagging. Once per process.
_warn_self_tools() {
[ -n "${_SELFTOOLS_WARNED:-}" ] && return 0
_wst_model=$(_cfg_model)
case "$_wst_model" in self/*) : ;; *) return 0 ;; esac
_wst_alias=${_wst_model#self/}
[ -f "$BYOK_CFG" ] || return 0
_wst_ok=$(python3 "$BYOK_MODELS" get "$BYOK_CFG" "$_wst_alias" tools_ok 2>/dev/null) || return 0
[ "$_wst_ok" = false ] || return 0
echo "byteask: $_wst_model failed the tool round-trip — it can't make tool calls, so file edits," >&2
echo " shell commands and code search will NOT work (plain questions still do). Re-check with:" >&2
echo " byteask models test $_wst_alias" >&2
_SELFTOOLS_WARNED=1
}
# Merge/read the JSON config via python3 (BYOK requires python3 anyway, so no jq dep).
_byok_py() { python3 - "$@"; }
_byok_keys_count() { # echo the number of keys set (0 if no config)
[ -f "$BYOK_CFG" ] || { echo 0; return; }
_byok_py "$BYOK_CFG" <<'PY' 2>/dev/null || echo 0
import json,sys
try: print(len((json.load(open(sys.argv[1])).get("keys") or {})))
except Exception: print(0)
PY
}
_byok_field() { # $1=field -> stdout (jwt|local_token|gateway)
[ -f "$BYOK_CFG" ] || return 0
_byok_py "$BYOK_CFG" "$1" <<'PY' 2>/dev/null
import json,sys
try: print(json.load(open(sys.argv[1])).get(sys.argv[2]) or "")
except Exception: print("")
PY
}
# Merge fields into byok-config.json (0600). Args: key=value pairs; a "keys.<prov>"
# path sets a provider key, plain names set a top-level field. Empty value deletes.
_byok_merge() {
_byok_py "$BYOK_CFG" "$@" <<'PY'
import json,sys,os
path=sys.argv[1]
try: cfg=json.load(open(path))
except Exception: cfg={}
if not isinstance(cfg,dict): cfg={}
cfg.setdefault("keys",{})
for pair in sys.argv[2:]:
k,_,v=pair.partition("=")
if k.startswith("keys."):
prov=k[5:]
if v: cfg["keys"][prov]=v
else: cfg["keys"].pop(prov,None)
else:
if v: cfg[k]=v
else: cfg.pop(k,None)
os.makedirs(os.path.dirname(path),exist_ok=True)
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600); os.write(fd,json.dumps(cfg).encode()); os.close(fd)
try: os.chmod(path,0o600)
except Exception: pass
PY
}
# Current JWT: from byok-config first (BYOK mode), else config.toml (managed).
_byok_current_jwt() {
_j="$(_byok_field jwt)"
[ -n "$_j" ] && { echo "$_j"; return; }
sed -n 's/^experimental_bearer_token = "\(.*\)"$/\1/p' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1
}
# Emails that have completed sign-in on THIS machine. Used to skip the referral-code
# prompt on re-login: referrals only credit a brand-new signup, so a returning email
# should never be asked. Persists across logout (logout does not clear it).
_email_known() { _ke="$CODEX_HOME/.known-emails"; [ -f "$_ke" ] && grep -qixF "$1" "$_ke" 2>/dev/null; }
_email_remember() { _ke="$CODEX_HOME/.known-emails"; _email_known "$1" || printf '%s\n' "$1" >> "$_ke" 2>/dev/null || true; }
# Authoritative "has this email signed in before?" via the gateway (works across
# machines, unlike the local cache). Echoes: yes | no | (empty = couldn't tell).
# Fail-open: any network error / older gateway without the route -> empty, and the
# caller falls back to _email_known. curl -G --data-urlencode encodes '+','@' safely.
_server_email_exists() {
_se_gw="$(resolve_gateway)"; _se_gw="${_se_gw%/}"
[ -n "$_se_gw" ] || return 0
_se_out=$(curl -fsS -m 3 -G --data-urlencode "email=$1" "$_se_gw/auth/account-exists" 2>/dev/null) || return 0
case "$_se_out" in
*'"exists"'*true*) printf 'yes' ;;
*'"exists"'*false*) printf 'no' ;;
esac
}
# True (=> SKIP the referral prompt) when the email is a returning user. Prefers the
# server's answer; falls back to the local cache when the gateway can't be reached.
_email_returning() {
case "$(_server_email_exists "$1")" in
yes) return 0 ;;
no) return 1 ;;
*) _email_known "$1" ;;
esac
}
# One gateway probe per sign-in attempt. From a single /auth/account-exists call it
# sets two globals: _EMAIL_BLOCK (the reason the address is refused, else empty) and
# _EMAIL_EXISTS (yes|no|"" when the gateway can't be reached). Fail-open: any network
# error leaves both empty -> allow, with /auth/start still the authoritative gate.
_email_probe() {
_EMAIL_BLOCK=""; _EMAIL_EXISTS=""
_ep_gw="$(resolve_gateway)"; _ep_gw="${_ep_gw%/}"
[ -n "$_ep_gw" ] || return 0
_ep_out=$(curl -fsS -m 3 -G --data-urlencode "email=$1" "$_ep_gw/auth/account-exists" 2>/dev/null) || return 0
case "$_ep_out" in
*'"blocked"'*true*) _EMAIL_BLOCK="$(printf '%s' "$_ep_out" | _json error)"; return 0 ;;
esac
case "$_ep_out" in
*'"exists"'*true*) _EMAIL_EXISTS="yes" ;;
*'"exists"'*false*) _EMAIL_EXISTS="no" ;;
esac
}
# True (0) => this email is a NEW signup, so the referral prompt should be shown. Reads
# _EMAIL_EXISTS from the preceding _email_probe (local cache when the server was silent).
_email_is_new() {
case "$_EMAIL_EXISTS" in
yes) return 1 ;;
no) return 0 ;;
*) if _email_known "$1"; then return 1; else return 0; fi ;;
esac
}
# Preserve the model line + catalog line across config rewrites.
_cfg_model() { sed -n 's/^model = "\(.*\)"$/\1/p' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1; }
_cfg_catalog() { grep -n '^model_catalog_json = ' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1 | sed 's/^[0-9]*://'; }
# A stable per-device token for the anonymous free trial. Minted once, persisted
# 0600, sent as X-Anon-Id so the gateway can meter pre-login trial calls per device
# (NOT per IP — a university shares one NAT). It's just a local file; resetting it
# is possible and accepted for a short promo.
# Is the anonymous free-trial promo currently on? Public gateway check; fail CLOSED
# (any error / unreachable -> treat as OFF so we fall back to normal email onboarding).
# BYTEASK_ANON_FORCE=1 forces on for tests without a live gateway.
_anon_promo_on() {
[ "${BYTEASK_ANON_FORCE:-}" = 1 ] && return 0
_as=$(curl -fsS --max-time 4 "$(resolve_gateway)/byteask/anon-status" 2>/dev/null) || return 1
case "$_as" in *'"enabled":true'*|*'"enabled": true'*) return 0 ;; *) return 1 ;; esac
}
_anon_id_file() { echo "$CODEX_HOME/anon-id"; }
_ensure_anon_id() {
_aif="$(_anon_id_file)"
if [ ! -s "$_aif" ]; then
_aid=$(python3 -c 'import secrets;print(secrets.token_hex(16))' 2>/dev/null \
|| od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n')
[ -n "$_aid" ] && { printf '%s' "$_aid" > "$_aif" 2>/dev/null; chmod 600 "$_aif" 2>/dev/null; }
fi
cat "$_aif" 2>/dev/null
}
# Managed config.toml (the today path). Shared by do_login, `byok off`, and do_logout.
# An EMPTY token ($4) writes an UNSIGNED config (no experimental_bearer_token line at all,
# not an empty one) so the launch-loop onboard check correctly sees "not signed in".
# An unsigned config also carries an X-Anon-Id header so the gateway can meter the
# anonymous free trial per device.
write_managed_config() { # $1=model $2=catalog_line $3=gateway $4=token (empty = unsigned)
{
cat <<EOF
model = "$1"
model_provider = "byteask"
web_search = "live"
$2
[model_providers.byteask]
name = "ByteAsk"
base_url = "$3/byteask/v1"
wire_api = "responses"
requires_openai_auth = false
EOF
[ -n "$4" ] && printf 'experimental_bearer_token = "%s"\n' "$4"
printf '\n[model_providers.byteask.http_headers]\nx-openai-actor-authorization = "byteask"\n'
# Terse-mode preference (durable across re-login): re-emit the saved level so a
# fresh config keeps it. Absent file = no header = the gateway's default (lite).
if [ -s "$CODEX_HOME/terse" ]; then printf 'x-byteask-terse = "%s"\n' "$(cat "$CODEX_HOME/terse")"; fi
# Unsigned config carries the anon-trial device token. `if` (not `&&`) so the
# function's exit status stays 0 when signed — a trailing false `&&` would make
# write_managed_config "fail" and the launch loop would re-onboard.
if [ -z "$4" ]; then printf 'X-Anon-Id = "%s"\n' "$(_ensure_anon_id)"; fi
} > "$CODEX_HOME/config.toml"
}
# BYOK config.toml: engine -> local sidecar, NO secret in the engine (JWT is in the
# sidecar store). supports_websockets=false so the engine never sends previous_response_id
# down a WS the local demux can't honor across providers (codex review).
write_byok_config() { # $1=model $2=catalog_line $3=local_token
cat > "$CODEX_HOME/config.toml" <<EOF
model = "$1"
model_provider = "byok-local"
web_search = "live"
$2
[model_providers.byok-local]
name = "ByteAsk (your key)"
base_url = "http://127.0.0.1:$BYOK_PORT/byteask/v1"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false
[model_providers.byok-local.http_headers]
X-BYOK-Token = "$3"
x-openai-actor-authorization = "byteask"
EOF
# Terse preference travels to BYOK configs too (durable across re-login).
if [ -s "$CODEX_HOME/terse" ]; then printf 'x-byteask-terse = "%s"\n' "$(cat "$CODEX_HOME/terse")" >> "$CODEX_HOME/config.toml"; fi
}
# Ensure the loopback sidecar is running + current. Lazy shared singleton: health
# probe; (re)start if down or if byok_sidecar.py is newer than our start marker
# (picks up a `byteask --update`). Returns non-zero if it can't come up.
ensure_sidecar() {
[ -n "${BYOK_SKIP_SIDECAR:-}" ] && return 0 # test seam: skip the real spawn
[ -f "$BYOK_SIDECAR" ] || { echo "byteask: BYOK sidecar not installed; run 'byteask --update'." >&2; return 1; }
_pidf="$CODEX_HOME/byok-sidecar.pid"
if curl -fsS "http://127.0.0.1:$BYOK_PORT/healthz" >/dev/null 2>&1; then
[ -f "$_pidf" ] && [ "$_pidf" -nt "$BYOK_SIDECAR" ] && return 0 # healthy + current
kill "$(cat "$_pidf" 2>/dev/null)" 2>/dev/null || true # stale -> restart
sleep 1
fi
command -v python3 >/dev/null 2>&1 || { echo "byteask: BYOK needs python3 (not found on PATH)." >&2; return 1; }
BYOK_SIDECAR_PORT="$BYOK_PORT" nohup python3 "$BYOK_SIDECAR" >"$CODEX_HOME/byok-sidecar.log" 2>&1 &
echo $! > "$_pidf"
_i=0; while [ "$_i" -lt 30 ]; do
curl -fsS "http://127.0.0.1:$BYOK_PORT/healthz" >/dev/null 2>&1 && return 0
sleep 0.1; _i=$((_i+1))
done
echo "byteask: BYOK sidecar didn't start (see $CODEX_HOME/byok-sidecar.log)." >&2; return 1
}
# Validate a key against the provider's own /models endpoint. 200 -> ok; a clear
# 401/403 -> reject; anything else (offline/odd) -> warn but allow (surfaced later).
validate_key() { # $1=provider $2=key -> 0 ok/allow, 1 reject
case "$1" in
openai) _u="https://api.openai.com/v1/models"; _h="Authorization: Bearer $2";;
anthropic) _u="https://api.anthropic.com/v1/models"; _h="x-api-key: $2";;
gemini) _u="https://generativelanguage.googleapis.com/v1beta/models"; _h="x-goog-api-key: $2";;
*) return 0;;
esac
_extra=""; [ "$1" = anthropic ] && _extra="-H anthropic-version:2023-06-01"
_code=$(curl -o /dev/null -s -w "%{http_code}" --max-time 15 -H "$_h" $_extra "$_u" 2>/dev/null || echo 000)
case "$_code" in
2*) return 0;;
401|403) echo " That $1 key was rejected by the provider (HTTP $_code)." >&2; return 1;;
*) echo " Couldn't verify the $1 key right now (HTTP $_code) — saving it anyway." >&2; return 0;;
esac
}
# Enter/refresh BYOK: migrate JWT into the sidecar store, ensure a local token,
# (re)start the sidecar, and flip config.toml to the byok-local provider.
_byok_enter() {
_jwt="$(_byok_current_jwt)"
_lt="$(_byok_field local_token)"
if [ -z "$_lt" ]; then
_lt="$(_byok_py <<'PY'
import secrets; print(secrets.token_hex(24))
PY
)"
fi
_gw="$(resolve_gateway)"; _gw="${_gw%/}"
_byok_merge "jwt=$_jwt" "local_token=$_lt" "gateway=$_gw"
ensure_sidecar || return 1
write_byok_config "$(_cfg_model)" "$(_cfg_catalog)" "$_lt"
return 0
}
# Prompt (hidden) + validate + store + activate ONE provider key. Returns 0/1 (NO
# exit) so the source menu can loop on failure. Reused by `byok set` (CLI) + the menu.
_byok_add_key() { # $1=provider
_prov="$1"
[ -f "$BYOK_SIDECAR" ] || { echo "byteask: BYOK needs the sidecar; run 'byteask --update' first." >&2; return 1; }
command -v python3 >/dev/null 2>&1 || { echo "byteask: BYOK needs python3 (not found)." >&2; return 1; }
# Interactive TTY -> hidden prompt; piped stdin -> read it (scriptable).
if [ -t 0 ]; then
_ui_title "Add your $_prov key"
_ui_note "Hidden while you paste; saved locally (0600), never sent to ByteAsk"
_ui_gap
printf " Paste key: "
# The engine's TUI can leave the terminal in bracketed-paste + raw mode after
# /login. On macOS Terminal that swallows the submit newline (paste wrapped in
# ESC[200~..ESC[201~, Enter arrives as CR not LF), so a plain hidden `read` hangs
# while Linux terminals deliver the newline fine. Disable bracketed paste + force
# canonical line-mode with CR->NL so Enter submits everywhere; then restore.
printf '\033[?2004l' 2>/dev/null
_KEY_STTY=$(stty -g 2>/dev/null)
stty -echo icanon icrnl 2>/dev/null
read -r _KEY
[ -n "$_KEY_STTY" ] && stty "$_KEY_STTY" 2>/dev/null || stty echo 2>/dev/null
echo
else
read -r _KEY
fi
[ -n "$_KEY" ] || { echo "No key entered." >&2; return 1; }
validate_key "$_prov" "$_KEY" || { _KEY=""; return 1; }
_byok_merge "keys.$_prov=$_KEY"; _KEY="" # drop from shell memory
_byok_enter || return 1
echo "Saved your $_prov key."
return 0
}
# One-line per-provider state for the source menu + status.
_byok_status_line() {
_byok_py "$BYOK_CFG" <<'PY' 2>/dev/null || echo "OpenAI=managed Anthropic=managed Gemini=managed"
import json,sys
try: keys=json.load(open(sys.argv[1])).get("keys") or {}
except Exception: keys={}
lbl={"openai":"OpenAI","anthropic":"Anthropic","gemini":"Gemini"}
print(" ".join("%s: %s"%(lbl[p],"your key" if keys.get(p) else "managed") for p in ("openai","anthropic","gemini")))
PY
}
# Per-provider menu verb: "change" if that key is already set, else "add". One python
# call returns all three, space-separated (openai anthropic gemini), so the menu label
# tells the user which providers are keyed without reading the Current: line.
_byok_key_verbs() {
_byok_py "$BYOK_CFG" <<'PY' 2>/dev/null || echo "add add add"
import json,sys
try: keys=json.load(open(sys.argv[1])).get("keys") or {}
except Exception: keys={}
print(" ".join("change" if keys.get(p) else "add" for p in ("openai","anthropic","gemini")))
PY
}
# Signed in iff a JWT exists AND (best-effort) is not expired. The exp claim is decoded
# read-only via python (fail-safe: no python or undecodable -> treat as valid). An expired
# token counts as signed-out so the launch loop re-onboards instead of hitting a 401.
_is_signed_in() {
_si_jwt="$(_byok_current_jwt)"; [ -n "$_si_jwt" ] || return 1
command -v python3 >/dev/null 2>&1 || return 0
BYOK_JWT="$_si_jwt" python3 - <<'PY' 2>/dev/null
import os, base64, json, time, sys
try:
seg = os.environ["BYOK_JWT"].split(".")[1]; seg += "=" * (-len(seg) % 4)
exp = json.loads(base64.urlsafe_b64decode(seg)).get("exp")
sys.exit(1 if (exp and int(exp) < int(time.time())) else 0) # expired -> signed-out
except Exception:
sys.exit(0) # undecodable -> assume valid (fail-safe)
PY
}
# The MANAGED provider authenticates with `experimental_bearer_token` in config.toml —
# that line IS the credential the engine sends. _is_signed_in reads byok-config.json
# FIRST, so a managed config whose token line is missing still reports "signed in"
# (and the settings screen shows the email), while every turn 401s with
# "Sign in to continue — type /login." True == that broken state.
_managed_missing_token() {
[ "$(sed -n 's/^model_provider = "\(.*\)"$/\1/p' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1)" = byteask ] \
|| return 1
! grep -q '^experimental_bearer_token = ' "$CODEX_HOME/config.toml" 2>/dev/null
}
# Interactive iff a real TTY, or BYOK_ASSUME_TTY is set (test seam: pipe menu input).
_interactive() { [ -n "${BYOK_ASSUME_TTY:-}" ] || { [ -t 0 ] && [ -t 1 ]; }; }
# True (exit 0) when the ACTIVE config needs a ByteAsk account to run; false (1) when it
# is fully self-served and requires no sign-in. Mirrors the sidecar's _route_turn:
# managed provider (byteask) -> needs us (our key, our bill)
# byok-local + model self/* -> DIRECT to the user's box -> no account
# byok-local + model auto -> always managed-forwards -> needs us
# byok-local + cloud model w/ own key -> DIRECT to the provider -> no account
# byok-local + cloud model, no own key -> managed-forwards -> needs us
# any other provider (openai/subscription) -> engine owns its auth -> not ours
# If the user later picks a managed model in /model, the gateway's 401 login_required
# asks for sign-in AT THAT REQUEST (never a silent failure) — see [[engine-401-vs-429-render]].
_needs_byteask_signin() {
_nbs_prov=$(sed -n 's/^model_provider = "\(.*\)"$/\1/p' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1)
case "$_nbs_prov" in
byteask) return 0 ;;
byok-local) : ;;
*) return 1 ;;
esac
_nbs_model="$(_cfg_model)"
case "$_nbs_model" in
self/*) return 1 ;; # the user's own server — pure-shell, no python needed
""|auto) return 0 ;; # auto is a managed-only concept
esac
# A cloud model: no account IFF the user holds their own key for its provider.
# Reuse the served translator predicates so the provider map can't drift from the
# sidecar. No python / unreadable store -> conservative "needs us" (never a silent
# launch that would 401 on the first turn).
command -v python3 >/dev/null 2>&1 || return 0
BYOK_MODEL="$_nbs_model" python3 - "$BYOK_CFG" "$CODEX_HOME" <<'PY'
import json, sys, os
model = os.environ.get("BYOK_MODEL", "")
sys.path.insert(0, sys.argv[2])
try:
import anthropic_translate, gemini_translate
if anthropic_translate.is_anthropic_model(model): prov = "anthropic"
elif gemini_translate.is_gemini_model(model): prov = "gemini"
else: prov = "openai"
except Exception:
sys.exit(1) # can't classify -> treat as managed (account needed)
try:
keys = json.load(open(sys.argv[1])).get("keys") or {}
except Exception:
keys = {}
sys.exit(0 if (keys.get(prov) or "").strip() else 1) # exit 0 = own key = DIRECT
PY
# python exit 0 (own key -> direct) => no account; nonzero => account needed.
[ "$?" = 0 ] && return 1 || return 0
}
# The signed-in email = the "sub" claim of the JWT (base64url middle segment). Best-effort
# via python3; empty if unavailable (menu then shows a generic "signed in" line).
_current_email() {
_ce_jwt="$(_byok_current_jwt)"; [ -n "$_ce_jwt" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
BYOK_JWT="$_ce_jwt" python3 - <<'PY' 2>/dev/null
import os, base64, json
try:
seg = os.environ["BYOK_JWT"].split(".")[1]
seg += "=" * (-len(seg) % 4)
print(json.loads(base64.urlsafe_b64decode(seg)).get("sub", "") or "")
except Exception:
pass
PY
}
byok_set() { # $1=provider — the scriptable CLI command (exits on error)
_prov="$1"
case "$_prov" in openai|anthropic|gemini) ;; *)
echo "usage: byteask byok set <openai|anthropic|gemini> [--subscription]" >&2; exit 2;; esac
# ChatGPT subscription (OpenAI only): engine-native pure session, NOT the sidecar.
if [ "$_prov" = openai ] && { [ "${2:-}" = "--subscription" ] || [ "${2:-}" = "--sub" ]; }; then
byok_subscription; return; fi
_byok_add_key "$_prov" || exit 1
echo "Keyed providers bill to your account; other models use ByteAsk managed (counts toward your usage)."
echo "Relaunching..."; return 0
}
byok_status() {
_n="$(_byok_keys_count)"
if [ "$_n" = 0 ]; then echo "BYOK: off (all traffic is managed)."; return 0; fi
echo "BYOK: on. Keyed providers (billed to you):"
_byok_py "$BYOK_CFG" <<'PY' 2>/dev/null
import json,sys
try: keys=json.load(open(sys.argv[1])).get("keys") or {}
except Exception: keys={}
for p in ("openai","anthropic","gemini"):
print(" - %s: %s" % (p, "your key" if keys.get(p) else "managed"))
PY
echo " Un-keyed providers use ByteAsk managed (billed to you, counts toward your usage)."
if curl -fsS "http://127.0.0.1:$BYOK_PORT/healthz" >/dev/null 2>&1; then
echo " sidecar: running on 127.0.0.1:$BYOK_PORT"
else echo " sidecar: not running (starts on next launch)"; fi
}
byok_remove() { # $1=provider
case "$1" in openai|anthropic|gemini) ;; *)
echo "usage: byteask byok remove <openai|anthropic|gemini>" >&2; exit 2;; esac
_byok_merge "keys.$1="
if [ "$(_byok_keys_count)" = 0 ]; then byok_off; else
_byok_enter || true; echo "Removed your $1 key."; fi
}
# Leave BYOK entirely: stop the sidecar, restore the managed config with the JWT.
byok_off() {
_jwt="$(_byok_current_jwt)"; _gw="$(resolve_gateway)"; _gw="${_gw%/}"
[ "$(_byok_keys_count)" != 0 ] && _off_had_keys=1 || _off_had_keys=0
[ -f "$CODEX_HOME/byok-sidecar.pid" ] && kill "$(cat "$CODEX_HOME/byok-sidecar.pid" 2>/dev/null)" 2>/dev/null || true
rm -f "$CODEX_HOME/byok-sidecar.pid" 2>/dev/null || true
_byok_merge "keys.openai=" "keys.anthropic=" "keys.gemini="
# A self/* model needs the sidecar; on managed it would fail every turn — reset
# to the default cloud model (mirrors models_remove's active-model handling).
_off_model="$(_cfg_model)"
case "$_off_model" in self/*|"") _off_model="${BYTEASK_MODEL:-gpt-5.4}";; esac
write_managed_config "$_off_model" "$(_cfg_catalog)" "$_gw" "$_jwt"
if [ "$_off_had_keys" = 1 ]; then
echo "Switched to ByteAsk managed (billed to ByteAsk, /usage as normal)."
else
echo "You're on ByteAsk managed (billed to ByteAsk, /usage as normal)."
fi
}
# ChatGPT subscription (D13=A): a pure OpenAI-only session via the engine's own
# login flow. Not the sidecar (its OAuth/refresh is engine-native).
byok_subscription() {
echo "Sign in with your ChatGPT subscription (Plus/Pro/Business)."
echo "Note: OpenAI's own sign-in screen appears (its Codex OAuth app); your"
echo "subscription is used per OpenAI's terms. Anthropic/Gemini keys don't mix"
echo "into a subscription session — use API keys for that."
"$ENGINE" login || { echo "ChatGPT sign-in failed." >&2; exit 1; }
# Switch to the built-in OpenAI provider (engine auto-selects the ChatGPT backend
# for AuthMode::Chatgpt); leaves the sidecar untouched.
cat > "$CODEX_HOME/config.toml" <<EOF
model = "$(_cfg_model)"
model_provider = "openai"
web_search = "live"
$(_cfg_catalog)
EOF
echo "ChatGPT subscription active (OpenAI-only session). 'byteask byok off' to return to managed."
}
do_byok() {
case "${1:-}" in
set) shift; byok_set "$@";;
remove|rm) shift; byok_remove "$@";;
status|"") byok_status;;
off) byok_off;;
*) echo "usage: byteask byok <set|status|remove|off> [provider]" >&2; exit 2;;
esac
}
# ===================== self-hosted / custom models (byteask models) =========
# Point ByteAsk at the user's OWN OpenAI-compatible server (vLLM/TGI/SGLang/Ollama/
# LM Studio/llama.cpp/DGX). The endpoint lives in the 0600 byok-config registry; the
# sidecar routes self/<alias> DIRECT to their box; a slim catalog row makes it show
# in /model. Their compute, their data — never billed, never through us. All the heavy
# logic (atomic registry, catalog merge + slim template, discovery, wire probe, test)
# is in the shared python helper so the sh + ps1 wrappers stay thin + identical.
_self_hosted_howto() {
_ui_title "Other providers & your own hosted model"
_ui_note "A cloud provider with your own key (OpenRouter, Groq, DeepSeek, …):"
_ui_note " byteask models add or --provider openrouter --key sk-... (they bill you)"
_ui_note "Your OWN OpenAI-compatible server (vLLM/TGI/Ollama/LM Studio/DGX):"
_ui_note " byteask models add my-model --url http://your-host:8000 (never billed)"
_ui_note "See providers: byteask models providers"
_ui_gap
}
# True (silently) when the shared helper is new enough for preset subcommands (D13).
_preset_helper_available() {
_pha=$(python3 "$BYOK_MODELS" version 2>/dev/null || echo 0)
case "$_pha" in ''|*[!0-9]*) return 1;; esac
[ "$_pha" -ge 2 ]
}
# Loud version gate for preset-using paths (installers fetch helpers fail-soft, so a
# NEW wrapper can meet an OLD helper — say "update", don't crash on a missing subcommand).
_require_preset_helper() {
_preset_helper_available && return 0
echo "byteask: provider presets need a newer helper — run 'byteask --update'." >&2
return 1
}
# Discover a server's model ids. Key rides BK_KEY (env, not argv — /proc/*/cmdline is
# world-readable, 3A). On failure maps the helper's HTTP status to a tailored hint (4A)
# and returns 1; on success echoes the ids (one per line).
_discover_models() { # $1=url (uses KEY/CA/INS from caller scope)
_dm_err=$(mktemp 2>/dev/null || echo "/tmp/byteask-disc.$$")
_dm_ids=$(BK_KEY="$KEY" python3 "$BYOK_MODELS" discover "$1" "" "$CA" "$INS" 2>"$_dm_err")
_dm_rc=$?
_dm_msg=$(cat "$_dm_err" 2>/dev/null | tr '\n' ' '); rm -f "$_dm_err" 2>/dev/null
if [ "$_dm_rc" != 0 ] || [ -z "$_dm_ids" ]; then
case "$_dm_msg" in
*"HTTP 401"*|*"HTTP 403"*) echo " the server rejected your key — check --key or your API key." >&2;;
*"HTTP 402"*) echo " your account needs credits before it can be used (402)." >&2;;
*"HTTP 404"*) echo " no /v1/models at $1 — check the URL, or pass --model <id>." >&2;;
*"HTTP "*) echo " couldn't list models (${_dm_msg# }) — pass --model <id>." >&2;;
*) echo " couldn't reach $1 — check the URL / network." >&2;;
esac
return 1
fi
printf '%s\n' "$_dm_ids"
}
# Interactive model picker (1B): shows up to 20 ids; on a bigger list (OpenRouter = 300+)
# a substring typed at the prompt filters the already-fetched set in place (no refetch).
# A pure number picks from the shown slice; an exact id is accepted verbatim. Echoes the id.
_pick_from_ids() { # $1 = newline-separated ids
_pfi="$1"
while :; do
_pfi_total=$(printf '%s\n' "$_pfi" | grep -c .)
_pfi_shown=$(printf '%s\n' "$_pfi" | grep . | head -20)
_i=1; printf '%s\n' "$_pfi_shown" | while IFS= read -r _m; do
printf ' %d) %s\n' "$_i" "$_m" >&2; _i=$((_i+1)); done
if [ "$_pfi_total" -gt 20 ]; then
printf ' … %d more — type a substring to filter, or the exact id\n' "$((_pfi_total-20))" >&2
fi
printf " pick a number, or type a substring/exact id [1]: " >&2
read -r _pfi_in </dev/tty 2>/dev/null || _pfi_in=""
[ -z "$_pfi_in" ] && { printf '%s\n' "$_pfi_shown" | sed -n 1p; return 0; }
case "$_pfi_in" in
*[!0-9]*) ;; # not a pure number → fall through to exact/substring
*) _pfi_sel=$(printf '%s\n' "$_pfi_shown" | sed -n "${_pfi_in}p")
[ -n "$_pfi_sel" ] && { printf '%s\n' "$_pfi_sel"; return 0; };;
esac
if printf '%s\n' "$_pfi" | grep -qxF -- "$_pfi_in"; then printf '%s\n' "$_pfi_in"; return 0; fi
_pfi_f=$(printf '%s\n' "$_pfi" | grep -iF -- "$_pfi_in" 2>/dev/null || true)
if [ -z "$_pfi_f" ]; then printf ' no id matches "%s".\n' "$_pfi_in" >&2; else _pfi="$_pfi_f"; fi
done
}
models_add() { # <alias> [--provider P | --url U] [--model M] [--key K] [--wire W] [--ctx N]
# [--tools T] [--name D] [--ca-bundle P] [--insecure] [--header K=V] [--ollama]
_models_ready || return 1
_ma_alias="${1:-}"; [ $# -gt 0 ] && shift
case "$_ma_alias" in ""|-*) echo "usage: byteask models add <alias> --provider <id> | --url <endpoint>" >&2; return 2;; esac
_ma_alias="${_ma_alias#self/}"
# Seed the key from BK_KEY so a caller can pass it out-of-argv (/proc/*/cmdline is
# world-readable) — the same channel this function already uses to reach the helper.
# An explicit `--key` is parsed below and still wins.
U=""; MID=""; KEY="${BK_KEY:-}"; WIRE="auto"; WIRE_SET=0; CTX=""; TOOLS="auto"; DISP=""; CA=""; INS=""; HDRS=""
OLLAMA=0; PROVIDER=""; PROVIDER_ID=""; KIND="self"
while [ $# -gt 0 ]; do case "$1" in
--provider) PROVIDER="$2"; shift 2;;
--url) U="$2"; shift 2;;
--model) MID="$2"; shift 2;;
--key) KEY="$2"; shift 2;;
--wire) WIRE="$2"; WIRE_SET=1; shift 2;;
--ctx) CTX="$2"; shift 2;;
--tools) TOOLS="$2"; shift 2;;
--name) DISP="$2"; shift 2;;
--ca-bundle) CA="$2"; shift 2;;
--insecure) INS=1; shift;;
--header) HDRS="$HDRS
$2"; shift 2;;
--ollama) OLLAMA=1; shift;;
*) echo "byteask models add: unknown option $1" >&2; return 2;;
esac; done
# --ollama is an alias for --provider ollama when the helper supports presets;
# on an older helper it degrades to the historical localhost default (no version gate).
if [ "$OLLAMA" = 1 ] && [ -z "$PROVIDER" ]; then
if _preset_helper_available; then PROVIDER="ollama"; else U="${U:-http://localhost:11434}"; fi
fi
# Resolve a provider preset → fills URL + wire + key env + kind. An explicit
# --url / --wire always wins (opencode's "override any preset" rule).
if [ -n "$PROVIDER" ]; then
_require_preset_helper || return 1
_pl=$(python3 "$BYOK_MODELS" preset "$PROVIDER" 2>/dev/null) || {
echo "byteask models add: unknown provider '$PROVIDER'. See: byteask models providers" >&2; return 2; }
_P_URL=$(printf '%s' "$_pl" | cut -d'|' -f2)
_P_WIRE=$(printf '%s' "$_pl" | cut -d'|' -f3)
_P_NEEDS=$(printf '%s' "$_pl" | cut -d'|' -f4)
_P_ENV=$(printf '%s' "$_pl" | cut -d'|' -f5)
KIND=$(printf '%s' "$_pl" | cut -d'|' -f6)
PROVIDER_ID="$PROVIDER"
[ -n "$U" ] || U="$_P_URL" # explicit --url wins
[ "$WIRE_SET" = 1 ] || WIRE="$_P_WIRE" # explicit --wire wins; else pin (no billed probe)
# Key ladder for a needs-key (cloud) preset: --key → $ENV → hidden TTY prompt → error.
if [ "$_P_NEEDS" = 1 ] && [ -z "$KEY" ]; then
case "$_P_ENV" in ''|*[!A-Z0-9_]*) ;; *) eval "KEY=\${$_P_ENV:-}";; esac
if [ -z "$KEY" ] && [ -t 0 ] && [ -t 1 ]; then
printf " %s API key (input hidden; Enter to abort): " "$PROVIDER" >&2
stty -echo 2>/dev/null || true; read -r KEY </dev/tty 2>/dev/null || KEY=""
stty echo 2>/dev/null || true; printf '\n' >&2
fi
if [ -z "$KEY" ]; then
echo "byteask models add: $PROVIDER needs an API key — pass --key, or set \$$_P_ENV." >&2
return 2
fi
fi
fi
[ -n "$U" ] || { echo "byteask models add: pass --provider <id> or --url <endpoint>." >&2; return 2; }
# Discover the server's model id if not given (kills the #1 misconfig: wrong id).
if [ -z "$MID" ]; then
echo " probing $U for available models..." >&2
_ids=$(_discover_models "$U") || return 1
if [ -t 0 ] && [ -t 1 ]; then
MID=$(_pick_from_ids "$_ids")
elif [ "$KIND" = cloud ]; then
# A cloud provider lists many non-equivalent ids — never silently bind the first
# one in a script; make the caller name it (D10).
echo "byteask models add: $PROVIDER lists many models — pass --model <id>. For example:" >&2
printf '%s\n' "$_ids" | head -3 | sed 's/^/ /' >&2
return 2
else
MID=$(printf '%s\n' "$_ids" | sed -n 1p) # a local box serves 1-2 models — first is right
fi
[ -n "$MID" ] || { echo " no model selected." >&2; return 1; }
echo " model: $MID" >&2
fi
# Auto-detect the wire only when it isn't already known (a preset pins it — 2A).
if [ "$WIRE" = auto ]; then
WIRE=$(BK_KEY="$KEY" python3 "$BYOK_MODELS" probe-wire "$U" "$MID" "" "$CA" "$INS" 2>/dev/null || echo chat)
echo " wire: $WIRE" >&2
fi
# Build the endpoint JSON via python (env vars dodge shell quoting) + atomic save.
_ep=$(BK_URL="$U" BK_MID="$MID" BK_KEY="$KEY" BK_WIRE="$WIRE" BK_CTX="$CTX" BK_TOOLS="$TOOLS" \
BK_DISP="$DISP" BK_CA="$CA" BK_INS="$INS" BK_HDRS="$HDRS" BK_PID="$PROVIDER_ID" python3 - <<'PY'
import os, json
ep = {"base_url": os.environ["BK_URL"], "model_id": os.environ["BK_MID"],
"wire": os.environ.get("BK_WIRE") or "auto", "tools": os.environ.get("BK_TOOLS") or "auto"}
if os.environ.get("BK_KEY"): ep["api_key"] = os.environ["BK_KEY"]
if os.environ.get("BK_CTX"): ep["context_window"] = int(os.environ["BK_CTX"])
if os.environ.get("BK_DISP"): ep["display_name"] = os.environ["BK_DISP"]
if os.environ.get("BK_CA"): ep["ca_bundle"] = os.environ["BK_CA"]
if os.environ.get("BK_INS"): ep["insecure"] = True
if os.environ.get("BK_PID"): ep["provider_id"] = os.environ["BK_PID"]
hdrs = {}
for ln in (os.environ.get("BK_HDRS") or "").splitlines():
ln = ln.strip()
if "=" in ln:
k, _, v = ln.partition("="); hdrs[k.strip()] = v.strip()
if hdrs: ep["headers"] = hdrs
print(json.dumps(ep))
PY
)
printf '%s' "$_ep" | python3 "$BYOK_MODELS" add "$BYOK_CFG" "$_ma_alias" >/dev/null \
|| { echo " failed to save the endpoint." >&2; return 1; }
# Force BYOK-local mode so the engine points at the sidecar — else self/<alias> would
# go to the managed gateway. (_byok_enter preserves the endpoints we just wrote.)
_byok_enter || echo " (warning: couldn't start the local sidecar — run 'byteask' to retry)" >&2
_models_merge
# Honest copy per endpoint kind (D8): a cloud provider is NOT "your compute".
if [ "$KIND" = cloud ]; then
echo "Added self/$_ma_alias ($MID via $PROVIDER). Runs on $PROVIDER's cloud under YOUR key — they bill you; agent loops consume credits."
else
echo "Added self/$_ma_alias ($MID, wire=$WIRE). Your compute — never billed by ByteAsk."
fi
echo "Pick it in /model, or run: byteask --model self/$_ma_alias"
# Consented smoke test (D11): discovery listing ≠ a working chat turn.
if [ -t 0 ] && [ -t 1 ]; then
printf " Run a quick test now (one request on your key)? [Y/n]: " >&2
read -r _mt_ans </dev/tty 2>/dev/null || _mt_ans=""
case "$_mt_ans" in
[Nn]*) echo " Skipped. Test later: byteask models test $_ma_alias" >&2;;
*) models_test "$_ma_alias";;
esac
else
echo "Test it end-to-end: byteask models test $_ma_alias"
fi
}
models_providers() {
_models_ready || return 1
_require_preset_helper || return 1
echo "Cloud providers — bring your own key (your key, the provider bills you):"
python3 "$BYOK_MODELS" presets 2>/dev/null | while IFS='|' read -r _pid _purl _pw _pn _pe _pk; do
[ "$_pk" = cloud ] && printf ' %-12s %s\n' "$_pid" "$_purl"
done
echo "Local servers — your compute, never billed:"
python3 "$BYOK_MODELS" presets 2>/dev/null | while IFS='|' read -r _pid _purl _pw _pn _pe _pk; do
[ "$_pk" = local ] && printf ' %-12s %s\n' "$_pid" "$_purl"
done
echo "Add one: byteask models add <alias> --provider <id> [--key K] [--model ID]"
}
models_list() {
_models_ready || return 1
python3 "$BYOK_MODELS" list "$BYOK_CFG"
if [ "${1:-}" = --check ]; then
echo " checking reachability..."
for _al in $(python3 "$BYOK_MODELS" list "$BYOK_CFG" --json 2>/dev/null \
| python3 -c 'import sys,json; print(" ".join(json.load(sys.stdin).keys()))' 2>/dev/null); do
_u=$(python3 "$BYOK_MODELS" get "$BYOK_CFG" "$_al" base_url 2>/dev/null)
_k=$(python3 "$BYOK_MODELS" get "$BYOK_CFG" "$_al" api_key 2>/dev/null)
if BK_KEY="$_k" python3 "$BYOK_MODELS" discover "$_u" >/dev/null 2>&1; then
echo " self/$_al: reachable"
else echo " self/$_al: UNREACHABLE"; fi
done
fi
}
models_test() {
_models_ready || return 1
_mt="${1:-}"; _mt="${_mt#self/}"
[ -n "$_mt" ] || { echo "usage: byteask models test <alias>" >&2; return 2; }
python3 "$BYOK_MODELS" test "$BYOK_CFG" "$_mt"
}
models_remove() {
_models_ready || return 1
_mr="${1:-}"; _mr="${_mr#self/}"
[ -n "$_mr" ] || { echo "usage: byteask models remove <alias>" >&2; return 2; }
_res=$(python3 "$BYOK_MODELS" remove "$BYOK_CFG" "$_mr" 2>/dev/null || echo notfound)
# If it was the ACTIVE model, switch back to a safe cloud default (else the next
# launch has model=self/<gone> which fail-closes on every turn).
if [ "$(_cfg_model)" = "self/$_mr" ]; then
_def="${BYTEASK_MODEL:-gpt-5.4}"
sed -i.bak "s|^model = \"self/$_mr\"\$|model = \"$_def\"|" "$CODEX_HOME/config.toml" 2>/dev/null || true
rm -f "$CODEX_HOME/config.toml.bak" 2>/dev/null || true
echo " (was your active model; switched to $_def)"
fi
_models_merge
case "$_res" in removed) echo "Removed self/$_mr.";; *) echo "No self-hosted model 'self/$_mr'.";; esac
}
do_models() {
case "${1:-}" in
add) shift; models_add "$@";;
providers) shift; models_providers "$@";;
list|ls) shift; models_list "$@";;
test) shift; models_test "$@";;
remove|rm) shift; models_remove "$@";;
""|help|--help|-h)
cat >&2 <<EOF
Use a cloud provider with YOUR key (OpenRouter/Groq/DeepSeek/… — the provider bills you):
byteask models add <alias> --provider <id> [--key K] [--model ID]
byteask models providers list the known providers
Use your OWN hosted model (vLLM/TGI/Ollama/LM Studio/DGX — never billed by ByteAsk):
byteask models add <alias> --url http://host:8000 [--model ID] [--key K] [--ollama]
[--wire auto|responses|chat] [--ctx N] [--ca-bundle P] [--insecure]
byteask models list [--check]
byteask models test <alias>
byteask models remove <alias>
Then pick self/<alias> in /model.
EOF
;;
*) echo "usage: byteask models <add|providers|list|test|remove>" >&2; exit 2;;
esac
}
# Numbered fallback (no real TTY / raw mode unavailable): print options, read a number.
# Echoes the chosen 1-based index, or 0 to cancel.
# Menu chrome — clean section title + dim helper lines. All to STDERR (fd 2), matching
# _menu_pick's option rendering, so they sit directly above the menu and never leak into
# a $(...) capture. ANSI: bold title, dim notes; degrade gracefully on dumb terminals.
_ui_title() { printf '\n \033[1m%s\033[0m\n' "$1" >&2; }
_ui_note() { printf ' \033[2m%s\033[0m\n' "$1" >&2; }
_ui_gap() { printf '\n' >&2; }
_menu_pick_numbered() {
_mp_n=$#; _mp_j=1
for _mp_o in "$@"; do printf ' %d) %s\n' "$_mp_j" "$_mp_o" >&2; _mp_j=$((_mp_j+1)); done
printf '> ' >&2
read -r _mp_sel || _mp_sel=""
case "$_mp_sel" in
''|q|Q) echo 0 ;;
*[!0-9]*) echo 0 ;;
*) if [ "$_mp_sel" -ge 1 ] 2>/dev/null && [ "$_mp_sel" -le "$_mp_n" ] 2>/dev/null; then echo "$_mp_sel"; else echo 0; fi ;;
esac
}
# Arrow-navigable single-select menu. Args = option labels. RENDERS to /dev/tty and
# echoes the chosen 1-based index (0 = cancel) on stdout, so call it as $(_menu_pick ...).
# Up/Down move; Enter/Space select; 1-9 jump-select; q/Esc cancel. Raw mode is always
# restored via a trap (terminal never left broken); falls back to a number prompt if the
# tty/raw mode isn't available (e.g. piped input in tests).
_menu_pick() {
_mp_n=$#
# Reads keystrokes from STDIN (fd 0), renders to STDERR (fd 2, since stdout carries the
# return value through $(...)). Requires stdin to be a real tty; otherwise (pipe, tests)
# fall back to a numbered prompt that reads the same stdin.
[ -t 0 ] || { _menu_pick_numbered "$@"; return; }
_mp_saved=$(stty -g 2>/dev/null) || { _menu_pick_numbered "$@"; return; }
# -icrnl -inlcr so Enter reads as a literal CR (distinct from EOF, which reads empty).
stty -echo -icanon -icrnl -inlcr min 1 time 0 2>/dev/null || { _menu_pick_numbered "$@"; return; }
trap 'stty "$_mp_saved" 2>/dev/null' EXIT INT TERM
# Preserve exact control bytes: $(...) strips trailing newlines, so append+strip an X
# sentinel. That lets us tell EOF (empty) from Enter (CR or LF, terminal-dependent).
_mp_esc=$(printf '\033'); _mp_cr=$(printf '\rX'); _mp_cr=${_mp_cr%X}; _mp_nl=$(printf '\nX'); _mp_nl=${_mp_nl%X}
# Initial highlighted row (1-based) from _MENU_START; clamp to range, default 1.
_mp_i=${_MENU_START:-1}
{ [ "$_mp_i" -ge 1 ] && [ "$_mp_i" -le "$_mp_n" ]; } 2>/dev/null || _mp_i=1
_mp_first=1
while : ; do
if [ "$_mp_first" = 1 ]; then _mp_first=0; else printf '\033[%dA' "$_mp_n" >&2; fi
_mp_j=1
for _mp_o in "$@"; do
if [ "$_mp_j" = "$_mp_i" ]; then printf '\r\033[K\033[1;36m> %s\033[0m\n' "$_mp_o" >&2
else printf '\r\033[K %s\n' "$_mp_o" >&2; fi
_mp_j=$((_mp_j+1))
done
_mp_k=$(dd bs=1 count=1 2>/dev/null; printf X); _mp_k=${_mp_k%X}
case "$_mp_k" in
"$_mp_esc")
_mp_k2=$(dd bs=1 count=1 2>/dev/null)
_mp_k3=$(dd bs=1 count=1 2>/dev/null)
case "$_mp_k2$_mp_k3" in
'[A'|'OA') if [ "$_mp_i" -gt 1 ]; then _mp_i=$((_mp_i-1)); else _mp_i=$_mp_n; fi ;;
'[B'|'OB') if [ "$_mp_i" -lt "$_mp_n" ]; then _mp_i=$((_mp_i+1)); else _mp_i=1; fi ;;
esac ;;
' '|"$_mp_cr"|"$_mp_nl") stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo "$_mp_i"; return ;; # Enter/Space = select
'') stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo 0; return ;; # EOF = cancel/done
q|Q) stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo 0; return ;; # q = cancel
[1-9]) if [ "$_mp_k" -le "$_mp_n" ]; then stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo "$_mp_k"; return; fi ;;
esac
done
}
# ==================== persistent settings screen ============================
# ONE anchored region — header, breadcrumb, menu, status line, footer — that
# repaints over itself every frame (relative cursor-up + clear-to-EOL on
# /dev/tty), so nothing ever stacks in scrollback. Design + every locked