@workweave/router 0.2.2 → 0.2.3

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,20 @@ 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 TWO managed providers into opencode's
537
+ # opencode.json plus the bundled Codex-subscription plugin:
538
+ # - provider.weave : Anthropic-shaped (@ai-sdk/anthropic) — the router
539
+ # speaks the Anthropic Messages API natively, so the
540
+ # bundled provider works unmodified. The default.
541
+ # - provider.weave-codex : OpenAI-shaped (@ai-sdk/openai, Responses wire)
542
+ # lets a caller's own ChatGPT (Codex) subscription
543
+ # pay for their opencode turns. Dormant until the
544
+ # user runs `opencode auth login` → ChatGPT and the
545
+ # weave-codex plugin's loader injects the bearer.
546
+ # The plugin (src bundled at $script_dir/opencode-weave/src/index.ts) is dropped
547
+ # into $opencode_dir/.weave/ and registered via opencode.json's `plugin` array
548
+ # by absolute path — scope-independent (no reliance on an auto-load dir).
549
+ # Re-running rewrites both blocks in-place via jq; uninstall strips them.
543
550
  #
544
551
  # Usage: write_opencode_config <config_file_path> <base_url> <api_key> [user_email] [user_name]
545
552
  write_opencode_config() {
@@ -601,24 +608,101 @@ write_opencode_config() {
601
608
  }
602
609
  ')"
603
610
 
611
+ # The OpenAI-shaped Codex-subscription provider. Reuses the same X-Weave-*
612
+ # header triplet (router key + identity) — the plugin's loader adds only the
613
+ # dynamic subscription Authorization + ChatGPT-Account-Id on top. baseURL
614
+ # KEEPS /v1: opencode's @ai-sdk/openai provider appends /responses, yielding
615
+ # the router's /v1/responses Codex passthrough. apiKey is the router key as a
616
+ # parse-time placeholder (the loader overrides it once a subscription is
617
+ # linked), mirroring the weave block above.
618
+ local codex_block
619
+ codex_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 (Codex subscription)",
626
+ options: { apiKey: $key, baseURL: $url, headers: $headers },
627
+ models: {
628
+ "gpt-5.5": { name: "GPT-5.5 — Codex subscription (via Weave Router)" },
629
+ "gpt-5.4": { name: "GPT-5.4 — Codex subscription (via Weave Router)" }
630
+ }
631
+ }
632
+ ')"
633
+
634
+ # Drop the Codex-subscription plugin next to the config and capture the
635
+ # absolute path we'll register in opencode.json's `plugin` array (so it loads
636
+ # regardless of scope). $config_file's dir is the already-created
637
+ # opencode_dir, so the `cd … && pwd` canonicalization is safe — uninstall.sh
638
+ # must canonicalize identically so the array entry matches on removal.
639
+ #
640
+ # Only register the path when the bundled source is actually present and
641
+ # copied: registering a path with no file on disk makes opencode fail to load
642
+ # a missing plugin. The plugin holds no secrets (router key lives in the
643
+ # config; ChatGPT tokens live in opencode's own auth store), so 644 is fine.
644
+ # Source is bundled alongside install.sh by the npm prepack
645
+ # (scripts/copy-installer.js), same as commands/ + pi-router/.
646
+ local plugin_dir plugin_spec plugin_src plugin_arg=""
647
+ plugin_dir="$(cd "$(dirname "$config_file")" && pwd)/.weave"
648
+ plugin_spec="$plugin_dir/opencode-weave.ts"
649
+ plugin_src="$script_dir/opencode-weave/src/index.ts"
650
+ if [ -f "$plugin_src" ]; then
651
+ mkdir -p "$plugin_dir"
652
+ cp "$plugin_src" "$plugin_spec"
653
+ chmod 644 "$plugin_spec"
654
+ plugin_arg="$plugin_spec"
655
+ else
656
+ warn "opencode Codex plugin source not found at $plugin_src — skipping the weave-codex plugin registration. (Use a packaged 'npx @workweave/router' install.)"
657
+ fi
658
+
604
659
  # 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.
660
+ # so re-install reflects the latest key/identity, but we leave the rest of the
661
+ # file (other providers, mcp, agent settings) untouched. Top-level `model` is
662
+ # only set when the user hasn't already picked one.
663
+ #
664
+ # The weave-codex provider AND its plugin entry are written together only when
665
+ # the bundled plugin was present and copied ($plugin non-empty). Codex
666
+ # subscription auth depends on that plugin, so registering the provider
667
+ # without it (e.g. the `curl | sh` path, which carries no plugin source)
668
+ # would leave a non-working provider — instead we omit it (and strip any
669
+ # stale one from a prior install).
608
670
  local merged
609
671
  if [ -f "$config_file" ]; then
610
- merged="$(jq --argjson block "$block" '
672
+ merged="$(jq \
673
+ --argjson block "$block" \
674
+ --argjson codex "$codex_block" \
675
+ --arg plugin "$plugin_arg" \
676
+ --arg pluginspec "$plugin_spec" '
611
677
  .provider = ((.provider // {}) | .weave = $block)
678
+ | (if $plugin != "" then .provider["weave-codex"] = $codex else .provider |= del(."weave-codex") end)
679
+ # Register the managed plugin path when we installed it; otherwise strip a
680
+ # stale entry left by a prior install (the provider was just removed, so a
681
+ # lingering plugin reference would be dead weight).
682
+ | (if $plugin != ""
683
+ then .plugin = ((.plugin // []) | if index($plugin) then . else . + [$plugin] end)
684
+ else (if (.plugin | type) == "array"
685
+ then (.plugin -= [$pluginspec]) | (if (.plugin | length) == 0 then del(.plugin) else . end)
686
+ else . end)
687
+ end)
612
688
  | (if (.model // "") == "" then .model = "weave/claude-sonnet-4-6" else . end)
689
+ # If we just stripped weave-codex (plugin-less re-install) but the default
690
+ # model still points at it, reset to the Anthropic weave model so opencode
691
+ # does not boot with a model whose provider no longer exists.
692
+ | (if $plugin == "" and (.model // "" | tostring | startswith("weave-codex/")) 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 codex "$codex_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-codex"] = $codex | .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
+ # Either managed provider counts as router-on: the Anthropic-shaped `weave/…`
1547
+ # default or a caller-selected `weave-codex/…` Codex-subscription model.
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 weave-codex model whose provider is no longer
1592
+ # registered (e.g. a plugin-less re-install stripped it after `off`
1593
+ # parked a weave-codex/… model). Fall back to the Anthropic weave default
1594
+ # so `on` can't leave opencode pointing at a missing 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,14 @@ 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 Codex-subscription path only when this run actually
1853
+ # registered the weave-codex provider. Gate on the provider's presence in the
1854
+ # written config (authoritative) rather than a plugin file on disk — a
1855
+ # leftover plugin from a prior install can outlive a plugin-less re-install
1856
+ # that stripped the provider, which would make these instructions misleading.
1857
+ if jq -e '(.provider // {}) | has("weave-codex")' "$opencode_config_file" >/dev/null 2>&1; then
1858
+ info "To pay for opencode turns with your own ChatGPT (Codex) subscription: run ${C_BOLD}opencode auth login${C_RESET} → ${C_BOLD}ChatGPT Pro/Plus${C_RESET}, then pick a ${C_BOLD}weave-codex${C_RESET} model."
1859
+ fi
1753
1860
  if [ -n "$install_dir" ]; then
1754
1861
  # --dir installs land outside opencode's discovery roots, so the caller
1755
1862
  # has to point opencode at the file explicitly.
@@ -1819,7 +1926,7 @@ if [ "$target" = "pi" ]; then
1819
1926
  # Billing note: pi normally draws on a Claude subscription (OAuth); routing
1820
1927
  # through the router switches to per-token billing on the router deployment
1821
1928
  # 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."
1929
+ info "pi bills per token on the Weave Router key, not your Claude subscription."
1823
1930
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1824
1931
  info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
1825
1932
  fi
@@ -1832,7 +1939,7 @@ fi
1832
1939
  cat > "$statusline_file" << 'STATUSLINE_EOF'
1833
1940
  #!/usr/bin/env bash
1834
1941
  #
1835
- # Claude Code statusline for the Weave router. CC pipes a JSON blob on stdin
1942
+ # Claude Code statusline for the Weave Router. CC pipes a JSON blob on stdin
1836
1943
  # whose `transcript_path` points at the JSONL log of the current session and
1837
1944
  # whose `model.display_name` is the user's CC-side model selection. The
1838
1945
  # router rewrites each request's `model` field before forwarding, so
@@ -1998,9 +2105,11 @@ prices='{
1998
2105
  "gpt-5.5-nano": 0.00015,
1999
2106
  "gpt-5.5-pro": 0.03,
2000
2107
  "minimax/minimax-m2.7": 0.0003,
2108
+ "minimax/minimax-m3": 0.0003,
2001
2109
  "mistralai/mistral-small-2603": 0.0002,
2002
2110
  "moonshotai/kimi-k2.5": 0.0006,
2003
2111
  "moonshotai/kimi-k2.6": 0.00095,
2112
+ "moonshotai/kimi-k2.7": 0.00095,
2004
2113
  "qwen/qwen3-235b-a22b-2507": 0.0002266,
2005
2114
  "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
2006
2115
  "qwen/qwen3-coder": 0.0009,
@@ -2008,6 +2117,7 @@ prices='{
2008
2117
  "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
2009
2118
  "qwen/qwen3.5-flash-02-23": 0.00005,
2010
2119
  "qwen/qwen3.6-35b-a3b": 0.00015,
2120
+ "qwen/qwen3.7-plus": 0.0004,
2011
2121
  "xiaomi/mimo-v2.5-pro": 0.001,
2012
2122
  "z-ai/glm-5": 0.0006,
2013
2123
  "z-ai/glm-5.1": 0.00105
@@ -2050,9 +2160,11 @@ prices='{
2050
2160
  "gpt-5.5-nano": 0.0006,
2051
2161
  "gpt-5.5-pro": 0.12,
2052
2162
  "minimax/minimax-m2.7": 0.0012,
2163
+ "minimax/minimax-m3": 0.0012,
2053
2164
  "mistralai/mistral-small-2603": 0.0006,
2054
2165
  "moonshotai/kimi-k2.5": 0.003,
2055
2166
  "moonshotai/kimi-k2.6": 0.004,
2167
+ "moonshotai/kimi-k2.7": 0.004,
2056
2168
  "qwen/qwen3-235b-a22b-2507": 0.0009064,
2057
2169
  "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
2058
2170
  "qwen/qwen3-coder": 0.0027,
@@ -2060,6 +2172,7 @@ prices='{
2060
2172
  "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
2061
2173
  "qwen/qwen3.5-flash-02-23": 0.00015,
2062
2174
  "qwen/qwen3.6-35b-a3b": 0.00095,
2175
+ "qwen/qwen3.7-plus": 0.0016,
2063
2176
  "xiaomi/mimo-v2.5-pro": 0.003,
2064
2177
  "z-ai/glm-5": 0.00208,
2065
2178
  "z-ai/glm-5.1": 0.0035
@@ -2068,6 +2181,8 @@ prices='{
2068
2181
  # END_GENERATED_PRICES
2069
2182
 
2070
2183
  routed=""
2184
+ forced="false"
2185
+ forced_model=""
2071
2186
  session_savings=""
2072
2187
  tot_in=0
2073
2188
  tot_out=0
@@ -2105,6 +2220,36 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2105
2220
  routed="$(normalize_model "$routed")"
2106
2221
  fi
2107
2222
 
2223
+ # Detect an active /force-model pin from the router's synthetic ack turns —
2224
+ # the only turns stamped message.model == "weave-router". The router emits
2225
+ # one whenever a pin changes state:
2226
+ # * /force-model → "force-model applied: <model> (<provider>) …"
2227
+ # * /unforce-model → "force-model cleared …"
2228
+ # * loop / no-progress break → "… clearing the session pin …" (expires the
2229
+ # pin, including a user-forced one)
2230
+ # * unrecognized model → "… isn't a recognized model · keeping
2231
+ # automatic routing" — a NO-OP: the prior
2232
+ # pin, if any, is left untouched
2233
+ # These persist on disk (the ingress stripper only scrubs them from upstream
2234
+ # requests). Classify each weave-router turn newest-first, skip the no-op
2235
+ # "rejected" acks, and let the latest real state change decide: an "applied"
2236
+ # marker means the session is pinned (and names the model); anything else
2237
+ # (cleared / loop-break / no-progress) means automatic routing has resumed.
2238
+ # Restricting to weave-router turns keeps a normal reply that merely quotes
2239
+ # these phrases from flipping the tag. (A silent server-side TTL expiry emits
2240
+ # no turn and so can't be reflected here — the pin TTL outlives a session.)
2241
+ force_state="$("${reverse[@]}" "$transcript_path" 2>/dev/null \
2242
+ | jq -r 'select(.type=="assistant" and .message.model=="weave-router")
2243
+ | ([.message.content[]? | select(.type? == "text") | .text] | join(" ") | gsub("[\n\r]"; " ")) as $t
2244
+ | if ($t | test("force-model applied:")) then "APPLIED " + ($t | capture("force-model applied: (?<m>[^ ]+)").m)
2245
+ elif ($t | test("isn.t a recognized model")) then "REJECTED"
2246
+ else "CLEARED" end' 2>/dev/null \
2247
+ | grep -m1 -v '^REJECTED$' || true)"
2248
+ if [[ "$force_state" == APPLIED\ * ]]; then
2249
+ forced="true"
2250
+ forced_model="${force_state#APPLIED }"
2251
+ fi
2252
+
2108
2253
  # Compute a session running total: savings across every assistant turn
2109
2254
  # whose marker reports a requested ≠ routed swap, plus cumulative token
2110
2255
  # counts across every assistant turn (rerouted or not — total work the
@@ -2207,7 +2352,14 @@ if [[ "$tot_in" -gt 0 || "$tot_out" -gt 0 || "$tot_cache_read" -gt 0 || "$tot_ca
2207
2352
  fi
2208
2353
  fi
2209
2354
 
2210
- if [[ "$routed" == "failure" ]]; then
2355
+ if [[ "$forced" == "true" ]]; then
2356
+ # Session is pinned via /force-model. The "← selection · saved $X" clause
2357
+ # describes automatic routing and would be misleading on a manual pin, so
2358
+ # show the pinned model with a [forced] tag instead. forced_model comes from
2359
+ # the marker; fall back to the routed/selected id if parsing came up empty.
2360
+ forced_display="${forced_model:-${routed:-$selected_display}}"
2361
+ printf '%s — %s [forced]%s' "$brand" "$forced_display" "$tokens_clause"
2362
+ elif [[ "$routed" == "failure" ]]; then
2211
2363
  # Latest turn was a CC-synthesized error stub — don't claim a routing
2212
2364
  # swap or compute savings against a non-model.
2213
2365
  printf '%s — %s%s' "$brand" "$routed" "$tokens_clause"
@@ -0,0 +1,59 @@
1
+ # opencode Weave Codex plugin
2
+
3
+ Lets a caller's own **ChatGPT (Codex) subscription** pay for their **opencode**
4
+ turns, routed through the Weave Router. Bundled into `@workweave/router`; the
5
+ installer (`--codex` / `--opencode`) drops `src/index.ts` into the user's
6
+ opencode plugins dir and writes a matching `weave-codex` provider block into
7
+ `opencode.json`.
8
+
9
+ ## Why a plugin (config alone can't do it)
10
+
11
+ opencode removed built-in subscription auth in 1.3.0 and binds OAuth to its own
12
+ first-party providers — its bundled `openai/codex.ts` plugin hardcodes the
13
+ upstream to `chatgpt.com` and binds provider id `openai`, so a custom router
14
+ provider can't reuse it. And a subscription needs the caller's OAuth token in
15
+ `Authorization`, which expires hourly — a static `options.headers` string can't
16
+ refresh it.
17
+
18
+ This plugin re-implements the same ChatGPT OAuth + refresh against a **custom**
19
+ provider id (`weave-codex`) and, crucially, **leaves the request URL on the
20
+ Weave Router** instead of rewriting it to `chatgpt.com`.
21
+
22
+ ## Wire shape it produces
23
+
24
+ Matches the router's `/v1/responses` Codex passthrough:
25
+
26
+ | | |
27
+ |---|---|
28
+ | `POST {router}/v1/responses` | Responses wire format (opencode's default for an `@ai-sdk/openai` provider) |
29
+ | `Authorization: Bearer <ChatGPT JWT>` | the caller's subscription, refreshed on expiry |
30
+ | `ChatGPT-Account-Id: <id>` | paired account id (required by the Codex backend) |
31
+ | `X-Weave-Router-Key: rk_…` | from `opencode.json` `options.headers` — the router authenticates off this, leaving `Authorization` free for the JWT |
32
+ | `originator`, `session-id` | from the plugin's `chat.headers` hook (Codex backend session continuity) |
33
+
34
+ The router detects the inbound Codex bearer and serves the turn on the caller's
35
+ own plan at the subscription fee.
36
+
37
+ ## Division of responsibility
38
+
39
+ - **Installer** writes the router key + identity headers (`X-Weave-Router-Key`,
40
+ `X-App`, `X-Weave-User-Email`) into `opencode.json` `options.headers`.
41
+ - **This plugin** manages only the dynamic, secret, refreshable subscription
42
+ credential (the `Authorization` bearer + `ChatGPT-Account-Id`) via the auth
43
+ `loader`, and the login flow via auth `methods` (browser PKCE + headless
44
+ device code). Tokens are stored in opencode's own auth store under the
45
+ `weave-codex` provider id.
46
+
47
+ ## Env overrides
48
+
49
+ - `WEAVE_CODEX_OAUTH_ISSUER` — override the OpenAI auth issuer (self-hosted
50
+ OpenAI auth proxies; also used by the capture tests).
51
+
52
+ ## Verification
53
+
54
+ Capture-tested end-to-end against a local server standing in for the router
55
+ (opencode 1.17.9, real ChatGPT login): the inject path forwards the real JWT +
56
+ account-id in Responses format to `/v1/responses`, and the refresh path detects
57
+ an expired token, refreshes against the issuer, rotates + persists the tokens,
58
+ and injects the refreshed bearer. Typechecks under `strict` against
59
+ `@opencode-ai/plugin`.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@workweave/router-opencode-extension",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "The opencode Codex-subscription auth plugin bundled into @workweave/router. NOT published separately: src/ is copied into the @workweave/router package at prepack and dropped into the user's opencode plugins dir by install.sh (--codex/--opencode). Lets a caller's own ChatGPT (Codex) subscription pay for their opencode turns, routed through the Weave Router. This manifest just marks the sources as ESM and declares the opencode peer dep for local typecheck.",
6
+ "peerDependencies": {
7
+ "@opencode-ai/plugin": "*"
8
+ }
9
+ }