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