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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user