diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 1ba15814..8683974e 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -27,41 +27,6 @@ import type { KnownProvider } from "./types.js"; let cachedVertexAdcCredentialsExists: boolean | null = null; -export type EnvApiKeyInfo = { - value: string; - label: string; -}; - -const ENV_API_KEY_MAP: Record = { - openai: ["OPENAI_API_KEY"], - "azure-openai-responses": ["AZURE_OPENAI_API_KEY"], - google: ["GEMINI_API_KEY"], - groq: ["GROQ_API_KEY"], - cerebras: ["CEREBRAS_API_KEY"], - xai: ["XAI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - zai: ["ZAI_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - "minimax-cn": ["MINIMAX_CN_API_KEY"], - huggingface: ["HF_TOKEN"], - fireworks: ["FIREWORKS_API_KEY"], - opencode: ["OPENCODE_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY"], - "kimi-coding": ["KIMI_API_KEY"], -}; - -function getFirstEnvCredential(envVars: readonly string[]): EnvApiKeyInfo | undefined { - for (const envVar of envVars) { - const value = process.env[envVar]; - if (value) { - return { value, label: envVar }; - } - } - return undefined; -} - function hasVertexAdcCredentials(): boolean { if (cachedVertexAdcCredentialsExists === null) { // If node modules haven't loaded yet (async import race at startup), @@ -90,6 +55,82 @@ function hasVertexAdcCredentials(): boolean { return cachedVertexAdcCredentialsExists; } +function getApiKeyEnvVars(provider: string): readonly string[] | undefined { + if (provider === "github-copilot") { + return ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; + } + + // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY + if (provider === "anthropic") { + return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]; + } + + const envMap: Record = { + openai: "OPENAI_API_KEY", + "azure-openai-responses": "AZURE_OPENAI_API_KEY", + google: "GEMINI_API_KEY", + "google-vertex": "GOOGLE_CLOUD_API_KEY", + groq: "GROQ_API_KEY", + cerebras: "CEREBRAS_API_KEY", + xai: "XAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + "vercel-ai-gateway": "AI_GATEWAY_API_KEY", + zai: "ZAI_API_KEY", + mistral: "MISTRAL_API_KEY", + minimax: "MINIMAX_API_KEY", + "minimax-cn": "MINIMAX_CN_API_KEY", + huggingface: "HF_TOKEN", + fireworks: "FIREWORKS_API_KEY", + opencode: "OPENCODE_API_KEY", + "opencode-go": "OPENCODE_API_KEY", + "kimi-coding": "KIMI_API_KEY", + }; + + const envVar = envMap[provider]; + return envVar ? [envVar] : undefined; +} + +/** + * Find configured environment variables that can provide an API key for a provider. + * + * This only reports actual API key variables. It intentionally excludes ambient + * credential sources such as AWS profiles, AWS IAM credentials, and Google + * Application Default Credentials. + */ +export function findEnvKeys(provider: KnownProvider): string[] | undefined; +export function findEnvKeys(provider: string): string[] | undefined; +export function findEnvKeys(provider: string): string[] | undefined { + const envVars = getApiKeyEnvVars(provider); + if (!envVars) return undefined; + + const found = envVars.filter((envVar) => !!process.env[envVar]); + return found.length > 0 ? found : undefined; +} + +/** + * Check if a provider has environment-backed authentication available. + * + * This includes non-API-key credential sources such as AWS credentials for Bedrock. + */ +export function hasEnvAuth(provider: KnownProvider): boolean; +export function hasEnvAuth(provider: string): boolean; +export function hasEnvAuth(provider: string): boolean { + if (findEnvKeys(provider)) return true; + + if (provider === "amazon-bedrock") { + return !!( + process.env.AWS_PROFILE || + (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) || + process.env.AWS_BEARER_TOKEN_BEDROCK || + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || + process.env.AWS_WEB_IDENTITY_TOKEN_FILE + ); + } + + return false; +} + /** * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY. * @@ -98,72 +139,20 @@ function hasVertexAdcCredentials(): boolean { export function getEnvApiKey(provider: KnownProvider): string | undefined; export function getEnvApiKey(provider: string): string | undefined; export function getEnvApiKey(provider: string): string | undefined { - return getEnvApiKeyInfo(provider)?.value; -} + const envKeys = findEnvKeys(provider); + if (envKeys?.[0]) return process.env[envKeys[0]]; -/** - * Get API key/auth information for provider from known environment variables. - * - * The label identifies the environment variable or credential source that matched, - * without exposing the credential value. - */ -export function getEnvApiKeyInfo(provider: KnownProvider): EnvApiKeyInfo | undefined; -export function getEnvApiKeyInfo(provider: string): EnvApiKeyInfo | undefined; -export function getEnvApiKeyInfo(provider: string): EnvApiKeyInfo | undefined { - // Fall back to environment variables - if (provider === "github-copilot") { - return getFirstEnvCredential(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); - } - - // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY - if (provider === "anthropic") { - return getFirstEnvCredential(["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]); - } - - // Vertex AI supports either an explicit API key or Application Default Credentials - // Auth is configured via `gcloud auth application-default login` + // Vertex AI supports either an explicit API key or Application Default Credentials. + // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { - if (process.env.GOOGLE_CLOUD_API_KEY) { - return { value: process.env.GOOGLE_CLOUD_API_KEY, label: "GOOGLE_CLOUD_API_KEY" }; - } - const hasCredentials = hasVertexAdcCredentials(); const hasProject = !!(process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT); const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION; if (hasCredentials && hasProject && hasLocation) { - return { value: "", label: "Application Default Credentials" }; + return ""; } } - if (provider === "amazon-bedrock") { - // Amazon Bedrock supports multiple credential sources: - // 1. AWS_PROFILE - named profile from ~/.aws/credentials - // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys - // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token) - // 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles - // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) - // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) - if (process.env.AWS_PROFILE) { - return { value: "", label: "AWS_PROFILE" }; - } - if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { - return { value: "", label: "AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY" }; - } - if (process.env.AWS_BEARER_TOKEN_BEDROCK) { - return { value: "", label: "AWS_BEARER_TOKEN_BEDROCK" }; - } - if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) { - return { value: "", label: "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" }; - } - if (process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI) { - return { value: "", label: "AWS_CONTAINER_CREDENTIALS_FULL_URI" }; - } - if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE) { - return { value: "", label: "AWS_WEB_IDENTITY_TOKEN_FILE" }; - } - } - - const envVars = ENV_API_KEY_MAP[provider]; - return envVars ? getFirstEnvCredential(envVars) : undefined; + return undefined; } diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index b7da0e3c..7fec9899 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getEnvApiKey, getEnvApiKeyInfo } from "../src/env-api-keys.js"; +import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.js"; import { getModel } from "../src/models.js"; const originalFireworksApiKey = process.env.FIREWORKS_API_KEY; @@ -44,10 +44,7 @@ describe("Fireworks models", () => { it("resolves FIREWORKS_API_KEY from the environment", () => { process.env.FIREWORKS_API_KEY = "test-fireworks-key"; + expect(findEnvKeys("fireworks")).toEqual(["FIREWORKS_API_KEY"]); expect(getEnvApiKey("fireworks")).toBe("test-fireworks-key"); - expect(getEnvApiKeyInfo("fireworks")).toEqual({ - value: "test-fireworks-key", - label: "FIREWORKS_API_KEY", - }); }); }); diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index f58e17cb..1efacb09 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -7,8 +7,9 @@ */ import { + findEnvKeys, getEnvApiKey, - getEnvApiKeyInfo, + hasEnvAuth, type OAuthCredentials, type OAuthLoginCallbacks, type OAuthProviderId, @@ -331,7 +332,7 @@ export class AuthStorage { hasAuth(provider: string): boolean { if (this.runtimeOverrides.has(provider)) return true; if (this.data[provider]) return true; - if (getEnvApiKey(provider)) return true; + if (hasEnvAuth(provider)) return true; if (this.fallbackResolver?.(provider)) return true; return false; } @@ -348,9 +349,9 @@ export class AuthStorage { return { configured: false, source: "runtime", label: "--api-key" }; } - const envKey = getEnvApiKeyInfo(provider); - if (envKey) { - return { configured: false, source: "environment", label: envKey.label }; + const envKeys = findEnvKeys(provider); + if (envKeys?.[0]) { + return { configured: false, source: "environment", label: envKeys[0] }; } if (this.fallbackResolver?.(provider)) {