@workweave/router 0.2.2 → 0.2.3
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/bin.js +3 -3
- package/cc-statusline.sh +47 -2
- package/commands/fm.md +1 -1
- package/commands/force-model.md +1 -1
- package/commands/rf.md +1 -1
- package/commands/router-feedback.md +1 -1
- package/commands/router-off.md +1 -1
- package/commands/router-on.md +1 -1
- package/commands/router-status.md +2 -2
- package/commands/ufm.md +1 -1
- package/commands/unforce-model.md +1 -1
- package/install.sh +193 -41
- package/opencode-weave/README.md +59 -0
- package/opencode-weave/package.json +9 -0
- package/opencode-weave/src/index.ts +438 -0
- package/package.json +2 -1
- package/pi-router/src/dispatch.ts +2 -2
- package/pi-router/src/index.ts +1 -1
- package/pi-router/src/provider.ts +1 -1
- package/pi-router/src/routed-model.ts +1 -1
- package/uninstall.sh +35 -11
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @workweave/router — use a caller's ChatGPT (Codex) subscription for their
|
|
3
|
+
* opencode turns, routed through the Weave Router.
|
|
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.
|
|
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)
|
|
17
|
+
*
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"
|
|
27
|
+
|
|
28
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
29
|
+
// Overridable for self-hosted OpenAI auth proxies and for tests (mirrors the
|
|
30
|
+
// bundled codex plugin's `options.issuer`).
|
|
31
|
+
const ISSUER = process.env.WEAVE_CODEX_OAUTH_ISSUER ?? "https://auth.openai.com"
|
|
32
|
+
const OAUTH_PORT = 1455
|
|
33
|
+
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"
|
|
38
|
+
// Placeholder so the @ai-sdk/openai provider considers auth configured; the
|
|
39
|
+
// loader's fetch overwrites Authorization with the real subscription bearer.
|
|
40
|
+
const DUMMY_KEY = "weave-router-oauth"
|
|
41
|
+
const USER_AGENT = "weave-router-opencode"
|
|
42
|
+
|
|
43
|
+
interface PkceCodes {
|
|
44
|
+
verifier: string
|
|
45
|
+
challenge: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function base64UrlEncode(buffer: ArrayBuffer): string {
|
|
49
|
+
const bytes = new Uint8Array(buffer)
|
|
50
|
+
const binary = String.fromCharCode(...bytes)
|
|
51
|
+
return btoa(binary)
|
|
52
|
+
.replace(/\+/g, "-")
|
|
53
|
+
.replace(/\//g, "_")
|
|
54
|
+
.replace(/=+$/, "")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function generatePKCE(): Promise<PkceCodes> {
|
|
58
|
+
// base64url of 32 random bytes → a 43-char verifier drawn uniformly from the
|
|
59
|
+
// PKCE unreserved set (RFC 7636 §4.1). Encoding the raw bytes avoids the
|
|
60
|
+
// modulo-on-a-CSPRNG bias that mapping bytes onto a 64-char alphabet would
|
|
61
|
+
// introduce (and that static analysis flags).
|
|
62
|
+
const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
63
|
+
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
|
64
|
+
return { verifier, challenge }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface IdTokenClaims {
|
|
68
|
+
chatgpt_account_id?: string
|
|
69
|
+
organizations?: Array<{ id: string }>
|
|
70
|
+
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseJwtClaims(token: string): IdTokenClaims | undefined {
|
|
74
|
+
const parts = token.split(".")
|
|
75
|
+
if (parts.length !== 3) return undefined
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString())
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
|
|
84
|
+
return (
|
|
85
|
+
claims.chatgpt_account_id ||
|
|
86
|
+
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
|
|
87
|
+
claims.organizations?.[0]?.id
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractAccountId(tokens: TokenResponse): string | undefined {
|
|
92
|
+
if (tokens.id_token) {
|
|
93
|
+
const claims = parseJwtClaims(tokens.id_token)
|
|
94
|
+
const accountId = claims && extractAccountIdFromClaims(claims)
|
|
95
|
+
if (accountId) return accountId
|
|
96
|
+
}
|
|
97
|
+
if (tokens.access_token) {
|
|
98
|
+
const claims = parseJwtClaims(tokens.access_token)
|
|
99
|
+
return claims ? extractAccountIdFromClaims(claims) : undefined
|
|
100
|
+
}
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface TokenResponse {
|
|
105
|
+
id_token: string
|
|
106
|
+
access_token: string
|
|
107
|
+
refresh_token: string
|
|
108
|
+
expires_in?: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
|
112
|
+
const params = new URLSearchParams({
|
|
113
|
+
response_type: "code",
|
|
114
|
+
client_id: CLIENT_ID,
|
|
115
|
+
redirect_uri: redirectUri,
|
|
116
|
+
scope: "openid profile email offline_access",
|
|
117
|
+
code_challenge: pkce.challenge,
|
|
118
|
+
code_challenge_method: "S256",
|
|
119
|
+
id_token_add_organizations: "true",
|
|
120
|
+
codex_cli_simplified_flow: "true",
|
|
121
|
+
state,
|
|
122
|
+
originator: "codex_cli_ts",
|
|
123
|
+
})
|
|
124
|
+
return `${ISSUER}/oauth/authorize?${params.toString()}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
|
128
|
+
const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
131
|
+
body: new URLSearchParams({
|
|
132
|
+
grant_type: "authorization_code",
|
|
133
|
+
code,
|
|
134
|
+
redirect_uri: redirectUri,
|
|
135
|
+
client_id: CLIENT_ID,
|
|
136
|
+
code_verifier: pkce.verifier,
|
|
137
|
+
}).toString(),
|
|
138
|
+
})
|
|
139
|
+
if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`)
|
|
140
|
+
return response.json() as Promise<TokenResponse>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
|
144
|
+
const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
147
|
+
body: new URLSearchParams({
|
|
148
|
+
grant_type: "refresh_token",
|
|
149
|
+
refresh_token: refreshToken,
|
|
150
|
+
client_id: CLIENT_ID,
|
|
151
|
+
}).toString(),
|
|
152
|
+
})
|
|
153
|
+
if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
|
|
154
|
+
return response.json() as Promise<TokenResponse>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- Browser OAuth loopback server (PKCE) --------------------------------
|
|
158
|
+
|
|
159
|
+
function escapeHtml(s: string): string {
|
|
160
|
+
return s.replace(/[&<>"']/g, (c) =>
|
|
161
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const HTML_SUCCESS = `<!doctype html><html><head><title>Weave Router — Codex authorized</title></head>
|
|
166
|
+
<body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
167
|
+
<div style="text-align:center"><h1>Authorization successful</h1><p>You can close this window and return to opencode.</p>
|
|
168
|
+
<script>setTimeout(()=>window.close(),2000)</script></div></body></html>`
|
|
169
|
+
|
|
170
|
+
const renderOAuthError = (error: string) => `<!doctype html><html><head><title>Weave Router — Codex authorization failed</title></head>
|
|
171
|
+
<body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
172
|
+
<div style="text-align:center"><h1 style="color:#fc533a">Authorization failed</h1>
|
|
173
|
+
<div style="color:#ff917b;font-family:monospace;margin-top:1rem;padding:1rem;background:#3c140d;border-radius:.5rem">${escapeHtml(error)}</div></div></body></html>`
|
|
174
|
+
|
|
175
|
+
interface PendingOAuth {
|
|
176
|
+
pkce: PkceCodes
|
|
177
|
+
state: string
|
|
178
|
+
resolve: (tokens: TokenResponse) => void
|
|
179
|
+
reject: (error: Error) => void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let oauthServer: import("http").Server | undefined
|
|
183
|
+
let pendingOAuth: PendingOAuth | undefined
|
|
184
|
+
|
|
185
|
+
async function startOAuthServer(): Promise<{ redirectUri: string }> {
|
|
186
|
+
const redirectUri = `http://localhost:${OAUTH_PORT}/auth/callback`
|
|
187
|
+
if (oauthServer) return { redirectUri }
|
|
188
|
+
const { createServer } = await import("node:http")
|
|
189
|
+
oauthServer = createServer((req, res) => {
|
|
190
|
+
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
|
191
|
+
if (url.pathname !== "/auth/callback") {
|
|
192
|
+
res.writeHead(404)
|
|
193
|
+
res.end("Not found")
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const code = url.searchParams.get("code")
|
|
197
|
+
const state = url.searchParams.get("state")
|
|
198
|
+
const error = url.searchParams.get("error_description") || url.searchParams.get("error")
|
|
199
|
+
const fail = (status: number, msg: string) => {
|
|
200
|
+
pendingOAuth?.reject(new Error(msg))
|
|
201
|
+
pendingOAuth = undefined
|
|
202
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" })
|
|
203
|
+
res.end(renderOAuthError(msg))
|
|
204
|
+
}
|
|
205
|
+
if (error) return fail(200, error)
|
|
206
|
+
if (!code) return fail(400, "Missing authorization code")
|
|
207
|
+
if (!pendingOAuth || state !== pendingOAuth.state) return fail(400, "Invalid state - potential CSRF attack")
|
|
208
|
+
const current = pendingOAuth
|
|
209
|
+
pendingOAuth = undefined
|
|
210
|
+
exchangeCodeForTokens(code, redirectUri, current.pkce)
|
|
211
|
+
.then((tokens) => current.resolve(tokens))
|
|
212
|
+
.catch((err) => current.reject(err))
|
|
213
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
214
|
+
res.end(HTML_SUCCESS)
|
|
215
|
+
})
|
|
216
|
+
await new Promise<void>((resolve, reject) => {
|
|
217
|
+
oauthServer!.listen(OAUTH_PORT, resolve)
|
|
218
|
+
oauthServer!.on("error", reject)
|
|
219
|
+
})
|
|
220
|
+
return { redirectUri }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stopOAuthServer(): void {
|
|
224
|
+
oauthServer?.close(() => {})
|
|
225
|
+
oauthServer = undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
|
229
|
+
// A new login supersedes any in-flight one: reject the old promise so it
|
|
230
|
+
// can't hang or later clobber this flow's state.
|
|
231
|
+
pendingOAuth?.reject(new Error("OAuth flow superseded by a new login"))
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
let entry: PendingOAuth | undefined
|
|
234
|
+
// Each handler clears the shared slot only if it still owns it, so a stale
|
|
235
|
+
// timer or callback never nulls out a newer flow's pendingOAuth.
|
|
236
|
+
const clearIfOwner = () => {
|
|
237
|
+
clearTimeout(timeout)
|
|
238
|
+
if (pendingOAuth === entry) pendingOAuth = undefined
|
|
239
|
+
}
|
|
240
|
+
const timeout = setTimeout(() => {
|
|
241
|
+
if (pendingOAuth === entry) {
|
|
242
|
+
pendingOAuth = undefined
|
|
243
|
+
reject(new Error("OAuth callback timeout - authorization took too long"))
|
|
244
|
+
}
|
|
245
|
+
}, 5 * 60 * 1000)
|
|
246
|
+
entry = {
|
|
247
|
+
pkce,
|
|
248
|
+
state,
|
|
249
|
+
resolve: (tokens) => {
|
|
250
|
+
clearIfOwner()
|
|
251
|
+
resolve(tokens)
|
|
252
|
+
},
|
|
253
|
+
reject: (error) => {
|
|
254
|
+
clearIfOwner()
|
|
255
|
+
reject(error)
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
pendingOAuth = entry
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- Plugin --------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> => {
|
|
265
|
+
return {
|
|
266
|
+
auth: {
|
|
267
|
+
provider: PROVIDER_ID,
|
|
268
|
+
async loader(getAuth) {
|
|
269
|
+
const auth = await getAuth()
|
|
270
|
+
if (auth.type !== "oauth") return {}
|
|
271
|
+
|
|
272
|
+
// Coalesce concurrent refreshes (opencode fires parallel turns).
|
|
273
|
+
let refreshPromise: Promise<{ access: string; accountId: string | undefined }> | undefined
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
apiKey: DUMMY_KEY,
|
|
277
|
+
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.
|
|
321
|
+
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
|
+
|
|
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.
|
|
328
|
+
return fetch(requestInput, { ...init, headers })
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
methods: [
|
|
333
|
+
{
|
|
334
|
+
label: "ChatGPT Pro/Plus (browser)",
|
|
335
|
+
type: "oauth",
|
|
336
|
+
authorize: async () => {
|
|
337
|
+
const { redirectUri } = await startOAuthServer()
|
|
338
|
+
const pkce = await generatePKCE()
|
|
339
|
+
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
340
|
+
const callbackPromise = waitForOAuthCallback(pkce, state)
|
|
341
|
+
return {
|
|
342
|
+
url: buildAuthorizeUrl(redirectUri, pkce, state),
|
|
343
|
+
instructions: "Complete authorization in your browser. This window will close automatically.",
|
|
344
|
+
method: "auto" as const,
|
|
345
|
+
callback: async () => {
|
|
346
|
+
const tokens = await callbackPromise
|
|
347
|
+
stopOAuthServer()
|
|
348
|
+
return {
|
|
349
|
+
type: "success" as const,
|
|
350
|
+
refresh: tokens.refresh_token,
|
|
351
|
+
access: tokens.access_token,
|
|
352
|
+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
353
|
+
accountId: extractAccountId(tokens),
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
label: "ChatGPT Pro/Plus (headless device code)",
|
|
361
|
+
type: "oauth",
|
|
362
|
+
authorize: async () => {
|
|
363
|
+
const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
|
|
366
|
+
body: JSON.stringify({ client_id: CLIENT_ID }),
|
|
367
|
+
})
|
|
368
|
+
if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
|
|
369
|
+
const deviceData = (await deviceResponse.json()) as {
|
|
370
|
+
device_auth_id: string
|
|
371
|
+
user_code: string
|
|
372
|
+
interval: string
|
|
373
|
+
}
|
|
374
|
+
const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
|
|
375
|
+
return {
|
|
376
|
+
url: `${ISSUER}/codex/device`,
|
|
377
|
+
instructions: `Enter code: ${deviceData.user_code}`,
|
|
378
|
+
method: "auto" as const,
|
|
379
|
+
async callback() {
|
|
380
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
381
|
+
while (true) {
|
|
382
|
+
const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
device_auth_id: deviceData.device_auth_id,
|
|
387
|
+
user_code: deviceData.user_code,
|
|
388
|
+
}),
|
|
389
|
+
})
|
|
390
|
+
if (response.ok) {
|
|
391
|
+
const data = (await response.json()) as { authorization_code: string; code_verifier: string }
|
|
392
|
+
const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
395
|
+
body: new URLSearchParams({
|
|
396
|
+
grant_type: "authorization_code",
|
|
397
|
+
code: data.authorization_code,
|
|
398
|
+
redirect_uri: `${ISSUER}/deviceauth/callback`,
|
|
399
|
+
client_id: CLIENT_ID,
|
|
400
|
+
code_verifier: data.code_verifier,
|
|
401
|
+
}).toString(),
|
|
402
|
+
})
|
|
403
|
+
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${tokenResponse.status}`)
|
|
404
|
+
const tokens = (await tokenResponse.json()) as TokenResponse
|
|
405
|
+
return {
|
|
406
|
+
type: "success" as const,
|
|
407
|
+
refresh: tokens.refresh_token,
|
|
408
|
+
access: tokens.access_token,
|
|
409
|
+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
410
|
+
accountId: extractAccountId(tokens),
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (response.status !== 403 && response.status !== 404) return { type: "failed" as const }
|
|
414
|
+
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
},
|
|
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.
|
|
425
|
+
"chat.headers": async (hookInput, output) => {
|
|
426
|
+
if (hookInput.model.providerID !== PROVIDER_ID) return
|
|
427
|
+
output.headers["originator"] = "codex_cli_ts"
|
|
428
|
+
output.headers["session-id"] = hookInput.sessionID
|
|
429
|
+
},
|
|
430
|
+
"chat.params": async (hookInput, output) => {
|
|
431
|
+
if (hookInput.model.providerID !== PROVIDER_ID) return
|
|
432
|
+
// Match codex cli: the Codex backend rejects an explicit max output cap.
|
|
433
|
+
output.maxOutputTokens = undefined
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
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.3",
|
|
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
|
],
|
|
@@ -301,7 +301,7 @@ export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
|
|
|
301
301
|
if (!key) {
|
|
302
302
|
return {
|
|
303
303
|
content: [
|
|
304
|
-
{ type: "text", text: "Weave dispatch unavailable: no router key (set WEAVE_ROUTER_KEY or run the --pi installer)." },
|
|
304
|
+
{ type: "text", text: "Weave Router dispatch unavailable: no router key (set WEAVE_ROUTER_KEY or run the --pi installer)." },
|
|
305
305
|
],
|
|
306
306
|
details: { results: [] as ChildResult[] },
|
|
307
307
|
isError: true,
|
|
@@ -337,7 +337,7 @@ export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
|
|
|
337
337
|
const preview = t.prompt.length > 60 ? `${t.prompt.slice(0, 60)}...` : t.prompt;
|
|
338
338
|
text += `\n ${theme.fg("dim", preview)}`;
|
|
339
339
|
}
|
|
340
|
-
if (tasks.length > 3) text += `\n ${theme.fg("muted",
|
|
340
|
+
if (tasks.length > 3) text += `\n ${theme.fg("muted", `… +${tasks.length - 3} more`)}`;
|
|
341
341
|
return new Text(text, 0, 0);
|
|
342
342
|
},
|
|
343
343
|
});
|
package/pi-router/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function registerWeave(pi: ExtensionAPI): void {
|
|
|
28
28
|
// key there is fatal rather than silently routing on quality knobs.
|
|
29
29
|
if (isSubagent()) {
|
|
30
30
|
throw new Error(
|
|
31
|
-
"Weave: no router key found (set WEAVE_ROUTER_KEY or write ~/.pi/agent/.weave_router_key).",
|
|
31
|
+
"Weave Router: no router key found (set WEAVE_ROUTER_KEY or write ~/.pi/agent/.weave_router_key).",
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
return;
|
|
@@ -23,7 +23,7 @@ export function registerRoutedModel(pi: ExtensionAPI): void {
|
|
|
23
23
|
|
|
24
24
|
if (ctx.hasUI) {
|
|
25
25
|
ctx.ui.setStatus(STATUS_KEY, `routed: ${model}`);
|
|
26
|
-
ctx.ui.notify(`Weave routed to ${model}`, "info");
|
|
26
|
+
ctx.ui.notify(`Weave Router routed to ${model}`, "info");
|
|
27
27
|
} else {
|
|
28
28
|
process.stderr.write(`${ROUTED_MODEL_STDERR_PREFIX} ${model}\n`);
|
|
29
29
|
}
|
package/uninstall.sh
CHANGED
|
@@ -54,7 +54,7 @@ while [ $# -gt 0 ]; do
|
|
|
54
54
|
;;
|
|
55
55
|
--dir)
|
|
56
56
|
install_dir="${2:-}"; shift 2
|
|
57
|
-
[ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
|
|
57
|
+
[ -n "$install_dir" ] || { err "--dir requires a path."; exit 2; }
|
|
58
58
|
;;
|
|
59
59
|
--codex)
|
|
60
60
|
target="codex"; shift
|
|
@@ -78,7 +78,7 @@ while [ $# -gt 0 ]; do
|
|
|
78
78
|
exit 0
|
|
79
79
|
;;
|
|
80
80
|
*)
|
|
81
|
-
err "
|
|
81
|
+
err "Unknown flag: $1. Run --help for usage."; exit 2
|
|
82
82
|
;;
|
|
83
83
|
esac
|
|
84
84
|
done
|
|
@@ -136,7 +136,7 @@ if [ "$target" = "opencode" ]; then
|
|
|
136
136
|
"~/"*) project_dir="$HOME/${project_dir#~/}" ;;
|
|
137
137
|
esac
|
|
138
138
|
if [ ! -d "$project_dir" ]; then
|
|
139
|
-
err "
|
|
139
|
+
err "Directory does not exist: $project_dir."
|
|
140
140
|
exit 1
|
|
141
141
|
fi
|
|
142
142
|
project_dir="$(cd "$project_dir" && pwd)"
|
|
@@ -155,14 +155,28 @@ if [ "$target" = "opencode" ]; then
|
|
|
155
155
|
opencode_config_file="$opencode_dir/opencode.json"
|
|
156
156
|
refuse_if_symlink "$opencode_config_file"
|
|
157
157
|
|
|
158
|
+
# Canonicalize the plugin path exactly as install.sh did (`cd … && pwd`) so
|
|
159
|
+
# the `plugin` array entry matches on removal — a raw "$opencode_dir/…" string
|
|
160
|
+
# can differ (symlinks, trailing slash) and leave the entry behind.
|
|
161
|
+
if [ -d "$opencode_dir" ]; then
|
|
162
|
+
opencode_plugin="$(cd "$opencode_dir" && pwd)/.weave/opencode-weave.ts"
|
|
163
|
+
else
|
|
164
|
+
opencode_plugin="$opencode_dir/.weave/opencode-weave.ts"
|
|
165
|
+
fi
|
|
158
166
|
if [ -f "$opencode_config_file" ]; then
|
|
159
|
-
# Strip
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
|
|
167
|
+
# Strip both managed providers (`weave`, `weave-codex`), the managed plugin
|
|
168
|
+
# 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.
|
|
173
|
+
cleaned="$(jq --arg plugin "$opencode_plugin" '
|
|
163
174
|
(if .provider.weave then del(.provider.weave) else . end)
|
|
175
|
+
| (if .provider["weave-codex"] then del(.provider["weave-codex"]) else . end)
|
|
164
176
|
| (if (.provider // {}) == {} then del(.provider) else . end)
|
|
165
|
-
| (if (.
|
|
177
|
+
| (if (.plugin | type) == "array" then .plugin -= [$plugin] else . end)
|
|
178
|
+
| (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)
|
|
166
180
|
' "$opencode_config_file")"
|
|
167
181
|
printf '%s\n' "$cleaned" >"$opencode_config_file"
|
|
168
182
|
|
|
@@ -179,6 +193,16 @@ if [ "$target" = "opencode" ]; then
|
|
|
179
193
|
info "No opencode config at $opencode_config_file (already uninstalled?)"
|
|
180
194
|
fi
|
|
181
195
|
|
|
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.
|
|
199
|
+
if [ -f "$opencode_plugin" ]; then
|
|
200
|
+
refuse_if_symlink "$opencode_plugin"
|
|
201
|
+
rm -f "$opencode_plugin"
|
|
202
|
+
rmdir "$opencode_dir/.weave" 2>/dev/null || true
|
|
203
|
+
ok "Removed $opencode_plugin"
|
|
204
|
+
fi
|
|
205
|
+
|
|
182
206
|
# Drop the toggle parked sidecar (holds the parked router model when off).
|
|
183
207
|
opencode_parked="$opencode_dir/.weave-parked.json"
|
|
184
208
|
if [ -f "$opencode_parked" ]; then
|
|
@@ -219,7 +243,7 @@ if [ "$target" = "pi" ]; then
|
|
|
219
243
|
"~/"*) project_dir="$HOME/${project_dir#~/}" ;;
|
|
220
244
|
esac
|
|
221
245
|
if [ ! -d "$project_dir" ]; then
|
|
222
|
-
err "
|
|
246
|
+
err "Directory does not exist: $project_dir."
|
|
223
247
|
exit 1
|
|
224
248
|
fi
|
|
225
249
|
project_dir="$(cd "$project_dir" && pwd)"
|
|
@@ -325,7 +349,7 @@ if [ "$target" = "codex" ]; then
|
|
|
325
349
|
"~/"*) project_dir="$HOME/${project_dir#~/}" ;;
|
|
326
350
|
esac
|
|
327
351
|
if [ ! -d "$project_dir" ]; then
|
|
328
|
-
err "
|
|
352
|
+
err "Directory does not exist: $project_dir."
|
|
329
353
|
exit 1
|
|
330
354
|
fi
|
|
331
355
|
project_dir="$(cd "$project_dir" && pwd)"
|
|
@@ -422,7 +446,7 @@ else
|
|
|
422
446
|
"~/"*) project_dir="$HOME/${project_dir#~/}" ;;
|
|
423
447
|
esac
|
|
424
448
|
if [ ! -d "$project_dir" ]; then
|
|
425
|
-
err "
|
|
449
|
+
err "Directory does not exist: $project_dir."
|
|
426
450
|
exit 1
|
|
427
451
|
fi
|
|
428
452
|
project_dir="$(cd "$project_dir" && pwd)"
|