479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
import path from "node:path";
|
|
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
|
import {
|
|
buildAuthHealthSummary,
|
|
DEFAULT_OAUTH_WARN_MS,
|
|
formatRemainingShort,
|
|
} from "../../agents/auth-health.js";
|
|
import {
|
|
ensureAuthProfileStore,
|
|
resolveAuthStorePathForDisplay,
|
|
resolveProfileUnusableUntilForDisplay,
|
|
} from "../../agents/auth-profiles.js";
|
|
import { resolveEnvApiKey } from "../../agents/model-auth.js";
|
|
import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js";
|
|
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
|
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
|
|
import {
|
|
formatUsageWindowSummary,
|
|
loadProviderUsageSummary,
|
|
resolveUsageProviderId,
|
|
type UsageProviderId,
|
|
} from "../../infra/provider-usage.js";
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
import { colorize, theme } from "../../terminal/theme.js";
|
|
import { shortenHomePath } from "../../utils.js";
|
|
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
|
import { isRich } from "./list.format.js";
|
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
|
|
|
|
export async function modelsStatusCommand(
|
|
opts: { json?: boolean; plain?: boolean; check?: boolean },
|
|
runtime: RuntimeEnv,
|
|
) {
|
|
ensureFlagCompatibility(opts);
|
|
const cfg = loadConfig();
|
|
const resolved = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
defaultModel: DEFAULT_MODEL,
|
|
});
|
|
|
|
const modelConfig = cfg.agents?.defaults?.model as
|
|
| { primary?: string; fallbacks?: string[] }
|
|
| string
|
|
| undefined;
|
|
const imageConfig = cfg.agents?.defaults?.imageModel as
|
|
| { primary?: string; fallbacks?: string[] }
|
|
| string
|
|
| undefined;
|
|
const rawModel =
|
|
typeof modelConfig === "string" ? modelConfig.trim() : (modelConfig?.primary?.trim() ?? "");
|
|
const resolvedLabel = `${resolved.provider}/${resolved.model}`;
|
|
const defaultLabel = rawModel || resolvedLabel;
|
|
const fallbacks = typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
|
|
const imageModel =
|
|
typeof imageConfig === "string" ? imageConfig.trim() : (imageConfig?.primary?.trim() ?? "");
|
|
const imageFallbacks = typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : [];
|
|
const aliases = Object.entries(cfg.agents?.defaults?.models ?? {}).reduce<Record<string, string>>(
|
|
(acc, [key, entry]) => {
|
|
const alias = entry?.alias?.trim();
|
|
if (alias) acc[alias] = key;
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
const allowed = Object.keys(cfg.agents?.defaults?.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>();
|
|
const providersInUse = 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);
|
|
}
|
|
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
|
|
// a provider isn't currently selected in config/models).
|
|
const envProbeProviders = [
|
|
"anthropic",
|
|
"github-copilot",
|
|
"google-vertex",
|
|
"openai",
|
|
"google",
|
|
"groq",
|
|
"cerebras",
|
|
"xai",
|
|
"openrouter",
|
|
"zai",
|
|
"mistral",
|
|
"synthetic",
|
|
];
|
|
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 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(
|
|
(entry) =>
|
|
entry.profiles.oauth > 0 || entry.profiles.token > 0 || entry.env?.value === "OAuth (env)",
|
|
)
|
|
.map((entry) => {
|
|
const count =
|
|
entry.profiles.oauth + entry.profiles.token + (entry.env?.value === "OAuth (env)" ? 1 : 0);
|
|
return `${entry.provider} (${count})`;
|
|
});
|
|
|
|
const authHealth = buildAuthHealthSummary({
|
|
store,
|
|
cfg,
|
|
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
|
providers,
|
|
});
|
|
const oauthProfiles = authHealth.profiles.filter(
|
|
(profile) => profile.type === "oauth" || profile.type === "token",
|
|
);
|
|
|
|
const unusableProfiles = (() => {
|
|
const now = Date.now();
|
|
const out: Array<{
|
|
profileId: string;
|
|
provider?: string;
|
|
kind: "cooldown" | "disabled";
|
|
reason?: string;
|
|
until: number;
|
|
remainingMs: number;
|
|
}> = [];
|
|
for (const profileId of Object.keys(store.usageStats ?? {})) {
|
|
const unusableUntil = resolveProfileUnusableUntilForDisplay(store, profileId);
|
|
if (!unusableUntil || now >= unusableUntil) continue;
|
|
const stats = store.usageStats?.[profileId];
|
|
const kind =
|
|
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
|
|
? "disabled"
|
|
: "cooldown";
|
|
out.push({
|
|
profileId,
|
|
provider: store.profiles[profileId]?.provider,
|
|
kind,
|
|
reason: stats?.disabledReason,
|
|
until: unusableUntil,
|
|
remainingMs: unusableUntil - now,
|
|
});
|
|
}
|
|
return out.sort((a, b) => a.remainingMs - b.remainingMs);
|
|
})();
|
|
|
|
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(
|
|
{
|
|
configPath: CONFIG_PATH_CLAWDBOT,
|
|
agentDir,
|
|
defaultModel: defaultLabel,
|
|
resolvedDefault: resolvedLabel,
|
|
fallbacks,
|
|
imageModel: imageModel || null,
|
|
imageFallbacks,
|
|
aliases,
|
|
allowed,
|
|
auth: {
|
|
storePath: resolveAuthStorePathForDisplay(),
|
|
shellEnvFallback: {
|
|
enabled: shellFallbackEnabled,
|
|
appliedKeys: applied,
|
|
},
|
|
providersWithOAuth: providersWithOauth,
|
|
missingProvidersInUse,
|
|
providers: providerAuth,
|
|
unusableProfiles,
|
|
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;
|
|
}
|
|
|
|
const rich = isRich(opts);
|
|
const label = (value: string) => colorize(rich, theme.accent, value.padEnd(14));
|
|
const displayDefault =
|
|
rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel;
|
|
|
|
runtime.log(
|
|
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, CONFIG_PATH_CLAWDBOT)}`,
|
|
);
|
|
runtime.log(
|
|
`${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
theme.info,
|
|
shortenHomePath(agentDir),
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label("Default")}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
theme.success,
|
|
displayDefault,
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
fallbacks.length ? theme.warn : theme.muted,
|
|
fallbacks.length ? fallbacks.join(", ") : "-",
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label("Image model")}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
imageModel ? theme.accentBright : theme.muted,
|
|
imageModel || "-",
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label(`Image fallbacks (${imageFallbacks.length || 0})`)}${colorize(
|
|
rich,
|
|
theme.muted,
|
|
":",
|
|
)} ${colorize(
|
|
rich,
|
|
imageFallbacks.length ? theme.accentBright : theme.muted,
|
|
imageFallbacks.length ? imageFallbacks.join(", ") : "-",
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label(`Aliases (${Object.keys(aliases).length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
Object.keys(aliases).length ? theme.accent : theme.muted,
|
|
Object.keys(aliases).length
|
|
? Object.entries(aliases)
|
|
.map(([alias, target]) =>
|
|
rich
|
|
? `${theme.accentDim(alias)} ${theme.muted("->")} ${theme.info(target)}`
|
|
: `${alias} -> ${target}`,
|
|
)
|
|
.join(", ")
|
|
: "-",
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label(`Configured models (${allowed.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
allowed.length ? theme.info : theme.muted,
|
|
allowed.length ? allowed.join(", ") : "all",
|
|
)}`,
|
|
);
|
|
|
|
runtime.log("");
|
|
runtime.log(colorize(rich, theme.heading, "Auth overview"));
|
|
runtime.log(
|
|
`${label("Auth store")}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
theme.info,
|
|
shortenHomePath(resolveAuthStorePathForDisplay()),
|
|
)}`,
|
|
);
|
|
runtime.log(
|
|
`${label("Shell env")}${colorize(rich, theme.muted, ":")} ${colorize(
|
|
rich,
|
|
shellFallbackEnabled ? theme.success : theme.muted,
|
|
shellFallbackEnabled ? "on" : "off",
|
|
)}${applied.length ? colorize(rich, theme.muted, ` (applied: ${applied.join(", ")})`) : ""}`,
|
|
);
|
|
runtime.log(
|
|
`${label(`Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`)}${colorize(
|
|
rich,
|
|
theme.muted,
|
|
":",
|
|
)} ${colorize(
|
|
rich,
|
|
providersWithOauth.length ? theme.info : theme.muted,
|
|
providersWithOauth.length ? providersWithOauth.join(", ") : "-",
|
|
)}`,
|
|
);
|
|
|
|
const formatKey = (key: string) => colorize(rich, theme.warn, key);
|
|
const formatKeyValue = (key: string, value: string) =>
|
|
`${formatKey(key)}=${colorize(rich, theme.info, value)}`;
|
|
const formatSeparator = () => colorize(rich, theme.muted, " | ");
|
|
|
|
for (const entry of providerAuth) {
|
|
const separator = formatSeparator();
|
|
const bits: string[] = [];
|
|
bits.push(
|
|
formatKeyValue(
|
|
"effective",
|
|
`${colorize(rich, theme.accentBright, entry.effective.kind)}:${colorize(
|
|
rich,
|
|
theme.muted,
|
|
entry.effective.detail,
|
|
)}`,
|
|
),
|
|
);
|
|
if (entry.profiles.count > 0) {
|
|
bits.push(
|
|
formatKeyValue(
|
|
"profiles",
|
|
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`,
|
|
),
|
|
);
|
|
if (entry.profiles.labels.length > 0) {
|
|
bits.push(colorize(rich, theme.info, entry.profiles.labels.join(", ")));
|
|
}
|
|
}
|
|
if (entry.env) {
|
|
bits.push(
|
|
formatKeyValue(
|
|
"env",
|
|
`${entry.env.value}${separator}${formatKeyValue("source", entry.env.source)}`,
|
|
),
|
|
);
|
|
}
|
|
if (entry.modelsJson) {
|
|
bits.push(
|
|
formatKeyValue(
|
|
"models.json",
|
|
`${entry.modelsJson.value}${separator}${formatKeyValue("source", entry.modelsJson.source)}`,
|
|
),
|
|
);
|
|
}
|
|
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/token status"));
|
|
if (oauthProfiles.length === 0) {
|
|
runtime.log(colorize(rich, theme.muted, "- none"));
|
|
return;
|
|
}
|
|
|
|
const usageByProvider = new Map<string, string>();
|
|
const usageProviders = Array.from(
|
|
new Set(
|
|
oauthProfiles
|
|
.map((profile) => resolveUsageProviderId(profile.provider))
|
|
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
|
),
|
|
);
|
|
if (usageProviders.length > 0) {
|
|
try {
|
|
const usageSummary = await loadProviderUsageSummary({
|
|
providers: usageProviders,
|
|
agentDir,
|
|
timeoutMs: 3500,
|
|
});
|
|
for (const snapshot of usageSummary.providers) {
|
|
const formatted = formatUsageWindowSummary(snapshot, {
|
|
now: Date.now(),
|
|
maxWindows: 2,
|
|
includeResets: true,
|
|
});
|
|
if (formatted) {
|
|
usageByProvider.set(snapshot.provider, formatted);
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore usage failures
|
|
}
|
|
}
|
|
|
|
const formatStatus = (status: string) => {
|
|
if (status === "ok") return colorize(rich, theme.success, "ok");
|
|
if (status === "static") return colorize(rich, theme.muted, "static");
|
|
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
|
|
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
|
return colorize(rich, theme.error, "expired");
|
|
};
|
|
|
|
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
|
for (const profile of oauthProfiles) {
|
|
const current = profilesByProvider.get(profile.provider);
|
|
if (current) current.push(profile);
|
|
else profilesByProvider.set(profile.provider, [profile]);
|
|
}
|
|
|
|
for (const [provider, profiles] of profilesByProvider) {
|
|
const usageKey = resolveUsageProviderId(provider);
|
|
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
|
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
|
|
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
|
|
for (const profile of profiles) {
|
|
const labelText = profile.label || profile.profileId;
|
|
const label = colorize(rich, theme.accent, labelText);
|
|
const status = formatStatus(profile.status);
|
|
const expiry =
|
|
profile.status === "static"
|
|
? ""
|
|
: 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);
|
|
}
|