@workweave/router 0.2.2 → 0.2.4
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/bin.js +3 -3
- package/cc-statusline.sh +51 -4
- package/commands/fm.md +1 -1
- package/commands/force-model.md +1 -1
- package/commands/rf.md +1 -1
- package/commands/router-feedback.md +1 -1
- package/commands/router-off.md +1 -1
- package/commands/router-on.md +1 -1
- package/commands/router-status.md +2 -2
- package/commands/ufm.md +1 -1
- package/commands/unforce-model.md +1 -1
- package/install.sh +218 -63
- package/opencode-weave/README.md +78 -0
- package/opencode-weave/package.json +9 -0
- package/opencode-weave/src/index.ts +655 -0
- package/package.json +2 -1
- package/pi-router/src/dispatch.ts +2 -2
- package/pi-router/src/index.ts +1 -1
- package/pi-router/src/provider.ts +1 -1
- package/pi-router/src/routed-model.ts +1 -1
- package/uninstall.sh +36 -10
package/install.sh
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
# Usage:
|
|
35
35
|
# npx @workweave/router # interactive picker (Claude Code, Codex, opencode)
|
|
36
36
|
# npx @workweave/router --claude # skip the picker, target Claude Code
|
|
37
|
-
# npx @workweave/router --codex # skip the picker, target
|
|
37
|
+
# npx @workweave/router --codex # skip the picker, target Codex
|
|
38
38
|
# npx @workweave/router --opencode # skip the picker, target opencode
|
|
39
39
|
# npx @workweave/router --pi # skip the picker, target pi
|
|
40
40
|
# npx @workweave/router --scope project # commit-with-team install
|
|
@@ -533,13 +533,26 @@ TOML
|
|
|
533
533
|
chmod 600 "$config_file"
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
-
# write_opencode_config merges
|
|
537
|
-
# opencode.json
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
#
|
|
536
|
+
# write_opencode_config merges the managed Weave provider(s) into opencode's
|
|
537
|
+
# opencode.json plus the bundled subscription plugin:
|
|
538
|
+
# - provider.weave : OpenAI/Responses-shaped (@ai-sdk/openai → /v1/responses).
|
|
539
|
+
# The single request provider. The router routes every
|
|
540
|
+
# turn across all models the caller's subscriptions +
|
|
541
|
+
# Weave key can pay for, and bills the plan matching the
|
|
542
|
+
# model it served. The default.
|
|
543
|
+
# - provider.weave-claude : login-only storage for a Claude (Pro/Max) subscription
|
|
544
|
+
# (no models, never serves requests). Written only when
|
|
545
|
+
# the bundled plugin is present, since the Claude login
|
|
546
|
+
# method lives in the plugin. The `weave` loader reads
|
|
547
|
+
# this slot and attaches the Claude sub.
|
|
548
|
+
# The plugin (src bundled at $script_dir/opencode-weave/src/index.ts) is dropped
|
|
549
|
+
# into $opencode_dir/.weave/ and registered via opencode.json's `plugin` array
|
|
550
|
+
# by absolute path — scope-independent (no reliance on an auto-load dir). It owns
|
|
551
|
+
# both the ChatGPT (on `weave`) and Claude (on `weave-claude`) logins and attaches
|
|
552
|
+
# whichever subscriptions are connected to every request via the router's
|
|
553
|
+
# dedicated X-Weave-*-Subscription headers. Re-running rewrites the blocks
|
|
554
|
+
# in-place via jq (and strips the legacy `weave-codex` provider); uninstall
|
|
555
|
+
# strips them.
|
|
543
556
|
#
|
|
544
557
|
# Usage: write_opencode_config <config_file_path> <base_url> <api_key> [user_email] [user_name]
|
|
545
558
|
write_opencode_config() {
|
|
@@ -566,59 +579,130 @@ write_opencode_config() {
|
|
|
566
579
|
|
|
567
580
|
# Headline models we surface in opencode's picker. The router re-routes
|
|
568
581
|
# each request anyway, so this list is mostly UX — what shows up when the
|
|
569
|
-
# user runs /models inside opencode.
|
|
570
|
-
#
|
|
582
|
+
# user runs /models inside opencode. We list a mix of GPT + Claude so the
|
|
583
|
+
# picker reflects that the router spans families; whichever model serves the
|
|
584
|
+
# turn, its matching subscription (if connected) pays.
|
|
571
585
|
#
|
|
572
|
-
#
|
|
573
|
-
# @ai-sdk/
|
|
574
|
-
#
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
#
|
|
578
|
-
#
|
|
579
|
-
#
|
|
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.
|
|
586
|
+
# npm is @ai-sdk/openai and baseURL KEEPS its /v1 here: opencode's
|
|
587
|
+
# @ai-sdk/openai provider appends /responses, yielding the router's
|
|
588
|
+
# /v1/responses surface (the canonical inbound — the router translates to
|
|
589
|
+
# Anthropic/OSS as it routes, and ships verbatim to the Codex backend for GPT
|
|
590
|
+
# turns). apiKey is the router key as a parse-time placeholder; the router
|
|
591
|
+
# authenticates off X-Weave-Router-Key (planted in headers) and the plugin's
|
|
592
|
+
# loader attaches subscriptions via the dedicated X-Weave-*-Subscription
|
|
593
|
+
# headers, so the apiKey value is never used upstream.
|
|
585
594
|
local block
|
|
586
595
|
block="$(jq -n \
|
|
587
596
|
--arg url "$block_url/v1" \
|
|
588
597
|
--arg key "$block_key" \
|
|
589
598
|
--argjson headers "$headers_json" '
|
|
590
599
|
{
|
|
591
|
-
npm: "@ai-sdk/
|
|
600
|
+
npm: "@ai-sdk/openai",
|
|
592
601
|
name: "Weave Router",
|
|
593
602
|
options: { apiKey: $key, baseURL: $url, headers: $headers },
|
|
594
603
|
models: {
|
|
595
|
-
"claude-fable-5": { name: "Claude Fable 5 (via Weave Router)" },
|
|
596
|
-
"claude-opus-4-8": { name: "Claude Opus 4.8 (via Weave Router)" },
|
|
597
|
-
"claude-opus-4-7": { name: "Claude Opus 4.7 (via Weave Router)" },
|
|
598
604
|
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (via Weave Router)" },
|
|
599
|
-
"claude-
|
|
605
|
+
"claude-opus-4-8": { name: "Claude Opus 4.8 (via Weave Router)" },
|
|
606
|
+
"claude-haiku-4-5": { name: "Claude Haiku 4.5 (via Weave Router)" },
|
|
607
|
+
"gpt-5.5": { name: "GPT-5.5 (via Weave Router)" },
|
|
608
|
+
"gpt-5.4": { name: "GPT-5.4 (via Weave Router)" }
|
|
600
609
|
}
|
|
601
610
|
}
|
|
602
611
|
')"
|
|
603
612
|
|
|
613
|
+
# Login-only storage provider for a Claude (Pro/Max) subscription. It serves
|
|
614
|
+
# no requests (no models), so its npm/baseURL are inert — opencode just needs
|
|
615
|
+
# it registered so the plugin's Claude login method has a home and `opencode
|
|
616
|
+
# auth login` lists it. The `weave` loader reads this slot off disk and
|
|
617
|
+
# attaches the Claude sub. Reuses the same router key + identity headers.
|
|
618
|
+
local claude_block
|
|
619
|
+
claude_block="$(jq -n \
|
|
620
|
+
--arg url "$block_url/v1" \
|
|
621
|
+
--arg key "$block_key" \
|
|
622
|
+
--argjson headers "$headers_json" '
|
|
623
|
+
{
|
|
624
|
+
npm: "@ai-sdk/openai",
|
|
625
|
+
name: "Weave Router — Claude plan",
|
|
626
|
+
options: { apiKey: $key, baseURL: $url, headers: $headers },
|
|
627
|
+
models: {}
|
|
628
|
+
}
|
|
629
|
+
')"
|
|
630
|
+
|
|
631
|
+
# Drop the Codex-subscription plugin next to the config and capture the
|
|
632
|
+
# absolute path we'll register in opencode.json's `plugin` array (so it loads
|
|
633
|
+
# regardless of scope). $config_file's dir is the already-created
|
|
634
|
+
# opencode_dir, so the `cd … && pwd` canonicalization is safe — uninstall.sh
|
|
635
|
+
# must canonicalize identically so the array entry matches on removal.
|
|
636
|
+
#
|
|
637
|
+
# Only register the path when the bundled source is actually present and
|
|
638
|
+
# copied: registering a path with no file on disk makes opencode fail to load
|
|
639
|
+
# a missing plugin. The plugin holds no secrets (router key lives in the
|
|
640
|
+
# config; ChatGPT tokens live in opencode's own auth store), so 644 is fine.
|
|
641
|
+
# Source is bundled alongside install.sh by the npm prepack
|
|
642
|
+
# (scripts/copy-installer.js), same as commands/ + pi-router/.
|
|
643
|
+
local plugin_dir plugin_spec plugin_src plugin_arg=""
|
|
644
|
+
plugin_dir="$(cd "$(dirname "$config_file")" && pwd)/.weave"
|
|
645
|
+
plugin_spec="$plugin_dir/opencode-weave.ts"
|
|
646
|
+
plugin_src="$script_dir/opencode-weave/src/index.ts"
|
|
647
|
+
if [ -f "$plugin_src" ]; then
|
|
648
|
+
mkdir -p "$plugin_dir"
|
|
649
|
+
cp "$plugin_src" "$plugin_spec"
|
|
650
|
+
chmod 644 "$plugin_spec"
|
|
651
|
+
plugin_arg="$plugin_spec"
|
|
652
|
+
else
|
|
653
|
+
warn "opencode subscription plugin source not found at $plugin_src — skipping the Claude login + subscription routing. (Use a packaged 'npx @workweave/router' install.)"
|
|
654
|
+
fi
|
|
655
|
+
|
|
604
656
|
# Merge into any existing opencode.json. We always overwrite provider.weave
|
|
605
|
-
# so re-install reflects the latest key/identity, but we leave the rest of
|
|
606
|
-
#
|
|
607
|
-
#
|
|
657
|
+
# so re-install reflects the latest key/identity, but we leave the rest of the
|
|
658
|
+
# file (other providers, mcp, agent settings) untouched. Top-level `model` is
|
|
659
|
+
# only set when the user hasn't already picked one.
|
|
660
|
+
#
|
|
661
|
+
# The weave-claude login provider AND the plugin entry are written together
|
|
662
|
+
# only when the bundled plugin was present and copied ($plugin non-empty): the
|
|
663
|
+
# Claude login method lives in the plugin, so registering the provider without
|
|
664
|
+
# it (e.g. the `curl | sh` path, which carries no plugin source) would leave a
|
|
665
|
+
# non-working login — instead we omit it (and strip any stale one). The legacy
|
|
666
|
+
# `weave-codex` provider is always stripped: the single Responses `weave`
|
|
667
|
+
# provider supersedes it.
|
|
608
668
|
local merged
|
|
609
669
|
if [ -f "$config_file" ]; then
|
|
610
|
-
merged="$(jq
|
|
670
|
+
merged="$(jq \
|
|
671
|
+
--argjson block "$block" \
|
|
672
|
+
--argjson claude "$claude_block" \
|
|
673
|
+
--arg plugin "$plugin_arg" \
|
|
674
|
+
--arg pluginspec "$plugin_spec" '
|
|
611
675
|
.provider = ((.provider // {}) | .weave = $block)
|
|
676
|
+
| (.provider |= del(."weave-codex"))
|
|
677
|
+
| (if $plugin != "" then .provider["weave-claude"] = $claude else .provider |= del(."weave-claude") end)
|
|
678
|
+
# Register the managed plugin path when we installed it; otherwise strip a
|
|
679
|
+
# stale entry left by a prior install (the provider was just removed, so a
|
|
680
|
+
# lingering plugin reference would be dead weight).
|
|
681
|
+
| (if $plugin != ""
|
|
682
|
+
then .plugin = ((.plugin // []) | if index($plugin) then . else . + [$plugin] end)
|
|
683
|
+
else (if (.plugin | type) == "array"
|
|
684
|
+
then (.plugin -= [$pluginspec]) | (if (.plugin | length) == 0 then del(.plugin) else . end)
|
|
685
|
+
else . end)
|
|
686
|
+
end)
|
|
612
687
|
| (if (.model // "") == "" then .model = "weave/claude-sonnet-4-6" else . end)
|
|
688
|
+
# Reset the default model if it points at a provider we just removed: the
|
|
689
|
+
# retired weave-codex, or weave-claude on a plugin-less re-install (it has
|
|
690
|
+
# no models anyway). Otherwise opencode boots with a dangling model.
|
|
691
|
+
| (if (.model // "" | tostring | startswith("weave-codex/")) then .model = "weave/claude-sonnet-4-6" else . end)
|
|
692
|
+
| (if $plugin == "" and (.model // "" | tostring | startswith("weave-claude/")) then .model = "weave/claude-sonnet-4-6" else . end)
|
|
613
693
|
| (.["$schema"] //= "https://opencode.ai/config.json")
|
|
614
694
|
' "$config_file")"
|
|
615
695
|
else
|
|
616
|
-
merged="$(jq -n
|
|
696
|
+
merged="$(jq -n \
|
|
697
|
+
--argjson block "$block" \
|
|
698
|
+
--argjson claude "$claude_block" \
|
|
699
|
+
--arg plugin "$plugin_arg" '
|
|
617
700
|
{
|
|
618
701
|
"$schema": "https://opencode.ai/config.json",
|
|
619
702
|
model: "weave/claude-sonnet-4-6",
|
|
620
703
|
provider: { weave: $block }
|
|
621
704
|
}
|
|
705
|
+
| (if $plugin != "" then .provider["weave-claude"] = $claude | .plugin = [$plugin] else . end)
|
|
622
706
|
')"
|
|
623
707
|
fi
|
|
624
708
|
printf '%s\n' "$merged" >"$config_file"
|
|
@@ -800,11 +884,11 @@ for arg in "$@"; do
|
|
|
800
884
|
# also gives us a chance to fail closed on 404 HTML pages before
|
|
801
885
|
# handing the content to bash.
|
|
802
886
|
if ! uninstall_body="$(curl -fsSL --max-time 30 "$url" 2>/dev/null)"; then
|
|
803
|
-
err "
|
|
887
|
+
err "Failed to fetch uninstall.sh from $url."
|
|
804
888
|
exit 1
|
|
805
889
|
fi
|
|
806
890
|
if [ -z "$uninstall_body" ] || [ "${uninstall_body:0:2}" != "#!" ]; then
|
|
807
|
-
err "
|
|
891
|
+
err "Fetched content from $url doesn't look like a bash script."
|
|
808
892
|
exit 1
|
|
809
893
|
fi
|
|
810
894
|
exec bash -c "$uninstall_body" weave-uninstall "${cleaned_args[@]+"${cleaned_args[@]}"}"
|
|
@@ -817,12 +901,12 @@ while [ $# -gt 0 ]; do
|
|
|
817
901
|
case "$1" in
|
|
818
902
|
--scope)
|
|
819
903
|
scope="${2:-}"; shift 2
|
|
820
|
-
[ "$scope" = "user" ] || [ "$scope" = "project" ] || { err "--scope must be 'user' or 'project'"; exit 2; }
|
|
904
|
+
[ "$scope" = "user" ] || [ "$scope" = "project" ] || { err "--scope must be 'user' or 'project'."; exit 2; }
|
|
821
905
|
scope_explicit="true"
|
|
822
906
|
;;
|
|
823
907
|
--base-url)
|
|
824
908
|
base_url="${2:-}"; shift 2
|
|
825
|
-
[ -n "$base_url" ] || { err "--base-url requires a value"; exit 2; }
|
|
909
|
+
[ -n "$base_url" ] || { err "--base-url requires a value."; exit 2; }
|
|
826
910
|
;;
|
|
827
911
|
--local)
|
|
828
912
|
# Shorthand for local dev: localhost:8080 (matches `wv mr` / `make dev` default PORT).
|
|
@@ -837,7 +921,7 @@ while [ $# -gt 0 ]; do
|
|
|
837
921
|
;;
|
|
838
922
|
--dir)
|
|
839
923
|
install_dir="${2:-}"; shift 2
|
|
840
|
-
[ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
|
|
924
|
+
[ -n "$install_dir" ] || { err "--dir requires a path."; exit 2; }
|
|
841
925
|
;;
|
|
842
926
|
--codex)
|
|
843
927
|
target="codex"; target_explicit="true"; shift
|
|
@@ -863,7 +947,7 @@ while [ $# -gt 0 ]; do
|
|
|
863
947
|
usage 0
|
|
864
948
|
;;
|
|
865
949
|
*)
|
|
866
|
-
err "
|
|
950
|
+
err "Unknown flag: $1."; usage 2
|
|
867
951
|
;;
|
|
868
952
|
esac
|
|
869
953
|
done
|
|
@@ -874,7 +958,7 @@ done
|
|
|
874
958
|
if [ "$mode" != "install" ]; then
|
|
875
959
|
non_interactive="true"
|
|
876
960
|
if [ "$target_explicit" != "true" ]; then
|
|
877
|
-
err "'$mode' requires an explicit client: --claude, --codex, or --opencode"
|
|
961
|
+
err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
|
|
878
962
|
exit 2
|
|
879
963
|
fi
|
|
880
964
|
fi
|
|
@@ -883,7 +967,7 @@ fi
|
|
|
883
967
|
# structural models.json/settings.json merge, reversed by the uninstaller
|
|
884
968
|
# rather than a single env/key line we can park and restore.
|
|
885
969
|
if [ "$mode" != "install" ] && [ "$target" = "pi" ]; then
|
|
886
|
-
err "
|
|
970
|
+
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."
|
|
887
971
|
exit 2
|
|
888
972
|
fi
|
|
889
973
|
|
|
@@ -914,7 +998,7 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
|
|
|
914
998
|
2|codex|x|X) target="codex" ;;
|
|
915
999
|
3|opencode|o|O) target="opencode" ;;
|
|
916
1000
|
4|pi|p|P) target="pi" ;;
|
|
917
|
-
*) err "
|
|
1001
|
+
*) err "Invalid choice: $target_choice."; exit 2 ;;
|
|
918
1002
|
esac
|
|
919
1003
|
fi
|
|
920
1004
|
|
|
@@ -970,7 +1054,7 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
|
|
|
970
1054
|
case "${scope_choice:-1}" in
|
|
971
1055
|
1|""|user|u|U) scope="user" ;;
|
|
972
1056
|
2|project|p|P) scope="project" ;;
|
|
973
|
-
*) err "
|
|
1057
|
+
*) err "Invalid choice: $scope_choice."; exit 2 ;;
|
|
974
1058
|
esac
|
|
975
1059
|
|
|
976
1060
|
# For project scope, ask which directory rather than silently assuming CWD.
|
|
@@ -987,7 +1071,7 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
|
|
|
987
1071
|
"~/"*) project_dir="$HOME/${project_dir#~/}" ;;
|
|
988
1072
|
esac
|
|
989
1073
|
if [ ! -d "$project_dir" ]; then
|
|
990
|
-
err "
|
|
1074
|
+
err "Directory does not exist: $project_dir."
|
|
991
1075
|
exit 1
|
|
992
1076
|
fi
|
|
993
1077
|
project_dir="$(cd "$project_dir" && pwd)"
|
|
@@ -1324,7 +1408,7 @@ toggle_claude() {
|
|
|
1324
1408
|
| (if (.env // {} | length) == 0 then del(.env) else . end)' "$settings_file")"
|
|
1325
1409
|
printf '%s\n' "$merged" >"$settings_file"
|
|
1326
1410
|
fi
|
|
1327
|
-
ok "Claude Code is
|
|
1411
|
+
ok "Claude Code is ${C_BOLD}off${C_RESET} (direct to Anthropic). Restart Claude Code for it to take effect."
|
|
1328
1412
|
;;
|
|
1329
1413
|
on)
|
|
1330
1414
|
if [ "$parked_present" != "true" ]; then
|
|
@@ -1348,7 +1432,7 @@ toggle_claude() {
|
|
|
1348
1432
|
| (if (.env // {} | length) == 0 then del(.env) else . end)' "$local_settings_file")"
|
|
1349
1433
|
printf '%s\n' "$merged" >"$local_settings_file"
|
|
1350
1434
|
chmod 600 "$local_settings_file"
|
|
1351
|
-
ok "Claude Code is
|
|
1435
|
+
ok "Claude Code is ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
|
|
1352
1436
|
else
|
|
1353
1437
|
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."
|
|
1354
1438
|
fi
|
|
@@ -1387,7 +1471,7 @@ toggle_claude() {
|
|
|
1387
1471
|
printf '%s\n' "$merged" >"$active"
|
|
1388
1472
|
[ "$proj" = "true" ] && chmod 600 "$active"
|
|
1389
1473
|
rm -f "$parked"
|
|
1390
|
-
ok "Claude Code is
|
|
1474
|
+
ok "Claude Code is ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
|
|
1391
1475
|
;;
|
|
1392
1476
|
esac
|
|
1393
1477
|
}
|
|
@@ -1425,7 +1509,7 @@ toggle_codex() {
|
|
|
1425
1509
|
{print}
|
|
1426
1510
|
' "$f" >"$tmp" && mv "$tmp" "$f"
|
|
1427
1511
|
chmod 600 "$f"
|
|
1428
|
-
ok "Codex is
|
|
1512
|
+
ok "Codex is ${C_BOLD}off${C_RESET} (default provider). Takes effect on your next 'codex' run."
|
|
1429
1513
|
;;
|
|
1430
1514
|
on)
|
|
1431
1515
|
if [ "$state" = "absent" ]; then warn "No managed Weave block in $f. Run the installer to set up Codex."; return 0; fi
|
|
@@ -1440,7 +1524,7 @@ toggle_codex() {
|
|
|
1440
1524
|
{print}
|
|
1441
1525
|
' "$f" >"$tmp" && mv "$tmp" "$f"
|
|
1442
1526
|
chmod 600 "$f"
|
|
1443
|
-
ok "Codex is
|
|
1527
|
+
ok "Codex is ${C_BOLD}on${C_RESET} (routing through the Weave Router). Takes effect on your next 'codex' run."
|
|
1444
1528
|
;;
|
|
1445
1529
|
esac
|
|
1446
1530
|
}
|
|
@@ -1459,7 +1543,9 @@ toggle_opencode() {
|
|
|
1459
1543
|
model="$(jq -r '.model // empty' "$f" 2>/dev/null || true)"
|
|
1460
1544
|
if [ "$(jq -r '((.provider // {}) | has("weave"))' "$f" 2>/dev/null || true)" = "true" ]; then has_weave="true"; fi
|
|
1461
1545
|
fi
|
|
1462
|
-
|
|
1546
|
+
# A managed model counts as router-on: the Responses-shaped `weave/…` default,
|
|
1547
|
+
# or a legacy `weave-codex/…` model parked by a pre-upgrade install.
|
|
1548
|
+
case "$model" in weave/* | weave-codex/*) on="true" ;; esac
|
|
1463
1549
|
|
|
1464
1550
|
case "$mode" in
|
|
1465
1551
|
status)
|
|
@@ -1482,7 +1568,7 @@ toggle_opencode() {
|
|
|
1482
1568
|
printf '%s\n' "$merged" >"$f"
|
|
1483
1569
|
chmod 600 "$f"
|
|
1484
1570
|
gitignore_add ".weave-parked.json"
|
|
1485
|
-
ok "opencode is
|
|
1571
|
+
ok "opencode is ${C_BOLD}off${C_RESET} — pick a non-Weave model with /models. Takes effect on your next opencode run."
|
|
1486
1572
|
;;
|
|
1487
1573
|
on)
|
|
1488
1574
|
if [ "$on" = "true" ]; then ok "opencode is already on — nothing to do."; return 0; fi
|
|
@@ -1502,11 +1588,22 @@ toggle_opencode() {
|
|
|
1502
1588
|
| "weave/" + .
|
|
1503
1589
|
' "$f" 2>/dev/null || echo "weave/claude-sonnet-4-6")"
|
|
1504
1590
|
fi
|
|
1591
|
+
# Never restore a legacy weave-codex model whose provider is no longer
|
|
1592
|
+
# registered (every current install strips weave-codex). Fall back to the
|
|
1593
|
+
# `weave` default so `on` can't leave opencode pointing at a missing
|
|
1594
|
+
# provider.
|
|
1595
|
+
case "$restore_model" in
|
|
1596
|
+
weave-codex/*)
|
|
1597
|
+
if [ "$(jq -r '((.provider // {}) | has("weave-codex"))' "$f" 2>/dev/null || true)" != "true" ]; then
|
|
1598
|
+
restore_model="weave/claude-sonnet-4-6"
|
|
1599
|
+
fi
|
|
1600
|
+
;;
|
|
1601
|
+
esac
|
|
1505
1602
|
merged="$(jq --arg m "$restore_model" '.model = $m' "$f")"
|
|
1506
1603
|
printf '%s\n' "$merged" >"$f"
|
|
1507
1604
|
chmod 600 "$f"
|
|
1508
1605
|
rm -f "$parked"
|
|
1509
|
-
ok "opencode is
|
|
1606
|
+
ok "opencode is ${C_BOLD}on${C_RESET} (default model $restore_model via the Weave Router). Takes effect on your next opencode run."
|
|
1510
1607
|
;;
|
|
1511
1608
|
esac
|
|
1512
1609
|
}
|
|
@@ -1535,19 +1632,19 @@ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
|
|
|
1535
1632
|
# `set -e` would abort on read returning 1). If /dev/tty isn't available
|
|
1536
1633
|
# (e.g. CI without a controlling terminal) the user must use --non-interactive.
|
|
1537
1634
|
if [ ! -r /dev/tty ]; then
|
|
1538
|
-
err "
|
|
1635
|
+
err "No controlling terminal — set WEAVE_ROUTER_KEY and re-run with --non-interactive."
|
|
1539
1636
|
exit 1
|
|
1540
1637
|
fi
|
|
1541
1638
|
# _spin_cleanup (installed globally above) already restores stty echo on
|
|
1542
1639
|
# any exit path, so we don't need a separate trap here — that would
|
|
1543
1640
|
# overwrite the spinner cleanup and leak the cursor / child PID on Ctrl-C.
|
|
1544
1641
|
printf "%sGet your Weave Router API key at %s%s\n" "$C_BRAND" "$base_url" "$C_RESET"
|
|
1545
|
-
printf "%sPaste your key here (rk_
|
|
1642
|
+
printf "%sPaste your key here (rk_…):%s " "$C_DIM" "$C_RESET"
|
|
1546
1643
|
stty -echo </dev/tty 2>/dev/null || true
|
|
1547
1644
|
read -r api_key </dev/tty
|
|
1548
1645
|
stty echo </dev/tty 2>/dev/null || true
|
|
1549
1646
|
printf "\n"
|
|
1550
|
-
[ -n "$api_key" ] || { err "
|
|
1647
|
+
[ -n "$api_key" ] || { err "No key provided."; exit 1; }
|
|
1551
1648
|
fi
|
|
1552
1649
|
|
|
1553
1650
|
# ---------- identity (user email + name) ----------
|
|
@@ -1716,18 +1813,20 @@ if [ "$target" = "opencode" ]; then
|
|
|
1716
1813
|
|
|
1717
1814
|
# Project scope: the per-teammate config carries the router key, so it
|
|
1718
1815
|
# stays out of git. Same reasoning as the Codex path — base URL is shared,
|
|
1719
|
-
# but the key is per-person.
|
|
1816
|
+
# but the key is per-person. The .weave/ plugin dir is per-person too (the
|
|
1817
|
+
# config references it by absolute path), so ignore it alongside.
|
|
1720
1818
|
if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
|
|
1721
1819
|
gitignore="$git_root/.gitignore"
|
|
1722
1820
|
refuse_if_symlink "$gitignore"
|
|
1723
1821
|
for entry in \
|
|
1724
|
-
"opencode.json"
|
|
1822
|
+
"opencode.json" \
|
|
1823
|
+
".weave/"
|
|
1725
1824
|
do
|
|
1726
1825
|
if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
|
|
1727
1826
|
printf '%s\n' "$entry" >>"$gitignore"
|
|
1728
1827
|
fi
|
|
1729
1828
|
done
|
|
1730
|
-
ok "Updated $gitignore (ignored opencode.json)"
|
|
1829
|
+
ok "Updated $gitignore (ignored opencode.json, .weave/)"
|
|
1731
1830
|
fi
|
|
1732
1831
|
|
|
1733
1832
|
# Post-install verification: same probes the Claude/Codex paths run.
|
|
@@ -1750,6 +1849,15 @@ if [ "$target" = "opencode" ]; then
|
|
|
1750
1849
|
printf "\n"
|
|
1751
1850
|
printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
|
|
1752
1851
|
"$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
|
|
1852
|
+
# Surface the optional subscription-routing path only when this run actually
|
|
1853
|
+
# registered the weave-claude login provider (which is written together with
|
|
1854
|
+
# the plugin). Gate on its presence in the written config (authoritative)
|
|
1855
|
+
# rather than a plugin file on disk — a leftover plugin from a prior install
|
|
1856
|
+
# can outlive a plugin-less re-install that stripped the provider, which would
|
|
1857
|
+
# make these instructions misleading.
|
|
1858
|
+
if jq -e '(.provider // {}) | has("weave-claude")' "$opencode_config_file" >/dev/null 2>&1; then
|
|
1859
|
+
info "Optional: connect your AI plans so they pay for the matching turns. Run ${C_BOLD}opencode auth login${C_RESET} → ${C_BOLD}Weave Router${C_RESET} for ${C_BOLD}ChatGPT Pro/Plus${C_RESET} (GPT/Codex turns) and/or ${C_BOLD}Weave Router — Claude plan${C_RESET} for ${C_BOLD}Claude Pro/Max${C_RESET} (Claude turns). The router still routes every turn; your Weave key pays for the rest."
|
|
1860
|
+
fi
|
|
1753
1861
|
if [ -n "$install_dir" ]; then
|
|
1754
1862
|
# --dir installs land outside opencode's discovery roots, so the caller
|
|
1755
1863
|
# has to point opencode at the file explicitly.
|
|
@@ -1819,7 +1927,7 @@ if [ "$target" = "pi" ]; then
|
|
|
1819
1927
|
# Billing note: pi normally draws on a Claude subscription (OAuth); routing
|
|
1820
1928
|
# through the router switches to per-token billing on the router deployment
|
|
1821
1929
|
# key (or BYOK). Surface it at install so the change isn't a surprise.
|
|
1822
|
-
info "pi
|
|
1930
|
+
info "pi bills per token on the Weave Router key, not your Claude subscription."
|
|
1823
1931
|
if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
|
|
1824
1932
|
info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
|
|
1825
1933
|
fi
|
|
@@ -1832,7 +1940,7 @@ fi
|
|
|
1832
1940
|
cat > "$statusline_file" << 'STATUSLINE_EOF'
|
|
1833
1941
|
#!/usr/bin/env bash
|
|
1834
1942
|
#
|
|
1835
|
-
# Claude Code statusline for the Weave
|
|
1943
|
+
# Claude Code statusline for the Weave Router. CC pipes a JSON blob on stdin
|
|
1836
1944
|
# whose `transcript_path` points at the JSONL log of the current session and
|
|
1837
1945
|
# whose `model.display_name` is the user's CC-side model selection. The
|
|
1838
1946
|
# router rewrites each request's `model` field before forwarding, so
|
|
@@ -1998,9 +2106,11 @@ prices='{
|
|
|
1998
2106
|
"gpt-5.5-nano": 0.00015,
|
|
1999
2107
|
"gpt-5.5-pro": 0.03,
|
|
2000
2108
|
"minimax/minimax-m2.7": 0.0003,
|
|
2109
|
+
"minimax/minimax-m3": 0.0003,
|
|
2001
2110
|
"mistralai/mistral-small-2603": 0.0002,
|
|
2002
2111
|
"moonshotai/kimi-k2.5": 0.0006,
|
|
2003
2112
|
"moonshotai/kimi-k2.6": 0.00095,
|
|
2113
|
+
"moonshotai/kimi-k2.7": 0.00095,
|
|
2004
2114
|
"qwen/qwen3-235b-a22b-2507": 0.0002266,
|
|
2005
2115
|
"qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
|
|
2006
2116
|
"qwen/qwen3-coder": 0.0009,
|
|
@@ -2008,9 +2118,11 @@ prices='{
|
|
|
2008
2118
|
"qwen/qwen3-next-80b-a3b-instruct": 0.00015,
|
|
2009
2119
|
"qwen/qwen3.5-flash-02-23": 0.00005,
|
|
2010
2120
|
"qwen/qwen3.6-35b-a3b": 0.00015,
|
|
2121
|
+
"qwen/qwen3.7-plus": 0.0004,
|
|
2011
2122
|
"xiaomi/mimo-v2.5-pro": 0.001,
|
|
2012
2123
|
"z-ai/glm-5": 0.0006,
|
|
2013
|
-
"z-ai/glm-5.1": 0.00105
|
|
2124
|
+
"z-ai/glm-5.1": 0.00105,
|
|
2125
|
+
"z-ai/glm-5.2": 0.0014
|
|
2014
2126
|
},
|
|
2015
2127
|
"output": {
|
|
2016
2128
|
"claude-fable-5": 0.05,
|
|
@@ -2050,9 +2162,11 @@ prices='{
|
|
|
2050
2162
|
"gpt-5.5-nano": 0.0006,
|
|
2051
2163
|
"gpt-5.5-pro": 0.12,
|
|
2052
2164
|
"minimax/minimax-m2.7": 0.0012,
|
|
2165
|
+
"minimax/minimax-m3": 0.0012,
|
|
2053
2166
|
"mistralai/mistral-small-2603": 0.0006,
|
|
2054
2167
|
"moonshotai/kimi-k2.5": 0.003,
|
|
2055
2168
|
"moonshotai/kimi-k2.6": 0.004,
|
|
2169
|
+
"moonshotai/kimi-k2.7": 0.004,
|
|
2056
2170
|
"qwen/qwen3-235b-a22b-2507": 0.0009064,
|
|
2057
2171
|
"qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
|
|
2058
2172
|
"qwen/qwen3-coder": 0.0027,
|
|
@@ -2060,14 +2174,18 @@ prices='{
|
|
|
2060
2174
|
"qwen/qwen3-next-80b-a3b-instruct": 0.0012,
|
|
2061
2175
|
"qwen/qwen3.5-flash-02-23": 0.00015,
|
|
2062
2176
|
"qwen/qwen3.6-35b-a3b": 0.00095,
|
|
2177
|
+
"qwen/qwen3.7-plus": 0.0016,
|
|
2063
2178
|
"xiaomi/mimo-v2.5-pro": 0.003,
|
|
2064
2179
|
"z-ai/glm-5": 0.00208,
|
|
2065
|
-
"z-ai/glm-5.1": 0.0035
|
|
2180
|
+
"z-ai/glm-5.1": 0.0035,
|
|
2181
|
+
"z-ai/glm-5.2": 0.0044
|
|
2066
2182
|
}
|
|
2067
2183
|
}'
|
|
2068
2184
|
# END_GENERATED_PRICES
|
|
2069
2185
|
|
|
2070
2186
|
routed=""
|
|
2187
|
+
forced="false"
|
|
2188
|
+
forced_model=""
|
|
2071
2189
|
session_savings=""
|
|
2072
2190
|
tot_in=0
|
|
2073
2191
|
tot_out=0
|
|
@@ -2105,6 +2223,36 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
|
2105
2223
|
routed="$(normalize_model "$routed")"
|
|
2106
2224
|
fi
|
|
2107
2225
|
|
|
2226
|
+
# Detect an active /force-model pin from the router's synthetic ack turns —
|
|
2227
|
+
# the only turns stamped message.model == "weave-router". The router emits
|
|
2228
|
+
# one whenever a pin changes state:
|
|
2229
|
+
# * /force-model → "force-model applied: <model> (<provider>) …"
|
|
2230
|
+
# * /unforce-model → "force-model cleared …"
|
|
2231
|
+
# * loop / no-progress break → "… clearing the session pin …" (expires the
|
|
2232
|
+
# pin, including a user-forced one)
|
|
2233
|
+
# * unrecognized model → "… isn't a recognized model · keeping
|
|
2234
|
+
# automatic routing" — a NO-OP: the prior
|
|
2235
|
+
# pin, if any, is left untouched
|
|
2236
|
+
# These persist on disk (the ingress stripper only scrubs them from upstream
|
|
2237
|
+
# requests). Classify each weave-router turn newest-first, skip the no-op
|
|
2238
|
+
# "rejected" acks, and let the latest real state change decide: an "applied"
|
|
2239
|
+
# marker means the session is pinned (and names the model); anything else
|
|
2240
|
+
# (cleared / loop-break / no-progress) means automatic routing has resumed.
|
|
2241
|
+
# Restricting to weave-router turns keeps a normal reply that merely quotes
|
|
2242
|
+
# these phrases from flipping the tag. (A silent server-side TTL expiry emits
|
|
2243
|
+
# no turn and so can't be reflected here — the pin TTL outlives a session.)
|
|
2244
|
+
force_state="$("${reverse[@]}" "$transcript_path" 2>/dev/null \
|
|
2245
|
+
| jq -r 'select(.type=="assistant" and .message.model=="weave-router")
|
|
2246
|
+
| ([.message.content[]? | select(.type? == "text") | .text] | join(" ") | gsub("[\n\r]"; " ")) as $t
|
|
2247
|
+
| if ($t | test("force-model applied:")) then "APPLIED " + ($t | capture("force-model applied: (?<m>[^ ]+)").m)
|
|
2248
|
+
elif ($t | test("isn.t a recognized model")) then "REJECTED"
|
|
2249
|
+
else "CLEARED" end' 2>/dev/null \
|
|
2250
|
+
| grep -m1 -v '^REJECTED$' || true)"
|
|
2251
|
+
if [[ "$force_state" == APPLIED\ * ]]; then
|
|
2252
|
+
forced="true"
|
|
2253
|
+
forced_model="${force_state#APPLIED }"
|
|
2254
|
+
fi
|
|
2255
|
+
|
|
2108
2256
|
# Compute a session running total: savings across every assistant turn
|
|
2109
2257
|
# whose marker reports a requested ≠ routed swap, plus cumulative token
|
|
2110
2258
|
# counts across every assistant turn (rerouted or not — total work the
|
|
@@ -2207,7 +2355,14 @@ if [[ "$tot_in" -gt 0 || "$tot_out" -gt 0 || "$tot_cache_read" -gt 0 || "$tot_ca
|
|
|
2207
2355
|
fi
|
|
2208
2356
|
fi
|
|
2209
2357
|
|
|
2210
|
-
if [[ "$
|
|
2358
|
+
if [[ "$forced" == "true" ]]; then
|
|
2359
|
+
# Session is pinned via /force-model. The "← selection · saved $X" clause
|
|
2360
|
+
# describes automatic routing and would be misleading on a manual pin, so
|
|
2361
|
+
# show the pinned model with a [forced] tag instead. forced_model comes from
|
|
2362
|
+
# the marker; fall back to the routed/selected id if parsing came up empty.
|
|
2363
|
+
forced_display="${forced_model:-${routed:-$selected_display}}"
|
|
2364
|
+
printf '%s — %s [forced]%s' "$brand" "$forced_display" "$tokens_clause"
|
|
2365
|
+
elif [[ "$routed" == "failure" ]]; then
|
|
2211
2366
|
# Latest turn was a CC-synthesized error stub — don't claim a routing
|
|
2212
2367
|
# swap or compute savings against a non-model.
|
|
2213
2368
|
printf '%s — %s%s' "$brand" "$routed" "$tokens_clause"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# opencode Weave subscription plugin
|
|
2
|
+
|
|
3
|
+
Lets a caller's own **AI subscriptions** pay for their **opencode** turns, routed
|
|
4
|
+
through the Weave Router. A subscription is a **credential scoped to the model
|
|
5
|
+
family it can pay for**, not a provider you pick: you connect your ChatGPT
|
|
6
|
+
(Codex) and/or Claude (Pro/Max) plan once, the router routes every turn to the
|
|
7
|
+
best model, and bills the plan that matches the model it served — ChatGPT pays
|
|
8
|
+
for GPT/Codex turns, Claude pays for Claude turns, your Weave key pays for
|
|
9
|
+
everything else.
|
|
10
|
+
|
|
11
|
+
Bundled into `@workweave/router`; the installer (`--codex` / `--opencode`) drops
|
|
12
|
+
`src/index.ts` into the user's opencode plugins dir and writes a single
|
|
13
|
+
Responses-format `weave` provider (plus a login-only `weave-claude` provider)
|
|
14
|
+
into `opencode.json`.
|
|
15
|
+
|
|
16
|
+
## Why a plugin (config alone can't do it)
|
|
17
|
+
|
|
18
|
+
opencode removed built-in subscription auth in 1.3.0 and binds OAuth to its own
|
|
19
|
+
first-party providers, so a custom router provider can't reuse it. And
|
|
20
|
+
subscription tokens expire hourly — a static `options.headers` string can't
|
|
21
|
+
refresh them, nor carry two subscriptions whose tokens rotate independently.
|
|
22
|
+
|
|
23
|
+
## Wire shape it produces
|
|
24
|
+
|
|
25
|
+
opencode talks to one Responses-format `weave` provider; the plugin's loader
|
|
26
|
+
attaches whichever subscriptions are connected to **every** request via the
|
|
27
|
+
router's dedicated headers:
|
|
28
|
+
|
|
29
|
+
| | |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `POST {router}/v1/responses` | Responses wire format (opencode's default for an `@ai-sdk/openai` provider) |
|
|
32
|
+
| `X-Weave-OpenAI-Subscription: <ChatGPT JWT>` | pays GPT/Codex turns, refreshed on expiry |
|
|
33
|
+
| `X-Weave-OpenAI-Account-ID: <id>` | paired account id (required by the Codex backend) |
|
|
34
|
+
| `X-Weave-Anthropic-Subscription: <sk-ant-oat token>` | pays Claude turns, refreshed on expiry |
|
|
35
|
+
| `X-Weave-Router-Key: rk_…` | from `opencode.json` `options.headers` — the router authenticates off this |
|
|
36
|
+
|
|
37
|
+
The router routes the turn across every model the caller's subs + key can pay
|
|
38
|
+
for and resolves the subscription matching the chosen provider, so a sub is
|
|
39
|
+
never billed for a turn outside its family.
|
|
40
|
+
|
|
41
|
+
## Two storage slots, one request provider
|
|
42
|
+
|
|
43
|
+
opencode stores one credential per provider id and the loader's `getAuth()` is
|
|
44
|
+
scoped to its own provider, so the two logins live in two slots:
|
|
45
|
+
|
|
46
|
+
- **`weave`** — the request provider. Owns the **ChatGPT** login and the loader
|
|
47
|
+
that attaches both subscriptions. Connecting ChatGPT activates sub-routing.
|
|
48
|
+
- **`weave-claude`** — login-only (no models, serves no requests). Owns the
|
|
49
|
+
**Claude** login. Its token is read from opencode's on-disk auth store by the
|
|
50
|
+
`weave` loader (the SDK exposes no get-by-id).
|
|
51
|
+
|
|
52
|
+
With neither connected, `weave` is a plain router provider (your Weave key pays)
|
|
53
|
+
— the loader simply doesn't run. Connecting ChatGPT is what turns on
|
|
54
|
+
subscription routing; the Claude sub then rides along when present.
|
|
55
|
+
|
|
56
|
+
## Login
|
|
57
|
+
|
|
58
|
+
`opencode auth login` → **Weave Router** → *ChatGPT Pro/Plus* (browser or
|
|
59
|
+
headless device code) and/or **Weave Router — Claude plan** → *Claude Pro/Max*
|
|
60
|
+
(browser; paste the `code#state` shown after authorizing).
|
|
61
|
+
|
|
62
|
+
## Env overrides (self-host + tests)
|
|
63
|
+
|
|
64
|
+
- `WEAVE_CODEX_OAUTH_ISSUER` — OpenAI auth issuer.
|
|
65
|
+
- `WEAVE_ANTHROPIC_OAUTH_AUTHORIZE` / `WEAVE_ANTHROPIC_OAUTH_TOKEN` — Anthropic
|
|
66
|
+
OAuth authorize host / token endpoint.
|
|
67
|
+
- `WEAVE_OPENCODE_AUTH_FILE` — path to opencode's `auth.json` (the `weave`
|
|
68
|
+
loader reads the `weave-claude` slot from here; defaults to
|
|
69
|
+
`$XDG_DATA_HOME/opencode/auth.json`).
|
|
70
|
+
|
|
71
|
+
## Verification
|
|
72
|
+
|
|
73
|
+
`bun test test/` (run under bun, opencode's own runtime) covers: dual-sub
|
|
74
|
+
injection via the dedicated headers with the router key preserved and
|
|
75
|
+
`Authorization` left clean; ChatGPT-only graceful degradation; expired Claude
|
|
76
|
+
token refresh + persist + rotated injection; the loader staying inert without
|
|
77
|
+
oauth; and the Claude login hook's canonical OAuth flow + `code#state` exchange.
|
|
78
|
+
Typechecks under `strict` against `@opencode-ai/plugin`.
|