From 0611e97dbce76409075c3c25350e7d070d2350dc Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 3 Mar 2026 05:33:35 +0100 Subject: [PATCH] fix(ai,coding-agent): resolve copilot endpoint at provider layer Fixes #260 --- .../ai/src/provider-models/openai-compat.ts | 7 ++- packages/ai/src/providers/anthropic.ts | 15 +++-- .../src/providers/github-copilot-headers.ts | 9 +++ .../ai/src/providers/openai-completions.ts | 22 +++++-- packages/ai/src/providers/openai-responses.ts | 17 ++++-- .../github-copilot-anthropic-auth.test.ts | 13 ++++ .../test/github-copilot-model-limits.test.ts | 21 +++++-- .../github-copilot-openai-base-url.test.ts | 61 +++++++++++++++++++ .../coding-agent/src/config/model-registry.ts | 14 +---- .../coding-agent/test/model-registry.test.ts | 30 +++++++++ 10 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 packages/ai/test/github-copilot-openai-base-url.test.ts diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index 98db52b5a..e70cf9770 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -7,6 +7,7 @@ import { type OpenAICompatibleModelMapperContext, type OpenAICompatibleModelRecord, } from "../utils/discovery/openai-compatible"; +import { getGitHubCopilotBaseUrl } from "../utils/oauth/github-copilot"; const MODELS_DEV_URL = "https://models.dev/api.json"; const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; @@ -1286,7 +1287,11 @@ function extractCopilotLimits(entry: OpenAICompatibleModelRecord): { export function githubCopilotModelManagerOptions(config?: GithubCopilotModelManagerConfig): ModelManagerOptions { const apiKey = config?.apiKey; - const baseUrl = config?.baseUrl ?? "https://api.individual.githubcopilot.com"; + const configuredBaseUrl = config?.baseUrl ?? "https://api.individual.githubcopilot.com"; + const baseUrl = + apiKey?.includes("proxy-ep=") && configuredBaseUrl.includes("githubcopilot.com") + ? getGitHubCopilotBaseUrl(apiKey) + : configuredBaseUrl; const references = createBundledReferenceMap("github-copilot"); const globalReferences = createGlobalReferenceMap(); return { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 861a98ac1..f4a2d0174 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -32,7 +32,11 @@ import { isAnthropicOAuthToken, normalizeToolCallId, resolveCacheRetention } fro import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { parseStreamingJson } from "../utils/json-parse"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; +import { + buildCopilotDynamicHeaders, + hasCopilotVisionInput, + resolveGitHubCopilotBaseUrl, +} from "./github-copilot-headers"; import { transformMessages } from "./transform-messages"; export type AnthropicHeaderOptions = { @@ -396,7 +400,10 @@ function normalizeBaseUrl(baseUrl: string | undefined): string | undefined { return trimmed ? trimmed.replace(/\/+$/, "") : undefined; } -function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">): string | undefined { +function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: string): string | undefined { + if (model.provider === "github-copilot") { + return normalizeBaseUrl(resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl); + } if (model.provider === "anthropic" && isFoundryEnabled()) { const foundryBaseUrl = normalizeBaseUrl($env.FOUNDRY_BASE_URL); if (foundryBaseUrl) { @@ -583,7 +590,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( try { const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; - const baseUrl = resolveAnthropicBaseUrl(model) ?? "https://api.anthropic.com"; + const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com"; const { client, isOAuthToken } = createClient(model, { model, @@ -932,7 +939,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A isOAuth, } = args; const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey); - const baseUrl = resolveAnthropicBaseUrl(model); + const baseUrl = resolveAnthropicBaseUrl(model, apiKey); const foundryCustomHeaders = resolveAnthropicCustomHeaders(model); const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl); if (model.provider === "github-copilot") { diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index f6833824d..6f9275c8a 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 } 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. @@ -10,6 +11,14 @@ export type CopilotDynamicHeaders = { initiator: CopilotInitiator; premiumRequests: CopilotPremiumRequests; }; +export function resolveGitHubCopilotBaseUrl( + baseUrl: string | undefined, + apiKey: string | undefined, +): string | undefined { + if (!apiKey?.includes("proxy-ep=")) return baseUrl; + if (baseUrl && !baseUrl.includes("githubcopilot.com")) return baseUrl; + return getGitHubCopilotBaseUrl(apiKey); +} 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 2e602bdaf..290d636d6 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -33,7 +33,11 @@ import { parseStreamingJson } from "../utils/json-parse"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { mapToOpenAICompletionsToolChoice } from "../utils/tool-choice"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; +import { + buildCopilotDynamicHeaders, + hasCopilotVisionInput, + resolveGitHubCopilotBaseUrl, +} from "./github-copilot-headers"; import { transformMessages } from "./transform-messages"; /** @@ -184,7 +188,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( try { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const { client, copilotPremiumRequests } = await createClient(model, context, apiKey, options?.headers); + const { client, copilotPremiumRequests, baseUrl } = await createClient( + model, + context, + apiKey, + options?.headers, + ); const params = buildParams(model, context, options); options?.onPayload?.(params); rawRequestDump = { @@ -192,7 +201,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( api: output.api, model: model.id, method: "POST", - url: `${model.baseUrl ?? "https://api.openai.com/v1"}/chat/completions`, + url: `${baseUrl ?? "https://api.openai.com/v1"}/chat/completions`, body: params, }; const openaiStream = await client.chat.completions.create(params, { signal: options?.signal }); @@ -509,6 +518,8 @@ async function createClient( headers = { ...(await getKimiCommonHeaders()), ...headers }; } let copilotPremiumRequests: number | undefined; + + let baseUrl = model.baseUrl; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilot = buildCopilotDynamicHeaders({ @@ -519,17 +530,18 @@ async function createClient( }); Object.assign(headers, copilot.headers); copilotPremiumRequests = copilot.premiumRequests; + baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl; } - return { client: new OpenAI({ apiKey, - baseURL: model.baseUrl, + baseURL: baseUrl, dangerouslyAllowBrowser: true, maxRetries: 5, defaultHeaders: headers, }), copilotPremiumRequests, + baseUrl, }; } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index a56301f3d..cc7223b5d 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -34,7 +34,11 @@ import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-ins import { parseStreamingJson } from "../utils/json-parse"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; +import { + buildCopilotDynamicHeaders, + hasCopilotVisionInput, + resolveGitHubCopilotBaseUrl, +} from "./github-copilot-headers"; import { transformMessages } from "./transform-messages"; /** @@ -109,7 +113,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( try { // Create OpenAI client const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const { client, copilotPremiumRequests } = createClient(model, context, apiKey, options?.headers); + const { client, copilotPremiumRequests, baseUrl } = createClient(model, context, apiKey, options?.headers); const params = buildParams(model, context, options); options?.onPayload?.(params); rawRequestDump = { @@ -117,7 +121,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( api: output.api, model: model.id, method: "POST", - url: `${model.baseUrl ?? "https://api.openai.com/v1"}/responses`, + url: `${baseUrl ?? "https://api.openai.com/v1"}/responses`, body: params, }; const openaiStream = await client.responses.create( @@ -391,6 +395,8 @@ function createClient( const headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; let copilotPremiumRequests: number | undefined; + + let baseUrl = model.baseUrl; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilot = buildCopilotDynamicHeaders({ @@ -401,17 +407,18 @@ function createClient( }); Object.assign(headers, copilot.headers); copilotPremiumRequests = copilot.premiumRequests; + baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl; } - return { client: new OpenAI({ apiKey, - baseURL: model.baseUrl, + baseURL: baseUrl, dangerouslyAllowBrowser: true, maxRetries: 5, defaultHeaders: headers, }), copilotPremiumRequests, + baseUrl, }; } diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index 342cf5385..2b260a147 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -44,6 +44,19 @@ describe("Anthropic Copilot auth config", () => { expect(options.defaultHeaders.Authorization).toBe(`Bearer ${token}`); }); + it("derives baseURL from proxy endpoint token", () => { + const model = makeCopilotClaudeModel(); + const token = "tid=2;proxy-ep=proxy.enterprise.githubcopilot.com;exp=9999999999"; + const options = buildAnthropicClientOptions({ + model, + apiKey: token, + extraBetas: [], + stream: true, + dynamicHeaders: {}, + }); + + expect(options.baseURL).toBe("https://api.enterprise.githubcopilot.com"); + }); it("includes Copilot static headers from model.headers", () => { const model = makeCopilotClaudeModel(); const options = buildAnthropicClientOptions({ diff --git a/packages/ai/test/github-copilot-model-limits.test.ts b/packages/ai/test/github-copilot-model-limits.test.ts index 92318d95b..1b0fc2b29 100644 --- a/packages/ai/test/github-copilot-model-limits.test.ts +++ b/packages/ai/test/github-copilot-model-limits.test.ts @@ -33,12 +33,16 @@ function getHeaderValue(headers: unknown, key: string): string | undefined { return undefined; } -async function discoverCopilotModels(payload: unknown) { +async function discoverCopilotModels( + payload: unknown, + apiKey = "copilot-test-key", + expectedBaseUrl = "https://api.individual.githubcopilot.com", +) { const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); - expect(url).toBe("https://api.individual.githubcopilot.com/models"); + expect(url).toBe(`${expectedBaseUrl}/models`); expect(init?.method).toBe("GET"); - expect(getHeaderValue(init?.headers, "Authorization")).toBe("Bearer copilot-test-key"); + expect(getHeaderValue(init?.headers, "Authorization")).toBe(`Bearer ${apiKey}`); return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" }, @@ -46,7 +50,7 @@ async function discoverCopilotModels(payload: unknown) { }); global.fetch = fetchMock as unknown as typeof fetch; - const options = githubCopilotModelManagerOptions({ apiKey: "copilot-test-key" }); + const options = githubCopilotModelManagerOptions({ apiKey }); expect(options.fetchDynamicModels).toBeDefined(); const models = await options.fetchDynamicModels?.(); expect(models).not.toBeNull(); @@ -54,6 +58,15 @@ async function discoverCopilotModels(payload: unknown) { } describe("github copilot model limits mapping", () => { + it("uses proxy endpoint from token for discovery base URL", async () => { + const enterpriseToken = "tid=2;proxy-ep=proxy.enterprise.githubcopilot.com;exp=9999999999"; + const { fetchMock } = await discoverCopilotModels( + { data: [] }, + enterpriseToken, + "https://api.enterprise.githubcopilot.com", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); it("uses capabilities.limits max_prompt_tokens as context window when context_length is absent", async () => { const { models, fetchMock } = await discoverCopilotModels({ data: [ diff --git a/packages/ai/test/github-copilot-openai-base-url.test.ts b/packages/ai/test/github-copilot-openai-base-url.test.ts new file mode 100644 index 000000000..8fd800586 --- /dev/null +++ b/packages/ai/test/github-copilot-openai-base-url.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { getBundledModel } from "../src/models"; +import { streamOpenAICompletions } from "../src/providers/openai-completions"; +import { streamOpenAIResponses } from "../src/providers/openai-responses"; +import type { Context, Model } from "../src/types"; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +const testContext: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +function getRequestUrl(input: string | URL | Request): string { + if (input instanceof Request) { + return input.url; + } + return typeof input === "string" ? input : input.toString(); +} + +const enterpriseToken = "tid=2;proxy-ep=proxy.enterprise.githubcopilot.com;exp=9999999999"; + +describe("GitHub Copilot OpenAI transport base URL", () => { + it("uses token-derived endpoint for chat completions", async () => { + const requestedUrls: string[] = []; + global.fetch = vi.fn(async (input: string | URL | Request) => { + requestedUrls.push(getRequestUrl(input)); + return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const model = getBundledModel("github-copilot", "gpt-4o") as Model<"openai-completions">; + const result = await streamOpenAICompletions(model, testContext, { apiKey: enterpriseToken }).result(); + + expect(result.stopReason).toBe("error"); + expect(requestedUrls[0]).toBe("https://api.enterprise.githubcopilot.com/chat/completions"); + }); + + it("uses token-derived endpoint for responses API", async () => { + const requestedUrls: string[] = []; + global.fetch = vi.fn(async (input: string | URL | Request) => { + requestedUrls.push(getRequestUrl(input)); + return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const model = getBundledModel("github-copilot", "gpt-5-mini") as Model<"openai-responses">; + const result = await streamOpenAIResponses(model, testContext, { apiKey: enterpriseToken }).result(); + + expect(result.stopReason).toBe("error"); + expect(requestedUrls[0]).toBe("https://api.enterprise.githubcopilot.com/responses"); + }); +}); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 67819819e..9aa525568 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -6,13 +6,11 @@ import { DEFAULT_LOCAL_TOKEN, getBundledModels, getBundledProviders, - getGitHubCopilotBaseUrl, googleAntigravityModelManagerOptions, googleGeminiCliModelManagerOptions, type Model, type ModelManagerOptions, type ModelRefreshStrategy, - normalizeDomain, type OAuthCredentials, type OAuthLoginCallbacks, openaiCodexModelManagerOptions, @@ -539,17 +537,7 @@ export class ModelRegistry { const builtInModels = this.#loadBuiltInModels(overrides, modelOverrides); const combined = this.#mergeCustomModels(builtInModels, customModels); - // Update github-copilot base URL based on OAuth credentials - const copilotCred = this.authStorage.getOAuthCredential("github-copilot"); - if (copilotCred) { - const domain = copilotCred.enterpriseUrl - ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined) - : undefined; - const baseUrl = getGitHubCopilotBaseUrl(copilotCred.access, domain); - this.#models = combined.map(m => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); - } else { - this.#models = combined; - } + this.#models = combined; } /** Load built-in models, applying provider and per-model overrides */ diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index ed1f4d6eb..a6e956cda 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -550,6 +550,36 @@ describe("ModelRegistry", () => { }); }); + describe("github-copilot oauth endpoint alignment", () => { + test("getApiKey does not mutate bundled github-copilot baseUrl", async () => { + await authStorage.set("github-copilot", [ + { + type: "oauth", + access: "tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=9999999999", + refresh: "refresh-individual", + expires: Date.now() + 60_000, + }, + { + type: "oauth", + access: "tid=2;proxy-ep=proxy.enterprise.githubcopilot.com;exp=9999999999", + refresh: "refresh-enterprise", + expires: Date.now() + 60_000, + }, + ]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const model = registry.find("github-copilot", "gpt-4o"); + expect(model).toBeDefined(); + if (!model) throw new Error("Expected github-copilot/gpt-4o model"); + + const initialBaseUrl = model.baseUrl; + const firstApiKey = await registry.getApiKey(model); + expect(firstApiKey).toContain("proxy.individual.githubcopilot.com"); + const secondApiKey = await registry.getApiKey(model); + expect(secondApiKey).toContain("proxy.enterprise.githubcopilot.com"); + expect(model.baseUrl).toBe(initialBaseUrl); + }); + }); describe("runtime discovery", () => { test("auto-discovers ollama models without provider config", async () => { const originalFetch = globalThis.fetch;