feat(models): add oauth auth health
This commit is contained in:
@@ -77,12 +77,17 @@ 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/auth-profiles.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../agents/auth-profiles.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-auth.js", () => ({
|
||||
resolveEnvApiKey: mocks.resolveEnvApiKey,
|
||||
@@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => {
|
||||
expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
|
||||
"OPENAI_API_KEY",
|
||||
);
|
||||
expect(payload.auth.missingProvidersInUse).toEqual([]);
|
||||
expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0);
|
||||
expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0);
|
||||
|
||||
const providers = payload.auth.providers as Array<{
|
||||
provider: string;
|
||||
@@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("exits non-zero when auth is missing", async () => {
|
||||
const originalProfiles = { ...mocks.store.profiles };
|
||||
mocks.store.profiles = {};
|
||||
const localRuntime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation();
|
||||
mocks.resolveEnvApiKey.mockImplementation(() => null);
|
||||
|
||||
try {
|
||||
await modelsStatusCommand(
|
||||
{ check: true, plain: true },
|
||||
localRuntime as never,
|
||||
);
|
||||
expect(localRuntime.exit).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
mocks.store.profiles = originalProfiles;
|
||||
mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
formatRemainingShort,
|
||||
} from "../../agents/auth-health.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
@@ -599,7 +604,7 @@ export async function modelsListCommand(
|
||||
}
|
||||
|
||||
export async function modelsStatusCommand(
|
||||
opts: { json?: boolean; plain?: boolean },
|
||||
opts: { json?: boolean; plain?: boolean; check?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
@@ -656,6 +661,7 @@ export async function modelsStatusCommand(
|
||||
.filter(Boolean),
|
||||
);
|
||||
const providersFromModels = new Set<string>();
|
||||
const providersInUse = new Set<string>();
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
@@ -666,6 +672,15 @@ export async function modelsStatusCommand(
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (parsed?.provider) providersFromModels.add(parsed.provider);
|
||||
}
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
imageModel,
|
||||
...imageFallbacks,
|
||||
]) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (parsed?.provider) providersInUse.add(parsed.provider);
|
||||
}
|
||||
|
||||
const providersFromEnv = new Set<string>();
|
||||
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
|
||||
@@ -715,6 +730,12 @@ export async function modelsStatusCommand(
|
||||
Boolean(entry.modelsJson);
|
||||
return hasAny;
|
||||
});
|
||||
const providerAuthMap = new Map(
|
||||
providerAuth.map((entry) => [entry.provider, entry]),
|
||||
);
|
||||
const missingProvidersInUse = Array.from(providersInUse)
|
||||
.filter((provider) => !providerAuthMap.has(provider))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const providersWithOauth = providerAuth
|
||||
.filter(
|
||||
@@ -726,6 +747,29 @@ export async function modelsStatusCommand(
|
||||
return `${entry.provider} (${count})`;
|
||||
});
|
||||
|
||||
const authHealth = buildAuthHealthSummary({
|
||||
store,
|
||||
cfg,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
providers,
|
||||
});
|
||||
const oauthProfiles = authHealth.profiles.filter(
|
||||
(profile) => profile.type === "oauth",
|
||||
);
|
||||
|
||||
const checkStatus = (() => {
|
||||
const hasExpiredOrMissing =
|
||||
oauthProfiles.some((profile) =>
|
||||
["expired", "missing"].includes(profile.status),
|
||||
) || missingProvidersInUse.length > 0;
|
||||
const hasExpiring = oauthProfiles.some(
|
||||
(profile) => profile.status === "expiring",
|
||||
);
|
||||
if (hasExpiredOrMissing) return 1;
|
||||
if (hasExpiring) return 2;
|
||||
return 0;
|
||||
})();
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
@@ -746,18 +790,30 @@ export async function modelsStatusCommand(
|
||||
appliedKeys: applied,
|
||||
},
|
||||
providersWithOAuth: providersWithOauth,
|
||||
missingProvidersInUse,
|
||||
providers: providerAuth,
|
||||
oauth: {
|
||||
warnAfterMs: authHealth.warnAfterMs,
|
||||
profiles: authHealth.profiles,
|
||||
providers: authHealth.providers,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.plain) {
|
||||
runtime.log(resolvedLabel);
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -933,4 +989,48 @@ export async function modelsStatusCommand(
|
||||
}
|
||||
runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`);
|
||||
}
|
||||
|
||||
if (missingProvidersInUse.length > 0) {
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "Missing auth"));
|
||||
for (const provider of missingProvidersInUse) {
|
||||
const hint =
|
||||
provider === "anthropic"
|
||||
? "Run `claude setup-token` or `clawdbot configure`."
|
||||
: "Run `clawdbot configure` or set an API key env var.";
|
||||
runtime.log(`- ${theme.heading(provider)} ${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "OAuth status"));
|
||||
if (oauthProfiles.length === 0) {
|
||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
||||
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
|
||||
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
||||
return colorize(rich, theme.error, "expired");
|
||||
};
|
||||
|
||||
for (const profile of oauthProfiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const label = colorize(rich, theme.accent, labelText);
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry = profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source =
|
||||
profile.source !== "store"
|
||||
? colorize(rich, theme.muted, ` (${profile.source})`)
|
||||
: "";
|
||||
runtime.log(`- ${label} ${status}${expiry}${source}`);
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user