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
@@ -0,0 +1,125 @@
import Foundation

/// Billing for a Copilot seat assigned with an empty organization list. Membership orgs are not
/// the billing home. Prefer GraphQL enterprise listing when the token has `read:enterprise`;
/// otherwise derive candidate slugs from `/user/orgs` and read each enterprise's Copilot usage
/// with no organization filter. Never query those orgs' own billing endpoints — a 503 there used
/// to fail the whole card.
extension CopilotProvider {
func enterpriseDirectBillingLookup(token: String) async -> OrgBillingLookup {
if let cached = defaults.string(forKey: Self.billingEnterpriseDefaultsKey) {
do {
switch try await enterpriseWideUsageLookup(enterprise: cached, token: token) {
case .usage(let lines):
return .usage(lines)
case .empty(let lines):
// This slug was cached after it reported Copilot usage, so its own zero is the
// owning enterprise's report — the same verified empty the org-managed path uses.
return .empty(lines, enterpriseVerified: true)
case .forbidden, .inaccessible, .notFound:
defaults.removeObject(forKey: Self.billingEnterpriseDefaultsKey)
}
} catch {
AppLog.warn(
LogTag.plugin("copilot"),
"enterprise AI credit lookup failed for the remembered enterprise: \(error.localizedDescription)"
)
return .temporarilyUnavailable
}
}

switch await CopilotEnterpriseDiscovery(client: orgBillingClient).lookupSlugs(token: token) {
case .slugs(let slugs):
return await probeEnterpriseWideSlugs(slugs, token: token, emptyAcceptance: .singleListedSlug)
case .noEnterprises:
return .managed(provenEnterpriseAssociation: false)
case .managed:
// GraphQL listing needs `read:enterprise`. Org owners can still read enterprise REST
// billing once the slug is known, so fall through to membership-derived candidates.
return await membershipDerivedEnterpriseBillingLookup(token: token)
case .temporarilyUnavailable:
return .temporarilyUnavailable
}
}

/// `/user/orgs` plus hyphen-prefix guesses, used only after GraphQL enterprise listing is denied.
private func membershipDerivedEnterpriseBillingLookup(token: String) async -> OrgBillingLookup {
let orgs: [String]
do {
let response = try await orgBillingClient.fetchUserOrgs(token: token)
guard response.statusCode == 200 else {
AppLog.info(
LogTag.plugin("copilot"),
"org list HTTP \(response.statusCode); skipping membership-derived enterprise billing"
)
if response.isGitHubRateLimited || response.statusCode >= 500 {
return .temporarilyUnavailable
}
return .managed(provenEnterpriseAssociation: false)
}
orgs = CopilotOrgBillingMapper.orgLogins(response)
} catch {
AppLog.warn(
LogTag.plugin("copilot"),
"org list fetch failed during enterprise-direct discovery: \(error.localizedDescription)"
)
return .temporarilyUnavailable
}
Comment thread
Copilot marked this conversation as resolved.

let slugs = CopilotOrgBillingMapper.candidateEnterpriseSlugs(fromOrgLogins: orgs)
guard !slugs.isEmpty else {
return .managed(provenEnterpriseAssociation: false)
}
return await probeEnterpriseWideSlugs(slugs, token: token, emptyAcceptance: .singleReadableSlug)
Comment thread
mstallone marked this conversation as resolved.
}

private enum EnterpriseEmptyAcceptance: Equatable {
/// GraphQL listed these enterprises. Only a single listed slug's empty report can stand.
case singleListedSlug
/// Guessed from membership orgs. Extra 404s are expected; only one HTTP 200 empty can stand.
case singleReadableSlug
}

private func probeEnterpriseWideSlugs(
_ slugs: [String],
token: String,
emptyAcceptance: EnterpriseEmptyAcceptance
) async -> OrgBillingLookup {
var sawTransientFailure = false
var emptyCandidate: [MetricLine]?
var readableCount = 0
for slug in slugs {
do {
switch try await enterpriseWideUsageLookup(enterprise: slug, token: token) {
case .usage(let lines):
defaults.set(slug, forKey: Self.billingEnterpriseDefaultsKey)
return .usage(lines)
case .empty(let lines):
readableCount += 1
emptyCandidate = emptyCandidate ?? lines
case .forbidden, .inaccessible, .notFound:
continue
}
} catch {
sawTransientFailure = true
AppLog.warn(
LogTag.plugin("copilot"),
"enterprise AI credit usage failed for one enterprise; trying the next: \(error.localizedDescription)"
)
}
}
let acceptEmpty: Bool
switch emptyAcceptance {
case .singleListedSlug:
acceptEmpty = slugs.count == 1
case .singleReadableSlug:
acceptEmpty = readableCount == 1
}
if let emptyCandidate, !sawTransientFailure, acceptEmpty {
return .empty(emptyCandidate, enterpriseVerified: false)
}
return sawTransientFailure
? .temporarilyUnavailable
: .managed(provenEnterpriseAssociation: false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ struct CopilotEnterpriseDiscovery: Sendable {
}

/// Enterprises visible to the token, with no organization filter. Used when Copilot assigned the
/// seat with an empty organization list.
/// seat with an empty organization list. A denied listing is not the last word: the provider can
/// still discover the slug from membership orgs and read enterprise REST billing.
func lookupSlugs(token: String) async -> SlugLookup {
let response: HTTPResponse
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ struct CopilotOrgBillingClient: Sendable {
}

/// One page of enterprises visible to the token's user. Used for enterprise-direct seats that
/// Copilot assigned with an empty organization list — there is no seat org to query against.
/// Copilot assigned with an empty organization list. When this GraphQL field is denied, the
/// provider falls back to membership-derived slugs and this same REST usage endpoint.
func fetchViewerEnterprises(after cursor: String?, token: String) async throws -> HTTPResponse {
let query = """
query RunwayCopilotBillingEnterpriseSlugs($enterpriseCursor: String) {
Expand Down
68 changes: 2 additions & 66 deletions Sources/Runway/Providers/Copilot/CopilotOrgBillingLookup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension CopilotProvider {
case temporarilyUnavailable
}

private enum OrgUsageLookup {
enum OrgUsageLookup {
case usage([MetricLine])
case empty([MetricLine])
case forbidden
Expand Down Expand Up @@ -286,70 +286,6 @@ extension CopilotProvider {
: .managed(provenEnterpriseAssociation: provenEnterpriseAssociation)
}

/// Billing for a seat Copilot assigned with an empty organization list. Membership orgs are not
/// the billing home; list the viewer's enterprises and read each enterprise's Copilot usage with
/// no organization filter.
private func enterpriseDirectBillingLookup(token: String) async -> OrgBillingLookup {
if let cached = defaults.string(forKey: Self.billingEnterpriseDefaultsKey) {
do {
switch try await enterpriseWideUsageLookup(enterprise: cached, token: token) {
case .usage(let lines):
return .usage(lines)
case .empty(let lines):
// This slug was cached after it reported Copilot usage, so its own zero is the
// owning enterprise's report — the same verified empty the org-managed path uses.
return .empty(lines, enterpriseVerified: true)
case .forbidden, .inaccessible, .notFound:
defaults.removeObject(forKey: Self.billingEnterpriseDefaultsKey)
}
} catch {
AppLog.warn(
LogTag.plugin("copilot"),
"enterprise AI credit lookup failed for the remembered enterprise: \(error.localizedDescription)"
)
return .temporarilyUnavailable
}
}

switch await CopilotEnterpriseDiscovery(client: orgBillingClient).lookupSlugs(token: token) {
case .slugs(let slugs):
var sawTransientFailure = false
var emptyCandidate: [MetricLine]?
for slug in slugs {
do {
switch try await enterpriseWideUsageLookup(enterprise: slug, token: token) {
case .usage(let lines):
defaults.set(slug, forKey: Self.billingEnterpriseDefaultsKey)
return .usage(lines)
case .empty(let lines):
emptyCandidate = emptyCandidate ?? lines
case .forbidden, .inaccessible, .notFound:
continue
}
} catch {
sawTransientFailure = true
AppLog.warn(
LogTag.plugin("copilot"),
"enterprise AI credit usage failed for one enterprise; trying the next: \(error.localizedDescription)"
)
}
}
if let emptyCandidate, !sawTransientFailure, slugs.count == 1 {
// Listing viewer enterprises does not prove which one owns the seat. A single
// readable empty is the month-start case; several candidates stay managed so an
// unrelated empty cannot stand in for an unreadable billing enterprise.
return .empty(emptyCandidate, enterpriseVerified: false)
}
return sawTransientFailure
? .temporarilyUnavailable
: .managed(provenEnterpriseAssociation: false)
case .noEnterprises, .managed:
return .managed(provenEnterpriseAssociation: false)
case .temporarilyUnavailable:
return .temporarilyUnavailable
}
}

private func enterpriseBillingLookup(
token: String,
seatOrgLogins: [String],
Expand Down Expand Up @@ -435,7 +371,7 @@ extension CopilotProvider {
return try billingUsageLookup(response, scope: "enterprise")
}

private func enterpriseWideUsageLookup(enterprise: String, token: String) async throws -> OrgUsageLookup {
func enterpriseWideUsageLookup(enterprise: String, token: String) async throws -> OrgUsageLookup {
let response = try await orgBillingClient.fetchAICreditUsage(enterprise: enterprise, token: token)
return try billingUsageLookup(response, scope: "enterprise")
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/Runway/Providers/Copilot/CopilotOrgBillingMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ enum CopilotOrgBillingMapper {
}
}

/// Enterprise slugs to try when GraphQL cannot list `viewer.enterprises` (`read:enterprise` is
/// missing) but REST billing still works for an org owner. GitHub lets an org owner read
/// `GET /enterprises/{slug}/settings/billing/ai_credit/usage` without that GraphQL scope, so the
/// remaining problem is discovering the slug. Each membership org login is a candidate, plus the
/// hyphen/underscore prefix (`nextbyte-ai` → `nextbyte`). Short leftovers under 3 characters are
/// dropped so a name like `AI-at-MIT` does not probe `/enterprises/ai`.
static func candidateEnterpriseSlugs(fromOrgLogins orgLogins: [String]) -> [String] {
var seen: Set<String> = []
var slugs: [String] = []
func add(_ raw: String) {
let slug = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard slug.count >= 3, seen.insert(slug).inserted else { return }
slugs.append(slug)
}
for login in orgLogins {
add(login)
let parts = login.split { $0 == "-" || $0 == "_" }
if parts.count >= 2 {
add(parts.dropLast().joined(separator: "-"))
}
}
return slugs
}

/// Enterprise slugs from a viewer-enterprises GraphQL page that does not filter by organization.
static func enterpriseSlugs(_ response: HTTPResponse) -> [String]? {
guard
Expand Down
8 changes: 4 additions & 4 deletions Sources/Runway/Providers/Copilot/CopilotProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,10 @@ final class CopilotProvider: ProviderRuntime {
// Credits count (issue #1094), which must survive alongside whatever the org lookup adds.
var lines = mapped.lines
if mapped.isOrgManagedSeat {
// A second local token may belong to another GitHub account. Membership discovery
// (`/user/orgs`) must stay on the credential that produced this Copilot card. When
// Copilot named the seat org — or explicitly named none (enterprise-direct) — a
// GitHub CLI token is safe for billing because it is aimed at a known billing home.
// A second local token may belong to another GitHub account. When Copilot named the
// seat org — or listed none (enterprise-direct) — prefer the GitHub CLI token for
// billing: it can carry org and enterprise REST billing access the editor token
// often lacks, and `read:org` is enough to guess an enterprise slug.
let billingTokens: [CopilotToken]
// Set when the preferred GitHub CLI credential exists but needs a manual load, so a
// failed billing lookup can name the real fix instead of blaming billing access.
Expand Down
7 changes: 4 additions & 3 deletions Sources/Runway/Providers/Copilot/CopilotUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ struct CopilotMappedUsage: Equatable, Sendable {
/// than replace it.
var isOrgManagedSeat: Bool = false
/// True when Copilot listed seat organizations and the list was empty. That is an enterprise-direct
/// seat, not "we don't know": `/user/orgs` memberships must not be probed, because a 503 on an
/// unrelated org would fail the whole card. Omitted lists stay `false` so older payloads can still
/// fall back to membership discovery.
/// seat, not "we don't know": those memberships' *billing* endpoints must not be queried, because
/// a 503 on an unrelated org would fail the whole card. `/user/orgs` is still used to guess
/// enterprise slugs when GraphQL cannot list enterprises. Omitted lists stay `false` so older
/// payloads can still fall back to membership discovery.
var hasNoSeatOrganization: Bool = false
}

Expand Down
Loading