diff --git a/Sources/Runway/Providers/Copilot/CopilotEnterpriseDirectBilling.swift b/Sources/Runway/Providers/Copilot/CopilotEnterpriseDirectBilling.swift new file mode 100644 index 000000000..ff69cb5b2 --- /dev/null +++ b/Sources/Runway/Providers/Copilot/CopilotEnterpriseDirectBilling.swift @@ -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 + } + + let slugs = CopilotOrgBillingMapper.candidateEnterpriseSlugs(fromOrgLogins: orgs) + guard !slugs.isEmpty else { + return .managed(provenEnterpriseAssociation: false) + } + return await probeEnterpriseWideSlugs(slugs, token: token, emptyAcceptance: .singleReadableSlug) + } + + 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) + } +} diff --git a/Sources/Runway/Providers/Copilot/CopilotEnterpriseDiscovery.swift b/Sources/Runway/Providers/Copilot/CopilotEnterpriseDiscovery.swift index 991ce8199..1d74ae542 100644 --- a/Sources/Runway/Providers/Copilot/CopilotEnterpriseDiscovery.swift +++ b/Sources/Runway/Providers/Copilot/CopilotEnterpriseDiscovery.swift @@ -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 { diff --git a/Sources/Runway/Providers/Copilot/CopilotOrgBillingClient.swift b/Sources/Runway/Providers/Copilot/CopilotOrgBillingClient.swift index b8defd865..b5ac0c34f 100644 --- a/Sources/Runway/Providers/Copilot/CopilotOrgBillingClient.swift +++ b/Sources/Runway/Providers/Copilot/CopilotOrgBillingClient.swift @@ -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) { diff --git a/Sources/Runway/Providers/Copilot/CopilotOrgBillingLookup.swift b/Sources/Runway/Providers/Copilot/CopilotOrgBillingLookup.swift index a380787c9..962844a3a 100644 --- a/Sources/Runway/Providers/Copilot/CopilotOrgBillingLookup.swift +++ b/Sources/Runway/Providers/Copilot/CopilotOrgBillingLookup.swift @@ -35,7 +35,7 @@ extension CopilotProvider { case temporarilyUnavailable } - private enum OrgUsageLookup { + enum OrgUsageLookup { case usage([MetricLine]) case empty([MetricLine]) case forbidden @@ -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], @@ -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") } diff --git a/Sources/Runway/Providers/Copilot/CopilotOrgBillingMapper.swift b/Sources/Runway/Providers/Copilot/CopilotOrgBillingMapper.swift index f4c3ba5cc..09ddfc9bf 100644 --- a/Sources/Runway/Providers/Copilot/CopilotOrgBillingMapper.swift +++ b/Sources/Runway/Providers/Copilot/CopilotOrgBillingMapper.swift @@ -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 = [] + 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 diff --git a/Sources/Runway/Providers/Copilot/CopilotProvider.swift b/Sources/Runway/Providers/Copilot/CopilotProvider.swift index 5f9c4a0c1..22811065d 100644 --- a/Sources/Runway/Providers/Copilot/CopilotProvider.swift +++ b/Sources/Runway/Providers/Copilot/CopilotProvider.swift @@ -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. diff --git a/Sources/Runway/Providers/Copilot/CopilotUsageMapper.swift b/Sources/Runway/Providers/Copilot/CopilotUsageMapper.swift index 2ee3d9125..5a322e400 100644 --- a/Sources/Runway/Providers/Copilot/CopilotUsageMapper.swift +++ b/Sources/Runway/Providers/Copilot/CopilotUsageMapper.swift @@ -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 } diff --git a/Tests/RunwayTests/CopilotProviderTests.swift b/Tests/RunwayTests/CopilotProviderTests.swift index 3feb8d872..ee93ce1c1 100644 --- a/Tests/RunwayTests/CopilotProviderTests.swift +++ b/Tests/RunwayTests/CopilotProviderTests.swift @@ -562,6 +562,22 @@ final class CopilotOrgBillingMapperTests: XCTestCase { XCTAssertEqual(CopilotOrgBillingMapper.orgLogins(response), []) } + func testCandidateEnterpriseSlugsUseOrgLoginAndHyphenPrefix() { + XCTAssertEqual( + CopilotOrgBillingMapper.candidateEnterpriseSlugs( + fromOrgLogins: ["MIC-DevOps", "nextbyte-ai", "TryNextByte", "AI-at-MIT"] + ), + ["mic-devops", "mic", "nextbyte-ai", "nextbyte", "trynextbyte", "ai-at-mit", "ai-at"] + ) + } + + func testCandidateEnterpriseSlugsDropShortPrefixesAndDuplicates() { + XCTAssertEqual( + CopilotOrgBillingMapper.candidateEnterpriseSlugs(fromOrgLogins: ["ab-cd", "NextByte", "nextbyte"]), + ["ab-cd", "nextbyte"] + ) + } + func testEnterpriseMembershipPageIncludesOnlyEnterpriseOwningSeatOrganization() throws { let response = ok(makeEnterpriseMembershipBody( enterprises: [ @@ -1078,14 +1094,14 @@ final class CopilotProviderTests: XCTestCase { ) } - func testEnterpriseDirectSeatSkipsMembershipOrgsAndKeepsPersonalCredits() async { - let unavailable = HTTPResponse(statusCode: 503, headers: [:], body: Data()) + func testEnterpriseDirectSeatKeepsPersonalCreditsWhenEnterpriseListingIsDenied() async { + let forbidden = HTTPResponse(statusCode: 403, headers: [:], body: Data()) let http = routedClient([ ( "/copilot_internal/user", ok(makeBusinessPlaceholderBodyWithPersonalCredits(283, seatOrgs: [])) ), - ("/user/orgs", unavailable), + ("/user/orgs", forbidden), ("/graphql", ok(makeInsufficientScopesGraphQLBody())) ]) let provider = makeOrgProvider(http: http, defaults: freshDefaults()) @@ -1098,10 +1114,89 @@ final class CopilotProviderTests: XCTestCase { } XCTAssertEqual(text, "Managed by Your Enterprise") XCTAssertFalse(snapshot.lines.contains { $0.isError }) - XCTAssertFalse(http.requests.contains { $0.url.path == "/user/orgs" }) + XCTAssertTrue(http.requests.contains { $0.url.path == "/user/orgs" }) + XCTAssertFalse(http.requests.contains { $0.url.path.contains("/organizations/") }) XCTAssertEqual(snapshot.applicableMetricIDs, ["copilot.premium", "copilot.orgManaged"]) } + func testEnterpriseDirectMembershipOrgListOutageFailsRefreshInsteadOfReplacingData() async { + let unavailable = HTTPResponse(statusCode: 503, headers: [:], body: Data()) + let http = routedClient([ + ( + "/copilot_internal/user", + ok(makeBusinessPlaceholderBodyWithPersonalCredits(283, seatOrgs: [])) + ), + ("/user/orgs", unavailable), + ("/graphql", ok(makeInsufficientScopesGraphQLBody())) + ]) + let provider = makeOrgProvider(http: http, defaults: freshDefaults()) + + let snapshot = await provider.refresh() + + XCTAssertTrue(snapshot.lines.contains { $0.isError }) + XCTAssertNil(snapshot.line(label: "Organization Usage")) + XCTAssertNil(snapshot.line(label: "Org Credits")) + XCTAssertFalse(http.requests.contains { $0.url.path.contains("/organizations/") }) + } + + func testEnterpriseDirectMembershipUsageOutageFailsRefreshInsteadOfReplacingData() async { + let unavailable = HTTPResponse(statusCode: 503, headers: [:], body: Data()) + let http = routedClient([ + ( + "/copilot_internal/user", + ok(makeBusinessPlaceholderBodyWithPersonalCredits(283, seatOrgs: [])) + ), + ("/graphql", ok(makeInsufficientScopesGraphQLBody())), + ("/user/orgs", okJSON([["login": "nextbyte-ai"]])), + ("/enterprises/nextbyte-ai/settings/billing/ai_credit/usage", unavailable), + ("/enterprises/nextbyte/settings/billing/ai_credit/usage", unavailable) + ]) + let provider = makeOrgProvider(http: http, defaults: freshDefaults()) + + let snapshot = await provider.refresh() + + XCTAssertTrue(snapshot.lines.contains { $0.isError }) + XCTAssertNil(snapshot.line(label: "Organization Usage")) + XCTAssertNil(snapshot.line(label: "Org Credits")) + XCTAssertFalse(http.requests.contains { $0.url.path.contains("/organizations/") }) + } + + func testEnterpriseDirectSeatReadsEnterpriseUsageFromMembershipOrgSlug() async { + // GraphQL `viewer.enterprises` needs `read:enterprise`. Org owners can still read the + // enterprise REST usage report, so membership orgs must supply the slug (`nextbyte-ai` → + // `nextbyte`) without probing those orgs' own billing endpoints. + let orgBillingUnavailable = HTTPResponse(statusCode: 503, headers: [:], body: Data()) + let notFound = HTTPResponse(statusCode: 404, headers: [:], body: Data()) + let http = routedClient([ + ( + "/copilot_internal/user", + ok(makeBusinessPlaceholderBodyWithPersonalCredits(1115, seatOrgs: [])) + ), + ("/graphql", ok(makeInsufficientScopesGraphQLBody())), + ("/user/orgs", okJSON([["login": "MIC-DevOps"], ["login": "nextbyte-ai"]])), + ("/organizations/MIC-DevOps/settings/billing/ai_credit/usage", orgBillingUnavailable), + ("/organizations/nextbyte-ai/settings/billing/ai_credit/usage", orgBillingUnavailable), + ("/enterprises/mic-devops/settings/billing/ai_credit/usage", notFound), + ("/enterprises/mic/settings/billing/ai_credit/usage", notFound), + ("/enterprises/nextbyte-ai/settings/billing/ai_credit/usage", notFound), + ("/enterprises/nextbyte/settings/billing/ai_credit/usage", ok(makeOrgSummaryBody())) + ]) + let defaults = freshDefaults() + let provider = makeOrgProvider(http: http, defaults: defaults) + + let snapshot = await provider.refresh() + + XCTAssertEqual(countValue(snapshot.lines, "Credits"), 1115) + XCTAssertEqual(orgCount(snapshot.lines, "Org Credits") ?? -1, 298.698546, accuracy: 0.0001) + XCTAssertNil(snapshot.line(label: "Organization Usage")) + XCTAssertEqual(defaults.string(forKey: CopilotProvider.billingEnterpriseDefaultsKey), "nextbyte") + XCTAssertFalse(http.requests.contains { $0.url.path.contains("/organizations/") }) + XCTAssertEqual( + snapshot.applicableMetricIDs, + ["copilot.premium", "copilot.orgCredits", "copilot.orgSpend"] + ) + } + func testEnterpriseDirectSeatReadsEnterpriseUsageWithoutOrganizationFilter() async { let http = routedClient([ ( diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index e728a295a..38e413a21 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -27,9 +27,9 @@ Since June 2026 GitHub Copilot bills all plans by AI credits: - **Free plans** have no credits. You see the fixed Chat and Completions quotas GitHub reports. Both rows stay available if a refresh omits one bucket. The omitted metric shows No data until GitHub reports it again. - **Org-managed seats** (Copilot Business or Enterprise assigned by an organization or directly by an enterprise) return no per-seat percent quota. If the response's premium bucket carries a `credits_used` count, Runway shows it as **Credits**, a plain count, since there is no allotment to divide by. This is your own consumption and needs no special access. Runway also looks up usage at the seat's billing entity (its organization, or its enterprise when the seat is assigned there) and shows **AI Credits Used** and **Additional Spend**. Caveats: - The numbers are organization-wide or enterprise-wide, not your personal share. GitHub does not expose per-seat usage. - - Reading an org's billing requires you to be an org owner or billing manager. Reading enterprise-wide billing requires enterprise billing access. Without that access you see a managed-account message instead of "No data" placeholders, plus your own Credits count when the response carries one. + - Reading an org's billing requires you to be an org owner or billing manager. Reading enterprise-wide billing requires enterprise billing access (an org owner in that enterprise is enough). Without that access you see a managed-account message instead of "No data" placeholders, plus your own Credits count when the response carries one. - When Copilot identifies the seat's organization, Runway checks its enterprise before accepting an empty organization report, because consolidated usage is billed at the enterprise level. If a proven enterprise association stays unreadable, or the seat is an Enterprise seat and the credential cannot see enterprise associations at all, Runway keeps the managed-account state. For a Business seat, when no enterprise claims the organization (or associations cannot be read), a readable empty organization report stands. At the start of a billing month the card shows zero credits used, not the managed-account message. An unrelated empty report is never attributed to the seat. - - When Copilot returns an empty organization list, the seat is assigned by the enterprise rather than by an org. Runway does not probe `/user/orgs` memberships. It lists enterprises the token can see and reads each enterprise's Copilot usage. A 503 on an unrelated membership org cannot take the card down. + - When Copilot returns an empty organization list, the seat is assigned by the enterprise rather than by an org. Runway lists enterprises the token can see (`read:enterprise`) and reads each enterprise's Copilot usage. If that listing is denied, it derives candidate enterprise slugs from `/user/orgs` (the org login and its hyphen prefix, so `nextbyte-ai` also tries `nextbyte`) and reads those enterprise usage reports. It does not query those orgs' own billing endpoints, so a 503 there cannot take the card down. - AI Credits Used is a plain count, not a percentage. The API reports total, included, and additional usage, but not the organization's full pool, and Runway does not invent a denominator. A dollar credit figure ("$12 of $15 used") is not shown. GitHub only exposes it through the logged-in web billing page, which requires browser cookies, and the Copilot provider does not read browser cookies. Editors like VS Code show the same credit percentage from this endpoint, not a dollar amount. @@ -62,10 +62,10 @@ Using Copilot in a supported editor is enough on its own. The editor writes the - **"Keychain access to the GitHub login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. - **"GitHub login couldn't be read"**: the login keychain is unavailable, most often locked. Unlock it and refresh. - **"GitHub token invalid or expired"**: the token was rejected (401/403). Re-authenticate with `gh auth login`. -- **"Managed by Your Organization"** or **"Managed by Your Enterprise"**: GitHub does not expose a per-seat percent quota for Business/Enterprise, and none of the local credentials could read the organization or enterprise billing. Your own Credits count still shows when the seat reports one. Organization reporting requires organization billing access. Enterprise-direct and consolidated reporting also require `read:enterprise` plus billing access (`gh auth refresh -s read:enterprise`). Some editor-plugin and GitHub CLI tokens do not carry those scopes. +- **"Managed by Your Organization"** or **"Managed by Your Enterprise"**: GitHub does not expose a per-seat percent quota for Business/Enterprise, and none of the local credentials could read the organization or enterprise billing. Your own Credits count still shows when the seat reports one. Organization reporting requires organization billing access. Enterprise reporting requires billing access on that enterprise (org owner is enough). Listing enterprises with GraphQL needs `read:enterprise` (`gh auth refresh -s read:enterprise`); without it Runway still tries enterprise slugs derived from your membership orgs. Some editor-plugin tokens cannot read billing at all. ## Under the hood `GET https://api.github.com/copilot_internal/user` with the standard Copilot client headers (API version `2025-04-01`). The response reports each bucket as percent remaining. The meters show percent used. -For org-managed seats (identified by the token-based-billing placeholder in that response), Runway first uses the response's organization list to query `GET /organizations/{org}/settings/billing/ai_credit/usage?product=Copilot`. GitHub defaults that endpoint to the current year and month. If an associated organization returns 403, 404, or an empty report, Runway resolves all enterprises visible to the token through GitHub GraphQL, verifies which enterprise owns that seat organization, and queries the enterprise AI-credit endpoint filtered to that organization and Copilot. This lets an enterprise billing manager see consolidated totals even when they do not administer the seat organization. If Copilot returns an empty organization list, Runway skips `/user/orgs`, lists the viewer's enterprises, and queries `GET /enterprises/{enterprise}/settings/billing/ai_credit/usage?product=Copilot` with no organization filter. Rate-limited and other retryable REST or GraphQL failures fail that refresh, so the card keeps its last-good numbers with the usual warning treatment. Only explicit access errors show the managed-account state. If the Copilot response omits organization lists entirely, Runway falls back to `GET /user/orgs`, but only positive Copilot usage (not an empty current or cached report) can identify the seat's organization. Other AI products are excluded at the API boundary and ignored by the mapper. An org or enterprise is remembered only after it reports Copilot usage. +For org-managed seats (identified by the token-based-billing placeholder in that response), Runway first uses the response's organization list to query `GET /organizations/{org}/settings/billing/ai_credit/usage?product=Copilot`. GitHub defaults that endpoint to the current year and month. If an associated organization returns 403, 404, or an empty report, Runway resolves all enterprises visible to the token through GitHub GraphQL, verifies which enterprise owns that seat organization, and queries the enterprise AI-credit endpoint filtered to that organization and Copilot. This lets an enterprise billing manager see consolidated totals even when they do not administer the seat organization. If Copilot returns an empty organization list, Runway lists the viewer's enterprises when the token has `read:enterprise`, and queries `GET /enterprises/{enterprise}/settings/billing/ai_credit/usage?product=Copilot` with no organization filter. If GraphQL listing is denied, it lists `/user/orgs`, guesses enterprise slugs from those logins, and queries the same enterprise usage endpoint. It never queries membership orgs' own billing endpoints on this path. Rate-limited and other retryable REST or GraphQL failures fail that refresh, so the card keeps its last-good numbers with the usual warning treatment. Only explicit access errors show the managed-account state. If the Copilot response omits organization lists entirely, Runway falls back to `GET /user/orgs`, but only positive Copilot usage (not an empty current or cached report) can identify the seat's organization. Other AI products are excluded at the API boundary and ignored by the mapper. An org or enterprise is remembered only after it reports Copilot usage.