From 596b9414757fbc6445012afbb8ee392a542aff93 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:33:05 -0600 Subject: [PATCH] Fix health widget false 'Anthropic key missing' when authenticated via GitHub Copilot (#1522) * Initial plan * Fix health widget false 'Anthropic key missing' when authenticated via GitHub Copilot - Use getEnvApiKey() from @gsd/pi-ai for authoritative env var resolution (checks ANTHROPIC_OAUTH_TOKEN, COPILOT_GITHUB_TOKEN, GH_TOKEN, etc.) - Add cross-provider routing: GitHub Copilot auth satisfies Anthropic/OpenAI requirements - Add github-copilot to modelToProviderId prefix map - Keep PROVIDER_REGISTRY env var fallback for non-LLM providers (search/tools) - Add tests for cross-provider routing and multi-env-var detection Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com> --- .../extensions/gsd/doctor-providers.ts | 39 +++++- .../gsd/tests/doctor-providers.test.ts | 111 +++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/doctor-providers.ts b/src/resources/extensions/gsd/doctor-providers.ts index 84fa05f5b..546a10f87 100644 --- a/src/resources/extensions/gsd/doctor-providers.ts +++ b/src/resources/extensions/gsd/doctor-providers.ts @@ -14,6 +14,7 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { AuthStorage } from "@gsd/pi-coding-agent"; +import { getEnvApiKey } from "@gsd/pi-ai"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js"; @@ -56,6 +57,7 @@ function modelToProviderId(model: string): string | null { google: "google", anthropic: "anthropic", openai: "openai", + "github-copilot": "github-copilot", }; if (prefixMap[prefix]) return prefixMap[prefix]; } @@ -139,7 +141,15 @@ function resolveKey(providerId: string): KeyLookup { } } - // Check environment variable + // Check environment variable using the authoritative env var resolution + // (handles multi-var lookups like ANTHROPIC_OAUTH_TOKEN || ANTHROPIC_API_KEY, + // COPILOT_GITHUB_TOKEN || GH_TOKEN || GITHUB_TOKEN, Vertex ADC, Bedrock, etc.) + if (getEnvApiKey(providerId)) { + return { found: true, source: "env", backedOff: false }; + } + + // Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey + // (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7) if (info?.envVar && process.env[info.envVar]) { return { found: true, source: "env", backedOff: false }; } @@ -149,6 +159,16 @@ function resolveKey(providerId: string): KeyLookup { // ── Individual check groups ──────────────────────────────────────────────────── +/** + * Providers that can serve models normally associated with another provider. + * Key = the provider whose models can be served, Value = alternative providers to check. + * e.g. GitHub Copilot subscriptions can access Claude and GPT models. + */ +const PROVIDER_ROUTES: Record = { + anthropic: ["github-copilot"], + openai: ["github-copilot"], +}; + function checkLlmProviders(): ProviderCheckResult[] { const required = collectConfiguredModelProviders(); const results: ProviderCheckResult[] = []; @@ -159,6 +179,23 @@ function checkLlmProviders(): ProviderCheckResult[] { const lookup = resolveKey(providerId); if (!lookup.found) { + // Check if a cross-provider can serve this provider's models + const routes = PROVIDER_ROUTES[providerId]; + const routeProvider = routes?.find(routeId => resolveKey(routeId).found); + if (routeProvider) { + const routeInfo = PROVIDER_REGISTRY.find(p => p.id === routeProvider); + const routeLabel = routeInfo?.label ?? routeProvider; + results.push({ + name: providerId, + label, + category: "llm", + status: "ok", + message: `${label} — available via ${routeLabel}`, + required: true, + }); + continue; + } + const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`; results.push({ name: providerId, diff --git a/src/resources/extensions/gsd/tests/doctor-providers.test.ts b/src/resources/extensions/gsd/tests/doctor-providers.test.ts index a4431e3e7..404d50da1 100644 --- a/src/resources/extensions/gsd/tests/doctor-providers.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-providers.test.ts @@ -184,7 +184,7 @@ test("runProviderChecks detects Anthropic key from ANTHROPIC_API_KEY env var", ( // Isolate from real HOME so loadEffectiveGSDPreferences returns null (default → anthropic) // and auth.json lookups hit an empty directory. const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-env-test-"))); - withEnv({ ANTHROPIC_API_KEY: "sk-ant-test-key", HOME: tmpHome }, () => { + withEnv({ ANTHROPIC_API_KEY: "sk-ant-test-key", ANTHROPIC_OAUTH_TOKEN: undefined, HOME: tmpHome }, () => { try { const results = runProviderChecks(); const anthropic = results.find(r => r.name === "anthropic"); @@ -199,7 +199,15 @@ test("runProviderChecks detects Anthropic key from ANTHROPIC_API_KEY env var", ( test("runProviderChecks returns error for Anthropic when no key present", () => { const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-test-"))); - withEnv({ ANTHROPIC_API_KEY: undefined, HOME: tmpHome }, () => { + withEnv({ + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, + // Clear cross-provider routing env vars (GitHub Copilot can serve Claude models) + COPILOT_GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + HOME: tmpHome, + }, () => { try { const results = runProviderChecks(); const anthropic = results.find(r => r.name === "anthropic"); @@ -275,7 +283,7 @@ test("runProviderChecks detects key from auth.json", () => { }); test("runProviderChecks ignores empty placeholder keys in auth.json", () => { - withEnv({ ANTHROPIC_API_KEY: undefined }, () => { + withEnv({ ANTHROPIC_API_KEY: undefined, ANTHROPIC_OAUTH_TOKEN: undefined, COPILOT_GITHUB_TOKEN: undefined, GH_TOKEN: undefined, GITHUB_TOKEN: undefined }, () => { const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-test-"))); const agentDir = join(tmpHome, ".gsd", "agent"); mkdirSync(agentDir, { recursive: true }); @@ -296,3 +304,100 @@ test("runProviderChecks ignores empty placeholder keys in auth.json", () => { rmSync(tmpHome, { recursive: true, force: true }); }); }); + +// ─── runProviderChecks — cross-provider routing ────────────────────────────── + +test("runProviderChecks reports ok for Anthropic when GitHub Copilot env var is set", () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-copilot-test-"))); + withEnv({ + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, + COPILOT_GITHUB_TOKEN: "ghu_copilot-token", + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + HOME: tmpHome, + }, () => { + try { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic result should exist"); + assert.equal(anthropic!.status, "ok", "should be ok when Copilot auth is available"); + assert.ok(anthropic!.message.includes("GitHub Copilot"), "should mention cross-provider source"); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); + +test("runProviderChecks reports ok for Anthropic via GITHUB_TOKEN cross-provider routing", () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-ghtoken-test-"))); + withEnv({ + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, + COPILOT_GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: "ghp_github-token", + HOME: tmpHome, + }, () => { + try { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic result should exist"); + assert.equal(anthropic!.status, "ok", "should be ok when GITHUB_TOKEN provides Copilot access"); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); + +test("runProviderChecks detects ANTHROPIC_OAUTH_TOKEN as valid Anthropic auth", () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-oauth-test-"))); + withEnv({ + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: "oauth-token-test", + COPILOT_GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + HOME: tmpHome, + }, () => { + try { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic result should exist"); + assert.equal(anthropic!.status, "ok", "should be ok when ANTHROPIC_OAUTH_TOKEN is set"); + assert.ok(anthropic!.message.includes("env"), "should report env source"); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); + +test("runProviderChecks reports ok via Copilot auth.json for Anthropic", () => { + withEnv({ + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, + COPILOT_GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + }, () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-copilot-auth-test-"))); + const agentDir = join(tmpHome, ".gsd", "agent"); + mkdirSync(agentDir, { recursive: true }); + + // GitHub Copilot OAuth in auth.json + const authData = { + "github-copilot": { type: "oauth", apiKey: "ghu_copilot-key", expires: Date.now() + 3_600_000 }, + }; + writeFileSync(join(agentDir, "auth.json"), JSON.stringify(authData)); + + withEnv({ HOME: tmpHome }, () => { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic result should exist"); + assert.equal(anthropic!.status, "ok", "should be ok when Copilot is authenticated in auth.json"); + assert.ok(anthropic!.message.includes("GitHub Copilot"), "should mention Copilot as source"); + }); + + rmSync(tmpHome, { recursive: true, force: true }); + }); +});