fix(ai,coding-agent): resolve copilot endpoint at provider layer
Fixes #260
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user