From c0394ba53d69aa47357e6e536e176507dc70f577 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 05:12:06 +0000 Subject: [PATCH] fix(catalog): scope Copilot model cache by credential Copilot discovery writes an authoritative cache, so online-if-uncached served the prior endpoint for the full TTL after COPILOT_GITHUB_TOKEN switched accounts. Keying the cache namespace on the credential forces fresh discovery for a new token instead of reusing a stale personal-endpoint cache. Fixes #8507 --- packages/catalog/CHANGELOG.md | 2 +- .../src/provider-models/cache-provider-id.ts | 14 ++++++ .../src/provider-models/openai-compat.ts | 1 + .../test/github-copilot-model-limits.test.ts | 47 +++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 1e8e07bb6..86a00ef43 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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)). +- 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 diff --git a/packages/catalog/src/provider-models/cache-provider-id.ts b/packages/catalog/src/provider-models/cache-provider-id.ts index 4c7dc1948..3c5032e9c 100644 --- a/packages/catalog/src/provider-models/cache-provider-id.ts +++ b/packages/catalog/src/provider-models/cache-provider-id.ts @@ -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": { diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 1c7841f40..bf985c9a5 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -5193,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 diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index f985cc567..b3aff18a2 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -104,6 +104,53 @@ describe("github copilot model limits mapping", () => { 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 () => { const structuredApiKey = JSON.stringify({