fix(coding-agent,ai): show env auth source in /login

This commit is contained in:
Armin Ronacher
2026-04-23 21:01:55 +02:00
parent 05e2e9d170
commit e97051313d
10 changed files with 352 additions and 48 deletions

View File

@@ -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<string, AuthCredential>;
export type AuthStatus = {
configured: boolean;
source?: "stored" | "runtime" | "environment" | "fallback";
label?: string;
};
type LockResult<T> = {
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).
*/

View File

@@ -17,6 +17,7 @@ export {
export {
type ApiKeyCredential,
type AuthCredential,
type AuthStatus,
AuthStorage,
type AuthStorageBackend,
FileAuthStorageBackend,

View File

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

View File

@@ -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

View File

@@ -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<string, string> = {
const API_KEY_LOGIN_PROVIDERS: Record<string, string> = {
anthropic: "Anthropic",
"azure-openai-responses": "Azure OpenAI Responses",
cerebras: "Cerebras",
fireworks: "Fireworks",
@@ -189,10 +197,25 @@ const API_KEY_PROVIDER_NAMES: Record<string, string> = {
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<string>(getProviders());
function getApiKeyProviderDisplayName(providerId: string): string {
return API_KEY_PROVIDER_NAMES[providerId] ?? providerId;
export function isApiKeyLoginProvider(
providerId: string,
oauthProviderIds: ReadonlySet<string>,
builtInProviderIds: ReadonlySet<string> = 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({

View File

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

View File

@@ -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({

View File

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