@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/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 the OpenAI Codex CLI
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 a managed `provider.weave` entry into opencode's
537
- # opencode.json (anthropic-compatible the router speaks the Anthropic
538
- # Messages API natively, so opencode's bundled @ai-sdk/anthropic provider
539
- # works unmodified). Re-running rewrites the block in-place via jq; uninstall
540
- # strips it the same way. We also set `model` at the top level so a fresh
541
- # `opencode` invocation picks the router by default; if the user has set
542
- # their own model already, we leave it alone.
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. Keep it short and Anthropic-shaped
570
- # so the bundled @ai-sdk/anthropic provider can request them.
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
- # apiKey is set to the router key as well as planted in headers. opencode's
573
- # @ai-sdk/anthropic provider treats apiKey as required at config-parse time
574
- # and otherwise falls back to ANTHROPIC_API_KEY from the environment;
575
- # without this, a user who's never had an Anthropic key in their shell hits
576
- # a startup error before the router ever sees a request. The router itself
577
- # ignores the value (auth runs off X-Weave-Router-Key); apiKey here is just
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.
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/anthropic",
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-haiku-4-5": { name: "Claude Haiku 4.5 (via Weave Router)" }
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
- # the file (other providers, mcp, agent settings) untouched. Top-level
607
- # `model` is only set when the user hasn't already picked one.
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 --argjson block "$block" '
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 --argjson block "$block" '
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 "failed to fetch uninstall.sh from $url"
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 "fetched content from $url doesn't look like a bash script"
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 "unknown flag: $1"; usage 2
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 "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."
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 "invalid choice: $target_choice"; exit 2 ;;
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 "invalid choice: $scope_choice"; exit 2 ;;
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 "directory does not exist: $project_dir"
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 now ${C_BOLD}off${C_RESET} (direct to Anthropic). Restart Claude Code for it to take effect."
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 now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
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 now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
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 now ${C_BOLD}off${C_RESET} (default provider). Takes effect on your next 'codex' run."
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 now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Takes effect on your next 'codex' run."
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
- case "$model" in weave/*) on="true" ;; esac
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 now ${C_BOLD}off${C_RESET} — pick a non-Weave model with /models. Takes effect on your next opencode run."
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 now ${C_BOLD}on${C_RESET} (default model $restore_model via the Weave Router). Takes effect on your next opencode run."
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 "no controlling terminal — set WEAVE_ROUTER_KEY and re-run with --non-interactive."
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_...):%s " "$C_DIM" "$C_RESET"
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 "no key provided"; exit 1; }
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 now bills per token on the Weave Router key, not your Claude subscription."
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 router. CC pipes a JSON blob on stdin
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 [[ "$routed" == "failure" ]]; then
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`.