fix: github.com enterprise routing and structured Copilot OAuth credentials
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user