@workweave/router 0.1.7 → 0.2.0
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/install.sh +342 -19
- package/package.json +25 -3
- package/pi-router/README.md +70 -0
- package/pi-router/package.json +13 -0
- package/pi-router/src/compaction.ts +26 -0
- package/pi-router/src/config.ts +261 -0
- package/pi-router/src/dispatch.ts +344 -0
- package/pi-router/src/index.ts +49 -0
- package/pi-router/src/metadata.ts +32 -0
- package/pi-router/src/provider.ts +51 -0
- package/pi-router/src/routed-model.ts +31 -0
- package/pi-router/src/safety.ts +66 -0
- package/uninstall.sh +114 -3
package/install.sh
CHANGED
|
@@ -1,35 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# Weave Router installer for Claude Code, Codex, and
|
|
3
|
+
# Weave Router installer for Claude Code, Codex, opencode, and pi.
|
|
4
4
|
#
|
|
5
|
-
# Configures Claude Code (default), the OpenAI Codex CLI (`--codex`),
|
|
6
|
-
# opencode (`--opencode`) to permanently route through the
|
|
7
|
-
# For Claude Code this writes the router base URL, router auth
|
|
8
|
-
# and a status line into Claude Code's settings.json. For Codex it
|
|
9
|
-
# a `model_providers.weave` entry plus `model_provider = "weave"` into
|
|
5
|
+
# Configures Claude Code (default), the OpenAI Codex CLI (`--codex`),
|
|
6
|
+
# opencode (`--opencode`), or pi (`--pi`) to permanently route through the
|
|
7
|
+
# Weave Router. For Claude Code this writes the router base URL, router auth
|
|
8
|
+
# header, and a status line into Claude Code's settings.json. For Codex it
|
|
9
|
+
# writes a `model_providers.weave` entry plus `model_provider = "weave"` into
|
|
10
10
|
# ~/.codex/config.toml (managed block delimited by markers). For opencode
|
|
11
11
|
# it merges a `provider.weave` block (anthropic-compatible) into
|
|
12
12
|
# opencode.json — since the file is JSON, install/uninstall are structural
|
|
13
|
-
# (jq) rather than marker-delimited.
|
|
13
|
+
# (jq) rather than marker-delimited. For pi it merges a `weave` provider into
|
|
14
|
+
# ~/.pi/agent/models.json, sets it as the default in settings.json, and adds
|
|
15
|
+
# the @workweave/router extension (which also adds a parallel subagent
|
|
16
|
+
# `dispatch` tool) — all structural (jq) merges.
|
|
14
17
|
#
|
|
15
18
|
# Two scopes (apply to all targets):
|
|
16
19
|
# - user (default): ~/.claude/settings.json + ~/.weave/cc-statusline.sh
|
|
17
20
|
# ~/.codex/config.toml (with --codex)
|
|
18
21
|
# ~/.config/opencode/opencode.json (with --opencode)
|
|
22
|
+
# ~/.pi/agent/{models,settings}.json (with --pi)
|
|
19
23
|
# - project: <repo>/.claude/settings.json + <repo>/.claude/cc-statusline.sh
|
|
20
24
|
# <repo>/.codex/config.toml (with --codex)
|
|
21
25
|
# <repo>/opencode.json (with --opencode)
|
|
26
|
+
# <repo>/.pi/ (run: PI_CODING_AGENT_DIR=<repo>/.pi pi) (with --pi)
|
|
22
27
|
#
|
|
23
28
|
# Or pass --dir to install into any directory:
|
|
24
29
|
# - dir: <dir>/.claude/settings.json + <dir>/.claude/cc-statusline.sh
|
|
25
30
|
# <dir>/.codex/config.toml (with --codex)
|
|
26
31
|
# <dir>/opencode.json (with --opencode)
|
|
32
|
+
# <dir>/.pi/ (run: PI_CODING_AGENT_DIR=<dir>/.pi pi) (with --pi)
|
|
27
33
|
#
|
|
28
34
|
# Usage:
|
|
29
35
|
# npx @workweave/router # interactive picker (Claude Code, Codex, opencode)
|
|
30
36
|
# npx @workweave/router --claude # skip the picker, target Claude Code
|
|
31
37
|
# npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
|
|
32
38
|
# npx @workweave/router --opencode # skip the picker, target opencode
|
|
39
|
+
# npx @workweave/router --pi # skip the picker, target pi
|
|
33
40
|
# npx @workweave/router --scope project # commit-with-team install
|
|
34
41
|
# npx @workweave/router --dir /tmp/my-sandbox # isolated throwaway install
|
|
35
42
|
# npx @workweave/router --local # local router on localhost:8080
|
|
@@ -124,6 +131,7 @@ uninstall_cmd() {
|
|
|
124
131
|
case "$target" in
|
|
125
132
|
codex) cmd="$cmd --codex" ;;
|
|
126
133
|
opencode) cmd="$cmd --opencode" ;;
|
|
134
|
+
pi) cmd="$cmd --pi" ;;
|
|
127
135
|
esac
|
|
128
136
|
[ "$scope" = "project" ] && cmd="$cmd --scope project"
|
|
129
137
|
[ -n "$install_dir" ] && cmd="$cmd --dir $(printf '%q' "$install_dir")"
|
|
@@ -149,6 +157,7 @@ print_banner() {
|
|
|
149
157
|
case "$target" in
|
|
150
158
|
codex) target_label="Codex installer" ;;
|
|
151
159
|
opencode) target_label="opencode installer" ;;
|
|
160
|
+
pi) target_label="pi installer" ;;
|
|
152
161
|
*) target_label="Claude Code installer" ;;
|
|
153
162
|
esac
|
|
154
163
|
printf '\n'
|
|
@@ -567,6 +576,12 @@ write_opencode_config() {
|
|
|
567
576
|
# a startup error before the router ever sees a request. The router itself
|
|
568
577
|
# ignores the value (auth runs off X-Weave-Router-Key); apiKey here is just
|
|
569
578
|
# a placeholder that satisfies the SDK's "is auth configured" check.
|
|
579
|
+
# baseURL KEEPS its /v1 here — do NOT align it with the pi block, which is
|
|
580
|
+
# root. opencode's @ai-sdk/anthropic (Vercel) appends only /messages to
|
|
581
|
+
# baseURL (its default is api.anthropic.com/v1), so /v1 yields the correct
|
|
582
|
+
# /v1/messages. Dropping it would hit /messages and 404. (pi uses the official
|
|
583
|
+
# @anthropic-ai/sdk, which appends /v1/messages to a root baseURL — opposite
|
|
584
|
+
# convention.) Verified by install/pi-router/test/opencode_smoke.sh.
|
|
570
585
|
local block
|
|
571
586
|
block="$(jq -n \
|
|
572
587
|
--arg url "$block_url/v1" \
|
|
@@ -611,6 +626,117 @@ write_opencode_config() {
|
|
|
611
626
|
chmod 600 "$config_file"
|
|
612
627
|
}
|
|
613
628
|
|
|
629
|
+
# write_pi_models_config merges a managed `weave` provider into pi's
|
|
630
|
+
# models.json (anthropic-compatible — the router speaks Anthropic Messages
|
|
631
|
+
# natively). The header set carries identity plus the main-loop routing knobs
|
|
632
|
+
# (quality bias); the @workweave/router extension re-registers the provider
|
|
633
|
+
# per process to flip those knobs for subagents/compaction. apiKey is the
|
|
634
|
+
# router key as well as a header — pi treats apiKey as required to consider auth
|
|
635
|
+
# configured, but the router authenticates off X-Weave-Router-Key
|
|
636
|
+
# (authHeader:false keeps Authorization free for BYOK). Re-running rewrites
|
|
637
|
+
# `.providers.weave` in place; uninstall strips it. chmod 600 — holds the key.
|
|
638
|
+
#
|
|
639
|
+
# Usage: write_pi_models_config <models_file> <base_url> <api_key> [user_email] [user_name]
|
|
640
|
+
write_pi_models_config() {
|
|
641
|
+
local config_file="$1"
|
|
642
|
+
local block_url="$2"
|
|
643
|
+
local block_key="$3"
|
|
644
|
+
local block_email="${4:-}"
|
|
645
|
+
local block_name="${5:-}"
|
|
646
|
+
|
|
647
|
+
# Identity + the main-loop (quality) routing knobs. Built piecewise so an
|
|
648
|
+
# empty email/name vanishes from the JSON entirely.
|
|
649
|
+
local headers_json
|
|
650
|
+
headers_json="$(jq -n \
|
|
651
|
+
--arg key "$block_key" \
|
|
652
|
+
--arg email "$block_email" \
|
|
653
|
+
--arg name "$block_name" '
|
|
654
|
+
{
|
|
655
|
+
"X-Weave-Router-Key": $key,
|
|
656
|
+
"X-App": "pi",
|
|
657
|
+
"x-weave-routing-marker": "off",
|
|
658
|
+
"x-weave-routing-alpha": "0.8",
|
|
659
|
+
"x-weave-routing-speed-weight": "0.05",
|
|
660
|
+
"x-weave-routing-output-cost-ratio": "0.5",
|
|
661
|
+
"x-weave-routing-expected-output-tokens": "3000"
|
|
662
|
+
}
|
|
663
|
+
| (if $email != "" then . + {"X-Weave-User-Email": $email} else . end)
|
|
664
|
+
| (if $name != "" then . + {"X-Weave-User-Name": $name } else . end)
|
|
665
|
+
')"
|
|
666
|
+
|
|
667
|
+
# Headline models surfaced in pi's /model picker. The router re-routes every
|
|
668
|
+
# request regardless, so this list is UX; keep it Anthropic-shaped and in
|
|
669
|
+
# sync with @workweave/router's WEAVE_MODELS constant.
|
|
670
|
+
#
|
|
671
|
+
# baseUrl is the router ROOT (no /v1): pi's anthropic-messages provider uses
|
|
672
|
+
# @anthropic-ai/sdk, which appends /v1/messages itself. Unlike the codex block
|
|
673
|
+
# above (OpenAI-style, base ends in /v1), a /v1 suffix here would produce
|
|
674
|
+
# /v1/v1/messages and 404.
|
|
675
|
+
local block
|
|
676
|
+
block="$(jq -n \
|
|
677
|
+
--arg url "$block_url" \
|
|
678
|
+
--arg key "$block_key" \
|
|
679
|
+
--argjson headers "$headers_json" '
|
|
680
|
+
{
|
|
681
|
+
baseUrl: $url,
|
|
682
|
+
api: "anthropic-messages",
|
|
683
|
+
apiKey: $key,
|
|
684
|
+
authHeader: false,
|
|
685
|
+
headers: $headers,
|
|
686
|
+
models: [
|
|
687
|
+
{ id: "claude-opus-4-8", name: "Claude Opus 4.8 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
|
|
688
|
+
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
|
|
689
|
+
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
|
|
690
|
+
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 }
|
|
691
|
+
]
|
|
692
|
+
}
|
|
693
|
+
')"
|
|
694
|
+
|
|
695
|
+
# Overwrite provider.weave only; leave any other providers/models the user
|
|
696
|
+
# added untouched.
|
|
697
|
+
local merged
|
|
698
|
+
if [ -f "$config_file" ]; then
|
|
699
|
+
merged="$(jq --argjson block "$block" '.providers = ((.providers // {}) | .weave = $block)' "$config_file")"
|
|
700
|
+
else
|
|
701
|
+
merged="$(jq -n --argjson block "$block" '{ providers: { weave: $block } }')"
|
|
702
|
+
fi
|
|
703
|
+
printf '%s\n' "$merged" >"$config_file"
|
|
704
|
+
# 0600: the headers + apiKey hold the router key.
|
|
705
|
+
chmod 600 "$config_file"
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
# write_pi_settings_config makes the `weave` provider pi's default and loads the
|
|
709
|
+
# @workweave/router extension. defaultProvider/defaultModel are set only when
|
|
710
|
+
# unset (don't clobber a user's pick); the npm package source is appended to
|
|
711
|
+
# `packages` idempotently — pi auto-installs missing packages on startup, so the
|
|
712
|
+
# source entry is enough — and the legacy `npm:@workweave/pi-router` id (from
|
|
713
|
+
# before the extension was folded into @workweave/router) is dropped so a config
|
|
714
|
+
# from the old layout can't keep a dangling/duplicate entry. No secret lives
|
|
715
|
+
# here, so no chmod 600.
|
|
716
|
+
#
|
|
717
|
+
# Usage: write_pi_settings_config <settings_file>
|
|
718
|
+
write_pi_settings_config() {
|
|
719
|
+
local settings_file="$1"
|
|
720
|
+
local pkg="npm:@workweave/router"
|
|
721
|
+
local merged
|
|
722
|
+
if [ -f "$settings_file" ]; then
|
|
723
|
+
merged="$(jq --arg pkg "$pkg" '
|
|
724
|
+
(.packages //= [])
|
|
725
|
+
| (.packages -= ["npm:@workweave/pi-router"])
|
|
726
|
+
| (if (.packages | index($pkg)) then . else .packages += [$pkg] end)
|
|
727
|
+
| (if (.defaultProvider // "") == "" then .defaultProvider = "weave" else . end)
|
|
728
|
+
| (if (.defaultModel // "") == "" then .defaultModel = "claude-sonnet-4-6" else . end)
|
|
729
|
+
' "$settings_file")"
|
|
730
|
+
else
|
|
731
|
+
merged="$(jq -n --arg pkg "$pkg" '{
|
|
732
|
+
defaultProvider: "weave",
|
|
733
|
+
defaultModel: "claude-sonnet-4-6",
|
|
734
|
+
packages: [$pkg]
|
|
735
|
+
}')"
|
|
736
|
+
fi
|
|
737
|
+
printf '%s\n' "$merged" >"$settings_file"
|
|
738
|
+
}
|
|
739
|
+
|
|
614
740
|
# resolve_user_name mirrors resolve_user_email but for display name. Priority:
|
|
615
741
|
# WEAVE_USER_NAME env override → git config user.name → empty. We don't
|
|
616
742
|
# prompt for name independently: if email prompting yielded nothing, name
|
|
@@ -716,6 +842,9 @@ while [ $# -gt 0 ]; do
|
|
|
716
842
|
--opencode)
|
|
717
843
|
target="opencode"; target_explicit="true"; shift
|
|
718
844
|
;;
|
|
845
|
+
--pi)
|
|
846
|
+
target="pi"; target_explicit="true"; shift
|
|
847
|
+
;;
|
|
719
848
|
--claude)
|
|
720
849
|
# No-op selector for symmetry with --codex / --opencode. Useful in
|
|
721
850
|
# pipelines that want to skip the interactive picker without depending
|
|
@@ -747,6 +876,14 @@ if [ "$mode" != "install" ]; then
|
|
|
747
876
|
fi
|
|
748
877
|
fi
|
|
749
878
|
|
|
879
|
+
# Toggle verbs (off/on/status) aren't implemented for pi — its config is a
|
|
880
|
+
# structural models.json/settings.json merge, reversed by the uninstaller
|
|
881
|
+
# rather than a single env/key line we can park and restore.
|
|
882
|
+
if [ "$mode" != "install" ] && [ "$target" = "pi" ]; then
|
|
883
|
+
err "toggle verbs (off/on/status) aren't supported for --pi. Use 'npx @workweave/router --uninstall --pi' to remove, or re-run the installer to refresh."
|
|
884
|
+
exit 2
|
|
885
|
+
fi
|
|
886
|
+
|
|
750
887
|
if [ -z "$base_url" ]; then
|
|
751
888
|
base_url="$DEFAULT_BASE_URL"
|
|
752
889
|
fi
|
|
@@ -766,12 +903,14 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
|
|
|
766
903
|
printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
767
904
|
printf " %s2)%s Codex %s— patches ~/.codex/config.toml (or <repo>/.codex/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
768
905
|
printf " %s3)%s opencode %s— patches ~/.config/opencode/opencode.json (or <repo>/opencode.json)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
769
|
-
printf "
|
|
906
|
+
printf " %s4)%s pi %s— patches ~/.pi/agent/models.json + settings.json (or <repo>/.pi/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
907
|
+
printf "Choose %s[1/2/3/4]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
|
|
770
908
|
read -r target_choice </dev/tty || target_choice=""
|
|
771
909
|
case "${target_choice:-1}" in
|
|
772
910
|
1|""|claude|c|C) target="claude" ;;
|
|
773
911
|
2|codex|x|X) target="codex" ;;
|
|
774
912
|
3|opencode|o|O) target="opencode" ;;
|
|
913
|
+
4|pi|p|P) target="pi" ;;
|
|
775
914
|
*) err "invalid choice: $target_choice"; exit 2 ;;
|
|
776
915
|
esac
|
|
777
916
|
fi
|
|
@@ -809,6 +948,11 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
|
|
|
809
948
|
scope_project_path="<repo>/opencode.json"
|
|
810
949
|
scope_cli_label="opencode"
|
|
811
950
|
;;
|
|
951
|
+
pi)
|
|
952
|
+
scope_user_path="~/.pi/agent/"
|
|
953
|
+
scope_project_path="<repo>/.pi/"
|
|
954
|
+
scope_cli_label="pi"
|
|
955
|
+
;;
|
|
812
956
|
*)
|
|
813
957
|
scope_user_path="~/.claude/"
|
|
814
958
|
scope_project_path="<repo>/.claude/"
|
|
@@ -859,7 +1003,7 @@ fi
|
|
|
859
1003
|
# Claude Code's settings.json and opencode's opencode.json patching both use
|
|
860
1004
|
# jq to deep-merge / structurally rewrite JSON. Toggling those clients reads
|
|
861
1005
|
# and rewrites the same JSON, so jq is required there too.
|
|
862
|
-
if [ "$target" = "claude" ] || [ "$target" = "opencode" ]; then
|
|
1006
|
+
if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ]; then
|
|
863
1007
|
require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
|
|
864
1008
|
fi
|
|
865
1009
|
# curl is only used by the install path's health/validate probes; toggles never
|
|
@@ -885,6 +1029,12 @@ case "$target" in
|
|
|
885
1029
|
warn "Continuing — opencode.json will be written and will take effect once opencode is installed."
|
|
886
1030
|
fi
|
|
887
1031
|
;;
|
|
1032
|
+
pi)
|
|
1033
|
+
if ! command -v pi >/dev/null 2>&1; then
|
|
1034
|
+
warn "'pi' not found on PATH. Install with 'npm install -g @mariozechner/pi-coding-agent', then re-run this script."
|
|
1035
|
+
warn "Continuing — models.json/settings.json will be written and take effect once pi is installed."
|
|
1036
|
+
fi
|
|
1037
|
+
;;
|
|
888
1038
|
esac
|
|
889
1039
|
|
|
890
1040
|
script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
@@ -970,6 +1120,31 @@ elif [ "$target" = "codex" ]; then
|
|
|
970
1120
|
fi
|
|
971
1121
|
|
|
972
1122
|
mkdir -p "$codex_dir"
|
|
1123
|
+
elif [ "$target" = "pi" ]; then
|
|
1124
|
+
# pi reads ~/.pi/agent/ by default, so a plain `pi` picks up a user-scope
|
|
1125
|
+
# install with no env var. models.json is global-only in pi (there is no
|
|
1126
|
+
# project-level models file), so for project/--dir scope we point
|
|
1127
|
+
# PI_CODING_AGENT_DIR at a repo-local .pi that holds the whole config
|
|
1128
|
+
# (models.json + settings.json + key) — the same shape as Codex's CODEX_HOME.
|
|
1129
|
+
# The router key is embedded, so .pi goes in .gitignore for project scope.
|
|
1130
|
+
case "$scope" in
|
|
1131
|
+
user) pi_dir="$settings_base/.pi/agent" ;;
|
|
1132
|
+
project) pi_dir="$settings_base/.pi" ;;
|
|
1133
|
+
esac
|
|
1134
|
+
# --dir is a self-contained sandbox: flat .pi, launched via PI_CODING_AGENT_DIR.
|
|
1135
|
+
[ -n "$install_dir" ] && pi_dir="$install_dir/.pi"
|
|
1136
|
+
pi_models_file="$pi_dir/models.json"
|
|
1137
|
+
pi_settings_file="$pi_dir/settings.json"
|
|
1138
|
+
pi_key_file="$pi_dir/.weave_router_key"
|
|
1139
|
+
|
|
1140
|
+
if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
|
|
1141
|
+
refuse_if_symlink "$pi_dir"
|
|
1142
|
+
refuse_if_symlink "$pi_models_file"
|
|
1143
|
+
refuse_if_symlink "$pi_settings_file"
|
|
1144
|
+
refuse_if_symlink "$pi_key_file"
|
|
1145
|
+
fi
|
|
1146
|
+
|
|
1147
|
+
mkdir -p "$pi_dir"
|
|
973
1148
|
else
|
|
974
1149
|
# opencode discovers config in this order: $XDG_CONFIG_HOME/opencode/opencode.json
|
|
975
1150
|
# (or ~/.config/opencode/opencode.json) for user scope, and opencode.json /
|
|
@@ -1035,6 +1210,19 @@ json_get() {
|
|
|
1035
1210
|
jq -r "${2} // empty" "$1" 2>/dev/null || true
|
|
1036
1211
|
}
|
|
1037
1212
|
|
|
1213
|
+
# claude_key_present returns 0 when the given settings file's
|
|
1214
|
+
# env.ANTHROPIC_CUSTOM_HEADERS carries the router key header. "On" is only valid
|
|
1215
|
+
# when this is true: in project scope the committed settings.json holds the
|
|
1216
|
+
# router URL but the key header lives only in the per-teammate settings.local.json
|
|
1217
|
+
# (or the parked sidecar), so a router URL alone doesn't mean requests can
|
|
1218
|
+
# authenticate.
|
|
1219
|
+
claude_key_present() {
|
|
1220
|
+
case "$(json_get "$1" '.env.ANTHROPIC_CUSTOM_HEADERS')" in
|
|
1221
|
+
*X-Weave-Router-Key*) return 0 ;;
|
|
1222
|
+
*) return 1 ;;
|
|
1223
|
+
esac
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1038
1226
|
# gitignore_add appends an entry to the repo .gitignore in project scope so a
|
|
1039
1227
|
# parked sidecar (which may carry the router key header) never gets committed.
|
|
1040
1228
|
# No-op for user scope and --dir, matching how install handles its own ignores.
|
|
@@ -1056,6 +1244,15 @@ toggle_claude() {
|
|
|
1056
1244
|
else
|
|
1057
1245
|
active="$settings_file"
|
|
1058
1246
|
fi
|
|
1247
|
+
# Symlink containment for the parked sidecar: project/--dir paths come from a
|
|
1248
|
+
# possibly-hostile repo, and `off` writes $parked via shell redirection (which
|
|
1249
|
+
# follows symlinks). A repo could pre-place it as a symlink to clobber an
|
|
1250
|
+
# arbitrary file or siphon the router-key-bearing parked data. The config
|
|
1251
|
+
# files themselves are already guarded during path resolution; this covers the
|
|
1252
|
+
# sidecar. User scope ($HOME) is trusted, matching the installer.
|
|
1253
|
+
if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
|
|
1254
|
+
refuse_if_symlink "$parked"
|
|
1255
|
+
fi
|
|
1059
1256
|
local parked_present="false"
|
|
1060
1257
|
if [ -f "$parked" ]; then parked_present="true"; fi
|
|
1061
1258
|
committed_base="$(json_get "$settings_file" '.env.ANTHROPIC_BASE_URL')"
|
|
@@ -1071,12 +1268,23 @@ toggle_claude() {
|
|
|
1071
1268
|
if [ -n "$local_base" ] && ! router_shaped_url "$local_base"; then
|
|
1072
1269
|
ok "Claude Code (project): ${C_BOLD}off${C_RESET} — routing directly to Anthropic. Run '$on_hint' to re-enable."
|
|
1073
1270
|
elif router_shaped_url "$committed_base"; then
|
|
1074
|
-
|
|
1271
|
+
# Router URL is committed, but it only authenticates if this teammate's
|
|
1272
|
+
# settings.local.json carries the key header. A fresh clone (shared
|
|
1273
|
+
# settings.json, no personal local file) has the URL but no key.
|
|
1274
|
+
if claude_key_present "$local_settings_file"; then
|
|
1275
|
+
ok "Claude Code (project): ${C_BOLD}on${C_RESET} — routing through $committed_base."
|
|
1276
|
+
else
|
|
1277
|
+
warn "Claude Code (project): router URL is set but your personal router key is missing (no settings.local.json) — requests won't authenticate. Run the installer to add your key."
|
|
1278
|
+
fi
|
|
1075
1279
|
else
|
|
1076
1280
|
info "Claude Code (project): not configured for the router. Run the installer first."
|
|
1077
1281
|
fi
|
|
1078
1282
|
elif router_shaped_url "$committed_base"; then
|
|
1079
|
-
|
|
1283
|
+
if claude_key_present "$settings_file"; then
|
|
1284
|
+
ok "Claude Code: ${C_BOLD}on${C_RESET} — routing through $committed_base."
|
|
1285
|
+
else
|
|
1286
|
+
warn "Claude Code: router URL is set but the router key header is missing — requests won't authenticate. Run the installer to restore it."
|
|
1287
|
+
fi
|
|
1080
1288
|
else
|
|
1081
1289
|
info "Claude Code: not configured for the router. Run the installer first."
|
|
1082
1290
|
fi
|
|
@@ -1125,22 +1333,53 @@ toggle_claude() {
|
|
|
1125
1333
|
if [ "$proj" = "true" ]; then
|
|
1126
1334
|
local_base="$(json_get "$local_settings_file" '.env.ANTHROPIC_BASE_URL')"
|
|
1127
1335
|
if [ -n "$local_base" ] && ! router_shaped_url "$local_base"; then
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1336
|
+
# We're off, but the parked sidecar is gone. The router key header
|
|
1337
|
+
# lives only in the local file / sidecar in project scope — never in
|
|
1338
|
+
# committed settings.json — so we can only re-enable cleanly if the
|
|
1339
|
+
# header survived in the local file. If it didn't, clearing the
|
|
1340
|
+
# override would point Claude Code at the router with no auth
|
|
1341
|
+
# (401s); leave the working direct setup in place and tell the user
|
|
1342
|
+
# to reinstall instead of faking success.
|
|
1343
|
+
if claude_key_present "$local_settings_file"; then
|
|
1344
|
+
merged="$(jq '(.env // {}) |= del(.ANTHROPIC_BASE_URL)
|
|
1345
|
+
| (if (.env // {} | length) == 0 then del(.env) else . end)' "$local_settings_file")"
|
|
1346
|
+
printf '%s\n' "$merged" >"$local_settings_file"
|
|
1347
|
+
chmod 600 "$local_settings_file"
|
|
1348
|
+
ok "Claude Code is now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
|
|
1349
|
+
else
|
|
1350
|
+
warn "Claude Code is off and the parked router key is missing (its sidecar was deleted). Re-run the installer to restore the router key — leaving the current direct-to-Anthropic setup in place so requests don't fail auth."
|
|
1351
|
+
fi
|
|
1133
1352
|
return 0
|
|
1134
1353
|
fi
|
|
1135
1354
|
fi
|
|
1136
|
-
|
|
1355
|
+
# No direct override (or user scope). "On" requires both the router URL
|
|
1356
|
+
# and the key header — a committed router URL with no local key (e.g. a
|
|
1357
|
+
# fresh clone) can't authenticate, so don't claim it's already on.
|
|
1358
|
+
if ! router_shaped_url "$committed_base"; then
|
|
1359
|
+
warn "No parked router config found. Run the installer to set up Claude Code."
|
|
1360
|
+
elif claude_key_present "$active"; then
|
|
1137
1361
|
ok "Claude Code is already on — nothing to do."
|
|
1138
1362
|
else
|
|
1139
|
-
|
|
1363
|
+
# $active is settings.local.json in project scope, settings.json for
|
|
1364
|
+
# user/--dir — name the right file so the hint isn't misleading.
|
|
1365
|
+
warn "Router URL is set but the router key is missing — run the installer to add your key (written to $(basename "$active"))."
|
|
1140
1366
|
fi
|
|
1141
1367
|
return 0
|
|
1142
1368
|
fi
|
|
1143
|
-
|
|
1369
|
+
# Sidecar present: restore it — but only if the result actually carries
|
|
1370
|
+
# the router key. An off that ran with an empty/absent settings.local.json
|
|
1371
|
+
# parks {"env":{}}; blindly restoring that would drop the direct override
|
|
1372
|
+
# and leave the committed router URL unauthenticated while printing
|
|
1373
|
+
# success. Refuse that and tell the user to reinstall.
|
|
1374
|
+
parked_env="$(jq -c '.env // {}' "$parked")"
|
|
1375
|
+
parked_has_key="false"
|
|
1376
|
+
if printf '%s' "$parked_env" | jq -e '(.ANTHROPIC_CUSTOM_HEADERS // "") | test("X-Weave-Router-Key")' >/dev/null 2>&1; then
|
|
1377
|
+
parked_has_key="true"
|
|
1378
|
+
fi
|
|
1379
|
+
if [ "$parked_has_key" != "true" ] && ! claude_key_present "$active"; then
|
|
1380
|
+
warn "Can't re-enable: the parked config has no router key (it was created without one). Re-run the installer to set up your router key — leaving the current direct-to-Anthropic setup in place."
|
|
1381
|
+
return 0
|
|
1382
|
+
fi
|
|
1144
1383
|
merged="$(jq --argjson p "$parked_env" '.env = (((.env // {}) | del(.ANTHROPIC_BASE_URL)) + $p)' "$active")"
|
|
1145
1384
|
printf '%s\n' "$merged" >"$active"
|
|
1146
1385
|
[ "$proj" = "true" ] && chmod 600 "$active"
|
|
@@ -1206,6 +1445,12 @@ toggle_codex() {
|
|
|
1206
1445
|
toggle_opencode() {
|
|
1207
1446
|
local f="$opencode_config_file" parked="$opencode_dir/.weave-parked.json"
|
|
1208
1447
|
local model="" has_weave="false" parked_present="false" on="false" restore_model merged
|
|
1448
|
+
# Symlink containment for the parked sidecar — `off` writes it via shell
|
|
1449
|
+
# redirection; a hostile project repo could pre-place it as a symlink. The
|
|
1450
|
+
# opencode.json itself is already guarded during path resolution.
|
|
1451
|
+
if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
|
|
1452
|
+
refuse_if_symlink "$parked"
|
|
1453
|
+
fi
|
|
1209
1454
|
if [ -f "$parked" ]; then parked_present="true"; fi
|
|
1210
1455
|
if [ -f "$f" ]; then
|
|
1211
1456
|
model="$(jq -r '.model // empty' "$f" 2>/dev/null || true)"
|
|
@@ -1243,6 +1488,16 @@ toggle_opencode() {
|
|
|
1243
1488
|
restore_model="$(jq -r '.model // "weave/claude-sonnet-4-6"' "$parked")"
|
|
1244
1489
|
elif [ "$has_weave" != "true" ]; then
|
|
1245
1490
|
warn "opencode isn't configured for the router. Run the installer first."; return 0
|
|
1491
|
+
else
|
|
1492
|
+
# No parked model (sidecar deleted by hand). Derive the default from the
|
|
1493
|
+
# installed provider.weave.models block rather than a hardcoded literal
|
|
1494
|
+
# that silently diverges when the installer's default changes — prefer a
|
|
1495
|
+
# sonnet entry, else the first model the installer registered.
|
|
1496
|
+
restore_model="$(jq -r '
|
|
1497
|
+
(.provider.weave.models // {} | keys) as $k
|
|
1498
|
+
| (([$k[] | select(test("sonnet"))] | first) // $k[0] // "claude-sonnet-4-6")
|
|
1499
|
+
| "weave/" + .
|
|
1500
|
+
' "$f" 2>/dev/null || echo "weave/claude-sonnet-4-6")"
|
|
1246
1501
|
fi
|
|
1247
1502
|
merged="$(jq --arg m "$restore_model" '.model = $m' "$f")"
|
|
1248
1503
|
printf '%s\n' "$merged" >"$f"
|
|
@@ -1501,6 +1756,74 @@ if [ "$target" = "opencode" ]; then
|
|
|
1501
1756
|
exit 0
|
|
1502
1757
|
fi
|
|
1503
1758
|
|
|
1759
|
+
# ---------- pi install path (dispatch + exit before the Claude-only writes) ----------
|
|
1760
|
+
|
|
1761
|
+
if [ "$target" = "pi" ]; then
|
|
1762
|
+
write_pi_models_config "$pi_models_file" "$base_url" "$api_key" "$user_email" "$user_name"
|
|
1763
|
+
ok "pi models config written to $pi_models_file"
|
|
1764
|
+
write_pi_settings_config "$pi_settings_file"
|
|
1765
|
+
ok "pi settings written to $pi_settings_file (provider weave + @workweave/router)"
|
|
1766
|
+
|
|
1767
|
+
if [ -n "$api_key" ]; then
|
|
1768
|
+
printf '%s\n' "$api_key" >"$pi_key_file"
|
|
1769
|
+
chmod 600 "$pi_key_file"
|
|
1770
|
+
ok "Router key written to $pi_key_file"
|
|
1771
|
+
fi
|
|
1772
|
+
|
|
1773
|
+
# Project scope: the repo-local .pi carries the router key, so keep it out of
|
|
1774
|
+
# git. Same reasoning as the Codex/opencode paths — base URL is shared, the
|
|
1775
|
+
# key is per-person.
|
|
1776
|
+
if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
|
|
1777
|
+
# Write the .gitignore in the directory that CONTAINS .pi (the chosen project
|
|
1778
|
+
# dir), not the git root: gitignore entries with a slash are anchored to the
|
|
1779
|
+
# .gitignore's own location, so a root-level ".pi/models.json" would NOT match
|
|
1780
|
+
# a nested <subdir>/.pi/ — leaking the router key. dirname "$pi_dir" == the
|
|
1781
|
+
# project dir (== git root when they're the same).
|
|
1782
|
+
gitignore="$(dirname "$pi_dir")/.gitignore"
|
|
1783
|
+
refuse_if_symlink "$gitignore"
|
|
1784
|
+
for entry in \
|
|
1785
|
+
".pi/models.json" \
|
|
1786
|
+
".pi/settings.json" \
|
|
1787
|
+
".pi/.weave_router_key"
|
|
1788
|
+
do
|
|
1789
|
+
if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
|
|
1790
|
+
printf '%s\n' "$entry" >>"$gitignore"
|
|
1791
|
+
fi
|
|
1792
|
+
done
|
|
1793
|
+
ok "Updated $gitignore (ignored repo-local .pi router config)"
|
|
1794
|
+
fi
|
|
1795
|
+
|
|
1796
|
+
# Post-install verification: same probes the Claude/Codex/opencode paths run.
|
|
1797
|
+
if [ "$quiet" != "true" ]; then
|
|
1798
|
+
if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
|
|
1799
|
+
warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
|
|
1800
|
+
fi
|
|
1801
|
+
fi
|
|
1802
|
+
|
|
1803
|
+
if [ -n "$api_key" ]; then
|
|
1804
|
+
validate_pi_key() {
|
|
1805
|
+
printf '%s: %s\n' "$router_key_header" "$api_key" \
|
|
1806
|
+
| curl -fsS --max-time 5 --header @- "$base_url/validate"
|
|
1807
|
+
}
|
|
1808
|
+
if ! spin "Validating API key" validate_pi_key; then
|
|
1809
|
+
warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
|
|
1810
|
+
fi
|
|
1811
|
+
fi
|
|
1812
|
+
|
|
1813
|
+
printf "\n"
|
|
1814
|
+
printf "%s✓%s %s%sWeave Router installed for pi.%s\n" \
|
|
1815
|
+
"$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
|
|
1816
|
+
# Billing note: pi normally draws on a Claude subscription (OAuth); routing
|
|
1817
|
+
# through the router switches to per-token billing on the router deployment
|
|
1818
|
+
# key (or BYOK). Surface it at install so the change isn't a surprise.
|
|
1819
|
+
info "pi now bills per token on the Weave Router key, not your Claude subscription."
|
|
1820
|
+
if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
|
|
1821
|
+
info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
|
|
1822
|
+
fi
|
|
1823
|
+
print_uninstall_hint
|
|
1824
|
+
exit 0
|
|
1825
|
+
fi
|
|
1826
|
+
|
|
1504
1827
|
# ---------- write the statusline script ----------
|
|
1505
1828
|
|
|
1506
1829
|
cat > "$statusline_file" << 'STATUSLINE_EOF'
|
package/package.json
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workweave/router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One-command installer that points Claude Code at the Weave Router.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"weave-router": "bin.js"
|
|
7
7
|
},
|
|
8
|
+
"pi": {
|
|
9
|
+
"extensions": [
|
|
10
|
+
"./pi-router/src/index.ts"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
8
13
|
"files": [
|
|
9
14
|
"bin.js",
|
|
10
15
|
"install.sh",
|
|
11
16
|
"uninstall.sh",
|
|
12
17
|
"cc-statusline.sh",
|
|
13
18
|
"commands/",
|
|
19
|
+
"pi-router/",
|
|
14
20
|
"README.md",
|
|
15
21
|
"LICENSE"
|
|
16
22
|
],
|
|
@@ -20,6 +26,20 @@
|
|
|
20
26
|
"engines": {
|
|
21
27
|
"node": ">=18"
|
|
22
28
|
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@mariozechner/pi-agent-core": "*",
|
|
31
|
+
"@mariozechner/pi-ai": "*",
|
|
32
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
33
|
+
"@mariozechner/pi-tui": "*",
|
|
34
|
+
"typebox": "*"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"@mariozechner/pi-agent-core": { "optional": true },
|
|
38
|
+
"@mariozechner/pi-ai": { "optional": true },
|
|
39
|
+
"@mariozechner/pi-coding-agent": { "optional": true },
|
|
40
|
+
"@mariozechner/pi-tui": { "optional": true },
|
|
41
|
+
"typebox": { "optional": true }
|
|
42
|
+
},
|
|
23
43
|
"repository": {
|
|
24
44
|
"type": "git",
|
|
25
45
|
"url": "git+https://github.com/workweave/router.git",
|
|
@@ -33,6 +53,8 @@
|
|
|
33
53
|
"claude",
|
|
34
54
|
"claude-code",
|
|
35
55
|
"anthropic",
|
|
36
|
-
"llm-router"
|
|
56
|
+
"llm-router",
|
|
57
|
+
"pi",
|
|
58
|
+
"pi-package"
|
|
37
59
|
]
|
|
38
60
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Weave Router pi extension
|
|
2
|
+
|
|
3
|
+
> Bundled inside the [`@workweave/router`](https://www.npmjs.com/package/@workweave/router) package — not published separately. `src/` here is the source of truth; `npm run prepack` copies it into the package.
|
|
4
|
+
|
|
5
|
+
A [pi](https://pi.dev) extension that routes every request through the
|
|
6
|
+
[WorkWeave Router](https://github.com/workweave/router) — a trained, per-request
|
|
7
|
+
LLM proxy that picks the most cost-efficient model that still solves each task.
|
|
8
|
+
|
|
9
|
+
Installed automatically by the Weave Router installer:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
WEAVE_ROUTER_KEY=rk_… npx -y @workweave/router --pi # user scope
|
|
13
|
+
WEAVE_ROUTER_KEY=rk_… npx -y @workweave/router --pi --local # local router (http://localhost:8080)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That writes `~/.pi/agent/models.json` (the `weave` provider), adds
|
|
17
|
+
`npm:@workweave/router` to `~/.pi/agent/settings.json` `packages`, and stores
|
|
18
|
+
the key in `~/.pi/agent/.weave_router_key`. pi auto-installs `@workweave/router`
|
|
19
|
+
from npm on next start and loads this extension via its `pi.extensions` field.
|
|
20
|
+
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
- **Automatic model selection.** All pi traffic flows through the router, which
|
|
24
|
+
selects the model per request. You don't pick a model — the router does.
|
|
25
|
+
- **Per-process routing bias.** Static `x-weave-routing-*` knob headers bias the
|
|
26
|
+
router: quality on the main loop, speed + cheap on subagents, cheapest on
|
|
27
|
+
compaction.
|
|
28
|
+
- **Sticky sessions.** `metadata.user_id = "pi:<sessionId>"` pins the main loop
|
|
29
|
+
to one model for the session; subagents get their own pins.
|
|
30
|
+
- **`dispatch` tool — parallel, context-isolated subagents.** pi has none
|
|
31
|
+
natively. `dispatch` spawns child `pi` processes (read-only by default), runs
|
|
32
|
+
them concurrently, and returns only each subagent's final answer — intermediate
|
|
33
|
+
tool output stays in the child, so the main context stays small.
|
|
34
|
+
- **Routed-model display.** Shows the model the router actually chose
|
|
35
|
+
(`x-router-model`) in the status bar, and opts the request out of the router's
|
|
36
|
+
in-band routing badge (`X-Weave-Routing-Marker: off`) — pi can't render that
|
|
37
|
+
separate marker text block inline, and the status bar already conveys the model.
|
|
38
|
+
- **Safety backstop.** Blocks a few catastrophic shell commands (`rm -rf /`,
|
|
39
|
+
`mkfs`, `dd of=/dev/…`, fork bombs, force-push to main). Disable with
|
|
40
|
+
`WEAVE_NO_SAFETY=1`.
|
|
41
|
+
|
|
42
|
+
## Configuration (environment)
|
|
43
|
+
|
|
44
|
+
| Variable | Default | Purpose |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `WEAVE_ROUTER_URL` | `http://localhost:8080` | Router base URL (children inherit it) |
|
|
47
|
+
| `WEAVE_ROUTER_KEY` | — | Router key (else read from `.weave_router_key`) |
|
|
48
|
+
| `WEAVE_ROUTER_KEY_FILE` | `<agentDir>/.weave_router_key` | Override key file path |
|
|
49
|
+
| `WEAVE_USER_EMAIL` / `WEAVE_USER_NAME` | from `git config` | Identity headers for attribution |
|
|
50
|
+
| `WEAVE_PI_SUBAGENT_MODEL` | `claude-sonnet-4-6` | `weave/<model>` handle children launch with (router re-routes) |
|
|
51
|
+
| `WEAVE_PI_DISPATCH_CONCURRENCY` | `4` | Max concurrent subagents |
|
|
52
|
+
| `WEAVE_PI_SUBAGENT_TIMEOUT_MS` | `600000` | Per-subagent timeout |
|
|
53
|
+
| `WEAVE_PI_ALLOW_SUBAGENT_TOOLS` | unset | `1` lets `dispatch` grant subagents write/exec tools (bash, write, edit); default strips them |
|
|
54
|
+
| `WEAVE_ROUTING_ALPHA` / `…_SPEED_WEIGHT` / `…_OUTPUT_COST_RATIO` / `…_EXPECTED_OUTPUT_TOKENS` | role preset | Override individual routing knobs (main process only — children always use their role preset) |
|
|
55
|
+
| `WEAVE_NO_SAFETY` | unset | `1` disables the catastrophic-bash gate |
|
|
56
|
+
| `WEAVE_CHEAP_COMPACTION` | unset | `1` enables the (experimental) cheap-compaction path |
|
|
57
|
+
|
|
58
|
+
Internal: `WEAVE_PI_SUBAGENT=1` and `WEAVE_PI_SUBAGENT_ID` are set by `dispatch`
|
|
59
|
+
on child processes; don't set them yourself.
|
|
60
|
+
|
|
61
|
+
## Billing
|
|
62
|
+
|
|
63
|
+
Routing through the router switches pi from Claude **subscription OAuth** to
|
|
64
|
+
**per-token** billing on the router deployment's key (or your BYOK key). BYOK
|
|
65
|
+
skips cross-provider failover; deployment-key billing is the default.
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
|
|
69
|
+
- Cheap compaction is currently a reserved flag — the handler defers to pi's
|
|
70
|
+
built-in compaction until the router-routed path is validated end-to-end.
|