fix: github.com enterprise routing and structured Copilot OAuth credentials

This commit is contained in:
Abir Biswas
2026-04-09 20:16:16 +05:30
committed by can1357
parent e5a74d5a64
commit bafa3b8a06
10 changed files with 179 additions and 17 deletions
+4 -2
View File
@@ -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,
@@ -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";
@@ -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({
@@ -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({
+52 -5
View File
@@ -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<unknown> {
@@ -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}`);
+6 -4
View File
@@ -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,
@@ -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({
@@ -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",
});
});
});
@@ -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<string | null> = [];
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<string | null> = [];
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<string | null> = [];
global.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
@@ -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);
});
});