feat(models): show auth overview

This commit is contained in:
Peter Steinberger
2026-01-06 20:07:04 +00:00
parent ea7836afad
commit 1bf44bf30c
7 changed files with 439 additions and 5 deletions

View File

@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const store = {
version: 1,
profiles: {
"anthropic:default": {
type: "oauth",
provider: "anthropic",
access: "sk-ant-oat01-ACCESS-TOKEN-1234567890",
refresh: "sk-ant-ort01-REFRESH-TOKEN-1234567890",
expires: Date.now() + 60_000,
email: "peter@example.com",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-ant-api-0123456789abcdefghijklmnopqrstuvwxyz",
},
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "eyJhbGciOi-ACCESS",
refresh: "oai-refresh-1234567890",
expires: Date.now() + 60_000,
},
},
};
return {
store,
resolveClawdbotAgentDir: vi.fn().mockReturnValue("/tmp/clawdbot-agent"),
ensureAuthProfileStore: vi.fn().mockReturnValue(store),
listProfilesForProvider: vi.fn((s: typeof store, provider: string) => {
return Object.entries(s.profiles)
.filter(([, cred]) => cred.provider === provider)
.map(([id]) => id);
}),
resolveAuthProfileDisplayLabel: vi.fn(
({ profileId }: { profileId: string }) => profileId,
),
resolveAuthStorePathForDisplay: vi
.fn()
.mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json"),
resolveEnvApiKey: vi.fn((provider: string) => {
if (provider === "openai") {
return {
apiKey: "sk-openai-0123456789abcdefghijklmnopqrstuvwxyz",
source: "shell env: OPENAI_API_KEY",
};
}
if (provider === "anthropic") {
return {
apiKey: "sk-ant-oat01-ACCESS-TOKEN-1234567890",
source: "env: ANTHROPIC_OAUTH_TOKEN",
};
}
return null;
}),
getCustomProviderApiKey: vi.fn().mockReturnValue(undefined),
getShellEnvAppliedKeys: vi
.fn()
.mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]),
shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true),
loadConfig: vi.fn().mockReturnValue({
agent: {
model: { primary: "anthropic/claude-opus-4-5", fallbacks: [] },
models: { "anthropic/claude-opus-4-5": { alias: "Opus" } },
},
models: { providers: {} },
env: { shellEnv: { enabled: true } },
}),
};
});
vi.mock("../../agents/agent-paths.js", () => ({
resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir,
}));
vi.mock("../../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: mocks.listProfilesForProvider,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
}));
vi.mock("../../agents/model-auth.js", () => ({
resolveEnvApiKey: mocks.resolveEnvApiKey,
getCustomProviderApiKey: mocks.getCustomProviderApiKey,
}));
vi.mock("../../infra/shell-env.js", () => ({
getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys,
shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback,
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
import { modelsStatusCommand } from "./list.js";
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
describe("modelsStatusCommand auth overview", () => {
it("includes masked auth sources in JSON output", async () => {
await modelsStatusCommand({ json: true }, runtime as never);
const payload = JSON.parse(
String((runtime.log as vi.Mock).mock.calls[0][0]),
);
expect(payload.defaultModel).toBe("anthropic/claude-opus-4-5");
expect(payload.auth.storePath).toBe(
"/tmp/clawdbot-agent/auth-profiles.json",
);
expect(payload.auth.shellEnvFallback.enabled).toBe(true);
expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
"OPENAI_API_KEY",
);
const providers = payload.auth.providers as Array<{
provider: string;
profiles: { labels: string[] };
env?: { value: string; source: string };
}>;
const anthropic = providers.find((p) => p.provider === "anthropic");
expect(anthropic).toBeTruthy();
expect(anthropic?.profiles.labels.join(" ")).toContain("OAuth");
expect(anthropic?.profiles.labels.join(" ")).toContain("...");
const openai = providers.find((p) => p.provider === "openai");
expect(openai?.env?.source).toContain("OPENAI_API_KEY");
expect(openai?.env?.value).toContain("...");
expect(
(payload.auth.providersWithOAuth as string[]).some((e) =>
e.startsWith("anthropic"),
),
).toBe(true);
expect(
(payload.auth.providersWithOAuth as string[]).some((e) =>
e.startsWith("openai-codex"),
),
).toBe(true);
});
});

View File

@@ -1,3 +1,5 @@
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
@@ -10,6 +12,8 @@ import {
type AuthProfileStore,
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
} from "../../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
@@ -28,7 +32,12 @@ import {
loadConfig,
} from "../../config/config.js";
import { info } from "../../globals.js";
import {
getShellEnvAppliedKeys,
shouldEnableShellEnvFallback,
} from "../../infra/shell-env.js";
import type { RuntimeEnv } from "../../runtime.js";
import { shortenHomePath } from "../../utils.js";
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
@@ -56,6 +65,13 @@ const truncate = (value: string, max: number) => {
return `${value.slice(0, max - 3)}...`;
};
const maskApiKey = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return "missing";
if (trimmed.length <= 16) return trimmed;
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
};
type ConfiguredEntry = {
key: string;
ref: { provider: string; model: string };
@@ -101,6 +117,109 @@ const hasAuthForProvider = (
return false;
};
type ProviderAuthOverview = {
provider: string;
effective: {
kind: "profiles" | "env" | "models.json" | "missing";
detail: string;
};
profiles: {
count: number;
oauth: number;
apiKey: number;
labels: string[];
};
env?: { value: string; source: string };
modelsJson?: { value: string; source: string };
};
function resolveProviderAuthOverview(params: {
provider: string;
cfg: ClawdbotConfig;
store: AuthProfileStore;
modelsPath: string;
}): ProviderAuthOverview {
const { provider, cfg, store } = params;
const profiles = listProfilesForProvider(store, provider);
const labels = profiles.map((profileId) => {
const profile = store.profiles[profileId];
if (!profile) return `${profileId}=missing`;
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix =
display === profileId
? ""
: display.startsWith(profileId)
? display.slice(profileId.length).trim()
: `(${display})`;
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
});
const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth",
).length;
const apiKeyCount = profiles.filter(
(id) => store.profiles[id]?.type === "api_key",
).length;
const envKey = resolveEnvApiKey(provider);
const customKey = getCustomProviderApiKey(cfg, provider);
const effective: ProviderAuthOverview["effective"] = (() => {
if (profiles.length > 0) {
return {
kind: "profiles",
detail: shortenHomePath(resolveAuthStorePathForDisplay()),
};
}
if (envKey) {
const isOAuthEnv =
envKey.source.includes("OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth");
return {
kind: "env",
detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey),
};
}
if (customKey) {
return { kind: "models.json", detail: maskApiKey(customKey) };
}
return { kind: "missing", detail: "missing" };
})();
return {
provider,
effective,
profiles: {
count: profiles.length,
oauth: oauthCount,
apiKey: apiKeyCount,
labels,
},
...(envKey
? {
env: {
value:
envKey.source.includes("OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth")
? "OAuth (env)"
: maskApiKey(envKey.apiKey),
source: envKey.source,
},
}
: {}),
...(customKey
? {
modelsJson: {
value: maskApiKey(customKey),
source: `models.json: ${shortenHomePath(params.modelsPath)}`,
},
}
: {}),
};
}
const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
@@ -462,11 +581,97 @@ export async function modelsStatusCommand(
}, {});
const allowed = Object.keys(cfg.agent?.models ?? {});
const agentDir = resolveClawdbotAgentDir();
const store = ensureAuthProfileStore();
const modelsPath = path.join(agentDir, "models.json");
const providersFromStore = new Set(
Object.values(store.profiles)
.map((profile) => profile.provider)
.filter((p): p is string => Boolean(p)),
);
const providersFromConfig = new Set(
Object.keys(cfg.models?.providers ?? {})
.map((p) => p.trim())
.filter(Boolean),
);
const providersFromModels = new Set<string>();
for (const raw of [
defaultLabel,
...fallbacks,
imageModel,
...imageFallbacks,
...allowed,
]) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersFromModels.add(parsed.provider);
}
const providersFromEnv = new Set<string>();
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
// a provider isn't currently selected in config/models).
const envProbeProviders = [
"anthropic",
"github-copilot",
"google-vertex",
"openai",
"google",
"groq",
"cerebras",
"xai",
"openrouter",
"zai",
"mistral",
];
for (const provider of envProbeProviders) {
if (resolveEnvApiKey(provider)) providersFromEnv.add(provider);
}
const providers = Array.from(
new Set([
...providersFromStore,
...providersFromConfig,
...providersFromModels,
...providersFromEnv,
]),
)
.map((p) => p.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const applied = getShellEnvAppliedKeys();
const shellFallbackEnabled =
shouldEnableShellEnvFallback(process.env) ||
cfg.env?.shellEnv?.enabled === true;
const providerAuth = providers
.map((provider) =>
resolveProviderAuthOverview({ provider, cfg, store, modelsPath }),
)
.filter((entry) => {
const hasAny =
entry.profiles.count > 0 ||
Boolean(entry.env) ||
Boolean(entry.modelsJson);
return hasAny;
});
const providersWithOauth = providerAuth
.filter(
(entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)",
)
.map((entry) => {
const count =
entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0);
return `${entry.provider} (${count})`;
});
if (opts.json) {
runtime.log(
JSON.stringify(
{
configPath: CONFIG_PATH_CLAWDBOT,
agentDir,
defaultModel: defaultLabel,
resolvedDefault: `${resolved.provider}/${resolved.model}`,
fallbacks,
@@ -474,6 +679,15 @@ export async function modelsStatusCommand(
imageFallbacks,
aliases,
allowed,
auth: {
storePath: resolveAuthStorePathForDisplay(),
shellEnvFallback: {
enabled: shellFallbackEnabled,
appliedKeys: applied,
},
providersWithOAuth: providersWithOauth,
providers: providerAuth,
},
},
null,
2,
@@ -488,6 +702,7 @@ export async function modelsStatusCommand(
}
runtime.log(info(`Config: ${CONFIG_PATH_CLAWDBOT}`));
runtime.log(info(`Agent dir: ${shortenHomePath(agentDir)}`));
runtime.log(`Default: ${defaultLabel}`);
runtime.log(
`Fallbacks (${fallbacks.length || 0}): ${fallbacks.join(", ") || "-"}`,
@@ -512,4 +727,36 @@ export async function modelsStatusCommand(
allowed.length ? allowed.join(", ") : "all"
}`,
);
runtime.log("");
runtime.log(info("Auth overview"));
runtime.log(
`Auth store: ${shortenHomePath(resolveAuthStorePathForDisplay())}`,
);
runtime.log(
`Shell env fallback: ${shellFallbackEnabled ? "on" : "off"}${
applied.length ? ` (applied: ${applied.join(", ")})` : ""
}`,
);
runtime.log(
`Providers with OAuth (${providersWithOauth.length || 0}): ${
providersWithOauth.length ? providersWithOauth.join(", ") : "-"
}`,
);
for (const entry of providerAuth) {
const bits: string[] = [];
bits.push(`effective=${entry.effective.kind}:${entry.effective.detail}`);
if (entry.profiles.count > 0) {
bits.push(
`profiles=${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`,
);
if (entry.profiles.labels.length > 0) {
bits.push(entry.profiles.labels.join(", "));
}
}
if (entry.env) bits.push(`env=${entry.env.value} (${entry.env.source})`);
if (entry.modelsJson) bits.push(`models.json=${entry.modelsJson.value}`);
runtime.log(`${entry.provider}: ${bits.join(" | ")}`);
}
}