@gnahz77/opencode-copilot-multi-auth 0.1.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/index.mjs +1211 -0
  4. package/package.json +12 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SST
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # opencode-copilot-multi-auth
2
+
3
+ Package on npm: https://www.npmjs.com/package/@gnahz77/opencode-copilot-multi-auth
4
+
5
+ This fork replaces the older GitHub Copilot chat-auth flow with the newer Copilot CLI-style OAuth flow and makes `opencode` use the live Copilot model metadata for your account.
6
+
7
+ ## How to use
8
+
9
+ Add the plugin to your `opencode` config:
10
+
11
+ ```json
12
+ {
13
+ "$schema": "https://opencode.ai/config.json",
14
+ "plugin": [
15
+ "@gnahz77/opencode-copilot-multi-auth@0.1.0"
16
+ ]
17
+ }
18
+ ```
19
+
20
+ Then start `opencode` and log in to the `github-copilot` provider. The plugin handles the Copilot CLI-style device flow and will reuse the stored GitHub OAuth token afterward.
21
+
22
+ For local development before publishing, you can load the file directly:
23
+
24
+ ```json
25
+ {
26
+ "$schema": "https://opencode.ai/config.json",
27
+ "plugin": [
28
+ "file:///absolute/path/to/index.mjs"
29
+ ]
30
+ }
31
+ ```
32
+
33
+ Important: if the file path contains `opencode-copilot-auth`, current `opencode` builds may skip loading it because of a hardcoded plugin-name filter. Use a path that does not contain that substring.
34
+
35
+ ## What changed in this fork
36
+
37
+ - Auth flow: uses the Copilot CLI-style OAuth client flow and keeps the GitHub OAuth token directly.
38
+ - Entitlement: fetches `/copilot_internal/user` and uses the entitlement-provided Copilot API base URL.
39
+ - Token exchange: does not call `/copilot_internal/v2/token`.
40
+ - Request profile: uses the newer `copilot-developer-cli` headers instead of the older chat profile.
41
+ - Model metadata: fetches the live Copilot `/models` response via the plugin `provider.models` hook so the final `opencode` model list comes from the entitlement-backed Copilot API.
42
+
43
+ ## Context window and model limits
44
+
45
+ The main practical difference from upstream is that this fork patches live per-model limits from Copilot instead of relying only on static metadata.
46
+
47
+ That means `opencode` can see the Copilot-advertised values for:
48
+
49
+ - `limit.context`
50
+ - `limit.input`
51
+ - `limit.output`
52
+
53
+ As of March 10, 2026, the live GitHub Copilot `/models` response used by this
54
+ fork exposes the Copilot CLI model profile. The table below compares the live
55
+ Copilot CLI context window against the static `github-copilot` catalog on
56
+ [`models.dev`](https://models.dev).
57
+
58
+ | Model | This Fork (CLI Context) | `models.dev` Context | Difference |
59
+ | ------------------- | ----------------------: | -------------------: | ---------: |
60
+ | `claude-opus-4.6` | 200,000 | 128,000 | +72,000 |
61
+ | `claude-sonnet-4.6` | 200,000 | 128,000 | +72,000 |
62
+ | `claude-haiku-4.5` | 144,000 | 128,000 | +16,000 |
63
+
64
+ The practical takeaway is that this fork exposes larger live Claude context
65
+ windows than the static `models.dev` values.
66
+
67
+ Examples observed with this fork:
68
+
69
+ - `claude-sonnet-4.6`
70
+ - context window: `200000`
71
+ - prompt/input limit: `168000`
72
+ - output limit: `32000`
73
+ - `claude-opus-4.6`
74
+ - context window: `200000`
75
+ - prompt/input limit: `168000`
76
+ - output limit: `64000`
77
+ - `claude-haiku-4.5`
78
+ - context window: `144000`
79
+ - prompt/input limit: `128000`
80
+ - output limit: `32000`
81
+
82
+ Without this patching, `opencode` may show stale or smaller limits depending on the static model catalog it started from.
83
+
84
+ ## Claude thinking budget behavior
85
+
86
+ This fork also changes Copilot Claude request behavior:
87
+
88
+ - when the `thinking` variant is selected, it sends `thinking_budget: 16000`
89
+ - when no variant is selected, it omits `thinking_budget` entirely
90
+
91
+ This differs from upstream `opencode`, which currently sends `thinking_budget: 4000` for the built-in `thinking` variant.
92
+
93
+ The plugin intentionally does not try to change the `opencode` core UI. So the visible Claude variant list is still controlled by `opencode` itself; this fork changes the request behavior, not the built-in variant picker labels.
94
+
95
+ ## Publishing
96
+
97
+ ```zsh
98
+ ./script/publish.ts
99
+ ```
100
+
101
+ ## Multi-Account Support
102
+
103
+ This fork can keep a pool of Copilot logins in `~/.local/share/opencode/copilot-auth.json`.
104
+ Each successful OAuth login updates that pool automatically: logging in with a new deployment-scoped account appends a new record, and logging in again with the same GitHub identity on the same deployment updates the existing record instead of creating a duplicate.
105
+
106
+ The pool stores one object per account under `accounts` in a `version: 1` document.
107
+
108
+ | Field | Meaning |
109
+ | --- | --- |
110
+ | `id` | Stable human-friendly identifier stored with the account record. |
111
+ | `name` | Display name for the account. |
112
+ | `enabled` | Whether the account can participate in automatic routing. Disabled accounts stay stored but are ignored for winner selection. |
113
+ | `priority` | Higher values win when multiple enabled accounts can serve the same raw model ID. |
114
+ | `allowlist` | Exact raw model IDs this account is allowed to serve. Empty means no allowlist restriction. |
115
+ | `blocklist` | Exact raw model IDs this account must never serve, even if its allowlist or priority would otherwise match. |
116
+
117
+ Automatic routing works on the raw Copilot model IDs that `opencode` already uses. The plugin filters eligible accounts by `enabled`, then applies `allowlist` and `blocklist`, and finally picks exactly one winning account by highest `priority` (with a stable key-based tie-breaker). The model ID itself is not rewritten, so account identity does not appear in model IDs.
118
+
119
+ Example pool file:
120
+
121
+ ```json
122
+ {
123
+ "version": 1,
124
+ "accounts": [
125
+ {
126
+ "key": "github.com:12345678",
127
+ "id": "work",
128
+ "name": "Work",
129
+ "enabled": true,
130
+ "priority": 100,
131
+ "allowlist": ["claude-sonnet-4.6"],
132
+ "blocklist": [],
133
+ "deployment": "github.com",
134
+ "domain": "github.com",
135
+ "baseUrl": "https://api.githubcopilot.com",
136
+ "identity": {
137
+ "login": "octocat",
138
+ "userId": 12345678
139
+ },
140
+ "auth": {
141
+ "type": "oauth",
142
+ "refresh": "<oauth token>"
143
+ },
144
+ "createdAt": "2026-04-15T00:00:00.000Z",
145
+ "updatedAt": "2026-04-15T00:00:00.000Z"
146
+ }
147
+ ]
148
+ }
149
+ ```
package/index.mjs ADDED
@@ -0,0 +1,1211 @@
1
+ import { homedir } from "os";
2
+ import {
3
+ readFileSync,
4
+ writeFileSync,
5
+ mkdirSync,
6
+ renameSync,
7
+ chmodSync,
8
+ existsSync,
9
+ } from "fs";
10
+ import { dirname } from "path";
11
+
12
+ const ACCOUNT_POOL_SCHEMA_VERSION = 1;
13
+ const ROUTING_ACCOUNT_KEY_HEADER = "x-opencode-copilot-account-key";
14
+ const ROUTING_SOURCE_HEADER = "x-opencode-copilot-route-source";
15
+ const INTERNAL_ROUTING_HEADERS = new Set([
16
+ ROUTING_ACCOUNT_KEY_HEADER,
17
+ ROUTING_SOURCE_HEADER,
18
+ ]);
19
+
20
+ export function getPoolPath() {
21
+ return `${homedir()}/.local/share/opencode/copilot-auth.json`;
22
+ }
23
+
24
+ function validatePoolSchema(pool, context) {
25
+ if (
26
+ !pool
27
+ || typeof pool !== "object"
28
+ || pool.version !== ACCOUNT_POOL_SCHEMA_VERSION
29
+ || !Array.isArray(pool.accounts)
30
+ ) {
31
+ throw new Error(
32
+ `[opencode-copilot-cli-auth] Invalid ${context}: expected { version: ${ACCOUNT_POOL_SCHEMA_VERSION}, accounts: [] } schema.`,
33
+ );
34
+ }
35
+
36
+ return pool;
37
+ }
38
+
39
+ export function readPool() {
40
+ const poolPath = getPoolPath();
41
+
42
+ if (!existsSync(poolPath)) {
43
+ const defaultPool = {
44
+ version: ACCOUNT_POOL_SCHEMA_VERSION,
45
+ accounts: [],
46
+ };
47
+ writePool(defaultPool);
48
+ return defaultPool;
49
+ }
50
+
51
+ const raw = readFileSync(poolPath, "utf8");
52
+
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch (error) {
57
+ throw new Error(
58
+ `[opencode-copilot-cli-auth] Malformed JSON in account pool file at ${poolPath}: ${error instanceof Error ? error.message : String(error)}`,
59
+ );
60
+ }
61
+
62
+ return validatePoolSchema(parsed, `account pool file at ${poolPath}`);
63
+ }
64
+
65
+ export function writePool(pool) {
66
+ const validatedPool = validatePoolSchema(pool, "account pool payload");
67
+ const poolPath = getPoolPath();
68
+ const dirPath = dirname(poolPath);
69
+ const tmpPath = `${poolPath}.tmp`;
70
+
71
+ mkdirSync(dirPath, { recursive: true });
72
+ writeFileSync(tmpPath, `${JSON.stringify(validatedPool, null, 2)}\n`, "utf8");
73
+ renameSync(tmpPath, poolPath);
74
+ chmodSync(poolPath, 0o600);
75
+ }
76
+
77
+ function normalizeHeaderObject(headers) {
78
+ if (!headers) {
79
+ return {};
80
+ }
81
+
82
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
83
+ return Object.fromEntries(headers.entries());
84
+ }
85
+
86
+ if (Array.isArray(headers)) {
87
+ return Object.fromEntries(headers);
88
+ }
89
+
90
+ return { ...headers };
91
+ }
92
+
93
+ function normalizeList(value) {
94
+ return Array.isArray(value) ? value : [];
95
+ }
96
+
97
+ function normalizePriority(value) {
98
+ return Number.isInteger(value) ? value : 0;
99
+ }
100
+
101
+ function normalizeDomain(urlOrDomain) {
102
+ if (!urlOrDomain || typeof urlOrDomain !== "string") {
103
+ return "github.com";
104
+ }
105
+
106
+ const value = urlOrDomain.trim();
107
+ if (!value) {
108
+ return "github.com";
109
+ }
110
+
111
+ try {
112
+ const parsed = value.includes("://")
113
+ ? new URL(value)
114
+ : new URL(`https://${value}`);
115
+ return parsed.hostname.toLowerCase();
116
+ } catch { // intentional: fall back to regex-based normalization if URL parsing fails
117
+ return value
118
+ .replace(/^https?:\/\//i, "")
119
+ .replace(/\/.*$/, "")
120
+ .replace(/\/+$/, "")
121
+ .toLowerCase();
122
+ }
123
+ }
124
+
125
+ function normalizeIdSource(value) {
126
+ return String(value ?? "")
127
+ .toLowerCase()
128
+ .replace(/[^a-z0-9-]/g, "-")
129
+ .replace(/-+/g, "-")
130
+ .replace(/^-+|-+$/g, "");
131
+ }
132
+
133
+ function preserveStringOrDefault(value, fallback) {
134
+ if (typeof value === "string" && value.trim()) {
135
+ return value;
136
+ }
137
+ return fallback;
138
+ }
139
+
140
+ function deriveDefaultAccountId(accounts, key, identity) {
141
+ const userIdText = String(identity.userId);
142
+ const baseId = normalizeIdSource(identity.login || userIdText) || userIdText;
143
+ const idTakenByDifferentKey = accounts.some(
144
+ (account) => account?.id === baseId && account?.key !== key,
145
+ );
146
+
147
+ if (!idTakenByDifferentKey) {
148
+ return baseId;
149
+ }
150
+
151
+ return `${baseId}-${userIdText.slice(-6)}`;
152
+ }
153
+
154
+ export function deriveAccountKey(deployment, userId) {
155
+ return `${deployment}:${userId}`;
156
+ }
157
+
158
+ export async function lookupGitHubIdentity(accessToken, enterpriseUrl) {
159
+ const deployment = enterpriseUrl ? normalizeDomain(enterpriseUrl) : "github.com";
160
+ const apiDomain = deployment === "github.com" ? "api.github.com" : `api.${deployment}`;
161
+ const identityUrl = `https://${apiDomain}/user`;
162
+
163
+ const response = await fetch(identityUrl, {
164
+ method: "GET",
165
+ headers: {
166
+ Accept: "application/json",
167
+ Authorization: `Bearer ${accessToken}`,
168
+ },
169
+ });
170
+
171
+ if (!response.ok) {
172
+ throw new Error(
173
+ `[opencode-copilot-cli-auth] Failed to lookup GitHub identity from ${identityUrl}: ${response.status} ${response.statusText}`,
174
+ );
175
+ }
176
+
177
+ const payload = await response.json();
178
+ const userId = Number(payload?.id);
179
+ if (!Number.isFinite(userId)) {
180
+ throw new Error(
181
+ "[opencode-copilot-cli-auth] Failed to lookup GitHub identity: response did not include a numeric user id.",
182
+ );
183
+ }
184
+
185
+ return {
186
+ login: typeof payload?.login === "string" ? payload.login : "",
187
+ userId,
188
+ };
189
+ }
190
+
191
+ export function upsertAccount(pool, accountData) {
192
+ const validatedPool = validatePoolSchema(pool, "account pool payload");
193
+ const now = new Date().toISOString();
194
+ const {
195
+ key,
196
+ deployment,
197
+ domain,
198
+ identity,
199
+ enterpriseUrl,
200
+ baseUrl,
201
+ auth,
202
+ authResult,
203
+ } = accountData ?? {};
204
+
205
+ if (!key || typeof key !== "string") {
206
+ throw new Error("[opencode-copilot-cli-auth] Cannot upsert account: missing key.");
207
+ }
208
+
209
+ const userId = Number(identity?.userId);
210
+ if (!Number.isFinite(userId)) {
211
+ throw new Error("[opencode-copilot-cli-auth] Cannot upsert account: missing numeric identity.userId.");
212
+ }
213
+
214
+ const login = typeof identity?.login === "string" ? identity.login : "";
215
+ const normalizedDeployment = normalizeDomain(deployment || domain || enterpriseUrl || "github.com");
216
+ const normalizedDomain = normalizeDomain(domain || normalizedDeployment);
217
+ const normalizedEnterpriseUrl =
218
+ normalizedDeployment === "github.com"
219
+ ? null
220
+ : normalizeDomain(enterpriseUrl || normalizedDeployment);
221
+ const normalizedIdentity = {
222
+ login,
223
+ userId,
224
+ };
225
+ const defaultId = deriveDefaultAccountId(validatedPool.accounts, key, normalizedIdentity);
226
+ const defaultName = login || String(userId);
227
+ const mergedAuth = auth ?? authResult ?? {};
228
+ const nextBaseUrl = baseUrl ?? authResult?.baseUrl ?? null;
229
+
230
+ const existingIndex = validatedPool.accounts.findIndex((account) => account?.key === key);
231
+ if (existingIndex === -1) {
232
+ return {
233
+ ...validatedPool,
234
+ accounts: [
235
+ ...validatedPool.accounts,
236
+ {
237
+ key,
238
+ id: defaultId,
239
+ name: defaultName,
240
+ enabled: true,
241
+ priority: 0,
242
+ deployment: normalizedDeployment,
243
+ domain: normalizedDomain,
244
+ identity: normalizedIdentity,
245
+ enterpriseUrl: normalizedEnterpriseUrl,
246
+ baseUrl: nextBaseUrl,
247
+ allowlist: [],
248
+ blocklist: [],
249
+ auth: mergedAuth,
250
+ createdAt: now,
251
+ updatedAt: now,
252
+ },
253
+ ],
254
+ };
255
+ }
256
+
257
+ const existing = validatedPool.accounts[existingIndex];
258
+ const updatedAccount = {
259
+ ...existing,
260
+ key,
261
+ deployment: normalizedDeployment,
262
+ domain: normalizedDomain,
263
+ identity: normalizedIdentity,
264
+ enterpriseUrl: normalizedEnterpriseUrl,
265
+ auth: mergedAuth,
266
+ baseUrl: nextBaseUrl ?? existing.baseUrl ?? null,
267
+ id: preserveStringOrDefault(existing.id, defaultId),
268
+ name: preserveStringOrDefault(existing.name, defaultName),
269
+ enabled: typeof existing.enabled === "boolean" ? existing.enabled : true,
270
+ priority: Number.isFinite(existing.priority) ? existing.priority : 0,
271
+ allowlist: Array.isArray(existing.allowlist) ? existing.allowlist : [],
272
+ blocklist: Array.isArray(existing.blocklist) ? existing.blocklist : [],
273
+ createdAt: existing.createdAt ?? now,
274
+ updatedAt: now,
275
+ };
276
+
277
+ const nextAccounts = [...validatedPool.accounts];
278
+ nextAccounts[existingIndex] = updatedAccount;
279
+
280
+ return {
281
+ ...validatedPool,
282
+ accounts: nextAccounts,
283
+ };
284
+ }
285
+
286
+ export function resolveWinnerAccount(rawModelId, pool) {
287
+ const candidates = (Array.isArray(pool?.accounts) ? pool.accounts : [])
288
+ .filter((account) => account?.enabled !== false)
289
+ .filter((account) => {
290
+ const allowlist = normalizeList(account?.allowlist);
291
+ return allowlist.length === 0 || allowlist.includes(rawModelId);
292
+ })
293
+ .filter((account) => !normalizeList(account?.blocklist).includes(rawModelId))
294
+ .sort((left, right) => {
295
+ const priorityDelta = normalizePriority(right?.priority) - normalizePriority(left?.priority);
296
+ if (priorityDelta !== 0) {
297
+ return priorityDelta;
298
+ }
299
+
300
+ return String(left?.key ?? "").localeCompare(String(right?.key ?? ""));
301
+ });
302
+
303
+ return candidates[0] ?? null;
304
+ }
305
+
306
+ export function injectRoutingHeaders(headers, accountKey) {
307
+ return {
308
+ ...normalizeHeaderObject(headers),
309
+ [ROUTING_ACCOUNT_KEY_HEADER]: accountKey,
310
+ [ROUTING_SOURCE_HEADER]: "model-resolution",
311
+ };
312
+ }
313
+
314
+ export function stripRoutingHeaders(headers) {
315
+ return Object.fromEntries(
316
+ Object.entries(normalizeHeaderObject(headers)).filter(
317
+ ([key]) => !INTERNAL_ROUTING_HEADERS.has(key.toLowerCase()),
318
+ ),
319
+ );
320
+ }
321
+
322
+ /**
323
+ * @type {import("@opencode-ai/plugin").Plugin}
324
+ */
325
+ export async function CopilotAuthPlugin(input = {}) {
326
+ const CLIENT_ID = "Ov23ctDVkRmgkPke0Mmm";
327
+ const API_VERSION = "2025-05-01";
328
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
329
+ const OAUTH_SCOPES = "read:user read:org repo gist";
330
+ const RESPONSES_API_ALTERNATE_INPUT_TYPES = [
331
+ "file_search_call",
332
+ "computer_call",
333
+ "computer_call_output",
334
+ "web_search_call",
335
+ "function_call",
336
+ "function_call_output",
337
+ "image_generation_call",
338
+ "code_interpreter_call",
339
+ "local_shell_call",
340
+ "local_shell_call_output",
341
+ "mcp_list_tools",
342
+ "mcp_approval_request",
343
+ "mcp_approval_response",
344
+ "mcp_call",
345
+ "reasoning",
346
+ ];
347
+
348
+ function getUrls(domain) {
349
+ const apiDomain = domain === "github.com" ? "api.github.com" : `api.${domain}`;
350
+ return {
351
+ DEVICE_CODE_URL: `https://${domain}/login/device/code`,
352
+ ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
353
+ COPILOT_ENTITLEMENT_URL: `https://${apiDomain}/copilot_internal/user`,
354
+ };
355
+ }
356
+
357
+ async function fetchEntitlement(info) {
358
+ const domain = info.enterpriseUrl ? normalizeDomain(info.enterpriseUrl) : "github.com";
359
+ const urls = getUrls(domain);
360
+
361
+ const response = await fetch(urls.COPILOT_ENTITLEMENT_URL, {
362
+ headers: {
363
+ Accept: "application/json",
364
+ Authorization: `Bearer ${info.refresh}`,
365
+ "User-Agent": "GithubCopilot/1.155.0",
366
+ },
367
+ });
368
+
369
+ if (!response.ok) {
370
+ throw new Error(`[opencode-copilot-cli-auth] Entitlement fetch failed: ${response.status}`);
371
+ }
372
+
373
+ return response.json();
374
+ }
375
+
376
+ async function getBaseURL(info) {
377
+ if (info.baseUrl) return info.baseUrl;
378
+ const entitlement = await fetchEntitlement(info);
379
+ return entitlement?.endpoints?.api;
380
+ }
381
+
382
+ async function fetchModels(info, baseURL) {
383
+ const response = await fetch(`${baseURL}/models`, {
384
+ headers: {
385
+ Authorization: `Bearer ${info.refresh}`,
386
+ "Copilot-Integration-Id": "copilot-developer-cli",
387
+ "Openai-Intent": "model-access",
388
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
389
+ "X-GitHub-Api-Version": API_VERSION,
390
+ "X-Interaction-Type": "model-access",
391
+ "X-Request-Id": crypto.randomUUID(),
392
+ },
393
+ });
394
+
395
+ if (!response.ok) {
396
+ throw new Error(`[opencode-copilot-cli-auth] Model fetch failed: ${response.status}`);
397
+ }
398
+
399
+ const data = await response.json();
400
+ return Array.isArray(data?.data) ? data.data : [];
401
+ }
402
+
403
+ function zeroCost() {
404
+ return {
405
+ input: 0,
406
+ output: 0,
407
+ cache: {
408
+ read: 0,
409
+ write: 0,
410
+ },
411
+ };
412
+ }
413
+
414
+ function isLiveChatModel(model) {
415
+ return model?.capabilities?.type === "chat";
416
+ }
417
+
418
+ function isPickerModel(model) {
419
+ return isLiveChatModel(model) && model?.model_picker_enabled !== false;
420
+ }
421
+
422
+ function getReleaseDate(id, version, fallback = "") {
423
+ if (typeof version === "string" && version.startsWith(`${id}-`)) {
424
+ return version.slice(id.length + 1);
425
+ }
426
+ return version || fallback;
427
+ }
428
+
429
+ function createProviderModel(existing, live, baseURL) {
430
+ const limits = live.capabilities?.limits ?? {};
431
+ const supports = live.capabilities?.supports ?? {};
432
+ const vision = !!supports.vision || !!limits.vision;
433
+ const reasoning =
434
+ existing?.capabilities?.reasoning
435
+ ?? (
436
+ !!supports.adaptive_thinking
437
+ || typeof supports.max_thinking_budget === "number"
438
+ || Array.isArray(supports.reasoning_effort)
439
+ );
440
+
441
+ return {
442
+ ...structuredClone(existing ?? {}),
443
+ id: live.id,
444
+ api: {
445
+ ...(existing?.api ?? {}),
446
+ id: live.id,
447
+ url: baseURL,
448
+ npm: "@ai-sdk/github-copilot",
449
+ },
450
+ name: live.name ?? existing?.name ?? live.id,
451
+ family: live.capabilities?.family ?? existing?.family ?? "",
452
+ cost: zeroCost(),
453
+ limit: {
454
+ context:
455
+ limits.max_context_window_tokens
456
+ ?? existing?.limit?.context
457
+ ?? 0,
458
+ input:
459
+ limits.max_prompt_tokens
460
+ ?? existing?.limit?.input
461
+ ?? limits.max_context_window_tokens,
462
+ output:
463
+ limits.max_output_tokens
464
+ ?? limits.max_non_streaming_output_tokens
465
+ ?? existing?.limit?.output
466
+ ?? 0,
467
+ },
468
+ capabilities: {
469
+ temperature: existing?.capabilities?.temperature ?? true,
470
+ reasoning,
471
+ attachment: existing?.capabilities?.attachment ?? vision,
472
+ toolcall: !!supports.tool_calls,
473
+ input: {
474
+ text: existing?.capabilities?.input?.text ?? true,
475
+ audio: existing?.capabilities?.input?.audio ?? false,
476
+ image: existing?.capabilities?.input?.image ?? vision,
477
+ video: existing?.capabilities?.input?.video ?? false,
478
+ pdf: existing?.capabilities?.input?.pdf ?? false,
479
+ },
480
+ output: {
481
+ text: existing?.capabilities?.output?.text ?? true,
482
+ audio: existing?.capabilities?.output?.audio ?? false,
483
+ image: existing?.capabilities?.output?.image ?? false,
484
+ video: existing?.capabilities?.output?.video ?? false,
485
+ pdf: existing?.capabilities?.output?.pdf ?? false,
486
+ },
487
+ interleaved: existing?.capabilities?.interleaved ?? false,
488
+ },
489
+ options: existing?.options ?? {},
490
+ headers: existing?.headers ?? {},
491
+ release_date: getReleaseDate(live.id, live.version, existing?.release_date ?? ""),
492
+ variants: existing?.variants ?? {},
493
+ status: "active",
494
+ };
495
+ }
496
+
497
+ function buildProviderModels(existingModels, liveModels, baseURL) {
498
+ const existingById = new Map(
499
+ Object.values(existingModels ?? {}).map((model) => [model?.api?.id ?? model?.id, model]),
500
+ );
501
+
502
+ return Object.fromEntries(
503
+ liveModels
504
+ .filter(isPickerModel)
505
+ .map((model) => [
506
+ model.id,
507
+ createProviderModel(existingById.get(model.id), model, baseURL),
508
+ ]),
509
+ );
510
+ }
511
+
512
+ function normalizeExistingModels(existingModels, baseURL) {
513
+ return Object.fromEntries(
514
+ Object.entries(existingModels ?? {}).map(([id, model]) => [
515
+ id,
516
+ {
517
+ ...structuredClone(model),
518
+ cost: zeroCost(),
519
+ api: {
520
+ ...model.api,
521
+ url: baseURL ?? model.api?.url,
522
+ npm: "@ai-sdk/github-copilot",
523
+ },
524
+ },
525
+ ]),
526
+ );
527
+ }
528
+
529
+ async function resolveProviderModels(existingModels, auth) {
530
+ const baseURL = auth ? await getBaseURL(auth) : undefined;
531
+ if (!auth || auth.type !== "oauth" || !baseURL) {
532
+ return normalizeExistingModels(existingModels, baseURL);
533
+ }
534
+
535
+ const liveModels = await fetchModels(auth, baseURL);
536
+ return buildProviderModels(existingModels, liveModels, baseURL);
537
+ }
538
+
539
+ async function buildPoolBackedModels(existingModels, pool) {
540
+ const enabledAccounts = (Array.isArray(pool?.accounts) ? pool.accounts : [])
541
+ .filter((account) => account?.enabled !== false);
542
+
543
+ if (enabledAccounts.length === 0) {
544
+ return {};
545
+ }
546
+
547
+ const candidatesByModel = new Map();
548
+
549
+ for (const account of enabledAccounts) {
550
+ try {
551
+ const baseURL = account.baseUrl ?? await getBaseURL(account.auth);
552
+ const liveModels = await fetchModels(account.auth, baseURL);
553
+
554
+ for (const liveModel of liveModels.filter(isPickerModel)) {
555
+ const winner = resolveWinnerAccount(liveModel.id, pool);
556
+ if (winner?.key === account.key) {
557
+ candidatesByModel.set(liveModel.id, {
558
+ account,
559
+ liveModel,
560
+ baseURL,
561
+ });
562
+ }
563
+ }
564
+ } catch (error) {
565
+ console.warn(
566
+ `[opencode-copilot-cli-auth] Skipping account ${account?.key ?? "unknown"} during model sync:`,
567
+ error?.message ?? error,
568
+ );
569
+ }
570
+ }
571
+
572
+ const existingById = new Map(
573
+ Object.values(existingModels ?? {}).map((model) => [model?.api?.id ?? model?.id, model]),
574
+ );
575
+
576
+ return Object.fromEntries(
577
+ [...candidatesByModel.entries()]
578
+ .sort(([left], [right]) => left.localeCompare(right))
579
+ .map(([rawModelId, { liveModel, baseURL }]) => [
580
+ rawModelId,
581
+ createProviderModel(existingById.get(rawModelId), liveModel, baseURL),
582
+ ]),
583
+ );
584
+ }
585
+
586
+ function getHeader(headers, name) {
587
+ if (!headers) return undefined;
588
+ const target = name.toLowerCase();
589
+
590
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
591
+ return headers.get(name) ?? headers.get(target) ?? undefined;
592
+ }
593
+
594
+ if (Array.isArray(headers)) {
595
+ const found = headers.find(([key]) => String(key).toLowerCase() === target);
596
+ return found?.[1];
597
+ }
598
+
599
+ for (const [key, value] of Object.entries(headers)) {
600
+ if (key.toLowerCase() === target) {
601
+ return value;
602
+ }
603
+ }
604
+
605
+ return undefined;
606
+ }
607
+
608
+ function getConversationMetadata(init) {
609
+ try {
610
+ const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body;
611
+
612
+ if (body?.messages) {
613
+ const lastMessage = body.messages[body.messages.length - 1];
614
+ return {
615
+ isVision: body.messages.some(
616
+ (message) =>
617
+ Array.isArray(message.content) &&
618
+ message.content.some((part) => part.type === "image_url"),
619
+ ),
620
+ isAgent:
621
+ lastMessage?.role &&
622
+ ["tool", "assistant"].includes(lastMessage.role),
623
+ };
624
+ }
625
+
626
+ if (body?.input) {
627
+ const lastInput = body.input[body.input.length - 1];
628
+ const isAssistant = lastInput?.role === "assistant";
629
+ const hasAgentType = lastInput?.type
630
+ ? RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(lastInput.type)
631
+ : false;
632
+
633
+ return {
634
+ isVision:
635
+ Array.isArray(lastInput?.content) &&
636
+ lastInput.content.some((part) => part.type === "input_image"),
637
+ isAgent: isAssistant || hasAgentType,
638
+ };
639
+ }
640
+ } catch {} // intentional: return safe defaults on any parse/inspection error
641
+
642
+ return {
643
+ isVision: false,
644
+ isAgent: false,
645
+ };
646
+ }
647
+
648
+ function getRequestedRawModelId(init) {
649
+ try {
650
+ const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body;
651
+ return typeof body?.model === "string" && body.model.trim() ? body.model.trim() : undefined;
652
+ } catch { // intentional: return undefined on body parse error
653
+ return undefined;
654
+ }
655
+ }
656
+
657
+ function applyBaseURLToRequestInput(input, baseURL) {
658
+ if (!baseURL) {
659
+ return input;
660
+ }
661
+
662
+ try {
663
+ const original =
664
+ typeof input === "string" || input instanceof URL
665
+ ? new URL(String(input))
666
+ : typeof Request !== "undefined" && input instanceof Request
667
+ ? new URL(input.url)
668
+ : null;
669
+
670
+ if (!original) {
671
+ return input;
672
+ }
673
+
674
+ const nextBase = new URL(baseURL);
675
+ const nextUrl = new URL(`${original.pathname}${original.search}${original.hash}`, nextBase);
676
+
677
+ if (typeof Request !== "undefined" && input instanceof Request) {
678
+ return new Request(nextUrl.toString(), input);
679
+ }
680
+
681
+ return nextUrl.toString();
682
+ } catch { // intentional: return original input on URL rewrite error
683
+ return input;
684
+ }
685
+ }
686
+
687
+ function buildHeaders(init, info, isVision, isAgent) {
688
+ const explicitInitiator = getHeader(init?.headers, "x-initiator");
689
+ const headers = {
690
+ ...(init?.headers ?? {}),
691
+ Authorization: `Bearer ${info.refresh}`,
692
+ "Copilot-Integration-Id": "copilot-developer-cli",
693
+ "Openai-Intent": "conversation-agent",
694
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
695
+ "X-GitHub-Api-Version": API_VERSION,
696
+ "X-Initiator": explicitInitiator ?? (isAgent ? "agent" : "user"),
697
+ "X-Interaction-Id": crypto.randomUUID(),
698
+ "X-Interaction-Type": "conversation-agent",
699
+ "X-Request-Id": crypto.randomUUID(),
700
+ };
701
+
702
+ if (isVision) {
703
+ headers["Copilot-Vision-Request"] = "true";
704
+ }
705
+
706
+ delete headers["x-api-key"];
707
+ delete headers["authorization"];
708
+ delete headers["x-initiator"];
709
+
710
+ return stripRoutingHeaders(headers);
711
+ }
712
+
713
+ function getSelectedAccountMissingError() {
714
+ return new Error(
715
+ "[opencode-copilot-cli-auth] Selected account is disabled or not found; re-login required",
716
+ );
717
+ }
718
+
719
+ function getSelectedAccountExpiredError(accountKey) {
720
+ return new Error(
721
+ `[opencode-copilot-cli-auth] Account auth expired for ${accountKey}; re-login required`,
722
+ );
723
+ }
724
+
725
+ function isValidBaseURL(value) {
726
+ if (typeof value !== "string" || !value.trim()) {
727
+ return false;
728
+ }
729
+
730
+ try {
731
+ new URL(value);
732
+ return true;
733
+ } catch { // intentional: invalid URL means value is not a valid URL
734
+ return false;
735
+ }
736
+ }
737
+
738
+ function resolveSelectedPoolAccount(pool, accountKey, requestedRawModelId) {
739
+ const accounts = Array.isArray(pool?.accounts) ? pool.accounts : [];
740
+
741
+ if (accounts.length === 0) {
742
+ return null;
743
+ }
744
+
745
+ let selectedAccount = null;
746
+
747
+ if (accountKey) {
748
+ const headerAccount = accounts.find((account) => account?.key === accountKey);
749
+ if (!headerAccount || headerAccount.enabled === false) {
750
+ throw getSelectedAccountMissingError();
751
+ }
752
+ selectedAccount = headerAccount;
753
+ }
754
+
755
+ if (requestedRawModelId) {
756
+ const winner = resolveWinnerAccount(requestedRawModelId, pool);
757
+ if (!winner) {
758
+ throw new Error(
759
+ `[opencode-copilot-cli-auth] No eligible account found for model ${requestedRawModelId}; re-login required`,
760
+ );
761
+ }
762
+
763
+ if (selectedAccount && winner.key !== selectedAccount.key) {
764
+ throw new Error(
765
+ `[opencode-copilot-cli-auth] Selected account cannot serve model ${requestedRawModelId}; re-login required`,
766
+ );
767
+ }
768
+
769
+ selectedAccount = winner;
770
+ }
771
+
772
+ if (!selectedAccount) {
773
+ throw new Error("[opencode-copilot-cli-auth] No eligible account found for routed request; re-login required");
774
+ }
775
+
776
+ if (
777
+ selectedAccount.auth?.type !== "oauth"
778
+ || typeof selectedAccount.auth.refresh !== "string"
779
+ || !selectedAccount.auth.refresh.trim()
780
+ ) {
781
+ throw getSelectedAccountExpiredError(selectedAccount.key ?? "unknown");
782
+ }
783
+
784
+ return selectedAccount;
785
+ }
786
+
787
+ async function refreshSelectedAccountBaseURL(accountKey) {
788
+ const currentPool = readPool();
789
+ const accountIndex = currentPool.accounts.findIndex((account) => account?.key === accountKey);
790
+ const currentAccount = accountIndex >= 0 ? currentPool.accounts[accountIndex] : null;
791
+
792
+ if (!currentAccount || currentAccount.enabled === false) {
793
+ throw getSelectedAccountMissingError();
794
+ }
795
+
796
+ if (
797
+ currentAccount.auth?.type !== "oauth"
798
+ || typeof currentAccount.auth.refresh !== "string"
799
+ || !currentAccount.auth.refresh.trim()
800
+ ) {
801
+ throw getSelectedAccountExpiredError(currentAccount.key ?? accountKey ?? "unknown");
802
+ }
803
+
804
+ let entitlement;
805
+ try {
806
+ entitlement = await fetchEntitlement({
807
+ refresh: currentAccount.auth.refresh,
808
+ enterpriseUrl: currentAccount.enterpriseUrl ?? null,
809
+ });
810
+ } catch { // intentional: entitlement fetch failure means account is expired/invalid; propagate as account error
811
+ throw getSelectedAccountExpiredError(currentAccount.key);
812
+ }
813
+
814
+ const nextBaseURL = entitlement?.endpoints?.api;
815
+ if (!isValidBaseURL(nextBaseURL)) {
816
+ throw getSelectedAccountExpiredError(currentAccount.key);
817
+ }
818
+
819
+ const updatedPool = {
820
+ ...currentPool,
821
+ accounts: currentPool.accounts.map((account, index) =>
822
+ index === accountIndex
823
+ ? {
824
+ ...account,
825
+ baseUrl: nextBaseURL,
826
+ updatedAt: new Date().toISOString(),
827
+ }
828
+ : account
829
+ ),
830
+ };
831
+
832
+ writePool(updatedPool);
833
+ return updatedPool.accounts[accountIndex];
834
+ }
835
+
836
+ async function fetchWithSelectedAccount(input, init, selectedAccount) {
837
+ const { isVision, isAgent } = getConversationMetadata(init);
838
+
839
+ const dispatch = async (account) => {
840
+ const headers = buildHeaders(init, account.auth, isVision, isAgent);
841
+ const requestInput = applyBaseURLToRequestInput(input, account.baseUrl);
842
+
843
+ return fetch(requestInput, {
844
+ ...init,
845
+ headers,
846
+ });
847
+ };
848
+
849
+ let activeAccount = selectedAccount;
850
+ if (!isValidBaseURL(activeAccount.baseUrl)) {
851
+ activeAccount = await refreshSelectedAccountBaseURL(activeAccount.key);
852
+ }
853
+
854
+ const response = await dispatch(activeAccount);
855
+ if (![401, 403].includes(response.status)) {
856
+ return response;
857
+ }
858
+
859
+ const refreshedAccount = await refreshSelectedAccountBaseURL(activeAccount.key);
860
+ const retryResponse = await dispatch(refreshedAccount);
861
+ if ([401, 403].includes(retryResponse.status)) {
862
+ throw getSelectedAccountExpiredError(refreshedAccount.key);
863
+ }
864
+
865
+ return retryResponse;
866
+ }
867
+
868
+ function resolveClaudeThinkingBudget(model, variant) {
869
+ if (!model?.id?.includes("claude")) return undefined;
870
+ return variant === "thinking" ? 16000 : undefined;
871
+ }
872
+
873
+ return {
874
+ provider: {
875
+ id: "github-copilot",
876
+ models: async (provider, ctx) => {
877
+ try {
878
+ const pool = readPool();
879
+ if (pool.accounts.length > 0) {
880
+ return await buildPoolBackedModels(provider.models, pool);
881
+ }
882
+
883
+ return await resolveProviderModels(provider.models, ctx.auth);
884
+ } catch (error) {
885
+ console.warn("[opencode-copilot-cli-auth] Failed to sync live Copilot models.", error?.message ?? error);
886
+ return normalizeExistingModels(provider.models);
887
+ }
888
+ },
889
+ },
890
+ auth: {
891
+ provider: "github-copilot",
892
+ loader: async (getAuth) => {
893
+ const info = await getAuth();
894
+ let poolFirstEnabled;
895
+
896
+ try {
897
+ const currentPool = readPool();
898
+ poolFirstEnabled = currentPool.accounts.find((account) => account?.enabled !== false);
899
+ if (currentPool.accounts.length === 0 && info && info.type === "oauth") {
900
+ // Best-effort migration bridge for legacy single-account auth.
901
+ // We intentionally avoid identity lookup here because loader runs on hot paths,
902
+ // and we do not have a stable userId without making a network request.
903
+ // The next successful OAuth authorize callback performs canonical persistence.
904
+ }
905
+ } catch {} // intentional: pool read/parse errors fall back to legacy singleton auth
906
+
907
+ const baseSource =
908
+ poolFirstEnabled?.auth?.type === "oauth"
909
+ ? poolFirstEnabled.auth
910
+ : info && info.type === "oauth"
911
+ ? info
912
+ : null;
913
+ if (!baseSource) return {};
914
+
915
+ const baseURL = poolFirstEnabled?.baseUrl ?? await getBaseURL(baseSource);
916
+
917
+ return {
918
+ ...(baseURL && { baseURL }),
919
+ apiKey: "",
920
+ async fetch(input, init) {
921
+ const pool = readPool();
922
+ const accountKey = getHeader(init?.headers, ROUTING_ACCOUNT_KEY_HEADER);
923
+ const requestedRawModelId = getRequestedRawModelId(init);
924
+
925
+ if (pool.accounts.length > 0) {
926
+ const selectedAccount = resolveSelectedPoolAccount(pool, accountKey, requestedRawModelId);
927
+ if (selectedAccount?.auth?.type === "oauth") {
928
+ return fetchWithSelectedAccount(input, init, selectedAccount);
929
+ }
930
+ }
931
+
932
+ const auth = await getAuth();
933
+ if (!auth || auth.type !== "oauth") {
934
+ return fetch(input, init);
935
+ }
936
+
937
+ const { isVision, isAgent } = getConversationMetadata(init);
938
+ const headers = buildHeaders(init, auth, isVision, isAgent);
939
+
940
+ return fetch(input, {
941
+ ...init,
942
+ headers,
943
+ });
944
+ },
945
+ };
946
+ },
947
+ methods: [
948
+ {
949
+ type: "oauth",
950
+ label: "Login with GitHub Copilot CLI",
951
+ prompts: [
952
+ {
953
+ type: "select",
954
+ key: "deploymentType",
955
+ message: "Select GitHub deployment type",
956
+ options: [
957
+ {
958
+ label: "GitHub.com (Add)",
959
+ value: "github.com",
960
+ hint: "Public",
961
+ },
962
+ {
963
+ label: "GitHub Enterprise (Add)",
964
+ value: "enterprise",
965
+ hint: "Data residency or self-hosted",
966
+ },
967
+ ],
968
+ },
969
+ {
970
+ type: "text",
971
+ key: "enterpriseUrl",
972
+ message: "Enter your GitHub Enterprise URL or domain",
973
+ placeholder: "github.com or https://github.com (default: github.com)",
974
+ condition: (inputs) => inputs.deploymentType === "enterprise",
975
+ validate: (value) => {
976
+ if (!value || !String(value).trim()) {
977
+ return undefined;
978
+ }
979
+ try {
980
+ const url = value.includes("://")
981
+ ? new URL(value)
982
+ : new URL(`https://${value}`);
983
+ if (!url.hostname) {
984
+ return "Please enter a valid URL or domain";
985
+ }
986
+ return undefined;
987
+ } catch { // intentional: invalid URL input returns a user-facing validation error message
988
+ return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)";
989
+ }
990
+ },
991
+ },
992
+ ],
993
+ async authorize(inputs = {}) {
994
+ const deploymentType = inputs.deploymentType || "github.com";
995
+
996
+ let domain = "github.com";
997
+ let actualProvider = "github-copilot";
998
+
999
+ if (deploymentType === "enterprise") {
1000
+ domain = normalizeDomain(inputs.enterpriseUrl);
1001
+ actualProvider = "github-copilot-enterprise";
1002
+ }
1003
+
1004
+ const urls = getUrls(domain);
1005
+
1006
+ const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
1007
+ method: "POST",
1008
+ headers: {
1009
+ Accept: "application/json",
1010
+ "Content-Type": "application/json",
1011
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
1012
+ },
1013
+ body: JSON.stringify({
1014
+ client_id: CLIENT_ID,
1015
+ scope: OAUTH_SCOPES,
1016
+ }),
1017
+ });
1018
+
1019
+ if (!deviceResponse.ok) {
1020
+ throw new Error("[opencode-copilot-cli-auth] Failed to initiate device authorization");
1021
+ }
1022
+
1023
+ const deviceData = await deviceResponse.json();
1024
+
1025
+ return {
1026
+ url: deviceData.verification_uri,
1027
+ instructions: `Enter code: ${deviceData.user_code}`,
1028
+ method: "auto",
1029
+ callback: async () => {
1030
+ while (true) {
1031
+ const response = await fetch(urls.ACCESS_TOKEN_URL, {
1032
+ method: "POST",
1033
+ headers: {
1034
+ Accept: "application/json",
1035
+ "Content-Type": "application/json",
1036
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
1037
+ },
1038
+ body: JSON.stringify({
1039
+ client_id: CLIENT_ID,
1040
+ device_code: deviceData.device_code,
1041
+ grant_type:
1042
+ "urn:ietf:params:oauth:grant-type:device_code",
1043
+ }),
1044
+ });
1045
+
1046
+ if (!response.ok) return { type: "failed" };
1047
+
1048
+ const data = await response.json();
1049
+
1050
+ if (data.access_token) {
1051
+ const entitlement = await fetchEntitlement({
1052
+ refresh: data.access_token,
1053
+ enterpriseUrl:
1054
+ actualProvider === "github-copilot-enterprise"
1055
+ ? domain
1056
+ : undefined,
1057
+ });
1058
+
1059
+ const result = {
1060
+ type: "success",
1061
+ refresh: data.access_token,
1062
+ access: data.access_token,
1063
+ expires: 0,
1064
+ baseUrl: entitlement?.endpoints?.api,
1065
+ };
1066
+
1067
+ if (actualProvider === "github-copilot-enterprise") {
1068
+ result.provider = "github-copilot-enterprise";
1069
+ result.enterpriseUrl = domain;
1070
+ }
1071
+
1072
+ try {
1073
+ const identity = await lookupGitHubIdentity(
1074
+ data.access_token,
1075
+ actualProvider === "github-copilot-enterprise" ? domain : undefined,
1076
+ );
1077
+ const deployment = actualProvider === "github-copilot-enterprise" ? domain : "github.com";
1078
+ const key = deriveAccountKey(deployment, identity.userId);
1079
+ const pool = readPool();
1080
+ const updatedPool = upsertAccount(pool, {
1081
+ key,
1082
+ deployment,
1083
+ domain: deployment,
1084
+ identity,
1085
+ enterpriseUrl: actualProvider === "github-copilot-enterprise" ? domain : null,
1086
+ baseUrl: result.baseUrl,
1087
+ auth: {
1088
+ type: "oauth",
1089
+ refresh: data.access_token,
1090
+ access: data.access_token,
1091
+ expires: 0,
1092
+ baseUrl: result.baseUrl ?? null,
1093
+ ...(result.provider && { provider: result.provider }),
1094
+ ...(result.enterpriseUrl && { enterpriseUrl: result.enterpriseUrl }),
1095
+ },
1096
+ });
1097
+ writePool(updatedPool);
1098
+ } catch (persistError) {
1099
+ console.warn(
1100
+ "[opencode-copilot-cli-auth] Failed to persist account to pool:",
1101
+ persistError?.message ?? persistError,
1102
+ );
1103
+ // Non-fatal: continue with the login flow.
1104
+ }
1105
+
1106
+ return result;
1107
+ }
1108
+
1109
+ if (data.error === "authorization_pending") {
1110
+ await new Promise((resolve) =>
1111
+ setTimeout(
1112
+ resolve,
1113
+ deviceData.interval * 1000
1114
+ + OAUTH_POLLING_SAFETY_MARGIN_MS,
1115
+ ),
1116
+ );
1117
+ continue;
1118
+ }
1119
+
1120
+ if (data.error === "slow_down") {
1121
+ const nextInterval =
1122
+ (typeof data.interval === "number" && data.interval > 0 ?
1123
+ data.interval
1124
+ : deviceData.interval + 5) * 1000;
1125
+ await new Promise((resolve) =>
1126
+ setTimeout(
1127
+ resolve,
1128
+ nextInterval + OAUTH_POLLING_SAFETY_MARGIN_MS,
1129
+ ),
1130
+ );
1131
+ continue;
1132
+ }
1133
+
1134
+ if (data.error) return { type: "failed" };
1135
+
1136
+ await new Promise((resolve) =>
1137
+ setTimeout(
1138
+ resolve,
1139
+ deviceData.interval * 1000
1140
+ + OAUTH_POLLING_SAFETY_MARGIN_MS,
1141
+ ),
1142
+ );
1143
+ }
1144
+ },
1145
+ };
1146
+ },
1147
+ },
1148
+ ],
1149
+ },
1150
+ "chat.params": async (input, output) => {
1151
+ if (input.model.providerID !== "github-copilot") return;
1152
+ if (input.model.api?.npm !== "@ai-sdk/github-copilot") return;
1153
+ if (!input.model.id.includes("claude")) return;
1154
+
1155
+ const thinkingBudget = resolveClaudeThinkingBudget(input.model, input.message.variant);
1156
+ if (thinkingBudget === undefined) return;
1157
+
1158
+ output.options.thinking_budget = thinkingBudget;
1159
+ },
1160
+ "chat.headers": async (incoming, output) => {
1161
+ if (!incoming.model.providerID.includes("github-copilot")) return;
1162
+
1163
+ const sdk = input.client;
1164
+ if (sdk?.session?.message && sdk?.session?.get) {
1165
+ const parts = await sdk.session
1166
+ .message({
1167
+ path: {
1168
+ id: incoming.message.sessionID,
1169
+ messageID: incoming.message.id,
1170
+ },
1171
+ query: {
1172
+ directory: input.directory,
1173
+ },
1174
+ throwOnError: true,
1175
+ })
1176
+ .catch(() => undefined);
1177
+
1178
+ if (parts?.data?.parts?.some((part) => part.type === "compaction")) {
1179
+ output.headers["x-initiator"] = "agent";
1180
+ } else {
1181
+ const session = await sdk.session
1182
+ .get({
1183
+ path: {
1184
+ id: incoming.sessionID,
1185
+ },
1186
+ query: {
1187
+ directory: input.directory,
1188
+ },
1189
+ throwOnError: true,
1190
+ })
1191
+ .catch(() => undefined);
1192
+
1193
+ if (session?.data?.parentID) {
1194
+ output.headers["x-initiator"] = "agent";
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ try {
1200
+ const pool = readPool();
1201
+ if (pool.accounts.length > 0) {
1202
+ const winner = resolveWinnerAccount(incoming.model.id, pool);
1203
+ if (winner) {
1204
+ output.headers[ROUTING_ACCOUNT_KEY_HEADER] = winner.key;
1205
+ output.headers[ROUTING_SOURCE_HEADER] = "model-resolution";
1206
+ }
1207
+ }
1208
+ } catch {} // intentional: routing header injection is best-effort; missing header falls back to request-time model resolution
1209
+ },
1210
+ };
1211
+ }
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@gnahz77/opencode-copilot-multi-auth",
3
+ "version": "0.1.0",
4
+ "main": "./index.mjs",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "devDependencies": {
9
+ "@opencode-ai/plugin": "^0.4.45"
10
+ },
11
+ "dependencies": {}
12
+ }