fix(providers): classify copilot native endpoints (#59644)

* fix(providers): classify copilot native endpoints

* fix(changelog): add copilot endpoint note

* fix(providers): handle copilot proxy hints
This commit is contained in:
Vincent Koc 2026-04-02 20:51:46 +09:00 committed by GitHub
parent 71d49012fc
commit 5abd5d889f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 88 additions and 4 deletions

View File

@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
- MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.
- Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson.
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. Thanks @vincentkoc.
## 2026.4.1-beta.1

View File

@ -1,7 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { resolveCopilotApiToken } from "./github-copilot-token.js";
import {
deriveCopilotApiBaseUrlFromToken,
resolveCopilotApiToken,
} from "./github-copilot-token.js";
describe("resolveCopilotApiToken", () => {
it("derives native Copilot base URLs from Copilot proxy hints", () => {
expect(
deriveCopilotApiBaseUrlFromToken(
"copilot-token;proxy-ep=https://proxy.individual.githubcopilot.com;",
),
).toBe("https://api.individual.githubcopilot.com");
expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=proxy.example.com;")).toBe(
"https://api.example.com",
);
expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=proxy.example.com:8443;")).toBe(
"https://api.example.com",
);
});
it("rejects malformed or non-http proxy hints", () => {
expect(
deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=javascript:alert(1);"),
).toBeNull();
expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=://bad;")).toBeNull();
});
it("treats 11-digit expires_at values as seconds epochs", async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,

View File

@ -1,6 +1,7 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
@ -55,6 +56,24 @@ function parseCopilotTokenResponse(value: unknown): {
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
function resolveCopilotProxyHost(proxyEp: string): string | null {
const trimmed = proxyEp.trim();
if (!trimmed) {
return null;
}
const urlText = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const url = new URL(urlText);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.hostname.toLowerCase();
} catch {
return null;
}
}
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
const trimmed = token.trim();
if (!trimmed) {
@ -71,12 +90,14 @@ export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
// pi-ai expects converting proxy.* -> api.*
// (see upstream getGitHubCopilotBaseUrl).
const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api.");
if (!host) {
const proxyHost = resolveCopilotProxyHost(proxyEp);
if (!proxyHost) {
return null;
}
const host = proxyHost.replace(/^proxy\./i, "api.");
return `https://${host}`;
const baseUrl = `https://${host}`;
return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl;
}
export async function resolveCopilotApiToken(params: {

View File

@ -340,6 +340,23 @@ describe("provider attribution", () => {
});
});
it("classifies native GitHub Copilot endpoints separately from custom hosts", () => {
expect(resolveProviderEndpoint("https://api.individual.githubcopilot.com")).toMatchObject({
endpointClass: "github-copilot-native",
hostname: "api.individual.githubcopilot.com",
});
expect(resolveProviderEndpoint("https://api.enterprise.githubcopilot.com")).toMatchObject({
endpointClass: "github-copilot-native",
hostname: "api.enterprise.githubcopilot.com",
});
expect(resolveProviderEndpoint("https://api.githubcopilot.example.com")).toMatchObject({
endpointClass: "custom",
hostname: "api.githubcopilot.example.com",
});
});
it("does not classify malformed or embedded Google host strings as native endpoints", () => {
expect(resolveProviderEndpoint("proxy/generativelanguage.googleapis.com")).toMatchObject({
endpointClass: "custom",
@ -516,4 +533,20 @@ describe("provider attribution", () => {
compatibilityFamily: "moonshot",
});
});
it("treats native GitHub Copilot base URLs as known native endpoints", () => {
expect(
resolveProviderRequestCapabilities({
provider: "github-copilot",
api: "openai-responses",
baseUrl: "https://api.individual.githubcopilot.com",
capability: "llm",
transport: "http",
}),
).toMatchObject({
endpointClass: "github-copilot-native",
knownProviderFamily: "github-copilot",
isKnownNativeEndpoint: true,
});
});
});

View File

@ -34,6 +34,7 @@ export type ProviderRequestCapability = "llm" | "audio" | "image" | "video" | "o
export type ProviderEndpointClass =
| "default"
| "anthropic-public"
| "github-copilot-native"
| "moonshot-native"
| "modelstudio-native"
| "openai-public"
@ -201,6 +202,9 @@ export function resolveProviderEndpoint(
if (host === "api.anthropic.com") {
return { endpointClass: "anthropic-public", hostname: host };
}
if (host.endsWith(".githubcopilot.com")) {
return { endpointClass: "github-copilot-native", hostname: host };
}
if (host === "chatgpt.com") {
return { endpointClass: "openai-codex", hostname: host };
}
@ -494,6 +498,7 @@ export function resolveProviderRequestCapabilities(
const endpointClass = policy.endpointClass;
const isKnownNativeEndpoint =
endpointClass === "anthropic-public" ||
endpointClass === "github-copilot-native" ||
endpointClass === "moonshot-native" ||
endpointClass === "modelstudio-native" ||
endpointClass === "openai-public" ||