@workweave/router 0.2.3 → 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/cc-statusline.sh CHANGED
@@ -181,7 +181,8 @@ prices='{
181
181
  "qwen/qwen3.7-plus": 0.0004,
182
182
  "xiaomi/mimo-v2.5-pro": 0.001,
183
183
  "z-ai/glm-5": 0.0006,
184
- "z-ai/glm-5.1": 0.00105
184
+ "z-ai/glm-5.1": 0.00105,
185
+ "z-ai/glm-5.2": 0.0014
185
186
  },
186
187
  "output": {
187
188
  "claude-fable-5": 0.05,
@@ -236,7 +237,8 @@ prices='{
236
237
  "qwen/qwen3.7-plus": 0.0016,
237
238
  "xiaomi/mimo-v2.5-pro": 0.003,
238
239
  "z-ai/glm-5": 0.00208,
239
- "z-ai/glm-5.1": 0.0035
240
+ "z-ai/glm-5.1": 0.0035,
241
+ "z-ai/glm-5.2": 0.0044
240
242
  }
241
243
  }'
242
244
  # END_GENERATED_PRICES
package/install.sh CHANGED
@@ -533,20 +533,26 @@ TOML
533
533
  chmod 600 "$config_file"
534
534
  }
535
535
 
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.
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.
546
548
  # The plugin (src bundled at $script_dir/opencode-weave/src/index.ts) is dropped
547
549
  # 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.
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.
550
556
  #
551
557
  # Usage: write_opencode_config <config_file_path> <base_url> <api_key> [user_email] [user_name]
552
558
  write_opencode_config() {
@@ -573,61 +579,52 @@ write_opencode_config() {
573
579
 
574
580
  # Headline models we surface in opencode's picker. The router re-routes
575
581
  # each request anyway, so this list is mostly UX — what shows up when the
576
- # user runs /models inside opencode. Keep it short and Anthropic-shaped
577
- # 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.
578
585
  #
579
- # apiKey is set to the router key as well as planted in headers. opencode's
580
- # @ai-sdk/anthropic provider treats apiKey as required at config-parse time
581
- # and otherwise falls back to ANTHROPIC_API_KEY from the environment;
582
- # without this, a user who's never had an Anthropic key in their shell hits
583
- # a startup error before the router ever sees a request. The router itself
584
- # ignores the value (auth runs off X-Weave-Router-Key); apiKey here is just
585
- # a placeholder that satisfies the SDK's "is auth configured" check.
586
- # baseURL KEEPS its /v1 here do NOT align it with the pi block, which is
587
- # root. opencode's @ai-sdk/anthropic (Vercel) appends only /messages to
588
- # baseURL (its default is api.anthropic.com/v1), so /v1 yields the correct
589
- # /v1/messages. Dropping it would hit /messages and 404. (pi uses the official
590
- # @anthropic-ai/sdk, which appends /v1/messages to a root baseURL — opposite
591
- # 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.
592
594
  local block
593
595
  block="$(jq -n \
594
596
  --arg url "$block_url/v1" \
595
597
  --arg key "$block_key" \
596
598
  --argjson headers "$headers_json" '
597
599
  {
598
- npm: "@ai-sdk/anthropic",
600
+ npm: "@ai-sdk/openai",
599
601
  name: "Weave Router",
600
602
  options: { apiKey: $key, baseURL: $url, headers: $headers },
601
603
  models: {
602
- "claude-fable-5": { name: "Claude Fable 5 (via Weave Router)" },
603
- "claude-opus-4-8": { name: "Claude Opus 4.8 (via Weave Router)" },
604
- "claude-opus-4-7": { name: "Claude Opus 4.7 (via Weave Router)" },
605
604
  "claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (via Weave Router)" },
606
- "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)" }
607
609
  }
608
610
  }
609
611
  ')"
610
612
 
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 \
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
620
  --arg url "$block_url/v1" \
621
621
  --arg key "$block_key" \
622
622
  --argjson headers "$headers_json" '
623
623
  {
624
624
  npm: "@ai-sdk/openai",
625
- name: "Weave Router (Codex subscription)",
625
+ name: "Weave Router Claude plan",
626
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
- }
627
+ models: {}
631
628
  }
632
629
  ')"
633
630
 
@@ -653,7 +650,7 @@ write_opencode_config() {
653
650
  chmod 644 "$plugin_spec"
654
651
  plugin_arg="$plugin_spec"
655
652
  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.)"
653
+ warn "opencode subscription plugin source not found at $plugin_src — skipping the Claude login + subscription routing. (Use a packaged 'npx @workweave/router' install.)"
657
654
  fi
658
655
 
659
656
  # Merge into any existing opencode.json. We always overwrite provider.weave
@@ -661,21 +658,23 @@ write_opencode_config() {
661
658
  # file (other providers, mcp, agent settings) untouched. Top-level `model` is
662
659
  # only set when the user hasn't already picked one.
663
660
  #
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).
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.
670
668
  local merged
671
669
  if [ -f "$config_file" ]; then
672
670
  merged="$(jq \
673
671
  --argjson block "$block" \
674
- --argjson codex "$codex_block" \
672
+ --argjson claude "$claude_block" \
675
673
  --arg plugin "$plugin_arg" \
676
674
  --arg pluginspec "$plugin_spec" '
677
675
  .provider = ((.provider // {}) | .weave = $block)
678
- | (if $plugin != "" then .provider["weave-codex"] = $codex else .provider |= del(."weave-codex") end)
676
+ | (.provider |= del(."weave-codex"))
677
+ | (if $plugin != "" then .provider["weave-claude"] = $claude else .provider |= del(."weave-claude") end)
679
678
  # Register the managed plugin path when we installed it; otherwise strip a
680
679
  # stale entry left by a prior install (the provider was just removed, so a
681
680
  # lingering plugin reference would be dead weight).
@@ -686,23 +685,24 @@ write_opencode_config() {
686
685
  else . end)
687
686
  end)
688
687
  | (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)
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)
693
693
  | (.["$schema"] //= "https://opencode.ai/config.json")
694
694
  ' "$config_file")"
695
695
  else
696
696
  merged="$(jq -n \
697
697
  --argjson block "$block" \
698
- --argjson codex "$codex_block" \
698
+ --argjson claude "$claude_block" \
699
699
  --arg plugin "$plugin_arg" '
700
700
  {
701
701
  "$schema": "https://opencode.ai/config.json",
702
702
  model: "weave/claude-sonnet-4-6",
703
703
  provider: { weave: $block }
704
704
  }
705
- | (if $plugin != "" then .provider["weave-codex"] = $codex | .plugin = [$plugin] else . end)
705
+ | (if $plugin != "" then .provider["weave-claude"] = $claude | .plugin = [$plugin] else . end)
706
706
  ')"
707
707
  fi
708
708
  printf '%s\n' "$merged" >"$config_file"
@@ -1543,8 +1543,8 @@ toggle_opencode() {
1543
1543
  model="$(jq -r '.model // empty' "$f" 2>/dev/null || true)"
1544
1544
  if [ "$(jq -r '((.provider // {}) | has("weave"))' "$f" 2>/dev/null || true)" = "true" ]; then has_weave="true"; fi
1545
1545
  fi
1546
- # Either managed provider counts as router-on: the Anthropic-shaped `weave/…`
1547
- # default or a caller-selected `weave-codex/…` Codex-subscription model.
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
1548
  case "$model" in weave/* | weave-codex/*) on="true" ;; esac
1549
1549
 
1550
1550
  case "$mode" in
@@ -1588,10 +1588,10 @@ toggle_opencode() {
1588
1588
  | "weave/" + .
1589
1589
  ' "$f" 2>/dev/null || echo "weave/claude-sonnet-4-6")"
1590
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.
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
1595
  case "$restore_model" in
1596
1596
  weave-codex/*)
1597
1597
  if [ "$(jq -r '((.provider // {}) | has("weave-codex"))' "$f" 2>/dev/null || true)" != "true" ]; then
@@ -1849,13 +1849,14 @@ if [ "$target" = "opencode" ]; then
1849
1849
  printf "\n"
1850
1850
  printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
1851
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."
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."
1859
1860
  fi
1860
1861
  if [ -n "$install_dir" ]; then
1861
1862
  # --dir installs land outside opencode's discovery roots, so the caller
@@ -2120,7 +2121,8 @@ prices='{
2120
2121
  "qwen/qwen3.7-plus": 0.0004,
2121
2122
  "xiaomi/mimo-v2.5-pro": 0.001,
2122
2123
  "z-ai/glm-5": 0.0006,
2123
- "z-ai/glm-5.1": 0.00105
2124
+ "z-ai/glm-5.1": 0.00105,
2125
+ "z-ai/glm-5.2": 0.0014
2124
2126
  },
2125
2127
  "output": {
2126
2128
  "claude-fable-5": 0.05,
@@ -2175,7 +2177,8 @@ prices='{
2175
2177
  "qwen/qwen3.7-plus": 0.0016,
2176
2178
  "xiaomi/mimo-v2.5-pro": 0.003,
2177
2179
  "z-ai/glm-5": 0.00208,
2178
- "z-ai/glm-5.1": 0.0035
2180
+ "z-ai/glm-5.1": 0.0035,
2181
+ "z-ai/glm-5.2": 0.0044
2179
2182
  }
2180
2183
  }'
2181
2184
  # END_GENERATED_PRICES
@@ -1,59 +1,78 @@
1
- # opencode Weave Codex plugin
1
+ # opencode Weave subscription plugin
2
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`.
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`.
8
15
 
9
16
  ## Why a plugin (config alone can't do it)
10
17
 
11
18
  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`.
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.
21
22
 
22
23
  ## Wire shape it produces
23
24
 
24
- Matches the router's `/v1/responses` Codex passthrough:
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:
25
28
 
26
29
  | | |
27
30
  |---|---|
28
31
  | `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) |
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).
33
51
 
34
- The router detects the inbound Codex bearer and serves the turn on the caller's
35
- own plan at the subscription fee.
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.
36
55
 
37
- ## Division of responsibility
56
+ ## Login
38
57
 
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.
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).
46
61
 
47
- ## Env overrides
62
+ ## Env overrides (self-host + tests)
48
63
 
49
- - `WEAVE_CODEX_OAUTH_ISSUER` — override the OpenAI auth issuer (self-hosted
50
- OpenAI auth proxies; also used by the capture tests).
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`).
51
70
 
52
71
  ## Verification
53
72
 
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`.
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`.
@@ -1,42 +1,80 @@
1
1
  /**
2
- * @workweave/router — use a caller's ChatGPT (Codex) subscription for their
3
- * opencode turns, routed through the Weave Router.
2
+ * @workweave/router — let a caller's own AI subscriptions pay for their opencode
3
+ * turns, routed through the Weave Router.
4
4
  *
5
- * opencode removed built-in subscription auth in 1.3.0 and binds OAuth to its
6
- * own first-party providers (the bundled `openai/codex.ts` plugin hardcodes the
7
- * upstream to chatgpt.com and binds provider "openai"), so a custom router
8
- * provider can't reuse it. This plugin re-implements the same ChatGPT OAuth +
9
- * refresh against a CUSTOM provider id (`weave-codex`) and, crucially, leaves
10
- * the request URL pointed at the Weave Router instead of chatgpt.com.
5
+ * Model: a subscription is a CREDENTIAL scoped to the model family it can pay
6
+ * for, not a provider you pick to force a model. You connect your ChatGPT
7
+ * (Codex) and/or Claude (Pro/Max) plan once; the Weave Router routes every turn
8
+ * to the best model and bills the plan that matches the model it served — and
9
+ * only that. ChatGPT plan pays for GPT/Codex turns, Claude plan pays for Claude
10
+ * turns, your Weave key pays for everything else. No manual provider-picking.
11
11
  *
12
- * Wire shape produced (matches the router's /v1/responses Codex passthrough):
13
- * - POST {router}/v1/responses (Responses wire format)
14
- * - Authorization: Bearer <ChatGPT JWT> (the caller's subscription)
15
- * - ChatGPT-Account-Id: <account id> (paired, required by Codex backend)
16
- * - X-Weave-Router-Key: rk_... (from config options.headers)
12
+ * opencode talks to one Responses-format `weave` provider; this plugin attaches
13
+ * BOTH subscriptions to every request via the router's dedicated headers:
14
+ * - POST {router}/v1/responses (Responses wire format)
15
+ * - X-Weave-OpenAI-Subscription: <ChatGPT JWT> (pays GPT/Codex turns)
16
+ * - X-Weave-OpenAI-Account-ID: <account id> (paired, Codex backend)
17
+ * - X-Weave-Anthropic-Subscription: <sk-ant-oat token> (pays Claude turns)
18
+ * - X-Weave-Router-Key: rk_... (from config options.headers)
17
19
  *
18
- * The router authenticates off X-Weave-Router-Key (so Authorization is free for
19
- * the subscription JWT), detects the inbound Codex bearer, and serves the turn
20
- * on the caller's own plan at the subscription fee. The router key + identity
21
- * headers (X-App, X-Weave-User-Email) live in opencode.json `options.headers`
22
- * written by the installer; this plugin manages only the dynamic, refreshable
23
- * subscription credential.
20
+ * The router authenticates off X-Weave-Router-Key, routes the turn across all
21
+ * models the caller's subs + key can pay for, and resolves the matching
22
+ * subscription per the chosen provider (so the ChatGPT plan can never be billed
23
+ * for a Claude/OSS turn, and vice-versa).
24
+ *
25
+ * opencode stores one credential per provider id and the loader's getAuth() is
26
+ * scoped to its own provider, so the two logins live in two slots:
27
+ * - provider `weave` : the request provider; owns the ChatGPT login and
28
+ * the loader that injects both subs.
29
+ * - provider `weave-claude` : login-only; owns the Claude login. Its token is
30
+ * read from opencode's on-disk auth store by the
31
+ * `weave` loader (the SDK has no get-by-id).
32
+ * Connecting ChatGPT activates sub-routing; the Claude sub then rides along when
33
+ * present. With neither connected, `weave` is a plain router provider (your
34
+ * Weave key pays) — the loader simply doesn't run.
24
35
  */
25
36
 
26
37
  import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"
38
+ import { readFile } from "node:fs/promises"
39
+ import { homedir } from "node:os"
40
+ import { join } from "node:path"
27
41
 
28
- const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
42
+ // ---- ChatGPT (Codex) OAuth -------------------------------------------------
43
+ const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
29
44
  // Overridable for self-hosted OpenAI auth proxies and for tests (mirrors the
30
45
  // bundled codex plugin's `options.issuer`).
31
- const ISSUER = process.env.WEAVE_CODEX_OAUTH_ISSUER ?? "https://auth.openai.com"
46
+ const CHATGPT_ISSUER = process.env.WEAVE_CODEX_OAUTH_ISSUER ?? "https://auth.openai.com"
32
47
  const OAUTH_PORT = 1455
33
48
  const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
34
- // Provider id this plugin owns. Must match the provider block the installer
35
- // writes into opencode.json. Deliberately NOT "openai" — that id is claimed by
36
- // opencode's bundled codex plugin, which rewrites the upstream to chatgpt.com.
37
- const PROVIDER_ID = "weave-codex"
49
+
50
+ // ---- Claude (Anthropic) OAuth ----------------------------------------------
51
+ // Canonical Claude Pro/Max OAuth (the same flow Claude Code uses): a manual
52
+ // code-paste browser flow. Authorize on claude.ai, exchange/refresh on the
53
+ // console token endpoint. The access token is an sk-ant-oat… subscription
54
+ // bearer; the router applies the Bearer + oauth beta header on the upstream leg.
55
+ const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
56
+ const ANTHROPIC_AUTHORIZE_BASE = process.env.WEAVE_ANTHROPIC_OAUTH_AUTHORIZE ?? "https://claude.ai"
57
+ const ANTHROPIC_TOKEN_URL = process.env.WEAVE_ANTHROPIC_OAUTH_TOKEN ?? "https://console.anthropic.com/v1/oauth/token"
58
+ const ANTHROPIC_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
59
+ const ANTHROPIC_SCOPE = "org:create_api_key user:profile user:inference"
60
+
61
+ // Provider ids this plugin owns. `weave` is the request provider the installer
62
+ // writes into opencode.json; `weave-claude` is login-only storage for the Claude
63
+ // subscription. Deliberately NOT "openai"/"anthropic" — those ids are claimed by
64
+ // opencode's bundled provider plugins, which rewrite the upstream off the router.
65
+ const PROVIDER_ID = "weave"
66
+ const ANTHROPIC_PROVIDER_ID = "weave-claude"
67
+
68
+ // Dedicated router subscription headers. Must match the constants in
69
+ // internal/server/middleware/auth.go so the router stashes each sub and resolves
70
+ // it per the routed provider.
71
+ const HEADER_OPENAI_SUB = "X-Weave-OpenAI-Subscription"
72
+ const HEADER_OPENAI_ACCOUNT_ID = "X-Weave-OpenAI-Account-ID"
73
+ const HEADER_ANTHROPIC_SUB = "X-Weave-Anthropic-Subscription"
74
+
38
75
  // Placeholder so the @ai-sdk/openai provider considers auth configured; the
39
- // loader's fetch overwrites Authorization with the real subscription bearer.
76
+ // loader's fetch carries the real subscriptions in the dedicated headers and the
77
+ // router authenticates off X-Weave-Router-Key, so this value is never used.
40
78
  const DUMMY_KEY = "weave-router-oauth"
41
79
  const USER_AGENT = "weave-router-opencode"
42
80
 
@@ -111,7 +149,7 @@ interface TokenResponse {
111
149
  function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
112
150
  const params = new URLSearchParams({
113
151
  response_type: "code",
114
- client_id: CLIENT_ID,
152
+ client_id: CHATGPT_CLIENT_ID,
115
153
  redirect_uri: redirectUri,
116
154
  scope: "openid profile email offline_access",
117
155
  code_challenge: pkce.challenge,
@@ -121,18 +159,18 @@ function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string):
121
159
  state,
122
160
  originator: "codex_cli_ts",
123
161
  })
124
- return `${ISSUER}/oauth/authorize?${params.toString()}`
162
+ return `${CHATGPT_ISSUER}/oauth/authorize?${params.toString()}`
125
163
  }
126
164
 
127
165
  async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
128
- const response = await fetch(`${ISSUER}/oauth/token`, {
166
+ const response = await fetch(`${CHATGPT_ISSUER}/oauth/token`, {
129
167
  method: "POST",
130
168
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
131
169
  body: new URLSearchParams({
132
170
  grant_type: "authorization_code",
133
171
  code,
134
172
  redirect_uri: redirectUri,
135
- client_id: CLIENT_ID,
173
+ client_id: CHATGPT_CLIENT_ID,
136
174
  code_verifier: pkce.verifier,
137
175
  }).toString(),
138
176
  })
@@ -141,20 +179,120 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
141
179
  }
142
180
 
143
181
  async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
144
- const response = await fetch(`${ISSUER}/oauth/token`, {
182
+ const response = await fetch(`${CHATGPT_ISSUER}/oauth/token`, {
145
183
  method: "POST",
146
184
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
147
185
  body: new URLSearchParams({
148
186
  grant_type: "refresh_token",
149
187
  refresh_token: refreshToken,
150
- client_id: CLIENT_ID,
188
+ client_id: CHATGPT_CLIENT_ID,
151
189
  }).toString(),
152
190
  })
153
191
  if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
154
192
  return response.json() as Promise<TokenResponse>
155
193
  }
156
194
 
157
- // ---- Browser OAuth loopback server (PKCE) --------------------------------
195
+ // ---- Claude (Anthropic) OAuth helpers --------------------------------------
196
+
197
+ interface AnthropicTokens {
198
+ access: string
199
+ refresh: string
200
+ expires: number
201
+ }
202
+
203
+ function buildAnthropicAuthorizeUrl(pkce: PkceCodes): string {
204
+ const url = new URL(`${ANTHROPIC_AUTHORIZE_BASE}/oauth/authorize`)
205
+ url.searchParams.set("code", "true")
206
+ url.searchParams.set("client_id", ANTHROPIC_CLIENT_ID)
207
+ url.searchParams.set("response_type", "code")
208
+ url.searchParams.set("redirect_uri", ANTHROPIC_REDIRECT_URI)
209
+ url.searchParams.set("scope", ANTHROPIC_SCOPE)
210
+ url.searchParams.set("code_challenge", pkce.challenge)
211
+ url.searchParams.set("code_challenge_method", "S256")
212
+ // Claude Code reuses the PKCE verifier as the state value; the manual code is
213
+ // returned to the user as "<code>#<state>".
214
+ url.searchParams.set("state", pkce.verifier)
215
+ return url.toString()
216
+ }
217
+
218
+ async function exchangeAnthropicCode(code: string, verifier: string): Promise<AnthropicTokens> {
219
+ // The manual flow returns "<code>#<state>"; without the separator the state
220
+ // would be undefined and the server would 4xx with an opaque error.
221
+ if (!code.includes("#")) {
222
+ throw new Error(`Expected the pasted code in "code#state" format; got: ${code.trim().slice(0, 24)}…`)
223
+ }
224
+ const [authCode, state] = code.trim().split("#")
225
+ const response = await fetch(ANTHROPIC_TOKEN_URL, {
226
+ method: "POST",
227
+ headers: { "Content-Type": "application/json" },
228
+ body: JSON.stringify({
229
+ grant_type: "authorization_code",
230
+ code: authCode,
231
+ state,
232
+ client_id: ANTHROPIC_CLIENT_ID,
233
+ redirect_uri: ANTHROPIC_REDIRECT_URI,
234
+ code_verifier: verifier,
235
+ }),
236
+ })
237
+ if (!response.ok) throw new Error(`Anthropic token exchange failed: ${response.status}`)
238
+ const json = (await response.json()) as { access_token: string; refresh_token: string; expires_in?: number }
239
+ return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + (json.expires_in ?? 3600) * 1000 }
240
+ }
241
+
242
+ async function refreshAnthropicToken(refreshToken: string): Promise<AnthropicTokens> {
243
+ const response = await fetch(ANTHROPIC_TOKEN_URL, {
244
+ method: "POST",
245
+ headers: { "Content-Type": "application/json" },
246
+ body: JSON.stringify({
247
+ grant_type: "refresh_token",
248
+ refresh_token: refreshToken,
249
+ client_id: ANTHROPIC_CLIENT_ID,
250
+ }),
251
+ })
252
+ if (!response.ok) throw new Error(`Anthropic token refresh failed: ${response.status}`)
253
+ const json = (await response.json()) as { access_token: string; refresh_token?: string; expires_in?: number }
254
+ return {
255
+ access: json.access_token,
256
+ // OAuth 2.0 lets the issuer omit a new refresh_token on refresh; keep the
257
+ // existing one in that case rather than clearing it.
258
+ refresh: json.refresh_token ?? refreshToken,
259
+ expires: Date.now() + (json.expires_in ?? 3600) * 1000,
260
+ }
261
+ }
262
+
263
+ // ---- opencode auth-store reader (cross-provider) ---------------------------
264
+ // The `weave` loader's getAuth() is scoped to `weave`, and the SDK exposes no
265
+ // get-by-id, so the Claude credential stored under `weave-claude` is read from
266
+ // opencode's on-disk auth store directly. Path mirrors opencode's Global.Path
267
+ // (XDG data home + /opencode/auth.json); overridable for tests.
268
+
269
+ interface StoredOAuth {
270
+ type: string
271
+ access?: string
272
+ refresh?: string
273
+ expires?: number
274
+ accountId?: string
275
+ }
276
+
277
+ function opencodeAuthFile(): string {
278
+ const override = process.env.WEAVE_OPENCODE_AUTH_FILE
279
+ if (override) return override
280
+ const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
281
+ return join(dataHome, "opencode", "auth.json")
282
+ }
283
+
284
+ async function readStoredOAuth(providerID: string): Promise<StoredOAuth | undefined> {
285
+ try {
286
+ const raw = await readFile(opencodeAuthFile(), "utf8")
287
+ const entry = (JSON.parse(raw) as Record<string, StoredOAuth>)[providerID]
288
+ if (entry && entry.type === "oauth") return entry
289
+ } catch {
290
+ // No store yet / unreadable / not logged in — no Claude sub to attach.
291
+ }
292
+ return undefined
293
+ }
294
+
295
+ // ---- Browser OAuth loopback server (PKCE, ChatGPT) -------------------------
158
296
 
159
297
  function escapeHtml(s: string): string {
160
298
  return s.replace(/[&<>"']/g, (c) =>
@@ -162,12 +300,12 @@ function escapeHtml(s: string): string {
162
300
  )
163
301
  }
164
302
 
165
- const HTML_SUCCESS = `<!doctype html><html><head><title>Weave Router — Codex authorized</title></head>
303
+ const HTML_SUCCESS = `<!doctype html><html><head><title>Weave Router — authorized</title></head>
166
304
  <body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
167
305
  <div style="text-align:center"><h1>Authorization successful</h1><p>You can close this window and return to opencode.</p>
168
306
  <script>setTimeout(()=>window.close(),2000)</script></div></body></html>`
169
307
 
170
- const renderOAuthError = (error: string) => `<!doctype html><html><head><title>Weave Router — Codex authorization failed</title></head>
308
+ const renderOAuthError = (error: string) => `<!doctype html><html><head><title>Weave Router — authorization failed</title></head>
171
309
  <body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
172
310
  <div style="text-align:center"><h1 style="color:#fc533a">Authorization failed</h1>
173
311
  <div style="color:#ff917b;font-family:monospace;margin-top:1rem;padding:1rem;background:#3c140d;border-radius:.5rem">${escapeHtml(error)}</div></div></body></html>`
@@ -259,7 +397,7 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
259
397
  })
260
398
  }
261
399
 
262
- // ---- Plugin --------------------------------------------------------------
400
+ // ---- Request provider: `weave` (Responses, both subs) ----------------------
263
401
 
264
402
  export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> => {
265
403
  return {
@@ -269,69 +407,112 @@ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> =>
269
407
  const auth = await getAuth()
270
408
  if (auth.type !== "oauth") return {}
271
409
 
272
- // Coalesce concurrent refreshes (opencode fires parallel turns).
273
- let refreshPromise: Promise<{ access: string; accountId: string | undefined }> | undefined
410
+ // Coalesce concurrent refreshes (opencode fires parallel turns), one
411
+ // in-flight promise per subscription.
412
+ let chatgptRefresh: Promise<{ access: string; accountId: string | undefined }> | undefined
413
+ let anthropicRefresh: Promise<string | undefined> | undefined
414
+
415
+ // Resolve the ChatGPT (Codex) sub from this provider's own slot,
416
+ // refreshing + persisting the rotated token on (or just before) expiry.
417
+ async function resolveChatGPT(): Promise<{ access: string; accountId?: string } | undefined> {
418
+ const current = (await getAuth()) as StoredOAuth
419
+ if (current.type !== "oauth") return undefined
420
+ // Use a still-valid access token regardless of whether a refresh token
421
+ // is present (a partial store could lack it). Only an expired/absent
422
+ // access needs a refresh — and that needs the refresh token.
423
+ if (current.access && (current.expires ?? 0) >= Date.now()) {
424
+ return { access: current.access, accountId: current.accountId }
425
+ }
426
+ // Access is expired/absent: a refresh is the only way forward, so
427
+ // without a refresh token there's no usable credential.
428
+ if (!current.refresh) return undefined
429
+ if (!chatgptRefresh) {
430
+ chatgptRefresh = refreshAccessToken(current.refresh)
431
+ .then(async (tokens) => {
432
+ const accountId = extractAccountId(tokens) || current.accountId
433
+ await input.client.auth.set({
434
+ path: { id: PROVIDER_ID },
435
+ body: {
436
+ type: "oauth",
437
+ refresh: tokens.refresh_token || current.refresh!,
438
+ access: tokens.access_token,
439
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
440
+ ...(accountId && { accountId }),
441
+ },
442
+ })
443
+ return { access: tokens.access_token, accountId }
444
+ })
445
+ .finally(() => {
446
+ chatgptRefresh = undefined
447
+ })
448
+ }
449
+ return chatgptRefresh
450
+ }
451
+
452
+ // Resolve the Claude sub from the `weave-claude` slot (read off disk),
453
+ // refreshing + persisting the rotated token via the auth store. Returns
454
+ // undefined when Claude isn't connected.
455
+ async function resolveAnthropic(): Promise<string | undefined> {
456
+ const current = await readStoredOAuth(ANTHROPIC_PROVIDER_ID)
457
+ if (!current) return undefined
458
+ if (current.access && (current.expires ?? 0) >= Date.now()) return current.access
459
+ // Access is expired/absent: only a refresh can produce a live token,
460
+ // so without a refresh token there's no usable credential to inject —
461
+ // returning the stale token would have the router treat a dead Claude
462
+ // sub as present instead of falling back to the Weave key. (Mirrors
463
+ // resolveChatGPT.)
464
+ if (!current.refresh) return undefined
465
+ if (!anthropicRefresh) {
466
+ anthropicRefresh = refreshAnthropicToken(current.refresh)
467
+ .then(async (tokens) => {
468
+ await input.client.auth.set({
469
+ path: { id: ANTHROPIC_PROVIDER_ID },
470
+ body: { type: "oauth", refresh: tokens.refresh, access: tokens.access, expires: tokens.expires },
471
+ })
472
+ return tokens.access
473
+ })
474
+ // A failed Claude refresh must not fail the turn — fall back to the
475
+ // (possibly stale) token; an expired Claude turn the router can't
476
+ // bill to the plan falls through to the Weave key on its end.
477
+ .catch(() => current.access)
478
+ .finally(() => {
479
+ anthropicRefresh = undefined
480
+ })
481
+ }
482
+ return anthropicRefresh
483
+ }
274
484
 
275
485
  return {
276
486
  apiKey: DUMMY_KEY,
277
487
  async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
278
- const currentAuth = (await getAuth()) as {
279
- type: string
280
- access: string
281
- refresh: string
282
- expires: number
283
- accountId?: string
284
- }
285
- if (currentAuth.type !== "oauth") return fetch(requestInput, init)
286
-
287
- // Refresh the access token on (or just before) expiry and persist
288
- // the rotated refresh token back into opencode's auth store.
289
- if (!currentAuth.access || currentAuth.expires < Date.now()) {
290
- if (!refreshPromise) {
291
- refreshPromise = refreshAccessToken(currentAuth.refresh)
292
- .then(async (tokens) => {
293
- const accountId = extractAccountId(tokens) || currentAuth.accountId
294
- await input.client.auth.set({
295
- path: { id: PROVIDER_ID },
296
- body: {
297
- type: "oauth",
298
- // OAuth 2.0 lets the issuer omit a new refresh_token on
299
- // refresh (the existing one stays valid). Keep the
300
- // stored one in that case rather than clearing it.
301
- refresh: tokens.refresh_token || currentAuth.refresh,
302
- access: tokens.access_token,
303
- expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
304
- ...(accountId && { accountId }),
305
- },
306
- })
307
- return { access: tokens.access_token, accountId }
308
- })
309
- .finally(() => {
310
- refreshPromise = undefined
311
- })
312
- }
313
- const refreshed = await refreshPromise
314
- currentAuth.access = refreshed.access
315
- currentAuth.accountId = refreshed.accountId
316
- }
317
-
318
- // Preserve the configured headers (X-Weave-Router-Key, X-App, ...
319
- // from opencode.json options.headers), then overwrite Authorization
320
- // with the caller's subscription bearer + the paired account id.
488
+ // Preserve the configured headers (X-Weave-Router-Key, X-App, …) and
489
+ // attach each connected subscription via its dedicated router header.
490
+ // Authorization is left as the @ai-sdk placeholder; the router authes
491
+ // off X-Weave-Router-Key and resolves the matching sub per the routed
492
+ // model, so neither sub rides in Authorization.
321
493
  const headers = new Headers(init?.headers as HeadersInit | undefined)
322
- headers.set("authorization", `Bearer ${currentAuth.access}`)
323
- if (currentAuth.accountId) headers.set("ChatGPT-Account-Id", currentAuth.accountId)
324
494
 
325
- // NOTE: unlike opencode's bundled codex plugin we deliberately do
326
- // NOT rewrite the URL the request stays on the Weave Router, which
327
- // forwards it to the Codex backend on the caller's plan.
495
+ // Resolve both subs independently and never let one failure (e.g. a
496
+ // failed ChatGPT token refresh) drop the other or fail the turn a
497
+ // Claude-routed turn must still get its sub when the ChatGPT refresh
498
+ // is down, and vice-versa. Each resolver already persists rotations.
499
+ const [chatgpt, anthropic] = await Promise.all([
500
+ resolveChatGPT().catch(() => undefined),
501
+ resolveAnthropic().catch(() => undefined),
502
+ ])
503
+ if (chatgpt?.access) {
504
+ headers.set(HEADER_OPENAI_SUB, chatgpt.access)
505
+ if (chatgpt.accountId) headers.set(HEADER_OPENAI_ACCOUNT_ID, chatgpt.accountId)
506
+ }
507
+ if (anthropic) headers.set(HEADER_ANTHROPIC_SUB, anthropic)
508
+
328
509
  return fetch(requestInput, { ...init, headers })
329
510
  },
330
511
  }
331
512
  },
332
513
  methods: [
333
514
  {
334
- label: "ChatGPT Pro/Plus (browser)",
515
+ label: "ChatGPT Pro/Plus — pays for GPT/Codex turns (browser)",
335
516
  type: "oauth",
336
517
  authorize: async () => {
337
518
  const { redirectUri } = await startOAuthServer()
@@ -357,13 +538,13 @@ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> =>
357
538
  },
358
539
  },
359
540
  {
360
- label: "ChatGPT Pro/Plus (headless device code)",
541
+ label: "ChatGPT Pro/Plus — pays for GPT/Codex turns (headless device code)",
361
542
  type: "oauth",
362
543
  authorize: async () => {
363
- const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
544
+ const deviceResponse = await fetch(`${CHATGPT_ISSUER}/api/accounts/deviceauth/usercode`, {
364
545
  method: "POST",
365
546
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
366
- body: JSON.stringify({ client_id: CLIENT_ID }),
547
+ body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
367
548
  })
368
549
  if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
369
550
  const deviceData = (await deviceResponse.json()) as {
@@ -373,13 +554,13 @@ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> =>
373
554
  }
374
555
  const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
375
556
  return {
376
- url: `${ISSUER}/codex/device`,
557
+ url: `${CHATGPT_ISSUER}/codex/device`,
377
558
  instructions: `Enter code: ${deviceData.user_code}`,
378
559
  method: "auto" as const,
379
560
  async callback() {
380
561
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
381
562
  while (true) {
382
- const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
563
+ const response = await fetch(`${CHATGPT_ISSUER}/api/accounts/deviceauth/token`, {
383
564
  method: "POST",
384
565
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
385
566
  body: JSON.stringify({
@@ -389,14 +570,14 @@ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> =>
389
570
  })
390
571
  if (response.ok) {
391
572
  const data = (await response.json()) as { authorization_code: string; code_verifier: string }
392
- const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
573
+ const tokenResponse = await fetch(`${CHATGPT_ISSUER}/oauth/token`, {
393
574
  method: "POST",
394
575
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
395
576
  body: new URLSearchParams({
396
577
  grant_type: "authorization_code",
397
578
  code: data.authorization_code,
398
- redirect_uri: `${ISSUER}/deviceauth/callback`,
399
- client_id: CLIENT_ID,
579
+ redirect_uri: `${CHATGPT_ISSUER}/deviceauth/callback`,
580
+ client_id: CHATGPT_CLIENT_ID,
400
581
  code_verifier: data.code_verifier,
401
582
  }).toString(),
402
583
  })
@@ -419,9 +600,9 @@ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> =>
419
600
  },
420
601
  ],
421
602
  },
422
- // The Codex backend (which the router forwards to) keys session continuity
423
- // off these headers; mirror opencode's bundled codex plugin. Scoped to our
424
- // provider so other providers are untouched.
603
+ // The Codex backend (which the router forwards GPT turns to) keys session
604
+ // continuity off these headers; mirror opencode's bundled codex plugin.
605
+ // Scoped to our provider so other providers are untouched.
425
606
  "chat.headers": async (hookInput, output) => {
426
607
  if (hookInput.model.providerID !== PROVIDER_ID) return
427
608
  output.headers["originator"] = "codex_cli_ts"
@@ -435,4 +616,40 @@ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> =>
435
616
  }
436
617
  }
437
618
 
619
+ // ---- Login-only provider: `weave-claude` (Claude Pro/Max) ------------------
620
+ // A second auth hook so the Claude subscription gets its own storage slot
621
+ // (opencode keys credentials by provider id). It serves no requests — the
622
+ // `weave` loader reads this slot and attaches the token — so it needs no loader.
623
+
624
+ export const WeaveClaude: Plugin = async (_input: PluginInput): Promise<Hooks> => {
625
+ return {
626
+ auth: {
627
+ provider: ANTHROPIC_PROVIDER_ID,
628
+ methods: [
629
+ {
630
+ label: "Claude Pro/Max — pays for Claude turns (browser)",
631
+ type: "oauth",
632
+ authorize: async () => {
633
+ const pkce = await generatePKCE()
634
+ return {
635
+ url: buildAnthropicAuthorizeUrl(pkce),
636
+ instructions: "Sign in with your Claude account, then paste the code shown (looks like `code#state`).",
637
+ method: "code" as const,
638
+ callback: async (code: string) => {
639
+ const tokens = await exchangeAnthropicCode(code, pkce.verifier)
640
+ return {
641
+ type: "success" as const,
642
+ refresh: tokens.refresh,
643
+ access: tokens.access,
644
+ expires: tokens.expires,
645
+ }
646
+ },
647
+ }
648
+ },
649
+ },
650
+ ],
651
+ },
652
+ }
653
+ }
654
+
438
655
  export default WeaveCodex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
5
5
  "bin": {
6
6
  "weave-router": "bin.js"
package/uninstall.sh CHANGED
@@ -164,19 +164,21 @@ if [ "$target" = "opencode" ]; then
164
164
  opencode_plugin="$opencode_dir/.weave/opencode-weave.ts"
165
165
  fi
166
166
  if [ -f "$opencode_config_file" ]; then
167
- # Strip both managed providers (`weave`, `weave-codex`), the managed plugin
167
+ # Strip every managed provider (`weave`, the login-only `weave-claude`, and
168
+ # the legacy `weave-codex` from pre-upgrade installs), the managed plugin
168
169
  # entry from the `plugin` array, and any router-pointing top-level model
169
- # (both the `weave/` and `weave-codex/` provider prefixes — otherwise a
170
- # `weave-codex/...` default survives and points at the deleted provider).
171
- # Other providers, user-set models that don't reference the router, other
172
- # plugins, and any unrelated keys are preserved.
170
+ # (the `weave/`, `weave-claude/`, and `weave-codex/` prefixes — otherwise a
171
+ # default survives and points at a deleted provider). Other providers,
172
+ # user-set models that don't reference the router, other plugins, and any
173
+ # unrelated keys are preserved.
173
174
  cleaned="$(jq --arg plugin "$opencode_plugin" '
174
175
  (if .provider.weave then del(.provider.weave) else . end)
176
+ | (if .provider["weave-claude"] then del(.provider["weave-claude"]) else . end)
175
177
  | (if .provider["weave-codex"] then del(.provider["weave-codex"]) else . end)
176
178
  | (if (.provider // {}) == {} then del(.provider) else . end)
177
179
  | (if (.plugin | type) == "array" then .plugin -= [$plugin] else . end)
178
180
  | (if (.plugin | type) == "array" and (.plugin | length) == 0 then del(.plugin) else . end)
179
- | (if (.model // "" | tostring | (startswith("weave/") or startswith("weave-codex/"))) then del(.model) else . end)
181
+ | (if (.model // "" | tostring | (startswith("weave/") or startswith("weave-claude/") or startswith("weave-codex/"))) then del(.model) else . end)
180
182
  ' "$opencode_config_file")"
181
183
  printf '%s\n' "$cleaned" >"$opencode_config_file"
182
184
 
@@ -193,9 +195,9 @@ if [ "$target" = "opencode" ]; then
193
195
  info "No opencode config at $opencode_config_file (already uninstalled?)"
194
196
  fi
195
197
 
196
- # Drop the bundled Codex-subscription plugin (no secrets; the config holds the
197
- # key, opencode's own auth store holds the ChatGPT tokens). Remove the .weave/
198
- # dir only if it's left empty so we don't clobber an unrelated user dir.
198
+ # Drop the bundled subscription plugin (no secrets; the config holds the key,
199
+ # opencode's own auth store holds the ChatGPT/Claude tokens). Remove the
200
+ # .weave/ dir only if it's left empty so we don't clobber an unrelated user dir.
199
201
  if [ -f "$opencode_plugin" ]; then
200
202
  refuse_if_symlink "$opencode_plugin"
201
203
  rm -f "$opencode_plugin"