@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 +4 -2
- package/install.sh +79 -76
- package/opencode-weave/README.md +58 -39
- package/opencode-weave/src/index.ts +315 -98
- package/package.json +1 -1
- package/uninstall.sh +11 -9
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
|
|
537
|
-
# opencode.json plus the bundled
|
|
538
|
-
# - provider.weave :
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
#
|
|
543
|
-
#
|
|
544
|
-
#
|
|
545
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
577
|
-
#
|
|
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
|
-
#
|
|
580
|
-
# @ai-sdk/
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
#
|
|
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/
|
|
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-
|
|
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
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
#
|
|
615
|
-
# the
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
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
|
|
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-
|
|
665
|
-
# the bundled plugin was present and copied ($plugin non-empty)
|
|
666
|
-
#
|
|
667
|
-
#
|
|
668
|
-
#
|
|
669
|
-
#
|
|
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
|
|
672
|
+
--argjson claude "$claude_block" \
|
|
675
673
|
--arg plugin "$plugin_arg" \
|
|
676
674
|
--arg pluginspec "$plugin_spec" '
|
|
677
675
|
.provider = ((.provider // {}) | .weave = $block)
|
|
678
|
-
| (
|
|
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
|
-
#
|
|
690
|
-
#
|
|
691
|
-
#
|
|
692
|
-
| (if
|
|
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
|
|
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-
|
|
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
|
-
#
|
|
1547
|
-
#
|
|
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 (
|
|
1593
|
-
#
|
|
1594
|
-
#
|
|
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
|
|
1853
|
-
# registered the weave-
|
|
1854
|
-
#
|
|
1855
|
-
#
|
|
1856
|
-
# that stripped the provider, which would
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
package/opencode-weave/README.md
CHANGED
|
@@ -1,59 +1,78 @@
|
|
|
1
|
-
# opencode Weave
|
|
1
|
+
# opencode Weave subscription plugin
|
|
2
2
|
|
|
3
|
-
Lets a caller's own **
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `X-Weave-
|
|
32
|
-
| `
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
##
|
|
56
|
+
## Login
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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` —
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
and
|
|
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 —
|
|
3
|
-
*
|
|
2
|
+
* @workweave/router — let a caller's own AI subscriptions pay for their opencode
|
|
3
|
+
* turns, routed through the Weave Router.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* - X-Weave-
|
|
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
|
|
19
|
-
* the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
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
|
|
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:
|
|
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 `${
|
|
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(`${
|
|
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:
|
|
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(`${
|
|
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:
|
|
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
|
-
// ----
|
|
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 —
|
|
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 —
|
|
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
|
-
// ----
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
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(`${
|
|
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:
|
|
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: `${
|
|
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(`${
|
|
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(`${
|
|
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: `${
|
|
399
|
-
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
|
|
423
|
-
// off these headers; mirror opencode's bundled codex plugin.
|
|
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
|
+
"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
|
|
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
|
-
# (
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
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
|
|
197
|
-
#
|
|
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"
|