fix(ai,coding-agent): resolve copilot endpoint at provider layer

Fixes #260
This commit is contained in:
can1357
2026-03-03 05:33:35 +01:00
parent b0fef96800
commit 0611e97dbc
10 changed files with 177 additions and 32 deletions
@@ -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<Api> {
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<Api>("github-copilot");
const globalReferences = createGlobalReferenceMap();
return {
+11 -4
View File
@@ -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") {
@@ -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";
@@ -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,
};
}
+12 -5
View File
@@ -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,
};
}
@@ -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({
@@ -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: [
@@ -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");
});
});
@@ -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 */
@@ -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;