From c92ba975386a82c03d9ee5d54146619f5225c2ae Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 04:41:27 +0000 Subject: [PATCH] fix(catalog): discovered Copilot endpoint for env tokens Shared the plan-endpoint probe between OAuth login and raw token model discovery so Business credentials route to their advertised API host. Added regression coverage for the raw environment-token path. Fixes #8507 --- .../ai/src/registry/oauth/github-copilot.ts | 23 +-------------- packages/catalog/CHANGELOG.md | 4 +++ .../src/provider-models/openai-compat.ts | 15 ++++++---- packages/catalog/src/wire/github-copilot.ts | 28 +++++++++++++++++++ .../test/github-copilot-model-limits.test.ts | 18 ++++++++---- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/ai/src/registry/oauth/github-copilot.ts b/packages/ai/src/registry/oauth/github-copilot.ts index 0d95ee2c5..fa8c496e6 100644 --- a/packages/ai/src/registry/oauth/github-copilot.ts +++ b/packages/ai/src/registry/oauth/github-copilot.ts @@ -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 { - 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. diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 8cde7ddb0..1e8e07bb6 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 ([#8507](https://github.com/can1357/oh-my-pi/issues/8507)). + ## [17.3.2] - 2026-08-13 ### Added diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 76ee02bd0..e5930337b 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -25,6 +25,7 @@ import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from ".. import { coreWeaveProjectHeaders } from "../wire/coreweave"; import { COPILOT_API_HEADERS, + discoverGitHubCopilotApiEndpoint, getGitHubCopilotBaseUrl, isPersonalGitHubCopilotBaseUrl, parseGitHubCopilotApiKey, @@ -5201,11 +5202,15 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana restorableHeaderFallback: { ...COPILOT_API_HEADERS }, ...(apiKey && { fetchDynamicModels: async () => { + const fetchImpl = discoveryFetch(config?.fetch); + const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl) + ? ((await discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl)) ?? baseUrl) + : baseUrl; const longContextVariants: ModelSpec[] = []; const models = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "github-copilot", - baseUrl, + baseUrl: requestBaseUrl, apiKey, headers: COPILOT_API_HEADERS, mapModel: ( @@ -5251,7 +5256,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana const input: ModelSpec["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 +5277,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana ...reference, api, provider: "github-copilot", - baseUrl, + baseUrl: requestBaseUrl, name, input, contextWindow: defaultTierWindow, @@ -5294,7 +5299,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana : { ...defaults, api, - baseUrl, + baseUrl: requestBaseUrl, name, input, contextWindow: defaultTierWindow, @@ -5340,7 +5345,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana } return base; }, - fetch: config?.fetch, + fetch: fetchImpl, }); if (models === null) { return null; diff --git a/packages/catalog/src/wire/github-copilot.ts b/packages/catalog/src/wire/github-copilot.ts index 8f0a03a3b..de0db263c 100644 --- a/packages/catalog/src/wire/github-copilot.ts +++ b/packages/catalog/src/wire/github-copilot.ts @@ -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,31 @@ 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. + */ +export async function discoverGitHubCopilotApiEndpoint( + token: string, + fetchImpl: FetchImpl, +): Promise { + try { + const response = await fetchImpl("https://api.github.com/copilot_internal/user", { + headers: { + Accept: "application/json", + Authorization: `token ${token}`, + ...OPENCODE_HEADERS, + }, + }); + 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 { diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index 9867b2c0a..8dc4ca545 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -42,6 +42,10 @@ async function discoverCopilotModels( const requestApiVersions: Array = []; 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}`); + 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 +76,15 @@ 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("unwraps structured OAuth keys for discovery and routes enterprise discovery to the enterprise host", async () => { @@ -355,7 +361,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 +396,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