@workweave/router 0.2.2 → 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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@workweave/router-opencode-extension",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "The opencode Codex-subscription auth plugin bundled into @workweave/router. NOT published separately: src/ is copied into the @workweave/router package at prepack and dropped into the user's opencode plugins dir by install.sh (--codex/--opencode). Lets a caller's own ChatGPT (Codex) subscription pay for their opencode turns, routed through the Weave Router. This manifest just marks the sources as ESM and declares the opencode peer dep for local typecheck.",
6
+ "peerDependencies": {
7
+ "@opencode-ai/plugin": "*"
8
+ }
9
+ }
@@ -0,0 +1,655 @@
1
+ /**
2
+ * @workweave/router — let a caller's own AI subscriptions pay for their opencode
3
+ * turns, routed through the Weave Router.
4
+ *
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
+ *
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)
19
+ *
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.
35
+ */
36
+
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"
41
+
42
+ // ---- ChatGPT (Codex) OAuth -------------------------------------------------
43
+ const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
44
+ // Overridable for self-hosted OpenAI auth proxies and for tests (mirrors the
45
+ // bundled codex plugin's `options.issuer`).
46
+ const CHATGPT_ISSUER = process.env.WEAVE_CODEX_OAUTH_ISSUER ?? "https://auth.openai.com"
47
+ const OAUTH_PORT = 1455
48
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
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
+
75
+ // Placeholder so the @ai-sdk/openai provider considers auth configured; the
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.
78
+ const DUMMY_KEY = "weave-router-oauth"
79
+ const USER_AGENT = "weave-router-opencode"
80
+
81
+ interface PkceCodes {
82
+ verifier: string
83
+ challenge: string
84
+ }
85
+
86
+ function base64UrlEncode(buffer: ArrayBuffer): string {
87
+ const bytes = new Uint8Array(buffer)
88
+ const binary = String.fromCharCode(...bytes)
89
+ return btoa(binary)
90
+ .replace(/\+/g, "-")
91
+ .replace(/\//g, "_")
92
+ .replace(/=+$/, "")
93
+ }
94
+
95
+ async function generatePKCE(): Promise<PkceCodes> {
96
+ // base64url of 32 random bytes → a 43-char verifier drawn uniformly from the
97
+ // PKCE unreserved set (RFC 7636 §4.1). Encoding the raw bytes avoids the
98
+ // modulo-on-a-CSPRNG bias that mapping bytes onto a 64-char alphabet would
99
+ // introduce (and that static analysis flags).
100
+ const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
101
+ const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
102
+ return { verifier, challenge }
103
+ }
104
+
105
+ interface IdTokenClaims {
106
+ chatgpt_account_id?: string
107
+ organizations?: Array<{ id: string }>
108
+ "https://api.openai.com/auth"?: { chatgpt_account_id?: string }
109
+ }
110
+
111
+ function parseJwtClaims(token: string): IdTokenClaims | undefined {
112
+ const parts = token.split(".")
113
+ if (parts.length !== 3) return undefined
114
+ try {
115
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString())
116
+ } catch {
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
122
+ return (
123
+ claims.chatgpt_account_id ||
124
+ claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
125
+ claims.organizations?.[0]?.id
126
+ )
127
+ }
128
+
129
+ function extractAccountId(tokens: TokenResponse): string | undefined {
130
+ if (tokens.id_token) {
131
+ const claims = parseJwtClaims(tokens.id_token)
132
+ const accountId = claims && extractAccountIdFromClaims(claims)
133
+ if (accountId) return accountId
134
+ }
135
+ if (tokens.access_token) {
136
+ const claims = parseJwtClaims(tokens.access_token)
137
+ return claims ? extractAccountIdFromClaims(claims) : undefined
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ interface TokenResponse {
143
+ id_token: string
144
+ access_token: string
145
+ refresh_token: string
146
+ expires_in?: number
147
+ }
148
+
149
+ function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
150
+ const params = new URLSearchParams({
151
+ response_type: "code",
152
+ client_id: CHATGPT_CLIENT_ID,
153
+ redirect_uri: redirectUri,
154
+ scope: "openid profile email offline_access",
155
+ code_challenge: pkce.challenge,
156
+ code_challenge_method: "S256",
157
+ id_token_add_organizations: "true",
158
+ codex_cli_simplified_flow: "true",
159
+ state,
160
+ originator: "codex_cli_ts",
161
+ })
162
+ return `${CHATGPT_ISSUER}/oauth/authorize?${params.toString()}`
163
+ }
164
+
165
+ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
166
+ const response = await fetch(`${CHATGPT_ISSUER}/oauth/token`, {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
169
+ body: new URLSearchParams({
170
+ grant_type: "authorization_code",
171
+ code,
172
+ redirect_uri: redirectUri,
173
+ client_id: CHATGPT_CLIENT_ID,
174
+ code_verifier: pkce.verifier,
175
+ }).toString(),
176
+ })
177
+ if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`)
178
+ return response.json() as Promise<TokenResponse>
179
+ }
180
+
181
+ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
182
+ const response = await fetch(`${CHATGPT_ISSUER}/oauth/token`, {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
185
+ body: new URLSearchParams({
186
+ grant_type: "refresh_token",
187
+ refresh_token: refreshToken,
188
+ client_id: CHATGPT_CLIENT_ID,
189
+ }).toString(),
190
+ })
191
+ if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
192
+ return response.json() as Promise<TokenResponse>
193
+ }
194
+
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) -------------------------
296
+
297
+ function escapeHtml(s: string): string {
298
+ return s.replace(/[&<>"']/g, (c) =>
299
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
300
+ )
301
+ }
302
+
303
+ const HTML_SUCCESS = `<!doctype html><html><head><title>Weave Router — authorized</title></head>
304
+ <body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
305
+ <div style="text-align:center"><h1>Authorization successful</h1><p>You can close this window and return to opencode.</p>
306
+ <script>setTimeout(()=>window.close(),2000)</script></div></body></html>`
307
+
308
+ const renderOAuthError = (error: string) => `<!doctype html><html><head><title>Weave Router — authorization failed</title></head>
309
+ <body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
310
+ <div style="text-align:center"><h1 style="color:#fc533a">Authorization failed</h1>
311
+ <div style="color:#ff917b;font-family:monospace;margin-top:1rem;padding:1rem;background:#3c140d;border-radius:.5rem">${escapeHtml(error)}</div></div></body></html>`
312
+
313
+ interface PendingOAuth {
314
+ pkce: PkceCodes
315
+ state: string
316
+ resolve: (tokens: TokenResponse) => void
317
+ reject: (error: Error) => void
318
+ }
319
+
320
+ let oauthServer: import("http").Server | undefined
321
+ let pendingOAuth: PendingOAuth | undefined
322
+
323
+ async function startOAuthServer(): Promise<{ redirectUri: string }> {
324
+ const redirectUri = `http://localhost:${OAUTH_PORT}/auth/callback`
325
+ if (oauthServer) return { redirectUri }
326
+ const { createServer } = await import("node:http")
327
+ oauthServer = createServer((req, res) => {
328
+ const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
329
+ if (url.pathname !== "/auth/callback") {
330
+ res.writeHead(404)
331
+ res.end("Not found")
332
+ return
333
+ }
334
+ const code = url.searchParams.get("code")
335
+ const state = url.searchParams.get("state")
336
+ const error = url.searchParams.get("error_description") || url.searchParams.get("error")
337
+ const fail = (status: number, msg: string) => {
338
+ pendingOAuth?.reject(new Error(msg))
339
+ pendingOAuth = undefined
340
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" })
341
+ res.end(renderOAuthError(msg))
342
+ }
343
+ if (error) return fail(200, error)
344
+ if (!code) return fail(400, "Missing authorization code")
345
+ if (!pendingOAuth || state !== pendingOAuth.state) return fail(400, "Invalid state - potential CSRF attack")
346
+ const current = pendingOAuth
347
+ pendingOAuth = undefined
348
+ exchangeCodeForTokens(code, redirectUri, current.pkce)
349
+ .then((tokens) => current.resolve(tokens))
350
+ .catch((err) => current.reject(err))
351
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
352
+ res.end(HTML_SUCCESS)
353
+ })
354
+ await new Promise<void>((resolve, reject) => {
355
+ oauthServer!.listen(OAUTH_PORT, resolve)
356
+ oauthServer!.on("error", reject)
357
+ })
358
+ return { redirectUri }
359
+ }
360
+
361
+ function stopOAuthServer(): void {
362
+ oauthServer?.close(() => {})
363
+ oauthServer = undefined
364
+ }
365
+
366
+ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
367
+ // A new login supersedes any in-flight one: reject the old promise so it
368
+ // can't hang or later clobber this flow's state.
369
+ pendingOAuth?.reject(new Error("OAuth flow superseded by a new login"))
370
+ return new Promise((resolve, reject) => {
371
+ let entry: PendingOAuth | undefined
372
+ // Each handler clears the shared slot only if it still owns it, so a stale
373
+ // timer or callback never nulls out a newer flow's pendingOAuth.
374
+ const clearIfOwner = () => {
375
+ clearTimeout(timeout)
376
+ if (pendingOAuth === entry) pendingOAuth = undefined
377
+ }
378
+ const timeout = setTimeout(() => {
379
+ if (pendingOAuth === entry) {
380
+ pendingOAuth = undefined
381
+ reject(new Error("OAuth callback timeout - authorization took too long"))
382
+ }
383
+ }, 5 * 60 * 1000)
384
+ entry = {
385
+ pkce,
386
+ state,
387
+ resolve: (tokens) => {
388
+ clearIfOwner()
389
+ resolve(tokens)
390
+ },
391
+ reject: (error) => {
392
+ clearIfOwner()
393
+ reject(error)
394
+ },
395
+ }
396
+ pendingOAuth = entry
397
+ })
398
+ }
399
+
400
+ // ---- Request provider: `weave` (Responses, both subs) ----------------------
401
+
402
+ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> => {
403
+ return {
404
+ auth: {
405
+ provider: PROVIDER_ID,
406
+ async loader(getAuth) {
407
+ const auth = await getAuth()
408
+ if (auth.type !== "oauth") return {}
409
+
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
+ }
484
+
485
+ return {
486
+ apiKey: DUMMY_KEY,
487
+ async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
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.
493
+ const headers = new Headers(init?.headers as HeadersInit | undefined)
494
+
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
+
509
+ return fetch(requestInput, { ...init, headers })
510
+ },
511
+ }
512
+ },
513
+ methods: [
514
+ {
515
+ label: "ChatGPT Pro/Plus — pays for GPT/Codex turns (browser)",
516
+ type: "oauth",
517
+ authorize: async () => {
518
+ const { redirectUri } = await startOAuthServer()
519
+ const pkce = await generatePKCE()
520
+ const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
521
+ const callbackPromise = waitForOAuthCallback(pkce, state)
522
+ return {
523
+ url: buildAuthorizeUrl(redirectUri, pkce, state),
524
+ instructions: "Complete authorization in your browser. This window will close automatically.",
525
+ method: "auto" as const,
526
+ callback: async () => {
527
+ const tokens = await callbackPromise
528
+ stopOAuthServer()
529
+ return {
530
+ type: "success" as const,
531
+ refresh: tokens.refresh_token,
532
+ access: tokens.access_token,
533
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
534
+ accountId: extractAccountId(tokens),
535
+ }
536
+ },
537
+ }
538
+ },
539
+ },
540
+ {
541
+ label: "ChatGPT Pro/Plus — pays for GPT/Codex turns (headless device code)",
542
+ type: "oauth",
543
+ authorize: async () => {
544
+ const deviceResponse = await fetch(`${CHATGPT_ISSUER}/api/accounts/deviceauth/usercode`, {
545
+ method: "POST",
546
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
547
+ body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
548
+ })
549
+ if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
550
+ const deviceData = (await deviceResponse.json()) as {
551
+ device_auth_id: string
552
+ user_code: string
553
+ interval: string
554
+ }
555
+ const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
556
+ return {
557
+ url: `${CHATGPT_ISSUER}/codex/device`,
558
+ instructions: `Enter code: ${deviceData.user_code}`,
559
+ method: "auto" as const,
560
+ async callback() {
561
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
562
+ while (true) {
563
+ const response = await fetch(`${CHATGPT_ISSUER}/api/accounts/deviceauth/token`, {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
566
+ body: JSON.stringify({
567
+ device_auth_id: deviceData.device_auth_id,
568
+ user_code: deviceData.user_code,
569
+ }),
570
+ })
571
+ if (response.ok) {
572
+ const data = (await response.json()) as { authorization_code: string; code_verifier: string }
573
+ const tokenResponse = await fetch(`${CHATGPT_ISSUER}/oauth/token`, {
574
+ method: "POST",
575
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
576
+ body: new URLSearchParams({
577
+ grant_type: "authorization_code",
578
+ code: data.authorization_code,
579
+ redirect_uri: `${CHATGPT_ISSUER}/deviceauth/callback`,
580
+ client_id: CHATGPT_CLIENT_ID,
581
+ code_verifier: data.code_verifier,
582
+ }).toString(),
583
+ })
584
+ if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${tokenResponse.status}`)
585
+ const tokens = (await tokenResponse.json()) as TokenResponse
586
+ return {
587
+ type: "success" as const,
588
+ refresh: tokens.refresh_token,
589
+ access: tokens.access_token,
590
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
591
+ accountId: extractAccountId(tokens),
592
+ }
593
+ }
594
+ if (response.status !== 403 && response.status !== 404) return { type: "failed" as const }
595
+ await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
596
+ }
597
+ },
598
+ }
599
+ },
600
+ },
601
+ ],
602
+ },
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.
606
+ "chat.headers": async (hookInput, output) => {
607
+ if (hookInput.model.providerID !== PROVIDER_ID) return
608
+ output.headers["originator"] = "codex_cli_ts"
609
+ output.headers["session-id"] = hookInput.sessionID
610
+ },
611
+ "chat.params": async (hookInput, output) => {
612
+ if (hookInput.model.providerID !== PROVIDER_ID) return
613
+ // Match codex cli: the Codex backend rejects an explicit max output cap.
614
+ output.maxOutputTokens = undefined
615
+ },
616
+ }
617
+ }
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
+
655
+ export default WeaveCodex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.2.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"
@@ -17,6 +17,7 @@
17
17
  "cc-statusline.sh",
18
18
  "commands/",
19
19
  "pi-router/",
20
+ "opencode-weave/",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],