feat(models): add per-agent auth order overrides
This commit is contained in:
@@ -1131,7 +1131,7 @@ describe("directive behavior", () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const authDir = path.join(home, ".clawdbot", "agent");
|
||||
const authDir = path.join(home, ".clawdbot", "agents", "main", "agent");
|
||||
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(
|
||||
path.join(authDir, "auth-profiles.json"),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
buildModelAliasIndex,
|
||||
type ModelAliasIndex,
|
||||
modelKey,
|
||||
normalizeProviderId,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
@@ -73,18 +78,104 @@ const maskApiKey = (value: string): string => {
|
||||
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
|
||||
};
|
||||
|
||||
type ModelAuthDetailMode = "compact" | "verbose";
|
||||
|
||||
const resolveAuthLabel = async (
|
||||
provider: string,
|
||||
cfg: ClawdbotConfig,
|
||||
modelsPath: string,
|
||||
agentDir?: string,
|
||||
mode: ModelAuthDetailMode = "compact",
|
||||
): Promise<{ label: string; source: string }> => {
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
const lastGood = (() => {
|
||||
const map = store.lastGood;
|
||||
if (!map) return undefined;
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (normalizeProviderId(key) === providerKey) return value;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const nextProfileId = order[0];
|
||||
const now = Date.now();
|
||||
|
||||
const formatUntil = (timestampMs: number) => {
|
||||
const remainingMs = Math.max(0, timestampMs - now);
|
||||
const minutes = Math.round(remainingMs / 60_000);
|
||||
if (minutes < 1) return "soon";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
};
|
||||
|
||||
if (order.length > 0) {
|
||||
if (mode === "compact") {
|
||||
const profileId = nextProfileId;
|
||||
if (!profileId) return { label: "missing", source: "missing" };
|
||||
const profile = store.profiles[profileId];
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
const missing =
|
||||
!profile ||
|
||||
(configProfile?.provider && configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode &&
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"));
|
||||
|
||||
const more = order.length > 1 ? ` (+${order.length - 1})` : "";
|
||||
if (missing) return { label: `${profileId} missing${more}`, source: "" };
|
||||
|
||||
if (profile.type === "api_key") {
|
||||
return {
|
||||
label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`,
|
||||
source: "",
|
||||
};
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
const exp =
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
? profile.expires <= now
|
||||
? " expired"
|
||||
: ` exp ${formatUntil(profile.expires)}`
|
||||
: "";
|
||||
return {
|
||||
label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`,
|
||||
source: "",
|
||||
};
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
const label = display === profileId ? profileId : display;
|
||||
const exp =
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
? profile.expires <= now
|
||||
? " expired"
|
||||
: ` exp ${formatUntil(profile.expires)}`
|
||||
: "";
|
||||
return { label: `${label} oauth${exp}${more}`, source: "" };
|
||||
}
|
||||
|
||||
const labels = order.map((profileId) => {
|
||||
const profile = store.profiles[profileId];
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
const flags: string[] = [];
|
||||
if (profileId === nextProfileId) flags.push("next");
|
||||
if (lastGood && profileId === lastGood) flags.push("lastGood");
|
||||
if (isProfileInCooldown(store, profileId)) {
|
||||
const until = store.usageStats?.[profileId]?.cooldownUntil;
|
||||
if (typeof until === "number" && Number.isFinite(until) && until > now) {
|
||||
flags.push(`cooldown ${formatUntil(until)}`);
|
||||
} else {
|
||||
flags.push("cooldown");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
@@ -93,13 +184,23 @@ const resolveAuthLabel = async (
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"))
|
||||
) {
|
||||
return `${profileId}=missing`;
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=missing${suffix}`;
|
||||
}
|
||||
if (profile.type === "api_key") {
|
||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=${maskApiKey(profile.key)}${suffix}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}`;
|
||||
if (
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||
}
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({
|
||||
cfg,
|
||||
@@ -112,13 +213,20 @@ const resolveAuthLabel = async (
|
||||
: display.startsWith(profileId)
|
||||
? display.slice(profileId.length).trim()
|
||||
: `(${display})`;
|
||||
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
|
||||
if (
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||
}
|
||||
const suffixLabel = suffix ? ` ${suffix}` : "";
|
||||
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=OAuth${suffixLabel}${suffixFlags}`;
|
||||
});
|
||||
return {
|
||||
label: labels.join(", "),
|
||||
source: `auth-profiles.json: ${formatPath(
|
||||
resolveAuthStorePathForDisplay(),
|
||||
)}`,
|
||||
source: `auth-profiles.json: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,13 +236,13 @@ const resolveAuthLabel = async (
|
||||
envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") ||
|
||||
envKey.source.toLowerCase().includes("oauth");
|
||||
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
|
||||
return { label, source: envKey.source };
|
||||
return { label, source: mode === "verbose" ? envKey.source : "" };
|
||||
}
|
||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||
if (customKey) {
|
||||
return {
|
||||
label: maskApiKey(customKey),
|
||||
source: `models.json: ${formatPath(modelsPath)}`,
|
||||
source: mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
|
||||
};
|
||||
}
|
||||
return { label: "missing", source: "missing" };
|
||||
@@ -151,10 +259,13 @@ const resolveProfileOverride = (params: {
|
||||
rawProfile?: string;
|
||||
provider: string;
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
}): { profileId?: string; error?: string } => {
|
||||
const raw = params.rawProfile?.trim();
|
||||
if (!raw) return {};
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const profile = store.profiles[raw];
|
||||
if (!profile) {
|
||||
return { error: `Auth profile "${raw}" not found.` };
|
||||
@@ -363,6 +474,10 @@ export async function handleDirectiveOnly(params: {
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
} = params;
|
||||
const activeAgentId = params.sessionKey
|
||||
? resolveAgentIdFromSessionKey(params.sessionKey)
|
||||
: resolveDefaultAgentId(params.cfg);
|
||||
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
|
||||
const runtimeIsSandboxed = (() => {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) return false;
|
||||
@@ -384,6 +499,10 @@ export async function handleDirectiveOnly(params: {
|
||||
const isModelListAlias =
|
||||
modelDirective === "status" || modelDirective === "list";
|
||||
if (!directives.rawModelDirective || isModelListAlias) {
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const authMode: ModelAuthDetailMode =
|
||||
modelDirective === "status" ? "verbose" : "compact";
|
||||
if (allowedModelCatalog.length === 0) {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
@@ -423,9 +542,6 @@ export async function handleDirectiveOnly(params: {
|
||||
if (fallbackCatalog.length === 0) {
|
||||
return { text: "No models available." };
|
||||
}
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const authByProvider = new Map<string, string>();
|
||||
for (const entry of fallbackCatalog) {
|
||||
if (authByProvider.has(entry.provider)) continue;
|
||||
@@ -433,6 +549,8 @@ export async function handleDirectiveOnly(params: {
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
agentDir,
|
||||
authMode,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -441,7 +559,8 @@ export async function handleDirectiveOnly(params: {
|
||||
const lines = [
|
||||
`Current: ${current}`,
|
||||
`Default: ${defaultLabel}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
|
||||
`Agent: ${activeAgentId}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
|
||||
`⚠️ Model catalog unavailable; showing configured models only.`,
|
||||
];
|
||||
const byProvider = new Map<string, typeof fallbackCatalog>();
|
||||
@@ -469,9 +588,6 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
return { text: lines.join("\n") };
|
||||
}
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const authByProvider = new Map<string, string>();
|
||||
for (const entry of allowedModelCatalog) {
|
||||
if (authByProvider.has(entry.provider)) continue;
|
||||
@@ -479,6 +595,8 @@ export async function handleDirectiveOnly(params: {
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
agentDir,
|
||||
authMode,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -487,7 +605,8 @@ export async function handleDirectiveOnly(params: {
|
||||
const lines = [
|
||||
`Current: ${current}`,
|
||||
`Default: ${defaultLabel}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
|
||||
`Agent: ${activeAgentId}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
|
||||
];
|
||||
if (resetModelOverride) {
|
||||
lines.push(`(previous selection reset to default)`);
|
||||
@@ -684,15 +803,16 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
modelSelection = resolved.selection;
|
||||
if (modelSelection) {
|
||||
if (directives.rawModelProfile) {
|
||||
const profileResolved = resolveProfileOverride({
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: modelSelection.provider,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
return { text: profileResolved.error };
|
||||
}
|
||||
if (directives.rawModelProfile) {
|
||||
const profileResolved = resolveProfileOverride({
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: modelSelection.provider,
|
||||
cfg: params.cfg,
|
||||
agentDir,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
return { text: profileResolved.error };
|
||||
}
|
||||
profileOverride = profileResolved.profileId;
|
||||
}
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
@@ -933,6 +1053,7 @@ export async function persistInlineDirectives(params: {
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: resolved.ref.provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
throw new Error(profileResolved.error);
|
||||
|
||||
Reference in New Issue
Block a user