Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,23 @@ Code completions and {% data variables.copilot.next_edit_suggestions %} are **no

## How do {% data variables.product.prodname_ai_credits_short %} work?

Each {% data variables.product.prodname_copilot_short %} individual plan subscription includes a monthly {% data variables.product.prodname_ai_credits_short %} allowance:
Each {% data variables.product.prodname_copilot_short %} individual plan subscription includes a monthly {% data variables.product.prodname_ai_credits_short %} allowance.

| Plan | Total {% data variables.product.prodname_ai_credits_short %} per month |
| --- | --- |
| {% data variables.copilot.copilot_pro_short %} | {% data variables.copilot.ai_credits_per_user_pro %} |
| {% data variables.copilot.copilot_pro_plus_short %} | {% data variables.copilot.ai_credits_per_user_pro_plus %} |
**Base credits** are included with your plan subscription each month. These match with your subscription price and they never change.

Each plan currently also includes a **flex allotment**. This in an additional monthly amount on top of your base credits. The flex allotment is a variable part of your included usage; it is designed to adapt as the economics of AI evolve, including model pricing, new models, and improvements in efficiency.

Your base credits are used first. If you go beyond your base credits, the flex allotment is applied automatically at the same rates across your IDE, {% data variables.product.prodname_dotcom_the_website %}, and the {% data variables.copilot.copilot_cli_short %}. No additional setup is required. Your usage dashboard shows your available allowance and what you've used.

If you use everything included in your plan, you can purchase more and keep working. See [What happens if I exceed my included {% data variables.product.prodname_ai_credits_short %}](#what-happens-if-i-exceed-my-included--data-variablesproductprodname_ai_credits_short-).

| Plan | Price per month | Base credits | Flex allotment | Total monthly {% data variables.product.prodname_ai_credits_short %} |
| --- | --- | --- | --- | --- |
| {% data variables.copilot.copilot_pro_short %} | {% data variables.copilot.cfi_price_per_month %} | {% data variables.copilot.ai_credits_per_user_pro %} | {% data variables.copilot.ai_credits_per_user_pro_flex %} | {% data variables.copilot.ai_credits_per_user_pro_total %} |
| {% data variables.copilot.copilot_pro_plus_short %} | {% data variables.copilot.cpp_price_per_month %} | {% data variables.copilot.ai_credits_per_user_pro_plus %} | {% data variables.copilot.ai_credits_per_user_pro_plus_flex %} | {% data variables.copilot.ai_credits_per_user_pro_plus_total %} |
| {% data variables.copilot.copilot_max_short %} | {% data variables.copilot.cm_price_per_month %} | {% data variables.copilot.ai_credits_per_user_max %} | {% data variables.copilot.ai_credits_per_user_max_flex %} | {% data variables.copilot.ai_credits_per_user_max_total %} |

{% data variables.copilot.copilot_free_short %} will include 2000 code completions per month, an allowance of {% data variables.product.prodname_ai_credits_short %} and {% data variables.copilot.copilot_auto_model_selection_short %}.

## What happens if I exceed my included {% data variables.product.prodname_ai_credits_short %}?

Expand Down Expand Up @@ -79,4 +90,4 @@ Note that, starting **June 1, 2026**, {% data variables.copilot.copilot_pro_shor

## Next steps

* For guidance on how to prepare for usage-based billing, see [AUTOTITLE](/copilot/how-tos/manage-and-track-spending/prepare-for-your-move-to-usage-based-billing).
* For guidance on how to prepare for usage-based billing, see [AUTOTITLE](/copilot/how-tos/manage-and-track-spending/prepare-for-your-move-to-usage-based-billing).
15 changes: 13 additions & 2 deletions data/variables/copilot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ copilot_free: 'GitHub Copilot Free'
copilot_free_short: 'Copilot Free'
copilot_student: 'GitHub Copilot Student'
copilot_student_short: 'Copilot Student'
copilot_max: 'GitHub Copilot Max'
copilot_max_short: 'Copilot Max'

## Copilot billing
# Price per additional premium request
Expand All @@ -24,6 +26,8 @@ additional_premium_requests: '$0.04 USD' # Note that these are also used to bill
cfi_price_per_month: '$10 USD'
# Price per month for Copilot Pro Plus
cpp_price_per_month: '$39 USD'
# Price per month for Copilot Max
cm_price_per_month: '$100 USD'
# Price per month for Copilot Business
cfb_price_per_month: '$19 USD'
# Price per month for Copilot Enterprise
Expand All @@ -34,8 +38,15 @@ ai_credits_per_user_business: '1,900'
ai_credits_per_user_enterprise: '3,900'
ai_credits_per_user_business_promo: '3,000'
ai_credits_per_user_enterprise_promo: '7,000'
ai_credits_per_user_pro: '1000'
ai_credits_per_user_pro_plus: '3900'
ai_credits_per_user_pro: '1,000'
ai_credits_per_user_pro_plus: '3,900'
ai_credits_per_user_max: '10,000'
ai_credits_per_user_pro_flex: '500'
ai_credits_per_user_pro_plus_flex: '3,100'
ai_credits_per_user_max_flex: '10,000'
ai_credits_per_user_pro_total: '1,500'
ai_credits_per_user_pro_plus_total: '7,000'
ai_credits_per_user_max_total: '20,000'

## Copilot partners: builders who can develop Copilot extensions
copilot_partners: 'Copilot Partners'
Expand Down
2 changes: 1 addition & 1 deletion src/graphql/data/ghec/schema.docs.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -69470,7 +69470,7 @@ type User implements Actor & Agentic & Node & PackageOwner & ProfileOwner & Proj
): Organization

"""
Verified email addresses that match verified domains for a specified organization the user is a member of. Results are unordered. There is no way to specify ordering, priority, or filtering, and this field should not be used to determine a user's canonical or current corporate email in multi-domain contexts.
Verified email addresses that match verified domains for a specified organization the user is a member of.
"""
organizationVerifiedDomainEmails(
"""
Expand Down
84 changes: 69 additions & 15 deletions src/links/lib/extract-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,34 @@ export interface LinkExtractionResult {
}

/**
* Get line and column number for a match in content
* Build an array of character offsets at which each line starts.
* offsets[0] is always 0. Called once per extractLinksFromMarkdown invocation
* so that getLineAndColumn can use binary search instead of repeated splits.
*/
function getLineAndColumn(content: string, matchIndex: number): { line: number; column: number } {
const lines = content.substring(0, matchIndex).split('\n')
const line = lines.length
const column = lines[lines.length - 1].length + 1
return { line, column }
function buildLineOffsets(content: string): number[] {
const offsets = [0]
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') offsets.push(i + 1)
}
return offsets
}

/**
* Get line and column number for a match using a precomputed line-offset index.
* Binary search gives O(log L) per call instead of O(matchIndex).
*/
function getLineAndColumn(
lineOffsets: number[],
matchIndex: number,
): { line: number; column: number } {
let lo = 0
let hi = lineOffsets.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (lineOffsets[mid] <= matchIndex) lo = mid
else hi = mid - 1
}
return { line: lo + 1, column: matchIndex - lineOffsets[lo] + 1 }
}

/**
Expand Down Expand Up @@ -109,10 +130,13 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
},
)

// Precompute line-start offsets once so every getLineAndColumn call is O(log L).
const lineOffsets = buildLineOffsets(strippedContent)

// Extract AUTOTITLE links first (they're a special case of internal links)
let match
while ((match = AUTOTITLE_LINK_PATTERN.exec(strippedContent)) !== null) {
const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
const href = match[1].split('#')[0] // Remove anchor if present
if (href.startsWith('/')) {
internalLinks.push({
Expand All @@ -136,7 +160,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
continue
}

const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
// Extract href from ](/path) format
const href = fullMatch.substring(2, fullMatch.length - 1).split('#')[0]
const text = extractLinkText(strippedContent, match.index)
Expand All @@ -155,7 +179,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult

// Extract external links
while ((match = EXTERNAL_LINK_PATTERN.exec(strippedContent)) !== null) {
const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
const href = match[1]
const text = extractLinkText(strippedContent, match.index)

Expand All @@ -172,7 +196,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult

// Extract anchor links
while ((match = ANCHOR_LINK_PATTERN.exec(strippedContent)) !== null) {
const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
const href = match[0].substring(2, match[0].length - 1)

anchorLinks.push({
Expand All @@ -188,7 +212,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult

// Extract image links
while ((match = IMAGE_LINK_PATTERN.exec(strippedContent)) !== null) {
const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
const href = match[1]

// Only include internal images (starting with /)
Expand All @@ -208,7 +232,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
// Extract reference-style link definitions ([id]: /path)
// These are distinct from inline links but point to the same targets that need validating.
while ((match = LINK_DEFINITION_PATTERN.exec(strippedContent)) !== null) {
const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
const href = match[1].split('#')[0]
internalLinks.push({
href,
Expand All @@ -223,7 +247,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult

// Extract links whose href starts with a Liquid tag
while ((match = LIQUID_HREF_PATTERN.exec(strippedContent)) !== null) {
const { line, column } = getLineAndColumn(strippedContent, match.index)
const { line, column } = getLineAndColumn(lineOffsets, match.index)
liquidPrefixedLinks.push({
href: match[1],
line,
Expand Down Expand Up @@ -274,6 +298,18 @@ export function createLiquidContext(
} as Context
}

// Cached reference to renderLiquid — avoids repeated dynamic-import overhead on every call.
// A dynamic import is still used (not a top-level import) to prevent circular dependency issues.
type RenderLiquidModule = (template: string, context: unknown) => Promise<string>
let _renderLiquid: RenderLiquidModule | null = null
async function getCachedRenderLiquid(): Promise<RenderLiquidModule> {
if (!_renderLiquid) {
const mod = await import('@/content-render/liquid/index')
_renderLiquid = mod.renderLiquid
}
return _renderLiquid
}

/**
* Render Liquid templates in content and extract links
*
Expand All @@ -285,8 +321,8 @@ export async function extractLinksWithLiquid(
context: Context,
): Promise<LinkExtractionResult> {
try {
// Dynamic import to avoid circular dependency issues
const { renderLiquid } = await import('@/content-render/liquid/index')
// Dynamic import to avoid circular dependency issues (cached after first load)
const renderLiquid = await getCachedRenderLiquid()
// Render Liquid to expand conditionals
const rendered = await renderLiquid(content, context)
return extractLinksFromMarkdown(rendered)
Expand All @@ -298,6 +334,24 @@ export async function extractLinksWithLiquid(
}
}

/**
* Render Liquid templates in content, returning both the rendered markdown string and
* extracted links. Use this when both are needed to avoid rendering the same content twice.
*/
export async function renderAndExtractLinks(
content: string,
context: Context,
): Promise<{ renderedMarkdown: string; result: LinkExtractionResult }> {
try {
const renderLiquid = await getCachedRenderLiquid()
const renderedMarkdown = await renderLiquid(content, context)
return { renderedMarkdown, result: extractLinksFromMarkdown(renderedMarkdown) }
} catch (error) {
console.warn('Liquid rendering failed, falling back to raw extraction:', error)
return { renderedMarkdown: content, result: extractLinksFromMarkdown(content) }
}
}

/**
* Read a file and extract links
*/
Expand Down
42 changes: 39 additions & 3 deletions src/links/lib/link-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface LinkReport {
title: string
summary: string
groups: GroupedBrokenLinks[]
selfReferentialGroups?: GroupedBrokenLinks[]
uniqueTargets: number
totalOccurrences: number
timestamp: string
Expand Down Expand Up @@ -96,6 +97,25 @@ ${statusInfo}${suggestion}**Found in ${count} file${plural}:**
${tableRows}`
},

// Self-referential links section
selfReferentialLinks: (title: string, groups: GroupedBrokenLinks[]) => {
const totalOccurrences = groups.reduce((sum, g) => sum + g.occurrences.length, 0)
const rows = groups
.map((g) => {
const uniqueFileCount = new Set(g.occurrences.map((occ) => occ.file)).size
const occRows = g.occurrences
.map((occ) => `| \`${occ.file}\` | ${occ.lines.join(', ')} |`)
.join('\n')
return `### \`${g.target}\`\n\n**Found in ${uniqueFileCount} file${uniqueFileCount === 1 ? '' : 's'}:**\n\n| File | Line(s) |\n|------|---------|\n${occRows}`
})
.join('\n\n')
return `## 🔗 ${title} (${groups.length} unique URL${groups.length === 1 ? '' : 's'}, ${totalOccurrences} occurrence${totalOccurrences === 1 ? '' : 's'})

The following links point to \`docs.github.com\`. Consider replacing them with relative internal links using the \`[AUTOTITLE](/path/to/article)\` syntax.

${rows}`
},

// Empty report
noIssues: () => 'No issues found! 🎉',

Expand Down Expand Up @@ -301,9 +321,12 @@ export function generateInternalLinkReport(
*/
export function generateExternalLinkReport(
brokenLinks: BrokenLink[],
options: { actionUrl?: string } = {},
options: { actionUrl?: string; selfReferentialLinks?: BrokenLink[] } = {},
): LinkReport {
const groups = groupExternalLinksByDomain(brokenLinks)
const selfReferentialGroups = options.selfReferentialLinks?.length
? groupBrokenLinks(options.selfReferentialLinks)
: undefined
const count = groups.length
const plural = count === 1 ? '' : 's'

Expand All @@ -314,6 +337,7 @@ export function generateExternalLinkReport(
? `Found **${brokenLinks.length}** broken external link${brokenLinks.length === 1 ? '' : 's'} across **${count}** domain${plural}.`
: 'All external links are valid! ✅',
groups,
selfReferentialGroups,
uniqueTargets: count,
totalOccurrences: brokenLinks.length,
timestamp: new Date().toISOString(),
Expand Down Expand Up @@ -360,14 +384,16 @@ function renderGroups(groups: GroupedBrokenLinks[], isExternal: boolean): string
*/
export function reportToMarkdown(report: LinkReport, isExternal = false): string {
const parts: string[] = []
const hasBrokenOrRedirectGroups = report.groups.length > 0
const hasSelfReferentialGroups = Boolean(report.selfReferentialGroups?.length)

// Header
parts.push(
TEMPLATES.reportHeader(report.title, report.summary, report.timestamp, report.actionUrl),
)
parts.push('')

if (report.groups.length === 0) {
if (!hasBrokenOrRedirectGroups && !hasSelfReferentialGroups) {
parts.push(TEMPLATES.noIssues())
return parts.join('\n')
}
Expand All @@ -379,7 +405,17 @@ export function reportToMarkdown(report: LinkReport, isExternal = false): string
}

// Groups
parts.push(renderGroups(report.groups, isExternal))
if (hasBrokenOrRedirectGroups) {
parts.push(renderGroups(report.groups, isExternal))
}

// Self-referential links section (external report only)
if (hasSelfReferentialGroups) {
parts.push(
TEMPLATES.selfReferentialLinks('Potential Internal Links', report.selfReferentialGroups!),
)
parts.push('')
}

return parts.join('\n')
}
Expand Down
Loading
Loading