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
16 changes: 14 additions & 2 deletions src/check-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineCommand } from "citty"
import consola from "consola"

import { ensurePaths } from "./lib/paths"
import { state } from "./lib/state"
import { setupGitHubToken } from "./lib/token"
import {
getCopilotUsage,
Expand All @@ -15,9 +16,20 @@ export const checkUsage = defineCommand({
},
async run() {
await ensurePaths()
await setupGitHubToken()
const githubToken = await setupGitHubToken()
try {
const usage = await getCopilotUsage()
const usage = await getCopilotUsage({
account: {
name: "_check-usage",
accountType: state.accountType,
githubToken,
copilotTokenRefreshAt: 0,
inFlight: 0,
lastUsedAt: 0,
failureCount: 0,
},
vsCodeVersion: state.vsCodeVersion,
})
const premium = usage.quota_snapshots.premium_interactions
const premiumTotal = premium.entitlement
const premiumUsed = premiumTotal - premium.remaining
Expand Down
26 changes: 16 additions & 10 deletions src/lib/api-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"

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

export const standardHeaders = () => ({
"content-type": "application/json",
Expand All @@ -13,16 +13,22 @@ const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`

const API_VERSION = "2025-04-01"

export const copilotBaseUrl = (state: State) =>
state.accountType === "individual" ?
export interface ApiContext {
account: Account
vsCodeVersion?: string
}

export const copilotBaseUrl = (ctx: ApiContext) =>
ctx.account.accountType === "individual" ?
"https://api.githubcopilot.com"
: `https://api.${state.accountType}.githubcopilot.com`
export const copilotHeaders = (state: State, vision: boolean = false) => {
: `https://api.${ctx.account.accountType}.githubcopilot.com`

export const copilotHeaders = (ctx: ApiContext, vision: boolean = false) => {
const headers: Record<string, string> = {
Authorization: `Bearer ${state.copilotToken}`,
Authorization: `Bearer ${ctx.account.copilotToken}`,
"content-type": standardHeaders()["content-type"],
"copilot-integration-id": "vscode-chat",
"editor-version": `vscode/${state.vsCodeVersion}`,
"editor-version": `vscode/${ctx.vsCodeVersion}`,
"editor-plugin-version": EDITOR_PLUGIN_VERSION,
"user-agent": USER_AGENT,
"openai-intent": "conversation-panel",
Expand All @@ -37,10 +43,10 @@ export const copilotHeaders = (state: State, vision: boolean = false) => {
}

export const GITHUB_API_BASE_URL = "https://api.github.com"
export const githubHeaders = (state: State) => ({
export const githubHeaders = (ctx: ApiContext) => ({
...standardHeaders(),
authorization: `token ${state.githubToken}`,
"editor-version": `vscode/${state.vsCodeVersion}`,
authorization: `token ${ctx.account.githubToken}`,
"editor-version": `vscode/${ctx.vsCodeVersion}`,
"editor-plugin-version": EDITOR_PLUGIN_VERSION,
"user-agent": USER_AGENT,
"x-github-api-version": API_VERSION,
Expand Down
11 changes: 2 additions & 9 deletions src/lib/state.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import type { ModelsResponse } from "~/services/copilot/get-models"

import type { Account, Strategy } from "./account-pool"
import type { AccountPool } from "./account-pool"
import type { Account, AccountPool, Strategy } 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

accountType: string
models?: ModelsResponse
vsCodeVersion?: string
Expand All @@ -34,7 +27,7 @@ export const state: State = {
showToken: false,
}

/** Convenience: the first usable account, used by legacy single-account paths. */
/** Convenience: the first usable account. */
export function defaultAccount(): Account | undefined {
return state.pool?.accounts[0]
}
63 changes: 30 additions & 33 deletions src/lib/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,22 @@ import { pollAccessToken } from "~/services/github/poll-access-token"

import { HTTPError } from "./error"
import { state } from "./state"
import { makeApiContext } from "./utils"

const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")

const writeGithubToken = (token: string) =>
fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token)

/**
* 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.
*/
/** Per-account Copilot token setup with auto-refresh. */
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()
const ctx = makeApiContext(account)
const { token, refresh_in } = await getCopilotToken(ctx)
/* 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

consola.debug(`[${account.name}] Copilot token fetched successfully`)
if (state.showToken) {
consola.info(`[${account.name}] Copilot token:`, token)
Expand All @@ -45,13 +36,11 @@ export const setupCopilotTokenFor = async (account: Account) => {
account.refreshTimer = setInterval(async () => {
consola.debug(`[${account.name}] Refreshing Copilot token`)
try {
state.githubToken = account.githubToken
const refreshed = await getCopilotToken()
const refreshed = await getCopilotToken(makeApiContext(account))
/* 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(
Expand All @@ -70,20 +59,23 @@ interface SetupGitHubTokenOptions {
force?: boolean
}

/**
* Reads or fetches a single GitHub token file at PATHS.GITHUB_TOKEN_PATH.
* Returns the token; the caller is responsible for putting it into the
* account pool.
*/
export async function setupGitHubToken(
options?: SetupGitHubTokenOptions,
): Promise<void> {
): Promise<string> {
try {
const githubToken = await readGithubToken()

if (githubToken && !options?.force) {
state.githubToken = githubToken
if (state.showToken) {
consola.info("GitHub token:", githubToken)
}
await logUser()

return
await logUser(githubToken)
return githubToken
}

consola.info("Not logged in, getting new access token")
Expand All @@ -96,12 +88,12 @@ export async function setupGitHubToken(

const token = await pollAccessToken(response)
await writeGithubToken(token)
state.githubToken = token

if (state.showToken) {
consola.info("GitHub token:", token)
}
await logUser()
await logUser(token)
return token
} catch (error) {
if (error instanceof HTTPError) {
consola.error("Failed to get GitHub token:", await error.response.json())
Expand All @@ -113,16 +105,21 @@ 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
async function logUser(githubToken: string) {
// Build a temporary "anonymous" account with just the GitHub token,
// so we can call /user without going through the pool.
const tempAccount: Account = {
name: "_setup",
accountType: state.accountType,
githubToken,
copilotTokenRefreshAt: 0,
inFlight: 0,
lastUsedAt: 0,
failureCount: 0,
}
// No pool yet (very early callers) — do nothing.
}

async function logUser() {
const user = await getGitHubUser()
const user = await getGitHubUser({
account: tempAccount,
vsCodeVersion: state.vsCodeVersion,
})
consola.info(`Logged in as ${user.login}`)
}
Loading