fix(coding-agent,ai): show env auth source in /login
This commit is contained in:
@@ -27,6 +27,41 @@ import type { KnownProvider } from "./types.js";
|
|||||||
|
|
||||||
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
||||||
|
|
||||||
|
export type EnvApiKeyInfo = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENV_API_KEY_MAP: Record<string, readonly string[]> = {
|
||||||
|
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 {
|
function hasVertexAdcCredentials(): boolean {
|
||||||
if (cachedVertexAdcCredentialsExists === null) {
|
if (cachedVertexAdcCredentialsExists === null) {
|
||||||
// If node modules haven't loaded yet (async import race at startup),
|
// 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: KnownProvider): string | undefined;
|
||||||
export function getEnvApiKey(provider: string): 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
|
// Fall back to environment variables
|
||||||
if (provider === "github-copilot") {
|
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
|
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
|
||||||
if (provider === "anthropic") {
|
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
|
// Vertex AI supports either an explicit API key or Application Default Credentials
|
||||||
// Auth is configured via `gcloud auth application-default login`
|
// Auth is configured via `gcloud auth application-default login`
|
||||||
if (provider === "google-vertex") {
|
if (provider === "google-vertex") {
|
||||||
if (process.env.GOOGLE_CLOUD_API_KEY) {
|
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();
|
const hasCredentials = hasVertexAdcCredentials();
|
||||||
@@ -85,7 +132,7 @@ export function getEnvApiKey(provider: any): string | undefined {
|
|||||||
const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION;
|
const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION;
|
||||||
|
|
||||||
if (hasCredentials && hasProject && hasLocation) {
|
if (hasCredentials && hasProject && hasLocation) {
|
||||||
return "<authenticated>";
|
return { value: "<authenticated>", label: "Application Default Credentials" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,38 +144,26 @@ export function getEnvApiKey(provider: any): string | undefined {
|
|||||||
// 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles
|
// 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles
|
||||||
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
|
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
|
||||||
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
|
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
|
||||||
if (
|
if (process.env.AWS_PROFILE) {
|
||||||
process.env.AWS_PROFILE ||
|
return { value: "<authenticated>", label: "AWS_PROFILE" };
|
||||||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
}
|
||||||
process.env.AWS_BEARER_TOKEN_BEDROCK ||
|
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
|
return { value: "<authenticated>", label: "AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY" };
|
||||||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI ||
|
}
|
||||||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE
|
if (process.env.AWS_BEARER_TOKEN_BEDROCK) {
|
||||||
) {
|
return { value: "<authenticated>", label: "AWS_BEARER_TOKEN_BEDROCK" };
|
||||||
return "<authenticated>";
|
}
|
||||||
|
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
|
||||||
|
return { value: "<authenticated>", label: "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" };
|
||||||
|
}
|
||||||
|
if (process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI) {
|
||||||
|
return { value: "<authenticated>", label: "AWS_CONTAINER_CREDENTIALS_FULL_URI" };
|
||||||
|
}
|
||||||
|
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE) {
|
||||||
|
return { value: "<authenticated>", label: "AWS_WEB_IDENTITY_TOKEN_FILE" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const envMap: Record<string, string> = {
|
const envVars = ENV_API_KEY_MAP[provider];
|
||||||
openai: "OPENAI_API_KEY",
|
return envVars ? getFirstEnvCredential(envVars) : undefined;
|
||||||
"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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
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";
|
import { getModel } from "../src/models.js";
|
||||||
|
|
||||||
const originalFireworksApiKey = process.env.FIREWORKS_API_KEY;
|
const originalFireworksApiKey = process.env.FIREWORKS_API_KEY;
|
||||||
@@ -45,5 +45,9 @@ describe("Fireworks models", () => {
|
|||||||
process.env.FIREWORKS_API_KEY = "test-fireworks-key";
|
process.env.FIREWORKS_API_KEY = "test-fireworks-key";
|
||||||
|
|
||||||
expect(getEnvApiKey("fireworks")).toBe("test-fireworks-key");
|
expect(getEnvApiKey("fireworks")).toBe("test-fireworks-key");
|
||||||
|
expect(getEnvApiKeyInfo("fireworks")).toEqual({
|
||||||
|
value: "test-fireworks-key",
|
||||||
|
label: "FIREWORKS_API_KEY",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getEnvApiKey,
|
getEnvApiKey,
|
||||||
|
getEnvApiKeyInfo,
|
||||||
type OAuthCredentials,
|
type OAuthCredentials,
|
||||||
type OAuthLoginCallbacks,
|
type OAuthLoginCallbacks,
|
||||||
type OAuthProviderId,
|
type OAuthProviderId,
|
||||||
@@ -32,6 +33,12 @@ export type AuthCredential = ApiKeyCredential | OAuthCredential;
|
|||||||
|
|
||||||
export type AuthStorageData = Record<string, AuthCredential>;
|
export type AuthStorageData = Record<string, AuthCredential>;
|
||||||
|
|
||||||
|
export type AuthStatus = {
|
||||||
|
configured: boolean;
|
||||||
|
source?: "stored" | "runtime" | "environment" | "fallback";
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type LockResult<T> = {
|
type LockResult<T> = {
|
||||||
result: T;
|
result: T;
|
||||||
next?: string;
|
next?: string;
|
||||||
@@ -329,6 +336,30 @@ export class AuthStorage {
|
|||||||
return false;
|
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).
|
* Get all credentials (for passing to getOAuthApiKey).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
type ApiKeyCredential,
|
type ApiKeyCredential,
|
||||||
type AuthCredential,
|
type AuthCredential,
|
||||||
|
type AuthStatus,
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
type AuthStorageBackend,
|
type AuthStorageBackend,
|
||||||
FileAuthStorageBackend,
|
FileAuthStorageBackend,
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import type { AuthStorage } from "../../../core/auth-storage.js";
|
import type { AuthStorage } from "../../../core/auth-storage.js";
|
||||||
import { theme } from "../theme/theme.js";
|
import { theme } from "../theme/theme.js";
|
||||||
|
import { getAuthSelectorIndicator } from "./auth-selector-status.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
||||||
export type AuthSelectorProvider = {
|
export type AuthSelectorProvider = {
|
||||||
@@ -114,11 +115,7 @@ export class OAuthSelectorComponent extends Container implements Focusable {
|
|||||||
|
|
||||||
const isSelected = i === this.selectedIndex;
|
const isSelected = i === this.selectedIndex;
|
||||||
|
|
||||||
// Check if user is configured for this provider
|
const statusIndicator = this.formatStatusIndicator(provider);
|
||||||
const credentials = this.authStorage.get(provider.id);
|
|
||||||
const statusIndicator = credentials
|
|
||||||
? theme.fg("success", " ✓ configured")
|
|
||||||
: theme.fg("muted", " • unconfigured");
|
|
||||||
let line = "";
|
let line = "";
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const prefix = theme.fg("accent", "→ ");
|
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 {
|
handleInput(keyData: string): void {
|
||||||
const kb = getKeybindings();
|
const kb = getKeybindings();
|
||||||
// Up arrow
|
// Up arrow
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import * as fs from "node:fs";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
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 {
|
import type {
|
||||||
AutocompleteItem,
|
AutocompleteItem,
|
||||||
AutocompleteProvider,
|
AutocompleteProvider,
|
||||||
@@ -168,7 +175,8 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof
|
|||||||
return providerId in defaultModelPerProvider;
|
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",
|
"azure-openai-responses": "Azure OpenAI Responses",
|
||||||
cerebras: "Cerebras",
|
cerebras: "Cerebras",
|
||||||
fireworks: "Fireworks",
|
fireworks: "Fireworks",
|
||||||
@@ -189,10 +197,25 @@ const API_KEY_PROVIDER_NAMES: Record<string, string> = {
|
|||||||
zai: "ZAI",
|
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 {
|
export function isApiKeyLoginProvider(
|
||||||
return API_KEY_PROVIDER_NAMES[providerId] ?? providerId;
|
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));
|
const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider));
|
||||||
for (const providerId of modelProviders) {
|
for (const providerId of modelProviders) {
|
||||||
if (oauthProviderIds.has(providerId) || API_KEY_LOGIN_PROVIDER_BLOCKLIST.has(providerId)) {
|
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
options.push({
|
options.push({
|
||||||
|
|||||||
64
packages/coding-agent/test/auth-selector-status.test.ts
Normal file
64
packages/coding-agent/test/auth-selector-status.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
describe("runtime overrides", () => {
|
||||||
test("runtime override takes priority over auth.json", async () => {
|
test("runtime override takes priority over auth.json", async () => {
|
||||||
writeAuthJson({
|
writeAuthJson({
|
||||||
|
|||||||
59
packages/coding-agent/test/oauth-selector.test.ts
Normal file
59
packages/coding-agent/test/oauth-selector.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user