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>
This commit is contained in:
Copilot 2026-03-19 23:33:05 -06:00 committed by GitHub
parent e4cd141503
commit 596b941475
2 changed files with 146 additions and 4 deletions

View file

@ -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<string, string[]> = {
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,

View file

@ -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 });
});
});