diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 278acbe6..1ba15814 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -27,6 +27,41 @@ 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), @@ -62,22 +97,34 @@ function hasVertexAdcCredentials(): boolean { */ export function getEnvApiKey(provider: KnownProvider): string | undefined; export function getEnvApiKey(provider: string): string | undefined; -export function getEnvApiKey(provider: any): string | undefined { +export function getEnvApiKey(provider: string): string | undefined { + return getEnvApiKeyInfo(provider)?.value; +} + +/** + * 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 process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + return getFirstEnvCredential(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); } // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY if (provider === "anthropic") { - return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; + 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` if (provider === "google-vertex") { if (process.env.GOOGLE_CLOUD_API_KEY) { - return process.env.GOOGLE_CLOUD_API_KEY; + return { value: process.env.GOOGLE_CLOUD_API_KEY, label: "GOOGLE_CLOUD_API_KEY" }; } const hasCredentials = hasVertexAdcCredentials(); @@ -85,7 +132,7 @@ export function getEnvApiKey(provider: any): string | undefined { const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION; if (hasCredentials && hasProject && hasLocation) { - return ""; + return { value: "", label: "Application Default Credentials" }; } } @@ -97,38 +144,26 @@ export function getEnvApiKey(provider: any): string | undefined { // 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 || - (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 ""; + 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 envMap: 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", - }; - - const envVar = envMap[provider]; - return envVar ? process.env[envVar] : undefined; + const envVars = ENV_API_KEY_MAP[provider]; + return envVars ? getFirstEnvCredential(envVars) : undefined; } diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 04c793ef..b7da0e3c 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 } from "../src/env-api-keys.js"; +import { getEnvApiKey, getEnvApiKeyInfo } from "../src/env-api-keys.js"; import { getModel } from "../src/models.js"; const originalFireworksApiKey = process.env.FIREWORKS_API_KEY; @@ -45,5 +45,9 @@ describe("Fireworks models", () => { process.env.FIREWORKS_API_KEY = "test-fireworks-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 dc435924..f58e17cb 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -8,6 +8,7 @@ import { getEnvApiKey, + getEnvApiKeyInfo, type OAuthCredentials, type OAuthLoginCallbacks, type OAuthProviderId, @@ -32,6 +33,12 @@ export type AuthCredential = ApiKeyCredential | OAuthCredential; export type AuthStorageData = Record; +export type AuthStatus = { + configured: boolean; + source?: "stored" | "runtime" | "environment" | "fallback"; + label?: string; +}; + type LockResult = { result: T; next?: string; @@ -329,6 +336,30 @@ export class AuthStorage { return false; } + /** + * Return auth status without exposing credential values or refreshing tokens. + */ + getAuthStatus(provider: string): AuthStatus { + if (this.data[provider]) { + return { configured: true, source: "stored" }; + } + + if (this.runtimeOverrides.has(provider)) { + return { configured: false, source: "runtime", label: "--api-key" }; + } + + const envKey = getEnvApiKeyInfo(provider); + if (envKey) { + return { configured: false, source: "environment", label: envKey.label }; + } + + if (this.fallbackResolver?.(provider)) { + return { configured: false, source: "fallback", label: "custom provider config" }; + } + + return { configured: false }; + } + /** * Get all credentials (for passing to getOAuthApiKey). */ diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index f506adde..8001509f 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -17,6 +17,7 @@ export { export { type ApiKeyCredential, type AuthCredential, + type AuthStatus, AuthStorage, type AuthStorageBackend, FileAuthStorageBackend, diff --git a/packages/coding-agent/src/modes/interactive/components/auth-selector-status.ts b/packages/coding-agent/src/modes/interactive/components/auth-selector-status.ts new file mode 100644 index 00000000..dc9ffc53 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/auth-selector-status.ts @@ -0,0 +1,44 @@ +import type { AuthCredential, AuthStatus } from "../../../core/auth-storage.js"; + +export type SelectorAuthType = "oauth" | "api_key"; + +export type AuthSelectorIndicator = + | { kind: "configured"; label: string } + | { kind: "configured-other"; label: string } + | { kind: "runtime" } + | { kind: "environment"; label: string } + | { kind: "fallback" } + | { kind: "unconfigured" }; + +function getConfiguredLabel(authType: SelectorAuthType): string { + return authType === "oauth" ? "subscription configured" : "api key configured"; +} + +export function getAuthSelectorIndicator( + authType: SelectorAuthType, + credential: AuthCredential | undefined, + authStatus?: AuthStatus, +): AuthSelectorIndicator { + if (credential) { + const label = getConfiguredLabel(credential.type); + return credential.type === authType ? { kind: "configured", label } : { kind: "configured-other", label }; + } + + if (authType === "oauth") { + return { kind: "unconfigured" }; + } + + if (authStatus?.source === "runtime") { + return { kind: "runtime" }; + } + + if (authStatus?.source === "environment") { + return { kind: "environment", label: authStatus.label ?? "API key" }; + } + + if (authStatus?.source === "fallback") { + return { kind: "fallback" }; + } + + return { kind: "unconfigured" }; +} diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index c310f13a..755287e6 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -9,6 +9,7 @@ import { } from "@mariozechner/pi-tui"; import type { AuthStorage } from "../../../core/auth-storage.js"; import { theme } from "../theme/theme.js"; +import { getAuthSelectorIndicator } from "./auth-selector-status.js"; import { DynamicBorder } from "./dynamic-border.js"; export type AuthSelectorProvider = { @@ -114,11 +115,7 @@ export class OAuthSelectorComponent extends Container implements Focusable { const isSelected = i === this.selectedIndex; - // Check if user is configured for this provider - const credentials = this.authStorage.get(provider.id); - const statusIndicator = credentials - ? theme.fg("success", " ✓ configured") - : theme.fg("muted", " • unconfigured"); + const statusIndicator = this.formatStatusIndicator(provider); let line = ""; if (isSelected) { const prefix = theme.fg("accent", "→ "); @@ -149,6 +146,32 @@ export class OAuthSelectorComponent extends Container implements Focusable { } } + private formatStatusIndicator(provider: AuthSelectorProvider): string { + const status = provider.authType === "api_key" ? this.authStorage.getAuthStatus(provider.id) : undefined; + const indicator = getAuthSelectorIndicator(provider.authType, this.authStorage.get(provider.id), status); + + if (indicator.kind === "configured") { + return theme.fg("success", ` ✓ ${indicator.label}`); + } + + if (indicator.kind === "configured-other") { + return theme.fg("muted", " • ") + theme.fg("warning", indicator.label); + } + + const base = theme.fg("muted", " • unconfigured"); + if (indicator.kind === "environment") { + return base + theme.fg("success", ` · env: ${indicator.label}`); + } + if (indicator.kind === "runtime") { + return base + theme.fg("success", " · runtime API key"); + } + if (indicator.kind === "fallback") { + return base + theme.fg("success", " · custom API key"); + } + + return base; + } + handleInput(keyData: string): void { const kb = getKeybindings(); // Up arrow diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fd38f14d..ae8df81c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -8,7 +8,14 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@mariozechner/pi-ai"; +import { + type AssistantMessage, + getProviders, + type ImageContent, + type Message, + type Model, + type OAuthProviderId, +} from "@mariozechner/pi-ai"; import type { AutocompleteItem, AutocompleteProvider, @@ -168,7 +175,8 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof return providerId in defaultModelPerProvider; } -const API_KEY_PROVIDER_NAMES: Record = { +const API_KEY_LOGIN_PROVIDERS: Record = { + anthropic: "Anthropic", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", fireworks: "Fireworks", @@ -189,10 +197,25 @@ const API_KEY_PROVIDER_NAMES: Record = { zai: "ZAI", }; -const API_KEY_LOGIN_PROVIDER_BLOCKLIST = new Set(["amazon-bedrock", "llama.cpp", "lmstudio", "ollama"]); +const BUILT_IN_API_KEY_LOGIN_PROVIDERS = new Set(Object.keys(API_KEY_LOGIN_PROVIDERS)); +const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders()); -function getApiKeyProviderDisplayName(providerId: string): string { - return API_KEY_PROVIDER_NAMES[providerId] ?? providerId; +export function isApiKeyLoginProvider( + providerId: string, + oauthProviderIds: ReadonlySet, + builtInProviderIds: ReadonlySet = BUILT_IN_MODEL_PROVIDERS, +): boolean { + if (BUILT_IN_API_KEY_LOGIN_PROVIDERS.has(providerId)) { + return true; + } + if (builtInProviderIds.has(providerId)) { + return false; + } + return !oauthProviderIds.has(providerId); +} + +export function getApiKeyProviderDisplayName(providerId: string): string { + return API_KEY_LOGIN_PROVIDERS[providerId] ?? providerId; } /** @@ -4325,7 +4348,7 @@ export class InteractiveMode { const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider)); for (const providerId of modelProviders) { - if (oauthProviderIds.has(providerId) || API_KEY_LOGIN_PROVIDER_BLOCKLIST.has(providerId)) { + if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) { continue; } options.push({ diff --git a/packages/coding-agent/test/auth-selector-status.test.ts b/packages/coding-agent/test/auth-selector-status.test.ts new file mode 100644 index 00000000..fc2fdb0c --- /dev/null +++ b/packages/coding-agent/test/auth-selector-status.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { getAuthSelectorIndicator } from "../src/modes/interactive/components/auth-selector-status.js"; + +describe("getAuthSelectorIndicator", () => { + test("shows subscription configured when oauth matches the selector", () => { + expect( + getAuthSelectorIndicator("oauth", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }), + ).toEqual({ kind: "configured", label: "subscription configured" }); + }); + + test("shows api key configured in the subscription selector when an api key is stored", () => { + expect( + getAuthSelectorIndicator("oauth", { + type: "api_key", + key: "sk-test", + }), + ).toEqual({ kind: "configured-other", label: "api key configured" }); + }); + + test("shows api key configured when api key auth matches the selector", () => { + expect( + getAuthSelectorIndicator("api_key", { + type: "api_key", + key: "sk-test", + }), + ).toEqual({ kind: "configured", label: "api key configured" }); + }); + + test("shows subscription configured in the api key selector when oauth is stored", () => { + expect( + getAuthSelectorIndicator("api_key", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }), + ).toEqual({ kind: "configured-other", label: "subscription configured" }); + }); + + test("keeps env api key hints in the api key selector", () => { + expect( + getAuthSelectorIndicator("api_key", undefined, { + configured: false, + source: "environment", + label: "ANTHROPIC_API_KEY", + }), + ).toEqual({ kind: "environment", label: "ANTHROPIC_API_KEY" }); + }); + + test("ignores api key env hints in the subscription selector", () => { + expect( + getAuthSelectorIndicator("oauth", undefined, { + configured: false, + source: "environment", + label: "ANTHROPIC_API_KEY", + }), + ).toEqual({ kind: "unconfigured" }); + }); +}); diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index eaac129d..d48be918 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -431,6 +431,26 @@ describe("AuthStorage", () => { }); }); + describe("auth status", () => { + test("does not expose stored API keys or OAuth tokens", () => { + authStorage = AuthStorage.inMemory({ + anthropic: { type: "api_key", key: "secret-api-key" }, + openai: { + type: "oauth", + access: "secret-access-token", + refresh: "secret-refresh-token", + expires: Date.now() + 1000, + }, + }); + + expect(authStorage.getAuthStatus("anthropic")).toEqual({ configured: true, source: "stored" }); + expect(authStorage.getAuthStatus("openai")).toEqual({ configured: true, source: "stored" }); + expect(JSON.stringify(authStorage.getAuthStatus("anthropic"))).not.toContain("secret-api-key"); + expect(JSON.stringify(authStorage.getAuthStatus("openai"))).not.toContain("secret-access-token"); + expect(JSON.stringify(authStorage.getAuthStatus("openai"))).not.toContain("secret-refresh-token"); + }); + }); + describe("runtime overrides", () => { test("runtime override takes priority over auth.json", async () => { writeAuthJson({ diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts new file mode 100644 index 00000000..c7be8665 --- /dev/null +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -0,0 +1,59 @@ +import { setKeybindings } from "@mariozechner/pi-tui"; +import stripAnsi from "strip-ansi"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { KeybindingsManager } from "../src/core/keybindings.js"; +import { OAuthSelectorComponent } from "../src/modes/interactive/components/oauth-selector.js"; +import { getApiKeyProviderDisplayName, isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +const originalOpenAiApiKey = process.env.OPENAI_API_KEY; + +describe("OAuthSelectorComponent", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + afterEach(() => { + if (originalOpenAiApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalOpenAiApiKey; + } + }); + + it("keeps built-in API key providers separate from OAuth-only providers", () => { + const oauthProviderIds = new Set(["anthropic", "github-copilot", "custom-oauth"]); + const builtInProviderIds = new Set(["anthropic", "github-copilot", "amazon-bedrock", "openai"]); + + expect(isApiKeyLoginProvider("anthropic", oauthProviderIds, builtInProviderIds)).toBe(true); + expect(getApiKeyProviderDisplayName("anthropic")).toBe("Anthropic"); + expect(isApiKeyLoginProvider("openai", oauthProviderIds, builtInProviderIds)).toBe(true); + expect(isApiKeyLoginProvider("github-copilot", oauthProviderIds, builtInProviderIds)).toBe(false); + expect(isApiKeyLoginProvider("amazon-bedrock", oauthProviderIds, builtInProviderIds)).toBe(false); + expect(isApiKeyLoginProvider("custom-oauth", oauthProviderIds, builtInProviderIds)).toBe(false); + expect(isApiKeyLoginProvider("custom-api", oauthProviderIds, builtInProviderIds)).toBe(true); + }); + + it("shows environment API key auth while keeping provider unconfigured", () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + const authStorage = AuthStorage.inMemory(); + const selector = new OAuthSelectorComponent( + "login", + authStorage, + [{ id: "openai", name: "OpenAI", authType: "api_key" }], + () => {}, + () => {}, + ); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("OpenAI"); + expect(output).toContain("unconfigured"); + expect(output).toContain("env: OPENAI_API_KEY"); + }); +});