From bafa3b8a06763b933259c75ce5df8e9b080850bb Mon Sep 17 00:00:00 2001 From: Abir Biswas <10178348+inprealpha@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:16:16 +0530 Subject: [PATCH] fix: github.com enterprise routing and structured Copilot OAuth credentials --- packages/ai/src/providers/anthropic.ts | 6 +- .../src/providers/github-copilot-headers.ts | 9 ++- .../ai/src/providers/openai-completions.ts | 5 +- packages/ai/src/providers/openai-responses.ts | 5 +- packages/ai/src/utils/oauth/github-copilot.ts | 57 +++++++++++++++++-- packages/ai/src/utils/oauth/index.ts | 10 ++-- .../github-copilot-anthropic-auth.test.ts | 27 +++++++++ packages/ai/test/github-copilot-oauth.test.ts | 31 ++++++++++ .../github-copilot-openai-base-url.test.ts | 35 ++++++++++++ .../coding-agent/test/model-registry.test.ts | 11 +++- 10 files changed, 179 insertions(+), 17 deletions(-) create mode 100644 packages/ai/test/github-copilot-oauth.test.ts diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 6dc853321..1d7df9d02 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -37,6 +37,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { createFirstEventWatchdog, getStreamFirstEventTimeoutMs, markFirstStreamEvent } from "../utils/idle-iterator"; import { parseStreamingJson } from "../utils/json-parse"; +import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput, @@ -1065,6 +1066,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A const foundryCustomHeaders = resolveAnthropicCustomHeaders(model); const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl); if (model.provider === "github-copilot") { + const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken; const betaFeatures = [...extraBetas]; if (interleavedThinking) { betaFeatures.push("interleaved-thinking-2025-05-14"); @@ -1073,7 +1075,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A { Accept: stream ? "text/event-stream" : "application/json", "Anthropic-Dangerous-Direct-Browser-Access": "true", - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${copilotApiKey}`, ...(betaFeatures.length > 0 ? { "anthropic-beta": buildBetaHeader([], betaFeatures) } : {}), }, model.headers, @@ -1084,7 +1086,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A return { isOAuthToken: false, apiKey: null, - authToken: apiKey, + authToken: copilotApiKey, baseURL: baseUrl, maxRetries: 5, dangerouslyAllowBrowser: true, diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index 290117588..c66fa8290 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,4 +1,5 @@ import type { Message } from "../types"; +import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; /** * Infer whether the current request to Copilot is user-initiated or agent-initiated. * Accepts `unknown[]` because providers may pass pre-converted message shapes. @@ -12,9 +13,13 @@ export type CopilotDynamicHeaders = { }; export function resolveGitHubCopilotBaseUrl( baseUrl: string | undefined, - _apiKey: string | undefined, + apiKey: string | undefined, ): string | undefined { - return baseUrl; + if (!apiKey) return baseUrl; + const { enterpriseUrl } = parseGitHubCopilotApiKey(apiKey); + if (!enterpriseUrl) return baseUrl; + if (baseUrl && !baseUrl.includes("githubcopilot.com")) return baseUrl; + return getGitHubCopilotBaseUrl(enterpriseUrl); } export function inferCopilotInitiator(messages: unknown[]): CopilotInitiator { if (messages.length === 0) return "user"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index bd0ae0931..ac4be5232 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -40,6 +40,7 @@ import { markFirstStreamEvent, } from "../utils/idle-iterator"; import { parseStreamingJson } from "../utils/json-parse"; +import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { mapToOpenAICompletionsToolChoice } from "../utils/tool-choice"; @@ -545,6 +546,7 @@ async function createClient( } apiKey = $env.OPENAI_API_KEY; } + const rawApiKey = apiKey; let headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; if (model.provider === "kimi-code") { @@ -554,6 +556,7 @@ async function createClient( let baseUrl = model.baseUrl; if (model.provider === "github-copilot") { + apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken; const hasImages = hasCopilotVisionInput(context.messages); const copilot = buildCopilotDynamicHeaders({ messages: context.messages, @@ -564,7 +567,7 @@ async function createClient( }); Object.assign(headers, copilot.headers); copilotPremiumRequests = copilot.premiumRequests; - baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl; + baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl; } return { client: new OpenAI({ diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index db4a79798..977159244 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -38,6 +38,7 @@ import { iterateWithIdleTimeout, markFirstStreamEvent, } from "../utils/idle-iterator"; +import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; import { @@ -272,12 +273,14 @@ function createClient( } apiKey = $env.OPENAI_API_KEY; } + const rawApiKey = apiKey; const headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; let copilotPremiumRequests: number | undefined; let baseUrl = model.baseUrl; if (model.provider === "github-copilot") { + apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken; const hasImages = hasCopilotVisionInput(context.messages); const copilot = buildCopilotDynamicHeaders({ messages: context.messages, @@ -288,7 +291,7 @@ function createClient( }); Object.assign(headers, copilot.headers); copilotPremiumRequests = copilot.premiumRequests; - baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl; + baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl; } return { client: new OpenAI({ diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index cda27ef4d..ec84999ab 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -33,6 +33,47 @@ type DeviceTokenErrorResponse = { interval?: number; }; +type GitHubCopilotApiKeyPayload = { + token?: unknown; + enterpriseUrl?: unknown; +}; + +export type ParsedGitHubCopilotApiKey = { + accessToken: string; + enterpriseUrl?: string; +}; + +const PUBLIC_GITHUB_HOSTS = new Set(["api.github.com", "github.com", "www.github.com"]); + +function isPublicGitHubHost(host: string): boolean { + return PUBLIC_GITHUB_HOSTS.has(host.trim().toLowerCase()); +} + +export function normalizeGitHubCopilotEnterpriseDomain(input: string | undefined): string | undefined { + const trimmed = input?.trim(); + if (!trimmed) return undefined; + const normalized = normalizeDomain(trimmed) ?? trimmed.toLowerCase(); + if (!normalized || isPublicGitHubHost(normalized)) return undefined; + return normalized; +} + +export function parseGitHubCopilotApiKey(apiKeyRaw: string): ParsedGitHubCopilotApiKey { + try { + const parsed = JSON.parse(apiKeyRaw) as GitHubCopilotApiKeyPayload; + if (typeof parsed.token === "string") { + return { + accessToken: parsed.token, + enterpriseUrl: + typeof parsed.enterpriseUrl === "string" + ? normalizeGitHubCopilotEnterpriseDomain(parsed.enterpriseUrl) + : undefined, + }; + } + } catch {} + + return { accessToken: apiKeyRaw }; +} + export function normalizeDomain(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; @@ -55,8 +96,12 @@ function getUrls(domain: string): { } export function getGitHubCopilotBaseUrl(enterpriseDomain?: string): string { - if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`; - return "https://api.githubcopilot.com"; + const normalizedEnterpriseDomain = normalizeGitHubCopilotEnterpriseDomain(enterpriseDomain); + if (!normalizedEnterpriseDomain) return "https://api.githubcopilot.com"; + const host = normalizedEnterpriseDomain.startsWith("copilot-api.") + ? normalizedEnterpriseDomain + : `copilot-api.${normalizedEnterpriseDomain}`; + return `https://${host}`; } async function fetchJson(url: string, init: RequestInit): Promise { @@ -273,11 +318,13 @@ export async function loginGitHubCopilot(options: { } const trimmed = input.trim(); - const enterpriseDomain = normalizeDomain(input); - if (trimmed && !enterpriseDomain) { + const normalizedDomain = normalizeDomain(input); + if (trimmed && !normalizedDomain) { throw new Error("Invalid GitHub Enterprise URL/domain"); } - const domain = enterpriseDomain || "github.com"; + const enterpriseDomain = normalizeGitHubCopilotEnterpriseDomain(normalizedDomain ?? undefined); + const domain = + normalizedDomain && isPublicGitHubHost(normalizedDomain) ? "github.com" : (normalizedDomain ?? "github.com"); const device = await startDeviceFlow(domain); options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 5500ee3c5..de7f46893 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -437,7 +437,7 @@ function getPerplexityJwtExpiryMs(token: string): number | undefined { * Get API key for a provider from OAuth credentials. * Automatically refreshes expired tokens. * - * For google-gemini-cli and antigravity, returns JSON-encoded credentials including token/projectId + * For providers that need credential metadata at request time, returns JSON-encoded credentials * plus refresh/expiry metadata for proactive refresh support. * @returns API key string, or null if no credentials * @throws Error if refresh fails @@ -476,11 +476,13 @@ export async function getOAuthApiKey( throw new Error(`Failed to refresh OAuth token for ${provider}: ${reason}`); } } - // For providers that need projectId, return JSON - const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity"; - const apiKey = needsProjectId + // For providers that need request-time credential metadata, return JSON. + const needsStructuredApiKey = + provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity"; + const apiKey = needsStructuredApiKey ? JSON.stringify({ token: creds.access, + enterpriseUrl: creds.enterpriseUrl, projectId: creds.projectId, refreshToken: creds.refresh, expiresAt: creds.expires, diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index 03ee3e705..a04e980da 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -64,6 +64,20 @@ describe("Anthropic Copilot auth config", () => { expect(options.defaultHeaders.Authorization).toBe(`Bearer ${token}`); }); + it("unwraps structured Copilot credentials before setting Authorization", () => { + const model = makeCopilotClaudeModel(); + const options = buildAnthropicClientOptions({ + model, + apiKey: JSON.stringify({ token: "ghu_test_token_12345", enterpriseUrl: "ghe.example.com" }), + extraBetas: [], + stream: true, + dynamicHeaders: {}, + }); + + expect(options.apiKey).toBeNull(); + expect(options.defaultHeaders.Authorization).toBe("Bearer ghu_test_token_12345"); + }); + it("uses model baseUrl directly (no proxy-ep extraction)", () => { const model = makeCopilotClaudeModel(); const token = "ghu_test_token_12345"; @@ -77,6 +91,19 @@ describe("Anthropic Copilot auth config", () => { expect(options.baseURL).toBe("https://api.githubcopilot.com"); }); + + it("routes structured enterprise credentials to the enterprise baseUrl", () => { + const model = makeCopilotClaudeModel(); + const options = buildAnthropicClientOptions({ + model, + apiKey: JSON.stringify({ token: "ghu_test_token_12345", enterpriseUrl: "ghe.example.com" }), + extraBetas: [], + stream: true, + dynamicHeaders: {}, + }); + + expect(options.baseURL).toBe("https://copilot-api.ghe.example.com"); + }); it("includes Copilot static headers from model.headers", () => { const model = makeCopilotClaudeModel(); const options = buildAnthropicClientOptions({ diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts new file mode 100644 index 000000000..43a0b6d16 --- /dev/null +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { + getGitHubCopilotBaseUrl, + normalizeGitHubCopilotEnterpriseDomain, + parseGitHubCopilotApiKey, +} from "../src/utils/oauth/github-copilot"; + +describe("GitHub Copilot OAuth helpers", () => { + it("treats github.com as the public Copilot host", () => { + expect(normalizeGitHubCopilotEnterpriseDomain("github.com")).toBeUndefined(); + expect(normalizeGitHubCopilotEnterpriseDomain("https://api.github.com")).toBeUndefined(); + expect(getGitHubCopilotBaseUrl("github.com")).toBe("https://api.githubcopilot.com"); + }); + + it("maps enterprise domains to the Copilot enterprise host", () => { + expect(normalizeGitHubCopilotEnterpriseDomain("https://ghe.example.com")).toBe("ghe.example.com"); + expect(getGitHubCopilotBaseUrl("ghe.example.com")).toBe("https://copilot-api.ghe.example.com"); + expect(getGitHubCopilotBaseUrl("copilot-api.ghe.example.com")).toBe("https://copilot-api.ghe.example.com"); + }); + + it("parses structured Copilot api keys", () => { + expect( + parseGitHubCopilotApiKey( + JSON.stringify({ token: "ghu_test_token", enterpriseUrl: "https://ghe.example.com" }), + ), + ).toEqual({ + accessToken: "ghu_test_token", + enterpriseUrl: "ghe.example.com", + }); + }); +}); diff --git a/packages/ai/test/github-copilot-openai-base-url.test.ts b/packages/ai/test/github-copilot-openai-base-url.test.ts index 96e42675e..65d9f6520 100644 --- a/packages/ai/test/github-copilot-openai-base-url.test.ts +++ b/packages/ai/test/github-copilot-openai-base-url.test.ts @@ -41,6 +41,7 @@ function createUnauthorizedResponse(): Response { } const testToken = "ghu_test_copilot_token"; +const enterpriseApiKey = JSON.stringify({ token: testToken, enterpriseUrl: "ghe.example.com" }); describe("GitHub Copilot OpenAI transport base URL", () => { it("uses model baseUrl for chat completions", async () => { @@ -71,6 +72,40 @@ describe("GitHub Copilot OpenAI transport base URL", () => { expect(requestedUrls[0]).toBe("https://api.githubcopilot.com/responses"); }); + it("routes structured enterprise credentials to the enterprise chat completions host", async () => { + const requestedUrls: string[] = []; + const requestedAuthHeaders: Array = []; + global.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + requestedUrls.push(getRequestUrl(input)); + requestedAuthHeaders.push(getRequestHeader(input, init, "Authorization")); + return createUnauthorizedResponse(); + }) as unknown as typeof fetch; + + const model = getBundledModel("github-copilot", "gpt-4o") as Model<"openai-completions">; + const result = await streamOpenAICompletions(model, testContext, { apiKey: enterpriseApiKey }).result(); + + expect(result.stopReason).toBe("error"); + expect(requestedUrls[0]).toBe("https://copilot-api.ghe.example.com/chat/completions"); + expect(requestedAuthHeaders[0]).toBe(`Bearer ${testToken}`); + }); + + it("routes structured enterprise credentials to the enterprise responses host", async () => { + const requestedUrls: string[] = []; + const requestedAuthHeaders: Array = []; + global.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + requestedUrls.push(getRequestUrl(input)); + requestedAuthHeaders.push(getRequestHeader(input, init, "Authorization")); + return createUnauthorizedResponse(); + }) as unknown as typeof fetch; + + const model = getBundledModel("github-copilot", "gpt-5-mini") as Model<"openai-responses">; + const result = await streamOpenAIResponses(model, testContext, { apiKey: enterpriseApiKey }).result(); + + expect(result.stopReason).toBe("error"); + expect(requestedUrls[0]).toBe("https://copilot-api.ghe.example.com/responses"); + expect(requestedAuthHeaders[0]).toBe(`Bearer ${testToken}`); + }); + it("forwards initiatorOverride to chat completions requests", async () => { const requestedInitiators: Array = []; global.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 65667157d..366bc41b6 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1021,6 +1021,7 @@ describe("ModelRegistry", () => { access: "ghu_enterprise_token_456", refresh: "ghu_enterprise_token_456", expires: Date.now() + 60_000, + enterpriseUrl: "ghe.example.com", }, ]); @@ -1031,9 +1032,15 @@ describe("ModelRegistry", () => { const initialBaseUrl = model.baseUrl; const firstApiKey = await registry.getApiKey(model); - expect(firstApiKey).toBe("ghu_individual_token_123"); + expect(firstApiKey).toBeDefined(); + const firstParsed = JSON.parse(firstApiKey!) as { token?: string; enterpriseUrl?: string }; + expect(firstParsed.token).toBe("ghu_individual_token_123"); + expect(firstParsed.enterpriseUrl).toBeUndefined(); const secondApiKey = await registry.getApiKey(model); - expect(secondApiKey).toBe("ghu_enterprise_token_456"); + expect(secondApiKey).toBeDefined(); + const secondParsed = JSON.parse(secondApiKey!) as { token?: string; enterpriseUrl?: string }; + expect(secondParsed.token).toBe("ghu_enterprise_token_456"); + expect(secondParsed.enterpriseUrl).toBe("ghe.example.com"); expect(model.baseUrl).toBe(initialBaseUrl); }); });