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:
parent
e4cd141503
commit
596b941475
2 changed files with 146 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue