Last active
June 20, 2026 08:20
-
-
Save elbruno/43fa7650da08f976d6347492c8af9241 to your computer and use it in GitHub Desktop.
AI-credit monitor for GitHub Copilot App
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "copilot-app-cost", | |
| "version": 1 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // .github/extensions/copilot-app-cost/extension.mjs | |
| import { execFile } from "node:child_process"; | |
| import { promisify } from "node:util"; | |
| import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; | |
| // .github/extensions/copilot-app-cost/lib/cost.mjs | |
| var AI_CREDIT_USD = 0.01; | |
| var NANO_AI_UNITS_PER_AI_CREDIT = 1e9; | |
| var TOKENS_PER_MILLION = 1e6; | |
| var PLAN_ALLOTMENTS = Object.freeze({ | |
| free: { baseAiCredits: 0, flexAiCredits: 0, promotionalAiCredits: 0, totalAiCredits: 0, pooled: false }, | |
| pro: { baseAiCredits: 1e3, flexAiCredits: 500, promotionalAiCredits: 0, totalAiCredits: 1500, pooled: false }, | |
| "pro-plus": { baseAiCredits: 3900, flexAiCredits: 3100, promotionalAiCredits: 0, totalAiCredits: 7e3, pooled: false }, | |
| max: { baseAiCredits: 1e4, flexAiCredits: 1e4, promotionalAiCredits: 0, totalAiCredits: 2e4, pooled: false }, | |
| business: { baseAiCredits: 1900, flexAiCredits: 0, promotionalAiCredits: 1100, totalAiCredits: 3e3, pooled: true }, | |
| enterprise: { baseAiCredits: 3900, flexAiCredits: 0, promotionalAiCredits: 3100, totalAiCredits: 7e3, pooled: true }, | |
| student: { baseAiCredits: 0, flexAiCredits: 0, promotionalAiCredits: 0, totalAiCredits: 0, pooled: false } | |
| }); | |
| var MODEL_ALIASES = Object.freeze({ | |
| "gpt-5 mini": "gpt-5-mini", | |
| "gpt-5.4 mini": "gpt-5.4-mini", | |
| "gpt-5.4 nano": "gpt-5.4-nano", | |
| "claude haiku 4.5": "claude-haiku-4.5", | |
| "claude sonnet 4": "claude-sonnet-4", | |
| "claude sonnet 4.5": "claude-sonnet-4.5", | |
| "claude sonnet 4.6": "claude-sonnet-4.6", | |
| "claude opus 4.5": "claude-opus-4.5", | |
| "claude opus 4.6": "claude-opus-4.6", | |
| "claude opus 4.7": "claude-opus-4.7", | |
| "claude opus 4.8": "claude-opus-4.8", | |
| "claude fable 5": "claude-fable-5", | |
| "gemini 2.5 pro": "gemini-2.5-pro", | |
| "gemini 3 flash": "gemini-3-flash", | |
| "gemini 3.1 pro": "gemini-3.1-pro", | |
| "gemini 3.5 flash": "gemini-3.5-flash", | |
| goldeneye: "mai-code-1-flash", | |
| "mai code 1 flash": "mai-code-1-flash", | |
| "raptor mini": "raptor-mini" | |
| }); | |
| var MODEL_RATES = Object.freeze({ | |
| "gpt-5-mini": rate({ input: 0.25, cachedInput: 0.025, output: 2 }), | |
| "gpt-5.3-codex": rate({ input: 1.75, cachedInput: 0.175, output: 14 }), | |
| "gpt-5.4": rate({ input: 2.5, cachedInput: 0.25, output: 15, longContext: { thresholdInputTokens: 272e3, input: 5, cachedInput: 0.5, output: 22.5 } }), | |
| "gpt-5.4-mini": rate({ input: 0.75, cachedInput: 0.075, output: 4.5 }), | |
| "gpt-5.4-nano": rate({ input: 0.2, cachedInput: 0.02, output: 1.25 }), | |
| "gpt-5.5": rate({ input: 5, cachedInput: 0.5, output: 30, longContext: { thresholdInputTokens: 272e3, input: 10, cachedInput: 1, output: 45 } }), | |
| "claude-haiku-4.5": rate({ input: 1, cachedInput: 0.1, cacheWrite: 1.25, output: 5 }), | |
| "claude-sonnet-4": rate({ input: 3, cachedInput: 0.3, cacheWrite: 3.75, output: 15 }), | |
| "claude-sonnet-4.5": rate({ input: 3, cachedInput: 0.3, cacheWrite: 3.75, output: 15 }), | |
| "claude-sonnet-4.6": rate({ input: 3, cachedInput: 0.3, cacheWrite: 3.75, output: 15 }), | |
| "claude-opus-4.5": rate({ input: 5, cachedInput: 0.5, cacheWrite: 6.25, output: 25 }), | |
| "claude-opus-4.6": rate({ input: 5, cachedInput: 0.5, cacheWrite: 6.25, output: 25 }), | |
| "claude-opus-4.7": rate({ input: 5, cachedInput: 0.5, cacheWrite: 6.25, output: 25 }), | |
| "claude-opus-4.8": rate({ input: 5, cachedInput: 0.5, cacheWrite: 6.25, output: 25 }), | |
| "claude-fable-5": rate({ input: 10, cachedInput: 1, cacheWrite: 12.5, output: 50 }), | |
| "gemini-2.5-pro": rate({ input: 1.25, cachedInput: 0.125, output: 10 }), | |
| "gemini-3-flash": rate({ input: 0.5, cachedInput: 0.05, output: 3 }), | |
| "gemini-3.1-pro": rate({ input: 2, cachedInput: 0.2, output: 12, longContext: { thresholdInputTokens: 2e5, input: 4, cachedInput: 0.4, output: 18 } }), | |
| "gemini-3.5-flash": rate({ input: 1.5, cachedInput: 0.15, output: 9 }), | |
| "mai-code-1-flash": rate({ input: 0.75, cachedInput: 0.075, output: 4.5 }), | |
| "raptor-mini": rate({ input: 0.25, cachedInput: 0.025, output: 2 }) | |
| }); | |
| function normalizePlanId(plan) { | |
| const normalized = String(plan ?? "pro").trim().toLowerCase().replace(/[_\s]+/g, "-"); | |
| if (["pro+", "pro-plus", "proplus", "copilot-pro+", "copilot-pro-plus", "github-copilot-pro-plus"].includes(normalized)) { | |
| return "pro-plus"; | |
| } | |
| if (["copilot-max", "github-copilot-max"].includes(normalized)) { | |
| return "max"; | |
| } | |
| if (["individual", "copilot-pro", "github-copilot-pro"].includes(normalized)) { | |
| return "pro"; | |
| } | |
| if (normalized === "enterprise-cloud") { | |
| return "enterprise"; | |
| } | |
| return normalized; | |
| } | |
| function getPlanAllowance(plan) { | |
| return PLAN_ALLOTMENTS[normalizePlanId(plan)] ?? PLAN_ALLOTMENTS.pro; | |
| } | |
| function metricsToSessionUsage(sessionId, metrics, metadata = {}) { | |
| const modelMetrics = metrics?.modelMetrics && typeof metrics.modelMetrics === "object" ? metrics.modelMetrics : {}; | |
| return { | |
| sessionId, | |
| source: metadata.source ?? "session.rpc.usage.getMetrics", | |
| metricsTimestamp: metadata.metricsTimestamp ?? (/* @__PURE__ */ new Date()).toISOString(), | |
| currentModel: metrics?.currentModel ?? null, | |
| totalNanoAiu: readOptionalNumber(metrics?.totalNanoAiu), | |
| totalUserRequests: readOptionalNumber(metrics?.totalUserRequests), | |
| totalPremiumRequestCost: readOptionalNumber(metrics?.totalPremiumRequestCost), | |
| totalApiDurationMs: readOptionalNumber(metrics?.totalApiDurationMs), | |
| sessionStartTime: metrics?.sessionStartTime ?? null, | |
| lastCallInputTokens: readOptionalNumber(metrics?.lastCallInputTokens), | |
| lastCallOutputTokens: readOptionalNumber(metrics?.lastCallOutputTokens), | |
| codeChanges: { | |
| linesAdded: readOptionalNumber(metrics?.codeChanges?.linesAdded), | |
| linesRemoved: readOptionalNumber(metrics?.codeChanges?.linesRemoved), | |
| filesModifiedCount: readOptionalNumber(metrics?.codeChanges?.filesModifiedCount) | |
| }, | |
| modelUsage: Object.entries(modelMetrics).map(([model, item]) => ({ | |
| model, | |
| requests: readOptionalNumber(item?.requests?.count) ?? 0, | |
| inputTokens: readOptionalNumber(item?.usage?.inputTokens) ?? 0, | |
| cachedInputTokens: readOptionalNumber(item?.usage?.cacheReadTokens) ?? 0, | |
| cacheWriteTokens: readOptionalNumber(item?.usage?.cacheWriteTokens) ?? 0, | |
| outputTokens: readOptionalNumber(item?.usage?.outputTokens) ?? 0, | |
| reasoningTokens: readOptionalNumber(item?.usage?.reasoningTokens) ?? 0, | |
| totalNanoAiu: readOptionalNumber(item?.totalNanoAiu) | |
| })) | |
| }; | |
| } | |
| function calculateSessionEstimate(sessionUsage, options = {}) { | |
| const plan = normalizePlanId(options.plan ?? sessionUsage?.plan ?? "pro"); | |
| const billReasoningTokens = options.billReasoningTokens === true; | |
| const modelBreakdown = (sessionUsage?.modelUsage ?? []).map((usage) => { | |
| const modelId = resolveKnownModelId(usage.model); | |
| const rateEntry = MODEL_RATES[modelId]; | |
| const rate2 = selectRate(rateEntry, usage); | |
| const directAiCredits = readDirectAiCredits(usage, "copilot-model-aiu"); | |
| const uncachedInputTokens = Math.max(numberOrZero(usage.inputTokens) - numberOrZero(usage.cachedInputTokens), 0); | |
| const inputUsd = rate2 ? costForTokens(uncachedInputTokens, rate2.inputPerMillionUsd) : 0; | |
| const cachedInputUsd = rate2 ? costForTokens(usage.cachedInputTokens, rate2.cachedInputPerMillionUsd) : 0; | |
| const cacheWriteUsd = rate2 ? costForTokens(usage.cacheWriteTokens, rate2.cacheWritePerMillionUsd) : 0; | |
| const outputUsd = rate2 ? costForTokens(usage.outputTokens, rate2.outputPerMillionUsd) : 0; | |
| const reasoningUsd = rate2 && billReasoningTokens ? costForTokens(usage.reasoningTokens, rate2.outputPerMillionUsd) : 0; | |
| const tokenEstimatedTotalUsd = round(inputUsd + cachedInputUsd + cacheWriteUsd + outputUsd + reasoningUsd); | |
| const aiCredits2 = directAiCredits?.aiCredits ?? usdToAiCredits(tokenEstimatedTotalUsd); | |
| const totalUsd2 = directAiCredits ? aiCreditsToUsd(directAiCredits.aiCredits) : tokenEstimatedTotalUsd; | |
| return { | |
| model: modelId, | |
| displayName: usage.model, | |
| requests: numberOrZero(usage.requests), | |
| inputTokens: numberOrZero(usage.inputTokens), | |
| cachedInputTokens: numberOrZero(usage.cachedInputTokens), | |
| cacheWriteTokens: numberOrZero(usage.cacheWriteTokens), | |
| outputTokens: numberOrZero(usage.outputTokens), | |
| reasoningTokens: numberOrZero(usage.reasoningTokens), | |
| uncachedInputTokens, | |
| totalNanoAiu: directAiCredits?.totalNanoAiu, | |
| totalUsd: totalUsd2, | |
| aiCredits: aiCredits2, | |
| rateTier: rate2?.tier ?? null, | |
| creditCalculationMethod: directAiCredits ? "copilot-aiu" : "token-estimate", | |
| creditCalculationSource: directAiCredits?.source ?? "token-rate-estimate" | |
| }; | |
| }); | |
| const directSessionAiCredits = readDirectAiCredits(sessionUsage, "copilot-session-aiu"); | |
| const totalUsd = directSessionAiCredits ? aiCreditsToUsd(directSessionAiCredits.aiCredits) : round(sum(modelBreakdown.map((item) => item.totalUsd))); | |
| const aiCredits = directSessionAiCredits?.aiCredits ?? usdToAiCredits(totalUsd); | |
| const allowance = getPlanAllowance(plan); | |
| return { | |
| plan, | |
| currency: "USD", | |
| totalUsd, | |
| aiCredits, | |
| totalNanoAiu: directSessionAiCredits?.totalNanoAiu ?? readOptionalNumber(sessionUsage?.totalNanoAiu), | |
| includedAiCredits: allowance.totalAiCredits, | |
| allowanceUsagePercentage: allowance.totalAiCredits > 0 ? round(aiCredits / allowance.totalAiCredits * 100) : null, | |
| pooledAllowance: allowance.pooled, | |
| calculationMethod: directSessionAiCredits ? "copilot-aiu" : "token-estimate", | |
| calculationSource: directSessionAiCredits?.source ?? getBreakdownMethod(modelBreakdown), | |
| billReasoningTokens, | |
| modelBreakdown | |
| }; | |
| } | |
| function resolveKnownModelId(model) { | |
| const raw = String(model ?? "").trim(); | |
| const normalized = MODEL_ALIASES[raw.toLowerCase()] ?? raw.toLowerCase().replace(/\s+/g, "-"); | |
| if (MODEL_RATES[normalized]) { | |
| return normalized; | |
| } | |
| return Object.keys(MODEL_RATES).filter((candidate) => normalized.startsWith(candidate)).sort((a, b) => b.length - a.length)[0] ?? normalized; | |
| } | |
| function selectRate(rateEntry, usage) { | |
| if (!rateEntry) { | |
| return void 0; | |
| } | |
| const threshold = numberOrZero(rateEntry.longContext?.thresholdInputTokens); | |
| if (rateEntry.longContext && threshold > 0 && numberOrZero(usage.inputTokens) > threshold) { | |
| return rateEntry.longContext; | |
| } | |
| return rateEntry; | |
| } | |
| function getBreakdownMethod(modelBreakdown) { | |
| if (modelBreakdown.length === 0) { | |
| return "token-rate-estimate"; | |
| } | |
| const aiuCount = modelBreakdown.filter((item) => item.creditCalculationMethod === "copilot-aiu").length; | |
| if (aiuCount === modelBreakdown.length) { | |
| return "copilot-model-aiu"; | |
| } | |
| if (aiuCount > 0) { | |
| return "mixed-model-aiu-token-estimate"; | |
| } | |
| return "token-rate-estimate"; | |
| } | |
| function readDirectAiCredits(value, source) { | |
| const totalNanoAiu = readOptionalNumber(value?.totalNanoAiu); | |
| if (totalNanoAiu !== void 0) { | |
| return { | |
| aiCredits: round(totalNanoAiu / NANO_AI_UNITS_PER_AI_CREDIT), | |
| source, | |
| totalNanoAiu | |
| }; | |
| } | |
| return void 0; | |
| } | |
| function costForTokens(tokens, perMillionUsd) { | |
| return round(numberOrZero(tokens) / TOKENS_PER_MILLION * numberOrZero(perMillionUsd)); | |
| } | |
| function usdToAiCredits(usd) { | |
| return round(numberOrZero(usd) / AI_CREDIT_USD); | |
| } | |
| function aiCreditsToUsd(aiCredits) { | |
| return round(numberOrZero(aiCredits) * AI_CREDIT_USD); | |
| } | |
| function sum(values) { | |
| return values.reduce((total, value) => total + numberOrZero(value), 0); | |
| } | |
| function numberOrZero(value) { | |
| const parsed = Number(value ?? 0); | |
| return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; | |
| } | |
| function readOptionalNumber(value) { | |
| if (value === void 0 || value === null || value === "") { | |
| return void 0; | |
| } | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0; | |
| } | |
| function round(value) { | |
| return Math.round((Number(value ?? 0) + Number.EPSILON) * 1e6) / 1e6; | |
| } | |
| function rate({ input, cachedInput, cacheWrite = 0, output, longContext }) { | |
| const base = { | |
| tier: "default", | |
| inputPerMillionUsd: input, | |
| cachedInputPerMillionUsd: cachedInput, | |
| cacheWritePerMillionUsd: cacheWrite, | |
| outputPerMillionUsd: output | |
| }; | |
| if (!longContext) { | |
| return base; | |
| } | |
| return { | |
| ...base, | |
| longContext: { | |
| tier: "long-context", | |
| thresholdInputTokens: longContext.thresholdInputTokens, | |
| inputPerMillionUsd: longContext.input, | |
| cachedInputPerMillionUsd: longContext.cachedInput, | |
| cacheWritePerMillionUsd: longContext.cacheWrite ?? cacheWrite, | |
| outputPerMillionUsd: longContext.output | |
| } | |
| }; | |
| } | |
| // .github/extensions/copilot-app-cost/lib/history.mjs | |
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| function getSessionStateDirectory(options = {}) { | |
| const copilotHome = options.copilotHome ?? process.env.COPILOT_HOME ?? path.join(os.homedir(), ".copilot"); | |
| return path.join(copilotHome, "session-state"); | |
| } | |
| function listRecentSessionUsages(currentSessionId, options = {}) { | |
| const directory = getSessionStateDirectory(options); | |
| if (!fs.existsSync(directory)) { | |
| return []; | |
| } | |
| const candidates = fs.readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => { | |
| const sessionId = entry.name; | |
| const eventsPath = path.join(directory, sessionId, "events.jsonl"); | |
| if (!fs.existsSync(eventsPath)) { | |
| return null; | |
| } | |
| return { | |
| sessionId, | |
| eventsPath, | |
| updatedAtMs: fs.statSync(eventsPath).mtimeMs | |
| }; | |
| }).filter(Boolean).sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, options.limit ?? 20); | |
| return candidates.map((candidate) => readSessionUsageFromEvents(candidate.sessionId, { ...options, eventsPath: candidate.eventsPath, currentSessionId })).filter(Boolean).sort((left, right) => Date.parse(right.updatedAt ?? "") - Date.parse(left.updatedAt ?? "")); | |
| } | |
| function readSessionUsageFromEvents(sessionId, options = {}) { | |
| const eventsPath = options.eventsPath ?? path.join(getSessionStateDirectory(options), sessionId, "events.jsonl"); | |
| if (!fs.existsSync(eventsPath)) { | |
| return null; | |
| } | |
| const events = readSessionEvents(eventsPath); | |
| const event = events.richestMetricsEvent ?? events.latestMetricsEvent; | |
| if (!event?.data?.modelMetrics && event?.data?.totalNanoAiu === void 0) { | |
| return null; | |
| } | |
| const workspace = readSessionWorkspaceMetadata(sessionId, options); | |
| const modelUsage = Object.entries(event.data.modelMetrics ?? {}).map(([model, metrics]) => ({ | |
| model, | |
| requests: numberOrZero2(metrics?.requests?.count), | |
| inputTokens: numberOrZero2(metrics?.usage?.inputTokens), | |
| cachedInputTokens: numberOrZero2(metrics?.usage?.cacheReadTokens), | |
| cacheWriteTokens: numberOrZero2(metrics?.usage?.cacheWriteTokens), | |
| outputTokens: numberOrZero2(metrics?.usage?.outputTokens), | |
| reasoningTokens: numberOrZero2(metrics?.usage?.reasoningTokens), | |
| totalNanoAiu: readOptionalNumber2(metrics?.totalNanoAiu) | |
| })); | |
| return { | |
| sessionId, | |
| sessionName: workspace.sessionName ?? events.metadata.sessionName ?? null, | |
| workspaceDirectory: workspace.workspaceDirectory ?? events.metadata.workspaceDirectory ?? null, | |
| repository: workspace.repository ?? null, | |
| branch: workspace.branch ?? null, | |
| source: sessionId === options.currentSessionId ? "active-session-events" : "completed-session-events", | |
| sourcePath: eventsPath, | |
| status: sessionId === options.currentSessionId ? "active" : "completed", | |
| currentModel: event.data.currentModel ?? null, | |
| metricsTimestamp: event.timestamp, | |
| latestEventType: events.latestEvent?.type ?? null, | |
| latestEventTimestamp: events.latestEvent?.timestamp ?? null, | |
| metricsStale: events.latestEvent?.timestamp !== event.timestamp, | |
| updatedAt: events.latestEvent?.timestamp ?? event.timestamp ?? new Date(fs.statSync(eventsPath).mtimeMs).toISOString(), | |
| totalNanoAiu: readOptionalNumber2(event.data.totalNanoAiu), | |
| totalUserRequests: readOptionalNumber2(event.data.totalUserRequests), | |
| totalApiDurationMs: readOptionalNumber2(event.data.totalApiDurationMs), | |
| sessionStartTime: event.data.sessionStartTime ?? null, | |
| lastCallInputTokens: readOptionalNumber2(event.data.lastCallInputTokens), | |
| lastCallOutputTokens: readOptionalNumber2(event.data.lastCallOutputTokens), | |
| modelUsage | |
| }; | |
| } | |
| function readSessionEvents(eventsPath) { | |
| let latestMetricsEvent = null; | |
| let richestMetricsEvent = null; | |
| let latestEvent = null; | |
| const metadata = {}; | |
| for (const line of fs.readFileSync(eventsPath, "utf8").split(/\r?\n/)) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| const event = JSON.parse(line); | |
| latestEvent = event; | |
| updateMetadata(metadata, event); | |
| if (event?.data?.modelMetrics || event?.data?.totalNanoAiu !== void 0) { | |
| latestMetricsEvent = event; | |
| if (!richestMetricsEvent || eventWeight(event) > eventWeight(richestMetricsEvent)) { | |
| richestMetricsEvent = event; | |
| } | |
| } | |
| } | |
| return { latestMetricsEvent, richestMetricsEvent, latestEvent, metadata }; | |
| } | |
| function readSessionWorkspaceMetadata(sessionId, options = {}) { | |
| const workspacePath = options.workspacePath ?? path.join(getSessionStateDirectory(options), sessionId, "workspace.yaml"); | |
| if (!fs.existsSync(workspacePath)) { | |
| return {}; | |
| } | |
| const result = {}; | |
| for (const line of fs.readFileSync(workspacePath, "utf8").split(/\r?\n/)) { | |
| const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); | |
| if (!match) { | |
| continue; | |
| } | |
| result[match[1]] = unquoteYamlScalar(match[2]); | |
| } | |
| return { | |
| sessionName: readString(result.name), | |
| workspaceDirectory: readString(result.cwd), | |
| repository: readString(result.repository), | |
| branch: readString(result.branch) | |
| }; | |
| } | |
| function updateMetadata(metadata, event) { | |
| const data = event.data ?? {}; | |
| metadata.sessionName = readString(data.sessionName ?? data.session_name ?? data.name ?? metadata.sessionName) ?? metadata.sessionName; | |
| metadata.workspaceDirectory = readString(data.workspaceDirectory ?? data.workspace_directory ?? data.cwd ?? metadata.workspaceDirectory) ?? metadata.workspaceDirectory; | |
| } | |
| function eventWeight(event) { | |
| let total = numberOrZero2(event?.data?.totalNanoAiu); | |
| for (const metrics of Object.values(event?.data?.modelMetrics ?? {})) { | |
| total += numberOrZero2(metrics?.totalNanoAiu) + numberOrZero2(metrics?.usage?.inputTokens) + numberOrZero2(metrics?.usage?.cacheReadTokens) + numberOrZero2(metrics?.usage?.cacheWriteTokens) + numberOrZero2(metrics?.usage?.outputTokens) + numberOrZero2(metrics?.usage?.reasoningTokens); | |
| } | |
| return total; | |
| } | |
| function readString(value) { | |
| if (value === void 0 || value === null || value === "") { | |
| return void 0; | |
| } | |
| return String(value); | |
| } | |
| function unquoteYamlScalar(value) { | |
| const trimmed = String(value ?? "").trim(); | |
| if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) { | |
| return trimmed.slice(1, -1); | |
| } | |
| return trimmed; | |
| } | |
| function readOptionalNumber2(value) { | |
| if (value === void 0 || value === null || value === "") { | |
| return void 0; | |
| } | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0; | |
| } | |
| function numberOrZero2(value) { | |
| const parsed = Number(value ?? 0); | |
| return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; | |
| } | |
| // .github/extensions/copilot-app-cost/lib/settings.mjs | |
| import fs2 from "node:fs"; | |
| import os2 from "node:os"; | |
| import path2 from "node:path"; | |
| var APP_NAME = "copilot-app-cost"; | |
| var SETTINGS_FILE = "settings.json"; | |
| function getAppDataDirectory(options = {}) { | |
| const env = options.env ?? process.env; | |
| const homeDirectory = options.homeDirectory ?? os2.homedir(); | |
| if (process.platform === "win32") { | |
| return path2.join(env.LOCALAPPDATA ?? path2.join(homeDirectory, "AppData", "Local"), APP_NAME); | |
| } | |
| if (process.platform === "darwin") { | |
| return path2.join(homeDirectory, "Library", "Application Support", APP_NAME); | |
| } | |
| return path2.join(env.XDG_STATE_HOME ?? path2.join(homeDirectory, ".local", "state"), APP_NAME); | |
| } | |
| function getSettingsPath(options = {}) { | |
| return path2.join(getAppDataDirectory(options), SETTINGS_FILE); | |
| } | |
| function getDefaultSettings(now = /* @__PURE__ */ new Date()) { | |
| return { | |
| billingScope: "user", | |
| userAccount: "", | |
| organizationAccount: "", | |
| billingYear: now.getUTCFullYear(), | |
| billingMonth: now.getUTCMonth() + 1, | |
| liveRefreshSeconds: 2, | |
| sessionHistoryRefreshSeconds: 30, | |
| billingRefreshMinutes: 5, | |
| alerts: { | |
| sessionAiCredits: null, | |
| sessionUsd: null, | |
| monthlyUsagePercent: null, | |
| monthlyNetUsd: null | |
| }, | |
| diagnosticsEnabled: true, | |
| billReasoningTokens: false, | |
| updatedAt: now.toISOString() | |
| }; | |
| } | |
| function loadSettings(options = {}) { | |
| const settingsPath = getSettingsPath(options); | |
| const defaults = getDefaultSettings(options.now ? new Date(options.now) : /* @__PURE__ */ new Date()); | |
| if (!fs2.existsSync(settingsPath)) { | |
| return defaults; | |
| } | |
| try { | |
| const parsed = JSON.parse(fs2.readFileSync(settingsPath, "utf8")); | |
| return normalizeSettings(parsed, defaults); | |
| } catch { | |
| return defaults; | |
| } | |
| } | |
| function saveSettings(input, options = {}) { | |
| const settingsPath = getSettingsPath(options); | |
| const normalized = normalizeSettings(input, getDefaultSettings(options.now ? new Date(options.now) : /* @__PURE__ */ new Date())); | |
| fs2.mkdirSync(path2.dirname(settingsPath), { recursive: true }); | |
| fs2.writeFileSync(settingsPath, `${JSON.stringify(normalized, null, 2)} | |
| `); | |
| return normalized; | |
| } | |
| function normalizeSettings(input, defaults = getDefaultSettings()) { | |
| const billingScope = ["user", "organization"].includes(String(input?.billingScope ?? "")) ? String(input.billingScope) : defaults.billingScope; | |
| return { | |
| billingScope, | |
| userAccount: sanitizeAccount(input?.userAccount), | |
| organizationAccount: sanitizeAccount(input?.organizationAccount), | |
| billingYear: clampInteger(input?.billingYear, 2024, 2100, defaults.billingYear), | |
| billingMonth: clampInteger(input?.billingMonth, 1, 12, defaults.billingMonth), | |
| liveRefreshSeconds: clampInteger(input?.liveRefreshSeconds, 2, 10, defaults.liveRefreshSeconds), | |
| sessionHistoryRefreshSeconds: clampInteger(input?.sessionHistoryRefreshSeconds, 10, 300, defaults.sessionHistoryRefreshSeconds), | |
| billingRefreshMinutes: clampInteger(input?.billingRefreshMinutes, 1, 60, defaults.billingRefreshMinutes), | |
| alerts: { | |
| sessionAiCredits: clampNullableNumber(input?.alerts?.sessionAiCredits, 0, 1e5), | |
| sessionUsd: clampNullableNumber(input?.alerts?.sessionUsd, 0, 1e5), | |
| monthlyUsagePercent: clampNullableNumber(input?.alerts?.monthlyUsagePercent, 0, 1e4), | |
| monthlyNetUsd: clampNullableNumber(input?.alerts?.monthlyNetUsd, 0, 1e5) | |
| }, | |
| diagnosticsEnabled: input?.diagnosticsEnabled !== false, | |
| billReasoningTokens: input?.billReasoningTokens === true, | |
| updatedAt: (/* @__PURE__ */ new Date()).toISOString() | |
| }; | |
| } | |
| function sanitizeAccount(value) { | |
| const trimmed = String(value ?? "").trim(); | |
| return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(trimmed) ? trimmed : ""; | |
| } | |
| function clampInteger(value, min, max, fallback) { | |
| const parsed = Number(value); | |
| if (!Number.isInteger(parsed)) { | |
| return fallback; | |
| } | |
| return Math.min(Math.max(parsed, min), max); | |
| } | |
| function clampNullableNumber(value, min, max) { | |
| if (value === void 0 || value === null || value === "") { | |
| return null; | |
| } | |
| const parsed = Number(value); | |
| if (!Number.isFinite(parsed)) { | |
| return null; | |
| } | |
| return Math.min(Math.max(parsed, min), max); | |
| } | |
| // .github/extensions/copilot-app-cost/lib/billing.mjs | |
| var BILLING_API_VERSION = "2026-03-10"; | |
| function validateAccountName(value) { | |
| return typeof value === "string" && /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(value); | |
| } | |
| function buildGhArgs(request) { | |
| if (!validateAccountName(request?.account)) { | |
| throw new Error(`Invalid ${request?.scope ?? "billing"} account.`); | |
| } | |
| const basePath = request.scope === "organization" ? `/organizations/${request.account}/settings/billing/ai_credit/usage` : `/users/${request.account}/settings/billing/ai_credit/usage`; | |
| const search = new URLSearchParams(); | |
| if (Number.isInteger(request.year)) { | |
| search.set("year", String(request.year)); | |
| } | |
| if (Number.isInteger(request.month)) { | |
| search.set("month", String(request.month)); | |
| } | |
| const pathWithQuery = search.size > 0 ? `${basePath}?${search.toString()}` : basePath; | |
| return [ | |
| "api", | |
| pathWithQuery, | |
| "-H", | |
| "Accept: application/vnd.github+json", | |
| "-H", | |
| `X-GitHub-Api-Version: ${request.apiVersion ?? BILLING_API_VERSION}` | |
| ]; | |
| } | |
| function normalizeBillingResponse(request, payload, options = {}) { | |
| const usageItems = Array.isArray(payload?.usageItems) ? payload.usageItems : []; | |
| const totals = usageItems.reduce((acc, item) => ({ | |
| grossQuantity: acc.grossQuantity + numberOrZero3(item?.grossQuantity), | |
| grossAmount: acc.grossAmount + numberOrZero3(item?.grossAmount), | |
| discountQuantity: acc.discountQuantity + numberOrZero3(item?.discountQuantity), | |
| discountAmount: acc.discountAmount + numberOrZero3(item?.discountAmount), | |
| netQuantity: acc.netQuantity + numberOrZero3(item?.netQuantity), | |
| netAmount: acc.netAmount + numberOrZero3(item?.netAmount) | |
| }), { | |
| grossQuantity: 0, | |
| grossAmount: 0, | |
| discountQuantity: 0, | |
| discountAmount: 0, | |
| netQuantity: 0, | |
| netAmount: 0 | |
| }); | |
| const usageByModelMap = /* @__PURE__ */ new Map(); | |
| for (const item of usageItems) { | |
| const model = readString2(item?.model) ?? "(unspecified)"; | |
| const target = usageByModelMap.get(model) ?? { | |
| model, | |
| product: readString2(item?.product) ?? null, | |
| sku: readString2(item?.sku) ?? null, | |
| grossQuantity: 0, | |
| grossAmount: 0, | |
| discountQuantity: 0, | |
| discountAmount: 0, | |
| netQuantity: 0, | |
| netAmount: 0, | |
| unitType: readString2(item?.unitType) ?? null, | |
| pricePerUnit: item?.pricePerUnit ?? null | |
| }; | |
| target.grossQuantity += numberOrZero3(item?.grossQuantity); | |
| target.grossAmount += numberOrZero3(item?.grossAmount); | |
| target.discountQuantity += numberOrZero3(item?.discountQuantity); | |
| target.discountAmount += numberOrZero3(item?.discountAmount); | |
| target.netQuantity += numberOrZero3(item?.netQuantity); | |
| target.netAmount += numberOrZero3(item?.netAmount); | |
| usageByModelMap.set(model, target); | |
| } | |
| const usageByModel = Array.from(usageByModelMap.values()).sort((a, b) => b.netAmount - a.netAmount || b.netQuantity - a.netQuantity); | |
| const reliability = request.scope === "organization" ? "authoritative-scope" : totals.netQuantity > 0 ? "authoritative-scope" : "managed-license-or-no-personal-usage"; | |
| return { | |
| source: "GITHUB BILLING", | |
| scope: request.scope, | |
| account: request.account, | |
| freshness: "point-in-time", | |
| confidence: "official", | |
| retrievedAt: options.retrievedAt ?? (/* @__PURE__ */ new Date()).toISOString(), | |
| apiVersion: request.apiVersion ?? BILLING_API_VERSION, | |
| timePeriod: payload?.timePeriod ?? { year: request.year, month: request.month }, | |
| product: payload?.product ?? null, | |
| model: payload?.model ?? null, | |
| user: payload?.user ?? null, | |
| organization: payload?.organization ?? null, | |
| totals, | |
| usageByModel, | |
| usageItemCount: usageItems.length, | |
| permissionStatus: "authorized", | |
| availability: true, | |
| reliability, | |
| managedLicenseNote: request.scope === "user" && totals.netQuantity <= 0 ? "Personal billing may be empty when Copilot usage is managed and billed by an organization or enterprise." : null | |
| }; | |
| } | |
| function classifyBillingError(message, scope) { | |
| const text = String(message ?? ""); | |
| if (/needs the "user" scope/i.test(text)) { | |
| return "missing-user-scope"; | |
| } | |
| if (/HTTP 403|Forbidden/i.test(text)) { | |
| return scope === "organization" ? "missing-organization-permission" : "forbidden"; | |
| } | |
| if (/HTTP 401|Unauthorized/i.test(text)) { | |
| return "unauthorized"; | |
| } | |
| if (/HTTP 404|Not Found/i.test(text)) { | |
| return scope === "organization" ? "not-found-or-no-org-access" : "not-found-or-no-billed-usage"; | |
| } | |
| if (/HTTP 429|rate limit/i.test(text)) { | |
| return "rate-limited"; | |
| } | |
| if (/HTTP 5\d\d/i.test(text)) { | |
| return "server-error"; | |
| } | |
| return "request-failed"; | |
| } | |
| function readString2(value) { | |
| if (value === void 0 || value === null || value === "") { | |
| return void 0; | |
| } | |
| return String(value); | |
| } | |
| function numberOrZero3(value) { | |
| const parsed = Number(value ?? 0); | |
| return Number.isFinite(parsed) ? parsed : 0; | |
| } | |
| // .github/extensions/copilot-app-cost/extension.mjs | |
| var execFileAsync = promisify(execFile); | |
| var EXTENSION_VERSION = "0.1.0"; | |
| var GH_TIMEOUT_MS = 1e4; | |
| var canvasState = { | |
| // Live session metrics from Copilot SDK | |
| liveEstimate: null, | |
| liveTimeline: [], | |
| // GitHub official billing | |
| billingData: null, | |
| billingScope: "user", | |
| billingAccount: "", | |
| // Local session history | |
| sessions: [], | |
| // User settings (persist locally) | |
| settings: loadSettings(), | |
| // UI state | |
| ui: { | |
| activeTab: "overview", | |
| expandedSessions: [], | |
| showDiagnostics: false | |
| }, | |
| // Provider status | |
| providers: { | |
| live: { status: "idle", error: null, lastRefresh: null }, | |
| billing: { status: "idle", error: null, lastRefresh: null }, | |
| sessions: { status: "idle", error: null, lastRefresh: null } | |
| }, | |
| // Version info | |
| extensionVersion: EXTENSION_VERSION | |
| }; | |
| async function refreshLiveMetrics(session) { | |
| canvasState.providers.live.status = "loading"; | |
| try { | |
| const metrics = await session.rpc.usage.getMetrics?.(); | |
| if (!metrics) { | |
| canvasState.liveEstimate = null; | |
| canvasState.providers.live.status = "unavailable"; | |
| canvasState.providers.live.error = "No active session metrics available"; | |
| return { success: false, error: "No active session metrics" }; | |
| } | |
| const usage = metricsToSessionUsage(metrics); | |
| const estimate = calculateSessionEstimate(usage); | |
| canvasState.liveEstimate = { usage, estimate, rawMetrics: metrics }; | |
| canvasState.providers.live.status = "success"; | |
| canvasState.providers.live.error = null; | |
| canvasState.providers.live.lastRefresh = (/* @__PURE__ */ new Date()).toISOString(); | |
| return { success: true, estimate }; | |
| } catch (error) { | |
| canvasState.providers.live.status = "error"; | |
| canvasState.providers.live.error = error?.message || String(error); | |
| return { success: false, error: error?.message }; | |
| } | |
| } | |
| async function refreshBillingUsage(session, scope, account) { | |
| canvasState.providers.billing.status = "loading"; | |
| try { | |
| if (!validateAccountName(account)) { | |
| throw new Error("Invalid account name"); | |
| } | |
| const endpoint = scope === "org" ? `/organizations/${account}/settings/billing/ai_credit/usage` : `/users/${account}/settings/billing/ai_credit/usage`; | |
| const args = buildGhArgs("api", endpoint, "--paginate"); | |
| const { stdout } = await execFileAsync("gh", args, { timeout: GH_TIMEOUT_MS }); | |
| const response = JSON.parse(stdout); | |
| const normalized = normalizeBillingResponse(response, scope); | |
| canvasState.billingData = normalized; | |
| canvasState.billingScope = scope; | |
| canvasState.billingAccount = account; | |
| canvasState.providers.billing.status = "success"; | |
| canvasState.providers.billing.error = null; | |
| canvasState.providers.billing.lastRefresh = (/* @__PURE__ */ new Date()).toISOString(); | |
| return { success: true, billing: normalized }; | |
| } catch (error) { | |
| const classification = classifyBillingError(error); | |
| canvasState.providers.billing.status = "error"; | |
| canvasState.providers.billing.error = classification; | |
| return { success: false, error: classification }; | |
| } | |
| } | |
| async function refreshSessionHistory() { | |
| canvasState.providers.sessions.status = "loading"; | |
| try { | |
| const sessions = await listRecentSessionUsages(); | |
| canvasState.sessions = sessions; | |
| canvasState.providers.sessions.status = "success"; | |
| canvasState.providers.sessions.error = null; | |
| canvasState.providers.sessions.lastRefresh = (/* @__PURE__ */ new Date()).toISOString(); | |
| return { success: true, sessions }; | |
| } catch (error) { | |
| canvasState.providers.sessions.status = "error"; | |
| canvasState.providers.sessions.error = error?.message || String(error); | |
| return { success: false, error: error?.message }; | |
| } | |
| } | |
| function updateSettings(newSettings) { | |
| const updated = { ...canvasState.settings, ...newSettings }; | |
| saveSettings(updated); | |
| canvasState.settings = updated; | |
| return { success: true, settings: updated }; | |
| } | |
| function updateUIState(uiChanges) { | |
| canvasState.ui = { ...canvasState.ui, ...uiChanges }; | |
| return { success: true, ui: canvasState.ui }; | |
| } | |
| function exportDashboardData() { | |
| return { | |
| exportedAt: (/* @__PURE__ */ new Date()).toISOString(), | |
| extensionVersion: EXTENSION_VERSION, | |
| live: canvasState.liveEstimate, | |
| billing: canvasState.billingData, | |
| sessions: canvasState.sessions, | |
| settings: { | |
| billingScope: canvasState.billingScope, | |
| billingAccount: canvasState.billingAccount | |
| } | |
| }; | |
| } | |
| function initialize(session) { | |
| return joinSession({ | |
| canvases: [ | |
| createCanvas({ | |
| name: "Copilot App Cost", | |
| description: "Monitor AI-credit usage from active session and official GitHub billing", | |
| type: "dashboard", | |
| state: canvasState, | |
| capabilities: { | |
| "refresh-live": () => refreshLiveMetrics(session), | |
| "refresh-billing": (scope, account) => refreshBillingUsage(session, scope, account), | |
| "refresh-sessions": () => refreshSessionHistory(), | |
| "update-settings": (settings) => updateSettings(settings), | |
| "update-ui": (ui) => updateUIState(ui), | |
| "export-data": () => exportDashboardData() | |
| } | |
| }) | |
| ] | |
| }); | |
| } | |
| export { | |
| initialize as default | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment