Merge remote-tracking branch 'origin/farm/73a063f1/fix-copilot-env-token-endpoint'
This commit is contained in:
@@ -5,10 +5,10 @@ import { scheduler } from "node:timers/promises";
|
||||
import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
|
||||
import {
|
||||
COPILOT_API_HEADERS,
|
||||
discoverGitHubCopilotApiEndpoint,
|
||||
getGitHubCopilotBaseUrl,
|
||||
isPublicGitHubHost,
|
||||
normalizeDomain,
|
||||
normalizeGitHubCopilotApiEndpoint,
|
||||
normalizeGitHubCopilotEnterpriseDomain,
|
||||
OPENCODE_HEADERS,
|
||||
} from "@oh-my-pi/pi-catalog/wire/github-copilot";
|
||||
@@ -226,27 +226,6 @@ export function refreshGitHubCopilotToken(
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverGitHubCopilotApiEndpoint(token: string, fetchImpl: FetchImpl): Promise<string | undefined> {
|
||||
try {
|
||||
const data = await fetchJson(
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...OPENCODE_HEADERS,
|
||||
},
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
if (!data || typeof data !== "object") return undefined;
|
||||
const endpoints = (data as { endpoints?: { api?: unknown } }).endpoints;
|
||||
return typeof endpoints?.api === "string" ? normalizeGitHubCopilotApiEndpoint(endpoints.api) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a model for the user's GitHub Copilot account.
|
||||
* This is required for some models (like Claude, Grok) before they can be used.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed raw `COPILOT_GITHUB_TOKEN` credentials skipping plan-specific endpoint discovery, which routed GitHub Copilot Business model requests to the personal endpoint and returned HTTP 403. The GitHub Copilot model cache is now scoped per credential, so switching the token no longer serves another account's stale endpoint for the cache TTL ([#8507](https://github.com/can1357/oh-my-pi/issues/8507)).
|
||||
|
||||
## [17.3.2] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PERSONAL_GITHUB_COPILOT_BASE_URL } from "../wire/github-copilot";
|
||||
|
||||
export interface ModelCacheProviderIdOptions {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
@@ -56,6 +58,18 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
|
||||
const scope = `${options.apiKey ?? ""}\u0000${discoveryBaseUrl}`;
|
||||
return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`;
|
||||
}
|
||||
case "github-copilot": {
|
||||
// Copilot model specs bake in the plan-specific endpoint (personal vs
|
||||
// Business/Enterprise) resolved from the credential. Discovery writes an
|
||||
// authoritative cache, so `online-if-uncached` serves it for the full
|
||||
// TTL without re-probing. Keying the namespace on the credential means
|
||||
// switching `COPILOT_GITHUB_TOKEN` to a different account misses the
|
||||
// prior endpoint's cache and re-runs discovery instead of hitting the
|
||||
// stale host and 403ing (PR #8510 review).
|
||||
const baseUrl = options.baseUrl ?? PERSONAL_GITHUB_COPILOT_BASE_URL;
|
||||
const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`;
|
||||
return `github-copilot:models-v1:${Bun.hash(scope).toString(36)}`;
|
||||
}
|
||||
case "openrouter":
|
||||
return "openrouter:pseudo-api";
|
||||
case "vllm": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
||||
import * as logger from "@oh-my-pi/pi-utils/logger";
|
||||
import {
|
||||
DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
|
||||
fetchOpenAICompatibleModels,
|
||||
type OpenAICompatibleModelMapperContext,
|
||||
type OpenAICompatibleModelRecord,
|
||||
@@ -25,6 +26,7 @@ import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "..
|
||||
import { coreWeaveProjectHeaders } from "../wire/coreweave";
|
||||
import {
|
||||
COPILOT_API_HEADERS,
|
||||
discoverGitHubCopilotApiEndpoint,
|
||||
getGitHubCopilotBaseUrl,
|
||||
isPersonalGitHubCopilotBaseUrl,
|
||||
parseGitHubCopilotApiKey,
|
||||
@@ -5191,6 +5193,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
const resolveReference = createReferenceResolver(getProviderReferences);
|
||||
return {
|
||||
providerId: "github-copilot",
|
||||
cacheProviderId: resolveModelCacheProviderId("github-copilot", { apiKey: rawApiKey, baseUrl }),
|
||||
dropCachedModelIdsOnStaticMismatch: COPILOT_CACHE_INVALIDATED_MODEL_IDS,
|
||||
// COPILOT_API_HEADERS are compile-time constants (User-Agent + API
|
||||
// version), not credentials. The cache omits all request headers for
|
||||
@@ -5201,11 +5204,17 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
restorableHeaderFallback: { ...COPILOT_API_HEADERS },
|
||||
...(apiKey && {
|
||||
fetchDynamicModels: async () => {
|
||||
const fetchImpl = discoveryFetch(config?.fetch);
|
||||
const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl)
|
||||
? ((await withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal =>
|
||||
discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl, signal),
|
||||
)) ?? baseUrl)
|
||||
: baseUrl;
|
||||
const longContextVariants: ModelSpec<Api>[] = [];
|
||||
const models = await fetchOpenAICompatibleModels<Api>({
|
||||
api: "openai-completions",
|
||||
provider: "github-copilot",
|
||||
baseUrl,
|
||||
baseUrl: requestBaseUrl,
|
||||
apiKey,
|
||||
headers: COPILOT_API_HEADERS,
|
||||
mapModel: (
|
||||
@@ -5251,7 +5260,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
const input: ModelSpec<Api>["input"] =
|
||||
supportsVision === true
|
||||
? ["text", "image"]
|
||||
: supportsVision === false || !isPersonalGitHubCopilotBaseUrl(baseUrl)
|
||||
: supportsVision === false || !isPersonalGitHubCopilotBaseUrl(requestBaseUrl)
|
||||
? ["text"]
|
||||
: (reference?.input ?? defaults.input);
|
||||
// With COPILOT_API_HEADERS the served window is the long-context
|
||||
@@ -5272,7 +5281,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
...reference,
|
||||
api,
|
||||
provider: "github-copilot",
|
||||
baseUrl,
|
||||
baseUrl: requestBaseUrl,
|
||||
name,
|
||||
input,
|
||||
contextWindow: defaultTierWindow,
|
||||
@@ -5294,7 +5303,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
: {
|
||||
...defaults,
|
||||
api,
|
||||
baseUrl,
|
||||
baseUrl: requestBaseUrl,
|
||||
name,
|
||||
input,
|
||||
contextWindow: defaultTierWindow,
|
||||
@@ -5340,7 +5349,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
}
|
||||
return base;
|
||||
},
|
||||
fetch: config?.fetch,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
if (models === null) {
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { FetchImpl } from "../types";
|
||||
import { isRecord } from "../utils";
|
||||
|
||||
/**
|
||||
* GitHub Copilot wire metadata: API-key envelope parsing and endpoint
|
||||
* derivation shared by catalog discovery and the pi-ai OAuth flow. The device
|
||||
@@ -72,6 +75,35 @@ export function normalizeGitHubCopilotApiEndpoint(input: string | undefined): st
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolve the plan-specific Copilot API endpoint advertised for a GitHub token.
|
||||
* Login and raw environment-token discovery share this best-effort probe. Pass
|
||||
* a `signal` to bound it against the same discovery deadline as `/models`; a
|
||||
* stalled probe otherwise blocks discovery indefinitely.
|
||||
*/
|
||||
export async function discoverGitHubCopilotApiEndpoint(
|
||||
token: string,
|
||||
fetchImpl: FetchImpl,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetchImpl("https://api.github.com/copilot_internal/user", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...OPENCODE_HEADERS,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const data: unknown = await response.json();
|
||||
if (!isRecord(data) || !isRecord(data.endpoints)) return undefined;
|
||||
const endpoint = data.endpoints.api;
|
||||
return typeof endpoint === "string" ? normalizeGitHubCopilotApiEndpoint(endpoint) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubCopilotApiKey(apiKeyRaw: string): ParsedGitHubCopilotApiKey {
|
||||
try {
|
||||
|
||||
@@ -42,6 +42,13 @@ async function discoverCopilotModels(
|
||||
const requestApiVersions: Array<string | undefined> = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
expect(getHeaderValue(init?.headers, "Authorization")).toBe(`token ${expectedAuthorizationToken}`);
|
||||
// The probe must be bounded by the shared discovery deadline so a
|
||||
// stalled endpoint cannot hang discovery (PR #8510 review).
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
return Response.json({ endpoints: { api: expectedBaseUrl } });
|
||||
}
|
||||
expect(url).toBe(`${expectedBaseUrl}/models`);
|
||||
expect(init?.method).toBe("GET");
|
||||
expect(getHeaderValue(init?.headers, "Authorization")).toBe(`Bearer ${expectedAuthorizationToken}`);
|
||||
@@ -72,13 +79,77 @@ function cachedCopilotCompletionModel(id: string, name: string): ModelSpec<"open
|
||||
}
|
||||
|
||||
describe("github copilot model limits mapping", () => {
|
||||
it("uses configured base URL for discovery", async () => {
|
||||
it("discovers the plan endpoint for a raw environment token before model discovery", async () => {
|
||||
const token = "ghu_valid_business_token";
|
||||
const { fetchMock } = await discoverCopilotModels(
|
||||
{ data: [] },
|
||||
"copilot-test-key",
|
||||
"https://api.githubcopilot.com",
|
||||
token,
|
||||
"https://api.business.githubcopilot.com",
|
||||
token,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("falls back to the personal endpoint when the raw-token probe fails", async () => {
|
||||
const token = "ghu_valid_business_token";
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
throw new DOMException("The operation timed out.", "TimeoutError");
|
||||
}
|
||||
expect(url).toBe("https://api.githubcopilot.com/models");
|
||||
return Response.json({ data: [] });
|
||||
});
|
||||
const models = await githubCopilotModelManagerOptions({ apiKey: token, fetch: fetchMock }).fetchDynamicModels?.();
|
||||
expect(models).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("does not reuse another token's authoritative cache after COPILOT_GITHUB_TOKEN switches", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-copilot-token-switch-"));
|
||||
const cacheDbPath = path.join(tempDir, "models.db");
|
||||
try {
|
||||
const personalFetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
return Response.json({ endpoints: { api: "https://api.githubcopilot.com" } });
|
||||
}
|
||||
if (url === "https://api.githubcopilot.com/models") {
|
||||
return Response.json({ data: [{ id: "gpt-5.5", name: "GPT-5.5" }] });
|
||||
}
|
||||
throw new Error(`unexpected personal request: ${url}`);
|
||||
});
|
||||
const personalManager = createModelManager({
|
||||
...githubCopilotModelManagerOptions({ apiKey: "ghu_personal_token", fetch: personalFetch }),
|
||||
cacheDbPath,
|
||||
});
|
||||
// Personal token discovery writes a fresh authoritative cache.
|
||||
await personalManager.refresh("online");
|
||||
|
||||
const businessSeen: string[] = [];
|
||||
const businessFetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
businessSeen.push(url);
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
return Response.json({ endpoints: { api: "https://api.business.githubcopilot.com" } });
|
||||
}
|
||||
if (url === "https://api.business.githubcopilot.com/models") {
|
||||
return Response.json({ data: [{ id: "gpt-5.5", name: "GPT-5.5" }] });
|
||||
}
|
||||
throw new Error(`unexpected business request: ${url}`);
|
||||
});
|
||||
const businessManager = createModelManager({
|
||||
...githubCopilotModelManagerOptions({ apiKey: "ghu_business_token", fetch: businessFetch }),
|
||||
cacheDbPath,
|
||||
});
|
||||
// Default online-if-uncached must not satisfy the switched token from the
|
||||
// prior token's fresh authoritative personal-endpoint cache.
|
||||
const { models } = await businessManager.refresh("online-if-uncached");
|
||||
expect(businessSeen).toContain("https://api.github.com/copilot_internal/user");
|
||||
expect(businessSeen).toContain("https://api.business.githubcopilot.com/models");
|
||||
expect(models.some(model => model.baseUrl === "https://api.business.githubcopilot.com")).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps structured OAuth keys for discovery and routes enterprise discovery to the enterprise host", async () => {
|
||||
@@ -355,7 +426,7 @@ describe("github copilot model limits mapping", () => {
|
||||
const { models } = await manager.refresh("online-if-uncached");
|
||||
const model = models.find(candidate => candidate.id === migration.id);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(model?.api).toBe("openai-responses");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
@@ -390,7 +461,7 @@ describe("github copilot model limits mapping", () => {
|
||||
});
|
||||
const { models } = await manager.refresh("online-if-uncached");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
// The bundled catalog now ships a responses-route grok-4.5, so the id
|
||||
// resurfaces from the bundle after the failed refresh. The migration
|
||||
// contract is that the stale cached COMPLETIONS route never comes
|
||||
|
||||
Reference in New Issue
Block a user