@workweave/router 0.2.3 → 0.2.5
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 +48 -33
- package/install.sh +134 -112
- package/opencode-weave/README.md +58 -39
- package/opencode-weave/src/index.ts +315 -98
- package/package.json +1 -1
- package/uninstall.sh +14 -12
|
@@ -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.5",
|
|
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"
|
|
@@ -473,13 +475,13 @@ else
|
|
|
473
475
|
fi
|
|
474
476
|
|
|
475
477
|
if [ -f "$settings_file" ]; then
|
|
476
|
-
# Only remove keys we actually installed: scrub our
|
|
478
|
+
# Only remove keys we actually installed: scrub our env vars, and only
|
|
477
479
|
# delete `statusLine` / `apiKeyHelper` when they point at scripts this
|
|
478
480
|
# installer used in older versions. Otherwise an unrelated user-configured
|
|
479
481
|
# statusLine or apiKeyHelper would be silently clobbered.
|
|
480
482
|
cleaned="$(jq '
|
|
481
483
|
if .env then
|
|
482
|
-
.env |= (del(.ANTHROPIC_BASE_URL, .ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS))
|
|
484
|
+
.env |= (del(.ANTHROPIC_BASE_URL, .ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS, .ENABLE_TOOL_SEARCH))
|
|
483
485
|
| (if (.env | length) == 0 then del(.env) else . end)
|
|
484
486
|
else . end
|
|
485
487
|
| (if (.statusLine.command // "" | tostring | endswith("cc-statusline.sh"))
|
|
@@ -499,7 +501,7 @@ if [ -n "$local_settings_file" ] && [ -f "$local_settings_file" ]; then
|
|
|
499
501
|
# uninstall fully reverts a toggled-off install.
|
|
500
502
|
cleaned="$(jq '
|
|
501
503
|
if .env then
|
|
502
|
-
.env |= (del(.ANTHROPIC_BASE_URL, .ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS))
|
|
504
|
+
.env |= (del(.ANTHROPIC_BASE_URL, .ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS, .ENABLE_TOOL_SEARCH))
|
|
503
505
|
| (if (.env | length) == 0 then del(.env) else . end)
|
|
504
506
|
else . end
|
|
505
507
|
| del(.apiKeyHelper)
|