@gnahz77/opencode-copilot-multi-auth 0.1.0 → 0.1.2

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/index.mjs CHANGED
@@ -1,1211 +1,12 @@
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
- }
1
+ export {
2
+ CopilotAuthPlugin,
3
+ deriveAccountKey,
4
+ getPoolPath,
5
+ injectRoutingHeaders,
6
+ lookupGitHubIdentity,
7
+ readPool,
8
+ resolveWinnerAccount,
9
+ stripRoutingHeaders,
10
+ upsertAccount,
11
+ writePool,
12
+ } from "./dist/index.js";