Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/lib/account-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export interface Account {
name: string
accountType: string
githubToken: string
copilotToken?: string
copilotTokenRefreshAt: number
inFlight: number
lastUsedAt: number
cooldownUntil?: number
failureCount: number
refreshTimer?: ReturnType<typeof setInterval>
}

export type Strategy = "round-robin" | "least-busy" | "least-recent"

export class AccountPool {
private cursor = 0
public readonly accounts: Array<Account>
public strategy: Strategy

constructor(accounts: Array<Account>, strategy: Strategy) {
this.accounts = accounts
this.strategy = strategy
}

/** Returns usable accounts: have copilot token AND not on cooldown. */
private usable(): Array<Account> {
const now = Date.now()
return this.accounts.filter(
(a) => a.copilotToken && (a.cooldownUntil ?? 0) <= now,
)
}

pick(): Account {
const candidates = this.usable()
if (candidates.length === 0) {
throw new Error(
"No usable Copilot accounts (all on cooldown or unauthenticated)",
)
}
// eslint-disable-next-line default-case
switch (this.strategy) {
case "round-robin": {
const a = candidates[this.cursor % candidates.length]
this.cursor = (this.cursor + 1) % Math.max(candidates.length, 1)
return a
}
case "least-busy": {
return candidates.reduce((best, cur) => {
if (cur.inFlight !== best.inFlight)
return cur.inFlight < best.inFlight ? cur : best
return cur.lastUsedAt < best.lastUsedAt ? cur : best
})
}
case "least-recent": {
return candidates.reduce((best, cur) =>
cur.lastUsedAt < best.lastUsedAt ? cur : best,
)
}
// No default — Strategy union is exhaustively handled.
}
}

acquire(): Account {
const a = this.pick()
a.inFlight += 1
return a
}

release(a: Account): void {
a.inFlight = Math.max(0, a.inFlight - 1)
a.lastUsedAt = Date.now()
}

markCooldown(a: Account, ms: number): void {
a.cooldownUntil = Date.now() + ms
}

markFailure(a: Account): void {
a.failureCount += 1
}

byName(name: string): Account | undefined {
return this.accounts.find((a) => a.name === name)
}
}
80 changes: 80 additions & 0 deletions src/lib/accounts-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import fs from "node:fs/promises"
import path from "node:path"

import type { Account } from "./account-pool"

import { getDb } from "./db"

export interface AccountsFileEntry {
name: string
github_token: string
account_type?: string
}

export interface AccountsFile {
accounts: Array<AccountsFileEntry>
}

export interface LoadAccountsOptions {
accountsFile?: string
legacyToken?: string
defaultAccountType: string
}

const FRESH = (): Pick<
Account,
| "copilotToken"
| "copilotTokenRefreshAt"
| "inFlight"
| "lastUsedAt"
| "failureCount"
> => ({
copilotToken: undefined,
copilotTokenRefreshAt: 0,
inFlight: 0,
lastUsedAt: 0,
failureCount: 0,
})

export async function loadAccounts(
options: LoadAccountsOptions,
): Promise<Array<Account>> {
const accounts: Array<Account> = []

if (options.accountsFile) {
const buf = await fs.readFile(path.resolve(options.accountsFile))
const parsed = JSON.parse(buf.toString("utf8")) as AccountsFile
for (const e of parsed.accounts) {
accounts.push({
name: e.name,
accountType: e.account_type ?? options.defaultAccountType,
githubToken: e.github_token,
...FRESH(),
})
}
} else if (options.legacyToken && options.legacyToken.length > 0) {
accounts.push({
name: "default",
accountType: options.defaultAccountType,
githubToken: options.legacyToken,
...FRESH(),
})
}

return accounts
}

/** Insert any new accounts into the `accounts` table (idempotent). */
export function persistAccounts(accounts: Array<Account>): void {
const db = getDb()
const stmt = db.prepare(
"INSERT OR IGNORE INTO accounts (name, account_type, created_at) VALUES (?, ?, ?)",
)
const now = Date.now()
const tx = db.transaction((rows: Array<Account>) => {
for (const a of rows) {
stmt.run(a.name, a.accountType, now)
}
})
tx(accounts)
}
15 changes: 15 additions & 0 deletions src/lib/state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { ModelsResponse } from "~/services/copilot/get-models"

import type { Account, Strategy } from "./account-pool"
import type { AccountPool } from "./account-pool"

export interface State {
// Multi-account pool. Until task 03 wires service code through it,
// legacy fields below mirror the "default" account.
pool?: AccountPool
strategy: Strategy

// Legacy fields (deprecated; will be removed in task 03):
githubToken?: string
copilotToken?: string

Expand All @@ -19,7 +28,13 @@ export interface State {

export const state: State = {
accountType: "individual",
strategy: "round-robin",
manualApprove: false,
rateLimitWait: false,
showToken: false,
}

/** Convenience: the first usable account, used by legacy single-account paths. */
export function defaultAccount(): Account | undefined {
return state.pool?.accounts[0]
}
55 changes: 44 additions & 11 deletions src/lib/token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import consola from "consola"
import fs from "node:fs/promises"

import type { Account } from "~/lib/account-pool"

import { PATHS } from "~/lib/paths"
import { getCopilotToken } from "~/services/github/get-copilot-token"
import { getDeviceCode } from "~/services/github/get-device-code"
Expand All @@ -15,28 +17,50 @@ const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")
const writeGithubToken = (token: string) =>
fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token)

export const setupCopilotToken = async () => {
/**
* Set up the Copilot token for a single account, including auto-refresh.
* The previous global helper `setupCopilotToken` is replaced by per-account
* setup; legacy `state.copilotToken` is mirrored for not-yet-migrated callers.
*/
export const setupCopilotTokenFor = async (account: Account) => {
// Temporarily expose this account's GitHub token for the legacy
// api-config helper which still reads `state.githubToken`.
state.githubToken = account.githubToken
const { token, refresh_in } = await getCopilotToken()
/* eslint-disable require-atomic-updates */
account.copilotToken = token
account.copilotTokenRefreshAt = Date.now() + refresh_in * 1000
/* eslint-enable require-atomic-updates */

// Mirror the first account's token into legacy state for callers
// not yet migrated to the pool (removed in task 03).
state.copilotToken = token

// Display the Copilot token to the screen
consola.debug("GitHub Copilot Token fetched successfully!")
consola.debug(`[${account.name}] Copilot token fetched successfully`)
if (state.showToken) {
consola.info("Copilot token:", token)
consola.info(`[${account.name}] Copilot token:`, token)
}

const refreshInterval = (refresh_in - 60) * 1000
setInterval(async () => {
consola.debug("Refreshing Copilot token")
account.refreshTimer = setInterval(async () => {
consola.debug(`[${account.name}] Refreshing Copilot token`)
try {
const { token } = await getCopilotToken()
state.copilotToken = token
consola.debug("Copilot token refreshed")
state.githubToken = account.githubToken
const refreshed = await getCopilotToken()
/* eslint-disable require-atomic-updates */
account.copilotToken = refreshed.token
account.copilotTokenRefreshAt = Date.now() + refreshed.refresh_in * 1000
/* eslint-enable require-atomic-updates */
state.copilotToken = refreshed.token
consola.debug(`[${account.name}] Copilot token refreshed`)
if (state.showToken) {
consola.info("Refreshed Copilot token:", token)
consola.info(
`[${account.name}] Refreshed Copilot token:`,
refreshed.token,
)
}
} catch (error) {
consola.error("Failed to refresh Copilot token:", error)
consola.error(`[${account.name}] Failed to refresh Copilot token:`, error)
throw error
}
}, refreshInterval)
Expand Down Expand Up @@ -89,6 +113,15 @@ export async function setupGitHubToken(
}
}

/** Backwards-compat wrapper: sets up Copilot token for the default account. */
export const setupCopilotToken = async () => {
if (state.pool && state.pool.accounts.length > 0) {
await setupCopilotTokenFor(state.pool.accounts[0])
return
}
// No pool yet (very early callers) — do nothing.
}

async function logUser() {
const user = await getGitHubUser()
consola.info(`Logged in as ${user.login}`)
Expand Down
58 changes: 52 additions & 6 deletions src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import consola from "consola"
import { serve, type ServerHandler } from "srvx"
import invariant from "tiny-invariant"

import { AccountPool, type Strategy } from "./lib/account-pool"
import { loadAccounts, persistAccounts } from "./lib/accounts-loader"
import { initDb } from "./lib/db"
import { ensurePaths, PATHS } from "./lib/paths"
import { initProxyFromEnv } from "./lib/proxy"
import { generateEnvScript } from "./lib/shell"
import { state } from "./lib/state"
import { setupCopilotToken, setupGitHubToken } from "./lib/token"
import { setupCopilotTokenFor, setupGitHubToken } from "./lib/token"
import { cacheModels, cacheVSCodeVersion } from "./lib/utils"
import { server } from "./server"

Expand All @@ -27,6 +29,8 @@ interface RunServerOptions {
showToken: boolean
proxyEnv: boolean
dbPath: string
accountsFile?: string
strategy: Strategy
}

export async function runServer(options: RunServerOptions): Promise<void> {
Expand All @@ -40,6 +44,7 @@ export async function runServer(options: RunServerOptions): Promise<void> {
}

state.accountType = options.accountType
state.strategy = options.strategy
if (options.accountType !== "individual") {
consola.info(`Using ${options.accountType} plan GitHub account`)
}
Expand All @@ -53,14 +58,42 @@ export async function runServer(options: RunServerOptions): Promise<void> {
initDb(options.dbPath)
await cacheVSCodeVersion()

if (options.githubToken) {
state.githubToken = options.githubToken
consola.info("Using provided GitHub token")
} else {
// Resolve legacy single token if no accounts file is provided.
let legacyToken = options.githubToken
if (!options.accountsFile && !legacyToken) {
await setupGitHubToken()
legacyToken = state.githubToken
} else if (legacyToken) {
consola.info("Using provided GitHub token")
}

const loaded = await loadAccounts({
accountsFile: options.accountsFile,
legacyToken,
defaultAccountType: options.accountType,
})

if (loaded.length === 0) {
throw new Error(
"No accounts available. Provide --accounts-file or --github-token, or run `auth`.",
)
}

const pool = new AccountPool(loaded, options.strategy)
// eslint-disable-next-line require-atomic-updates
state.pool = pool
persistAccounts(loaded)

consola.info(
`Loaded ${loaded.length} account${loaded.length === 1 ? "" : "s"} (strategy: ${options.strategy})`,
)

// Fetch Copilot token for each account in parallel.
await Promise.all(loaded.map((a) => setupCopilotTokenFor(a)))
for (const a of loaded) {
consola.info(`[${a.name}] ready`)
}

await setupCopilotToken()
await cacheModels()

consola.info(
Expand Down Expand Up @@ -193,6 +226,17 @@ export const start = defineCommand({
description:
"Path to the usage SQLite database (defaults to ~/.local/share/copilot-api/usage.sqlite)",
},
"accounts-file": {
type: "string",
description:
"Path to a JSON file containing multiple GitHub Copilot accounts",
},
strategy: {
type: "string",
default: "round-robin",
description:
"Account selection strategy: round-robin | least-busy | least-recent",
},
},
run({ args }) {
const rateLimitRaw = args["rate-limit"]
Expand All @@ -212,6 +256,8 @@ export const start = defineCommand({
showToken: args["show-token"],
proxyEnv: args["proxy-env"],
dbPath: args["db-path"],
accountsFile: args["accounts-file"],
strategy: args.strategy as Strategy,
})
},
})
Loading