@workweave/router 0.2.1 → 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() {
@@ -592,6 +599,7 @@ write_opencode_config() {
592
599
  name: "Weave Router",
593
600
  options: { apiKey: $key, baseURL: $url, headers: $headers },
594
601
  models: {
602
+ "claude-fable-5": { name: "Claude Fable 5 (via Weave Router)" },
595
603
  "claude-opus-4-8": { name: "Claude Opus 4.8 (via Weave Router)" },
596
604
  "claude-opus-4-7": { name: "Claude Opus 4.7 (via Weave Router)" },
597
605
  "claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (via Weave Router)" },
@@ -600,24 +608,101 @@ write_opencode_config() {
600
608
  }
601
609
  ')"
602
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
+
603
659
  # Merge into any existing opencode.json. We always overwrite provider.weave
604
- # so re-install reflects the latest key/identity, but we leave the rest of
605
- # the file (other providers, mcp, agent settings) untouched. Top-level
606
- # `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).
607
670
  local merged
608
671
  if [ -f "$config_file" ]; then
609
- 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" '
610
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)
611
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)
612
693
  | (.["$schema"] //= "https://opencode.ai/config.json")
613
694
  ' "$config_file")"
614
695
  else
615
- merged="$(jq -n --argjson block "$block" '
696
+ merged="$(jq -n \
697
+ --argjson block "$block" \
698
+ --argjson codex "$codex_block" \
699
+ --arg plugin "$plugin_arg" '
616
700
  {
617
701
  "$schema": "https://opencode.ai/config.json",
618
702
  model: "weave/claude-sonnet-4-6",
619
703
  provider: { weave: $block }
620
704
  }
705
+ | (if $plugin != "" then .provider["weave-codex"] = $codex | .plugin = [$plugin] else . end)
621
706
  ')"
622
707
  fi
623
708
  printf '%s\n' "$merged" >"$config_file"
@@ -684,6 +769,7 @@ write_pi_models_config() {
684
769
  authHeader: false,
685
770
  headers: $headers,
686
771
  models: [
772
+ { id: "claude-fable-5", name: "Claude Fable 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
687
773
  { id: "claude-opus-4-8", name: "Claude Opus 4.8 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
688
774
  { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
689
775
  { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
@@ -798,11 +884,11 @@ for arg in "$@"; do
798
884
  # also gives us a chance to fail closed on 404 HTML pages before
799
885
  # handing the content to bash.
800
886
  if ! uninstall_body="$(curl -fsSL --max-time 30 "$url" 2>/dev/null)"; then
801
- err "failed to fetch uninstall.sh from $url"
887
+ err "Failed to fetch uninstall.sh from $url."
802
888
  exit 1
803
889
  fi
804
890
  if [ -z "$uninstall_body" ] || [ "${uninstall_body:0:2}" != "#!" ]; then
805
- 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."
806
892
  exit 1
807
893
  fi
808
894
  exec bash -c "$uninstall_body" weave-uninstall "${cleaned_args[@]+"${cleaned_args[@]}"}"
@@ -815,12 +901,12 @@ while [ $# -gt 0 ]; do
815
901
  case "$1" in
816
902
  --scope)
817
903
  scope="${2:-}"; shift 2
818
- [ "$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; }
819
905
  scope_explicit="true"
820
906
  ;;
821
907
  --base-url)
822
908
  base_url="${2:-}"; shift 2
823
- [ -n "$base_url" ] || { err "--base-url requires a value"; exit 2; }
909
+ [ -n "$base_url" ] || { err "--base-url requires a value."; exit 2; }
824
910
  ;;
825
911
  --local)
826
912
  # Shorthand for local dev: localhost:8080 (matches `wv mr` / `make dev` default PORT).
@@ -835,7 +921,7 @@ while [ $# -gt 0 ]; do
835
921
  ;;
836
922
  --dir)
837
923
  install_dir="${2:-}"; shift 2
838
- [ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
924
+ [ -n "$install_dir" ] || { err "--dir requires a path."; exit 2; }
839
925
  ;;
840
926
  --codex)
841
927
  target="codex"; target_explicit="true"; shift
@@ -861,7 +947,7 @@ while [ $# -gt 0 ]; do
861
947
  usage 0
862
948
  ;;
863
949
  *)
864
- err "unknown flag: $1"; usage 2
950
+ err "Unknown flag: $1."; usage 2
865
951
  ;;
866
952
  esac
867
953
  done
@@ -872,7 +958,7 @@ done
872
958
  if [ "$mode" != "install" ]; then
873
959
  non_interactive="true"
874
960
  if [ "$target_explicit" != "true" ]; then
875
- err "'$mode' requires an explicit client: --claude, --codex, or --opencode"
961
+ err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
876
962
  exit 2
877
963
  fi
878
964
  fi
@@ -881,7 +967,7 @@ fi
881
967
  # structural models.json/settings.json merge, reversed by the uninstaller
882
968
  # rather than a single env/key line we can park and restore.
883
969
  if [ "$mode" != "install" ] && [ "$target" = "pi" ]; then
884
- 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."
885
971
  exit 2
886
972
  fi
887
973
 
@@ -912,7 +998,7 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
912
998
  2|codex|x|X) target="codex" ;;
913
999
  3|opencode|o|O) target="opencode" ;;
914
1000
  4|pi|p|P) target="pi" ;;
915
- *) err "invalid choice: $target_choice"; exit 2 ;;
1001
+ *) err "Invalid choice: $target_choice."; exit 2 ;;
916
1002
  esac
917
1003
  fi
918
1004
 
@@ -968,7 +1054,7 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
968
1054
  case "${scope_choice:-1}" in
969
1055
  1|""|user|u|U) scope="user" ;;
970
1056
  2|project|p|P) scope="project" ;;
971
- *) err "invalid choice: $scope_choice"; exit 2 ;;
1057
+ *) err "Invalid choice: $scope_choice."; exit 2 ;;
972
1058
  esac
973
1059
 
974
1060
  # For project scope, ask which directory rather than silently assuming CWD.
@@ -985,7 +1071,7 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
985
1071
  "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
986
1072
  esac
987
1073
  if [ ! -d "$project_dir" ]; then
988
- err "directory does not exist: $project_dir"
1074
+ err "Directory does not exist: $project_dir."
989
1075
  exit 1
990
1076
  fi
991
1077
  project_dir="$(cd "$project_dir" && pwd)"
@@ -1322,7 +1408,7 @@ toggle_claude() {
1322
1408
  | (if (.env // {} | length) == 0 then del(.env) else . end)' "$settings_file")"
1323
1409
  printf '%s\n' "$merged" >"$settings_file"
1324
1410
  fi
1325
- 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."
1326
1412
  ;;
1327
1413
  on)
1328
1414
  if [ "$parked_present" != "true" ]; then
@@ -1346,7 +1432,7 @@ toggle_claude() {
1346
1432
  | (if (.env // {} | length) == 0 then del(.env) else . end)' "$local_settings_file")"
1347
1433
  printf '%s\n' "$merged" >"$local_settings_file"
1348
1434
  chmod 600 "$local_settings_file"
1349
- 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."
1350
1436
  else
1351
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."
1352
1438
  fi
@@ -1385,7 +1471,7 @@ toggle_claude() {
1385
1471
  printf '%s\n' "$merged" >"$active"
1386
1472
  [ "$proj" = "true" ] && chmod 600 "$active"
1387
1473
  rm -f "$parked"
1388
- 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."
1389
1475
  ;;
1390
1476
  esac
1391
1477
  }
@@ -1423,7 +1509,7 @@ toggle_codex() {
1423
1509
  {print}
1424
1510
  ' "$f" >"$tmp" && mv "$tmp" "$f"
1425
1511
  chmod 600 "$f"
1426
- 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."
1427
1513
  ;;
1428
1514
  on)
1429
1515
  if [ "$state" = "absent" ]; then warn "No managed Weave block in $f. Run the installer to set up Codex."; return 0; fi
@@ -1438,7 +1524,7 @@ toggle_codex() {
1438
1524
  {print}
1439
1525
  ' "$f" >"$tmp" && mv "$tmp" "$f"
1440
1526
  chmod 600 "$f"
1441
- 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."
1442
1528
  ;;
1443
1529
  esac
1444
1530
  }
@@ -1457,7 +1543,9 @@ toggle_opencode() {
1457
1543
  model="$(jq -r '.model // empty' "$f" 2>/dev/null || true)"
1458
1544
  if [ "$(jq -r '((.provider // {}) | has("weave"))' "$f" 2>/dev/null || true)" = "true" ]; then has_weave="true"; fi
1459
1545
  fi
1460
- 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
1461
1549
 
1462
1550
  case "$mode" in
1463
1551
  status)
@@ -1480,7 +1568,7 @@ toggle_opencode() {
1480
1568
  printf '%s\n' "$merged" >"$f"
1481
1569
  chmod 600 "$f"
1482
1570
  gitignore_add ".weave-parked.json"
1483
- 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."
1484
1572
  ;;
1485
1573
  on)
1486
1574
  if [ "$on" = "true" ]; then ok "opencode is already on — nothing to do."; return 0; fi
@@ -1500,11 +1588,22 @@ toggle_opencode() {
1500
1588
  | "weave/" + .
1501
1589
  ' "$f" 2>/dev/null || echo "weave/claude-sonnet-4-6")"
1502
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
1503
1602
  merged="$(jq --arg m "$restore_model" '.model = $m' "$f")"
1504
1603
  printf '%s\n' "$merged" >"$f"
1505
1604
  chmod 600 "$f"
1506
1605
  rm -f "$parked"
1507
- 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."
1508
1607
  ;;
1509
1608
  esac
1510
1609
  }
@@ -1533,19 +1632,19 @@ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
1533
1632
  # `set -e` would abort on read returning 1). If /dev/tty isn't available
1534
1633
  # (e.g. CI without a controlling terminal) the user must use --non-interactive.
1535
1634
  if [ ! -r /dev/tty ]; then
1536
- 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."
1537
1636
  exit 1
1538
1637
  fi
1539
1638
  # _spin_cleanup (installed globally above) already restores stty echo on
1540
1639
  # any exit path, so we don't need a separate trap here — that would
1541
1640
  # overwrite the spinner cleanup and leak the cursor / child PID on Ctrl-C.
1542
1641
  printf "%sGet your Weave Router API key at %s%s\n" "$C_BRAND" "$base_url" "$C_RESET"
1543
- printf "%sPaste your key here (rk_...):%s " "$C_DIM" "$C_RESET"
1642
+ printf "%sPaste your key here (rk_):%s " "$C_DIM" "$C_RESET"
1544
1643
  stty -echo </dev/tty 2>/dev/null || true
1545
1644
  read -r api_key </dev/tty
1546
1645
  stty echo </dev/tty 2>/dev/null || true
1547
1646
  printf "\n"
1548
- [ -n "$api_key" ] || { err "no key provided"; exit 1; }
1647
+ [ -n "$api_key" ] || { err "No key provided."; exit 1; }
1549
1648
  fi
1550
1649
 
1551
1650
  # ---------- identity (user email + name) ----------
@@ -1616,8 +1715,8 @@ install_slash_commands() {
1616
1715
  # installer to flip the *local* config, so they're Claude Code-only and need
1617
1716
  # the install scope baked into the command (the .md can't discover it at
1618
1717
  # invocation time). {{SCOPE}} is substituted accordingly.
1619
- installed="force-model, unforce-model"
1620
- cmds="force-model unforce-model"
1718
+ installed="force-model, unforce-model, router-feedback, fm, ufm, rf"
1719
+ cmds="force-model unforce-model router-feedback fm ufm rf"
1621
1720
  if [ "$target" = "claude" ]; then
1622
1721
  cmds="$cmds router-off router-on router-status"
1623
1722
  installed="$installed, router-off, router-on, router-status"
@@ -1714,18 +1813,20 @@ if [ "$target" = "opencode" ]; then
1714
1813
 
1715
1814
  # Project scope: the per-teammate config carries the router key, so it
1716
1815
  # stays out of git. Same reasoning as the Codex path — base URL is shared,
1717
- # 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.
1718
1818
  if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
1719
1819
  gitignore="$git_root/.gitignore"
1720
1820
  refuse_if_symlink "$gitignore"
1721
1821
  for entry in \
1722
- "opencode.json"
1822
+ "opencode.json" \
1823
+ ".weave/"
1723
1824
  do
1724
1825
  if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
1725
1826
  printf '%s\n' "$entry" >>"$gitignore"
1726
1827
  fi
1727
1828
  done
1728
- ok "Updated $gitignore (ignored opencode.json)"
1829
+ ok "Updated $gitignore (ignored opencode.json, .weave/)"
1729
1830
  fi
1730
1831
 
1731
1832
  # Post-install verification: same probes the Claude/Codex paths run.
@@ -1748,6 +1849,14 @@ if [ "$target" = "opencode" ]; then
1748
1849
  printf "\n"
1749
1850
  printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
1750
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
1751
1860
  if [ -n "$install_dir" ]; then
1752
1861
  # --dir installs land outside opencode's discovery roots, so the caller
1753
1862
  # has to point opencode at the file explicitly.
@@ -1817,7 +1926,7 @@ if [ "$target" = "pi" ]; then
1817
1926
  # Billing note: pi normally draws on a Claude subscription (OAuth); routing
1818
1927
  # through the router switches to per-token billing on the router deployment
1819
1928
  # key (or BYOK). Surface it at install so the change isn't a surprise.
1820
- 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."
1821
1930
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1822
1931
  info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
1823
1932
  fi
@@ -1830,7 +1939,7 @@ fi
1830
1939
  cat > "$statusline_file" << 'STATUSLINE_EOF'
1831
1940
  #!/usr/bin/env bash
1832
1941
  #
1833
- # 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
1834
1943
  # whose `transcript_path` points at the JSONL log of the current session and
1835
1944
  # whose `model.display_name` is the user's CC-side model selection. The
1836
1945
  # router rewrites each request's `model` field before forwarding, so
@@ -1959,6 +2068,7 @@ normalize_model() {
1959
2068
  # BEGIN_GENERATED_PRICES
1960
2069
  prices='{
1961
2070
  "input": {
2071
+ "claude-fable-5": 0.01,
1962
2072
  "claude-haiku-4-5": 0.0008,
1963
2073
  "claude-opus-4-6": 0.005,
1964
2074
  "claude-opus-4-7": 0.005,
@@ -1995,9 +2105,11 @@ prices='{
1995
2105
  "gpt-5.5-nano": 0.00015,
1996
2106
  "gpt-5.5-pro": 0.03,
1997
2107
  "minimax/minimax-m2.7": 0.0003,
2108
+ "minimax/minimax-m3": 0.0003,
1998
2109
  "mistralai/mistral-small-2603": 0.0002,
1999
2110
  "moonshotai/kimi-k2.5": 0.0006,
2000
2111
  "moonshotai/kimi-k2.6": 0.00095,
2112
+ "moonshotai/kimi-k2.7": 0.00095,
2001
2113
  "qwen/qwen3-235b-a22b-2507": 0.0002266,
2002
2114
  "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
2003
2115
  "qwen/qwen3-coder": 0.0009,
@@ -2005,11 +2117,13 @@ prices='{
2005
2117
  "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
2006
2118
  "qwen/qwen3.5-flash-02-23": 0.00005,
2007
2119
  "qwen/qwen3.6-35b-a3b": 0.00015,
2120
+ "qwen/qwen3.7-plus": 0.0004,
2008
2121
  "xiaomi/mimo-v2.5-pro": 0.001,
2009
2122
  "z-ai/glm-5": 0.0006,
2010
2123
  "z-ai/glm-5.1": 0.00105
2011
2124
  },
2012
2125
  "output": {
2126
+ "claude-fable-5": 0.05,
2013
2127
  "claude-haiku-4-5": 0.004,
2014
2128
  "claude-opus-4-6": 0.025,
2015
2129
  "claude-opus-4-7": 0.025,
@@ -2046,9 +2160,11 @@ prices='{
2046
2160
  "gpt-5.5-nano": 0.0006,
2047
2161
  "gpt-5.5-pro": 0.12,
2048
2162
  "minimax/minimax-m2.7": 0.0012,
2163
+ "minimax/minimax-m3": 0.0012,
2049
2164
  "mistralai/mistral-small-2603": 0.0006,
2050
2165
  "moonshotai/kimi-k2.5": 0.003,
2051
2166
  "moonshotai/kimi-k2.6": 0.004,
2167
+ "moonshotai/kimi-k2.7": 0.004,
2052
2168
  "qwen/qwen3-235b-a22b-2507": 0.0009064,
2053
2169
  "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
2054
2170
  "qwen/qwen3-coder": 0.0027,
@@ -2056,6 +2172,7 @@ prices='{
2056
2172
  "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
2057
2173
  "qwen/qwen3.5-flash-02-23": 0.00015,
2058
2174
  "qwen/qwen3.6-35b-a3b": 0.00095,
2175
+ "qwen/qwen3.7-plus": 0.0016,
2059
2176
  "xiaomi/mimo-v2.5-pro": 0.003,
2060
2177
  "z-ai/glm-5": 0.00208,
2061
2178
  "z-ai/glm-5.1": 0.0035
@@ -2064,6 +2181,8 @@ prices='{
2064
2181
  # END_GENERATED_PRICES
2065
2182
 
2066
2183
  routed=""
2184
+ forced="false"
2185
+ forced_model=""
2067
2186
  session_savings=""
2068
2187
  tot_in=0
2069
2188
  tot_out=0
@@ -2101,6 +2220,36 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2101
2220
  routed="$(normalize_model "$routed")"
2102
2221
  fi
2103
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
+
2104
2253
  # Compute a session running total: savings across every assistant turn
2105
2254
  # whose marker reports a requested ≠ routed swap, plus cumulative token
2106
2255
  # counts across every assistant turn (rerouted or not — total work the
@@ -2203,7 +2352,14 @@ if [[ "$tot_in" -gt 0 || "$tot_out" -gt 0 || "$tot_cache_read" -gt 0 || "$tot_ca
2203
2352
  fi
2204
2353
  fi
2205
2354
 
2206
- 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
2207
2363
  # Latest turn was a CC-synthesized error stub — don't claim a routing
2208
2364
  # swap or compute savings against a non-model.
2209
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
+ }