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

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/dist/index.js ADDED
@@ -0,0 +1,307 @@
1
+ import { CLIENT_ID, OAUTH_POLLING_SAFETY_MARGIN_MS, OAUTH_SCOPES, ROUTING_ACCOUNT_KEY_HEADER, ROUTING_SOURCE_HEADER, } from "./constants.js";
2
+ import { buildHeaders, fetchEntitlement, fetchWithSelectedAccount, getBaseURL, getUrls, lookupGitHubIdentity, resolveSelectedPoolAccount, } from "./auth.js";
3
+ import { buildPoolBackedModels, normalizeExistingModels, resolveProviderModels } from "./models.js";
4
+ import { deriveAccountKey, getPoolPath, readPool, resolveWinnerAccount, upsertAccount, writePool, } from "./pool.js";
5
+ import { getHeader, getConversationMetadata, getRequestedRawModelId, normalizeDomain, resolveClaudeThinkingBudget, } from "./utils.js";
6
+ export { getPoolPath, readPool, writePool, deriveAccountKey, lookupGitHubIdentity, upsertAccount, resolveWinnerAccount };
7
+ export const CopilotAuthPlugin = async (input) => {
8
+ return {
9
+ provider: {
10
+ id: "github-copilot",
11
+ models: async (provider, ctx) => {
12
+ try {
13
+ const pool = readPool();
14
+ if (pool.accounts.length > 0) {
15
+ return await buildPoolBackedModels(provider.models, pool);
16
+ }
17
+ const oauthAuth = ctx.auth && ctx.auth.type === "oauth" ? ctx.auth : undefined;
18
+ return await resolveProviderModels(provider.models, oauthAuth);
19
+ }
20
+ catch (error) {
21
+ console.warn("[opencode-copilot-cli-auth] Failed to sync live Copilot models.", error instanceof Error ? error.message : String(error));
22
+ return normalizeExistingModels(provider.models);
23
+ }
24
+ },
25
+ },
26
+ auth: {
27
+ provider: "github-copilot",
28
+ loader: async (getAuth) => {
29
+ const info = await getAuth();
30
+ let poolFirstEnabled = null;
31
+ try {
32
+ const currentPool = readPool();
33
+ poolFirstEnabled = currentPool.accounts.find((account) => account?.enabled !== false) ?? null;
34
+ if (currentPool.accounts.length === 0 && info && info.type === "oauth") {
35
+ // Best-effort migration bridge for legacy single-account auth.
36
+ // We intentionally avoid identity lookup here because loader runs on hot paths,
37
+ // and we do not have a stable userId without making a network request.
38
+ // The next successful OAuth authorize callback performs canonical persistence.
39
+ }
40
+ }
41
+ catch {
42
+ // intentional: pool read/parse errors fall back to legacy singleton auth
43
+ }
44
+ const baseSource = poolFirstEnabled?.auth?.type === "oauth"
45
+ ? poolFirstEnabled.auth
46
+ : info && info.type === "oauth"
47
+ ? info
48
+ : null;
49
+ if (!baseSource)
50
+ return {};
51
+ const baseURL = poolFirstEnabled?.baseUrl ?? await getBaseURL(baseSource);
52
+ return {
53
+ ...(baseURL && { baseURL }),
54
+ apiKey: "",
55
+ async fetch(inputRequest, init) {
56
+ const pool = readPool();
57
+ const accountKey = getHeader(init?.headers, ROUTING_ACCOUNT_KEY_HEADER);
58
+ const requestedRawModelId = getRequestedRawModelId(init);
59
+ if (pool.accounts.length > 0) {
60
+ const selectedAccount = resolveSelectedPoolAccount(pool, accountKey, requestedRawModelId);
61
+ if (selectedAccount?.auth?.type === "oauth") {
62
+ return fetchWithSelectedAccount(inputRequest, init, selectedAccount);
63
+ }
64
+ }
65
+ const auth = await getAuth();
66
+ if (!auth || auth.type !== "oauth") {
67
+ return fetch(inputRequest, init);
68
+ }
69
+ const { isVision, isAgent } = getConversationMetadata(init);
70
+ const headers = buildHeaders(init, auth, isVision, isAgent);
71
+ return fetch(inputRequest, {
72
+ ...init,
73
+ headers,
74
+ });
75
+ },
76
+ };
77
+ },
78
+ methods: [
79
+ {
80
+ type: "oauth",
81
+ label: "Login with GitHub Copilot CLI",
82
+ prompts: [
83
+ {
84
+ type: "select",
85
+ key: "deploymentType",
86
+ message: "Select GitHub deployment type",
87
+ options: [
88
+ {
89
+ label: "GitHub.com (Add)",
90
+ value: "github.com",
91
+ hint: "Public",
92
+ },
93
+ {
94
+ label: "GitHub Enterprise (Add)",
95
+ value: "enterprise",
96
+ hint: "Data residency or self-hosted",
97
+ },
98
+ ],
99
+ },
100
+ {
101
+ type: "text",
102
+ key: "enterpriseUrl",
103
+ message: "Enter your GitHub Enterprise URL or domain",
104
+ placeholder: "github.com or https://github.com (default: github.com)",
105
+ condition: (inputs) => inputs.deploymentType === "enterprise",
106
+ validate: (value) => {
107
+ if (!value || !String(value).trim()) {
108
+ return undefined;
109
+ }
110
+ try {
111
+ const url = value.includes("://")
112
+ ? new URL(value)
113
+ : new URL(`https://${value}`);
114
+ if (!url.hostname) {
115
+ return "Please enter a valid URL or domain";
116
+ }
117
+ return undefined;
118
+ }
119
+ catch {
120
+ return "Please enter a valid URL";
121
+ }
122
+ },
123
+ },
124
+ ],
125
+ async authorize(inputs = {}) {
126
+ const deploymentType = inputs.deploymentType || "github.com";
127
+ let domain = "github.com";
128
+ let actualProvider = "github-copilot";
129
+ if (deploymentType === "enterprise") {
130
+ domain = normalizeDomain(inputs.enterpriseUrl);
131
+ actualProvider = "github-copilot-enterprise";
132
+ }
133
+ const urls = getUrls(domain);
134
+ const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
135
+ method: "POST",
136
+ headers: {
137
+ Accept: "application/json",
138
+ "Content-Type": "application/json",
139
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
140
+ },
141
+ body: JSON.stringify({
142
+ client_id: CLIENT_ID,
143
+ scope: OAUTH_SCOPES,
144
+ }),
145
+ });
146
+ if (!deviceResponse.ok) {
147
+ throw new Error("[opencode-copilot-cli-auth] Failed to initiate device authorization");
148
+ }
149
+ const deviceData = await deviceResponse.json();
150
+ return {
151
+ url: deviceData.verification_uri,
152
+ instructions: `Enter code: ${deviceData.user_code}`,
153
+ method: "auto",
154
+ callback: async () => {
155
+ while (true) {
156
+ const response = await fetch(urls.ACCESS_TOKEN_URL, {
157
+ method: "POST",
158
+ headers: {
159
+ Accept: "application/json",
160
+ "Content-Type": "application/json",
161
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
162
+ },
163
+ body: JSON.stringify({
164
+ client_id: CLIENT_ID,
165
+ device_code: deviceData.device_code,
166
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
167
+ }),
168
+ });
169
+ if (!response.ok)
170
+ return { type: "failed" };
171
+ const data = await response.json();
172
+ if (data.access_token) {
173
+ const entitlement = await fetchEntitlement({
174
+ refresh: data.access_token,
175
+ enterpriseUrl: actualProvider === "github-copilot-enterprise"
176
+ ? domain
177
+ : undefined,
178
+ });
179
+ const result = {
180
+ type: "success",
181
+ refresh: data.access_token,
182
+ access: data.access_token,
183
+ expires: 0,
184
+ baseUrl: entitlement?.endpoints?.api,
185
+ };
186
+ if (actualProvider === "github-copilot-enterprise") {
187
+ result.provider = "github-copilot-enterprise";
188
+ result.enterpriseUrl = domain;
189
+ }
190
+ try {
191
+ const identity = await lookupGitHubIdentity(data.access_token, actualProvider === "github-copilot-enterprise" ? domain : undefined);
192
+ const deployment = actualProvider === "github-copilot-enterprise" ? domain : "github.com";
193
+ const key = deriveAccountKey(deployment, identity.userId);
194
+ const pool = readPool();
195
+ const updatedPool = upsertAccount(pool, {
196
+ key,
197
+ deployment,
198
+ domain: deployment,
199
+ identity,
200
+ enterpriseUrl: actualProvider === "github-copilot-enterprise" ? domain : null,
201
+ baseUrl: result.baseUrl ?? null,
202
+ auth: {
203
+ type: "oauth",
204
+ refresh: data.access_token,
205
+ access: data.access_token,
206
+ expires: 0,
207
+ baseUrl: result.baseUrl ?? null,
208
+ ...(result.provider && { provider: result.provider }),
209
+ ...(result.enterpriseUrl && { enterpriseUrl: result.enterpriseUrl }),
210
+ },
211
+ });
212
+ writePool(updatedPool);
213
+ }
214
+ catch (persistError) {
215
+ console.warn("[opencode-copilot-cli-auth] Failed to persist account to pool:", persistError instanceof Error ? persistError.message : String(persistError));
216
+ }
217
+ return result;
218
+ }
219
+ if (data.error === "authorization_pending") {
220
+ await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000
221
+ + OAUTH_POLLING_SAFETY_MARGIN_MS));
222
+ continue;
223
+ }
224
+ if (data.error === "slow_down") {
225
+ const nextInterval = (typeof data.interval === "number" && data.interval > 0
226
+ ? data.interval
227
+ : deviceData.interval + 5) * 1000;
228
+ await new Promise((resolve) => setTimeout(resolve, nextInterval + OAUTH_POLLING_SAFETY_MARGIN_MS));
229
+ continue;
230
+ }
231
+ if (data.error)
232
+ return { type: "failed" };
233
+ await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000
234
+ + OAUTH_POLLING_SAFETY_MARGIN_MS));
235
+ }
236
+ },
237
+ };
238
+ },
239
+ },
240
+ ],
241
+ },
242
+ "chat.params": async (input, output) => {
243
+ if (input.model.providerID !== "github-copilot")
244
+ return;
245
+ if (input.model.api?.npm !== "@ai-sdk/github-copilot")
246
+ return;
247
+ if (!input.model.id.includes("claude"))
248
+ return;
249
+ const messageWithVariant = input.message;
250
+ const thinkingBudget = resolveClaudeThinkingBudget(input.model, messageWithVariant.variant);
251
+ if (thinkingBudget === undefined)
252
+ return;
253
+ output.options.thinking_budget = thinkingBudget;
254
+ },
255
+ "chat.headers": async (incoming, output) => {
256
+ if (!incoming.model.providerID.includes("github-copilot"))
257
+ return;
258
+ const sdk = input.client;
259
+ if (sdk?.session?.message && sdk?.session?.get) {
260
+ const parts = await sdk.session
261
+ .message({
262
+ path: {
263
+ id: incoming.message.sessionID,
264
+ messageID: incoming.message.id,
265
+ },
266
+ query: {
267
+ directory: input.directory,
268
+ },
269
+ throwOnError: true,
270
+ })
271
+ .catch(() => undefined);
272
+ if (parts?.data?.parts?.some((part) => part.type === "compaction")) {
273
+ output.headers["x-initiator"] = "agent";
274
+ }
275
+ else {
276
+ const session = await sdk.session
277
+ .get({
278
+ path: {
279
+ id: incoming.sessionID,
280
+ },
281
+ query: {
282
+ directory: input.directory,
283
+ },
284
+ throwOnError: true,
285
+ })
286
+ .catch(() => undefined);
287
+ if (session?.data?.parentID) {
288
+ output.headers["x-initiator"] = "agent";
289
+ }
290
+ }
291
+ }
292
+ try {
293
+ const pool = readPool();
294
+ if (pool.accounts.length > 0) {
295
+ const winner = resolveWinnerAccount(incoming.model.id, pool);
296
+ if (winner) {
297
+ output.headers[ROUTING_ACCOUNT_KEY_HEADER] = winner.key;
298
+ output.headers[ROUTING_SOURCE_HEADER] = "model-resolution";
299
+ }
300
+ }
301
+ }
302
+ catch {
303
+ // intentional: routing header injection is best-effort; missing header falls back to request-time model resolution
304
+ }
305
+ },
306
+ };
307
+ };
@@ -0,0 +1,134 @@
1
+ import type { Model as SDKModel } from "@opencode-ai/sdk/v2";
2
+ import type { AccountPool, AuthInput, LiveModel } from "./types.js";
3
+ export declare function fetchModels(info: AuthInput, baseURL: string): Promise<LiveModel[]>;
4
+ export declare function createProviderModel(existing: SDKModel | undefined, live: LiveModel, baseURL: string): SDKModel;
5
+ export declare function buildProviderModels(existingModels: Record<string, SDKModel> | undefined, liveModels: LiveModel[], baseURL: string): {
6
+ [k: string]: SDKModel;
7
+ };
8
+ export declare function normalizeExistingModels(existingModels: Record<string, SDKModel> | undefined, baseURL?: string): {
9
+ [k: string]: {
10
+ cost: {
11
+ input: number;
12
+ output: number;
13
+ cache: {
14
+ read: number;
15
+ write: number;
16
+ };
17
+ };
18
+ api: {
19
+ url: string;
20
+ npm: string;
21
+ id: string;
22
+ };
23
+ id: string;
24
+ providerID: string;
25
+ name: string;
26
+ family?: string;
27
+ capabilities: {
28
+ temperature: boolean;
29
+ reasoning: boolean;
30
+ attachment: boolean;
31
+ toolcall: boolean;
32
+ input: {
33
+ text: boolean;
34
+ audio: boolean;
35
+ image: boolean;
36
+ video: boolean;
37
+ pdf: boolean;
38
+ };
39
+ output: {
40
+ text: boolean;
41
+ audio: boolean;
42
+ image: boolean;
43
+ video: boolean;
44
+ pdf: boolean;
45
+ };
46
+ interleaved: boolean | {
47
+ field: "reasoning_content" | "reasoning_details";
48
+ };
49
+ };
50
+ limit: {
51
+ context: number;
52
+ input?: number;
53
+ output: number;
54
+ };
55
+ status: "alpha" | "beta" | "deprecated" | "active";
56
+ options: {
57
+ [key: string]: unknown;
58
+ };
59
+ headers: {
60
+ [key: string]: string;
61
+ };
62
+ release_date: string;
63
+ variants?: {
64
+ [key: string]: {
65
+ [key: string]: unknown;
66
+ };
67
+ };
68
+ };
69
+ };
70
+ export declare function resolveProviderModels(existingModels: Record<string, SDKModel> | undefined, auth: AuthInput | null | undefined): Promise<{
71
+ [k: string]: {
72
+ cost: {
73
+ input: number;
74
+ output: number;
75
+ cache: {
76
+ read: number;
77
+ write: number;
78
+ };
79
+ };
80
+ api: {
81
+ url: string;
82
+ npm: string;
83
+ id: string;
84
+ };
85
+ id: string;
86
+ providerID: string;
87
+ name: string;
88
+ family?: string;
89
+ capabilities: {
90
+ temperature: boolean;
91
+ reasoning: boolean;
92
+ attachment: boolean;
93
+ toolcall: boolean;
94
+ input: {
95
+ text: boolean;
96
+ audio: boolean;
97
+ image: boolean;
98
+ video: boolean;
99
+ pdf: boolean;
100
+ };
101
+ output: {
102
+ text: boolean;
103
+ audio: boolean;
104
+ image: boolean;
105
+ video: boolean;
106
+ pdf: boolean;
107
+ };
108
+ interleaved: boolean | {
109
+ field: "reasoning_content" | "reasoning_details";
110
+ };
111
+ };
112
+ limit: {
113
+ context: number;
114
+ input?: number;
115
+ output: number;
116
+ };
117
+ status: "alpha" | "beta" | "deprecated" | "active";
118
+ options: {
119
+ [key: string]: unknown;
120
+ };
121
+ headers: {
122
+ [key: string]: string;
123
+ };
124
+ release_date: string;
125
+ variants?: {
126
+ [key: string]: {
127
+ [key: string]: unknown;
128
+ };
129
+ };
130
+ };
131
+ }>;
132
+ export declare function buildPoolBackedModels(existingModels: Record<string, SDKModel> | undefined, pool: AccountPool): Promise<{
133
+ [k: string]: SDKModel;
134
+ }>;
package/dist/models.js ADDED
@@ -0,0 +1,147 @@
1
+ import { getBaseURL } from "./auth.js";
2
+ import { resolveWinnerAccount } from "./pool.js";
3
+ import { getReleaseDate, isPickerModel, zeroCost } from "./utils.js";
4
+ export async function fetchModels(info, baseURL) {
5
+ const response = await fetch(`${baseURL}/models`, {
6
+ headers: {
7
+ Authorization: `Bearer ${info.refresh}`,
8
+ "Copilot-Integration-Id": "copilot-developer-cli",
9
+ "Openai-Intent": "model-access",
10
+ "User-Agent": "opencode-copilot-cli-auth/0.0.16",
11
+ "X-GitHub-Api-Version": "2025-05-01",
12
+ "X-Interaction-Type": "model-access",
13
+ "X-Request-Id": crypto.randomUUID(),
14
+ },
15
+ });
16
+ if (!response.ok) {
17
+ throw new Error(`[opencode-copilot-cli-auth] Model fetch failed: ${response.status}`);
18
+ }
19
+ const data = await response.json();
20
+ return Array.isArray(data?.data) ? data.data : [];
21
+ }
22
+ export function createProviderModel(existing, live, baseURL) {
23
+ const limits = live.capabilities?.limits ?? {};
24
+ const supports = live.capabilities?.supports ?? {};
25
+ const vision = !!supports.vision || !!limits.vision;
26
+ const reasoning = existing?.capabilities?.reasoning
27
+ ?? (!!supports.adaptive_thinking
28
+ || typeof supports.max_thinking_budget === "number"
29
+ || Array.isArray(supports.reasoning_effort));
30
+ return {
31
+ ...structuredClone(existing ?? {}),
32
+ id: live.id,
33
+ providerID: existing?.providerID ?? "github-copilot",
34
+ api: {
35
+ ...(existing?.api ?? {}),
36
+ id: live.id,
37
+ url: baseURL,
38
+ npm: "@ai-sdk/github-copilot",
39
+ },
40
+ name: live.name ?? existing?.name ?? live.id,
41
+ family: live.capabilities?.family ?? existing?.family ?? "",
42
+ cost: zeroCost(),
43
+ limit: {
44
+ context: limits.max_context_window_tokens
45
+ ?? existing?.limit?.context
46
+ ?? 0,
47
+ input: limits.max_prompt_tokens
48
+ ?? existing?.limit?.input
49
+ ?? limits.max_context_window_tokens,
50
+ output: limits.max_output_tokens
51
+ ?? limits.max_non_streaming_output_tokens
52
+ ?? existing?.limit?.output
53
+ ?? 0,
54
+ },
55
+ capabilities: {
56
+ temperature: existing?.capabilities?.temperature ?? true,
57
+ reasoning,
58
+ attachment: existing?.capabilities?.attachment ?? vision,
59
+ toolcall: !!supports.tool_calls,
60
+ input: {
61
+ text: existing?.capabilities?.input?.text ?? true,
62
+ audio: existing?.capabilities?.input?.audio ?? false,
63
+ image: existing?.capabilities?.input?.image ?? vision,
64
+ video: existing?.capabilities?.input?.video ?? false,
65
+ pdf: existing?.capabilities?.input?.pdf ?? false,
66
+ },
67
+ output: {
68
+ text: existing?.capabilities?.output?.text ?? true,
69
+ audio: existing?.capabilities?.output?.audio ?? false,
70
+ image: existing?.capabilities?.output?.image ?? false,
71
+ video: existing?.capabilities?.output?.video ?? false,
72
+ pdf: existing?.capabilities?.output?.pdf ?? false,
73
+ },
74
+ interleaved: existing?.capabilities?.interleaved ?? false,
75
+ },
76
+ options: existing?.options ?? {},
77
+ headers: existing?.headers ?? {},
78
+ release_date: getReleaseDate(live.id, live.version, existing?.release_date ?? ""),
79
+ variants: existing?.variants ?? {},
80
+ status: "active",
81
+ };
82
+ }
83
+ export function buildProviderModels(existingModels, liveModels, baseURL) {
84
+ const existingById = new Map(Object.values(existingModels ?? {}).map((model) => [model?.api?.id ?? model?.id, model]));
85
+ return Object.fromEntries(liveModels
86
+ .filter(isPickerModel)
87
+ .map((model) => [
88
+ model.id,
89
+ createProviderModel(existingById.get(model.id), model, baseURL),
90
+ ]));
91
+ }
92
+ export function normalizeExistingModels(existingModels, baseURL) {
93
+ return Object.fromEntries(Object.entries(existingModels ?? {}).map(([id, model]) => [
94
+ id,
95
+ {
96
+ ...structuredClone(model),
97
+ cost: zeroCost(),
98
+ api: {
99
+ ...model.api,
100
+ url: baseURL ?? model.api?.url,
101
+ npm: "@ai-sdk/github-copilot",
102
+ },
103
+ },
104
+ ]));
105
+ }
106
+ export async function resolveProviderModels(existingModels, auth) {
107
+ const baseURL = auth ? await getBaseURL(auth) : undefined;
108
+ if (!auth || auth.type !== "oauth" || !baseURL) {
109
+ return normalizeExistingModels(existingModels, baseURL);
110
+ }
111
+ const liveModels = await fetchModels(auth, baseURL);
112
+ return buildProviderModels(existingModels, liveModels, baseURL);
113
+ }
114
+ export async function buildPoolBackedModels(existingModels, pool) {
115
+ const enabledAccounts = (Array.isArray(pool?.accounts) ? pool.accounts : [])
116
+ .filter((account) => account?.enabled !== false);
117
+ if (enabledAccounts.length === 0) {
118
+ return {};
119
+ }
120
+ const candidatesByModel = new Map();
121
+ for (const account of enabledAccounts) {
122
+ try {
123
+ const baseURL = account.baseUrl ?? await getBaseURL(account.auth);
124
+ const liveModels = await fetchModels(account.auth, baseURL);
125
+ for (const liveModel of liveModels.filter(isPickerModel)) {
126
+ const winner = resolveWinnerAccount(liveModel.id, pool);
127
+ if (winner?.key === account.key) {
128
+ candidatesByModel.set(liveModel.id, {
129
+ account,
130
+ liveModel,
131
+ baseURL,
132
+ });
133
+ }
134
+ }
135
+ }
136
+ catch (error) {
137
+ console.warn(`[opencode-copilot-cli-auth] Skipping account ${account?.key ?? "unknown"} during model sync:`, error instanceof Error ? error.message : String(error));
138
+ }
139
+ }
140
+ const existingById = new Map(Object.values(existingModels ?? {}).map((model) => [model?.api?.id ?? model?.id, model]));
141
+ return Object.fromEntries([...candidatesByModel.entries()]
142
+ .sort(([left], [right]) => left.localeCompare(right))
143
+ .map(([rawModelId, { liveModel, baseURL }]) => [
144
+ rawModelId,
145
+ createProviderModel(existingById.get(rawModelId), liveModel, baseURL),
146
+ ]));
147
+ }
package/dist/pool.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { AccountPool, PoolAccount, UpsertAccountData } from "./types.js";
2
+ export declare function getPoolPath(): string;
3
+ export declare function validatePoolSchema(pool: unknown, context: string): AccountPool;
4
+ export declare function readPool(): AccountPool;
5
+ export declare function writePool(pool: AccountPool): void;
6
+ export declare function deriveAccountKey(deployment: string, userId: number): string;
7
+ export declare function upsertAccount(pool: AccountPool, accountData: UpsertAccountData): AccountPool;
8
+ export declare function resolveWinnerAccount(rawModelId: string, pool: AccountPool): PoolAccount;