From 3e400ff9f279a61770f3d03a122a59a76be3b660 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:07:29 +0000 Subject: [PATCH] feat(models): add per-agent auth order overrides --- CHANGELOG.md | 1 + src/agents/auth-profiles.test.ts | 33 ++++ src/agents/auth-profiles.ts | 81 ++++++++- src/auto-reply/reply.directive.test.ts | 2 +- src/auto-reply/reply/directive-handling.ts | 181 +++++++++++++++++---- src/cli/models-cli.ts | 71 ++++++++ src/commands/models.ts | 5 + src/commands/models/auth-order.ts | 129 +++++++++++++++ 8 files changed, 467 insertions(+), 36 deletions(-) create mode 100644 src/commands/models/auth-order.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd550fee..f38f6e1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Commands: accept /models as an alias for /model. +- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete - Debugging: add raw model stream logging flags and document gateway watch mode. - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). - CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured. diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index 144471f48..c2ba606a2 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -130,6 +130,39 @@ describe("resolveAuthProfileOrder", () => { expect(order).toEqual(["anthropic:work", "anthropic:default"]); }); + it("prefers store order over config order", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + order: { anthropic: ["anthropic:default", "anthropic:work"] }, + profiles: cfg.auth.profiles, + }, + }, + store: { + ...store, + order: { anthropic: ["anthropic:work", "anthropic:default"] }, + }, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:work", "anthropic:default"]); + }); + + it("pushes cooldown profiles to the end even with store order", () => { + const now = Date.now(); + const order = resolveAuthProfileOrder({ + store: { + ...store, + order: { anthropic: ["anthropic:default", "anthropic:work"] }, + usageStats: { + "anthropic:default": { cooldownUntil: now + 60_000 }, + "anthropic:work": { lastUsed: 1 }, + }, + }, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:work", "anthropic:default"]); + }); + it("pushes cooldown profiles to the end even with configured order", () => { const now = Date.now(); const order = resolveAuthProfileOrder({ diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index b60888a26..c2ac71824 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -82,6 +82,12 @@ export type ProfileUsageStats = { export type AuthProfileStore = { version: number; profiles: Record; + /** + * Optional per-agent preferred profile order overrides. + * This lets you lock/override auth rotation for a specific agent without + * changing the global config. + */ + order?: Record; lastGood?: Record; /** Usage statistics per profile for round-robin rotation */ usageStats?: Record; @@ -133,6 +139,7 @@ function syncAuthProfileStore( ): void { target.version = source.version; target.profiles = source.profiles; + target.order = source.order; target.lastGood = source.lastGood; target.usageStats = source.usageStats; } @@ -270,9 +277,25 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null { if (!typed.provider) continue; normalized[key] = typed as AuthProfileCredential; } + const order = + record.order && typeof record.order === "object" + ? Object.entries(record.order as Record).reduce( + (acc, [provider, value]) => { + if (!Array.isArray(value)) return acc; + const list = value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + if (list.length === 0) return acc; + acc[provider] = list; + return acc; + }, + {} as Record, + ) + : undefined; return { version: Number(record.version ?? AUTH_STORE_VERSION), profiles: normalized, + order, lastGood: record.lastGood && typeof record.lastGood === "object" ? (record.lastGood as Record) @@ -680,12 +703,49 @@ export function saveAuthProfileStore( const payload = { version: AUTH_STORE_VERSION, profiles: store.profiles, + order: store.order ?? undefined, lastGood: store.lastGood ?? undefined, usageStats: store.usageStats ?? undefined, } satisfies AuthProfileStore; saveJsonFile(authPath, payload); } +export async function setAuthProfileOrder(params: { + agentDir?: string; + provider: string; + order?: string[] | null; +}): Promise { + const providerKey = normalizeProviderId(params.provider); + const sanitized = + params.order && Array.isArray(params.order) + ? params.order + .map((entry) => String(entry).trim()) + .filter(Boolean) + : []; + + const deduped: string[] = []; + for (const entry of sanitized) { + if (!deduped.includes(entry)) deduped.push(entry); + } + + return await updateAuthProfileStoreWithLock({ + agentDir: params.agentDir, + updater: (store) => { + store.order = store.order ?? {}; + if (deduped.length === 0) { + if (!store.order[providerKey]) return false; + delete store.order[providerKey]; + if (Object.keys(store.order).length === 0) { + store.order = undefined; + } + return true; + } + store.order[providerKey] = deduped; + return true; + }, + }); +} + export function upsertAuthProfile(params: { profileId: string; credential: AuthProfileCredential; @@ -863,6 +923,14 @@ export function resolveAuthProfileOrder(params: { }): string[] { const { cfg, store, provider, preferredProfile } = params; const providerKey = normalizeProviderId(provider); + const storedOrder = (() => { + const order = store.order; + if (!order) return undefined; + for (const [key, value] of Object.entries(order)) { + if (normalizeProviderId(key) === providerKey) return value; + } + return undefined; + })(); const configuredOrder = (() => { const order = cfg?.auth?.order; if (!order) return undefined; @@ -871,6 +939,7 @@ export function resolveAuthProfileOrder(params: { } return undefined; })(); + const explicitOrder = storedOrder ?? configuredOrder; const explicitProfiles = cfg?.auth?.profiles ? Object.entries(cfg.auth.profiles) .filter( @@ -880,7 +949,7 @@ export function resolveAuthProfileOrder(params: { .map(([profileId]) => profileId) : []; const baseOrder = - configuredOrder ?? + explicitOrder ?? (explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey)); @@ -895,8 +964,10 @@ export function resolveAuthProfileOrder(params: { if (!deduped.includes(entry)) deduped.push(entry); } - // If user specified explicit order in config, respect it exactly - if (configuredOrder && configuredOrder.length > 0) { + // If user specified explicit order (store override or config), respect it + // exactly, but still apply cooldown sorting to avoid repeatedly selecting + // known-bad/rate-limited keys as the first candidate. + if (explicitOrder && explicitOrder.length > 0) { // ...but still respect cooldown tracking to avoid repeatedly selecting a // known-bad/rate-limited key as the first candidate. const now = Date.now(); @@ -1118,8 +1189,8 @@ export async function markAuthProfileGood(params: { saveAuthProfileStore(store, agentDir); } -export function resolveAuthStorePathForDisplay(): string { - const pathname = resolveAuthStorePath(); +export function resolveAuthStorePathForDisplay(agentDir?: string): string { + const pathname = resolveAuthStorePath(agentDir); return pathname.startsWith("~") ? pathname : resolveUserPath(pathname); } diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index b21314030..650b2d586 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -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"), diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 6a50d1281..ce5248966 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -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(); 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(); @@ -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(); 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); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 75b749ed8..dd2ca826b 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -5,6 +5,9 @@ import { modelsAliasesListCommand, modelsAliasesRemoveCommand, modelsAuthAddCommand, + modelsAuthOrderClearCommand, + modelsAuthOrderGetCommand, + modelsAuthOrderSetCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, modelsFallbacksAddCommand, @@ -360,4 +363,72 @@ export function registerModelsCli(program: Command) { defaultRuntime.exit(1); } }); + + const order = auth + .command("order") + .description("Manage per-agent auth profile order overrides"); + + order + .command("get") + .description("Show per-agent auth order override (from auth-profiles.json)") + .requiredOption("--provider ", "Provider id (e.g. anthropic)") + .option("--agent ", "Agent id (default: configured default agent)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + try { + await modelsAuthOrderGetCommand( + { + provider: opts.provider as string, + agent: opts.agent as string | undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + order + .command("set") + .description("Set per-agent auth order override (locks rotation to this list)") + .requiredOption("--provider ", "Provider id (e.g. anthropic)") + .option("--agent ", "Agent id (default: configured default agent)") + .argument("", "Auth profile ids (e.g. anthropic:claude-cli)") + .action(async (profileIds: string[], opts) => { + try { + await modelsAuthOrderSetCommand( + { + provider: opts.provider as string, + agent: opts.agent as string | undefined, + order: profileIds, + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + order + .command("clear") + .description("Clear per-agent auth order override (fall back to config/round-robin)") + .requiredOption("--provider ", "Provider id (e.g. anthropic)") + .option("--agent ", "Agent id (default: configured default agent)") + .action(async (opts) => { + try { + await modelsAuthOrderClearCommand( + { + provider: opts.provider as string, + agent: opts.agent as string | undefined, + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); } diff --git a/src/commands/models.ts b/src/commands/models.ts index 636a738cb..90664838c 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -8,6 +8,11 @@ export { modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, } from "./models/auth.js"; +export { + modelsAuthOrderClearCommand, + modelsAuthOrderGetCommand, + modelsAuthOrderSetCommand, +} from "./models/auth-order.js"; export { modelsFallbacksAddCommand, modelsFallbacksClearCommand, diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts new file mode 100644 index 000000000..e0429a372 --- /dev/null +++ b/src/commands/models/auth-order.ts @@ -0,0 +1,129 @@ +import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + ensureAuthProfileStore, + setAuthProfileOrder, + type AuthProfileStore, +} from "../../agents/auth-profiles.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; +import { loadConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { shortenHomePath } from "../../utils.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; + +function resolveTargetAgent(cfg: ReturnType, raw?: string): { + agentId: string; + agentDir: string; +} { + const agentId = raw?.trim() + ? normalizeAgentId(raw.trim()) + : resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, agentId); + return { agentId, agentDir }; +} + +function describeOrder(store: AuthProfileStore, provider: string): string[] { + const providerKey = normalizeProviderId(provider); + const order = store.order?.[providerKey]; + return Array.isArray(order) ? order : []; +} + +export async function modelsAuthOrderGetCommand( + opts: { provider: string; agent?: string; json?: boolean }, + runtime: RuntimeEnv, +) { + const rawProvider = opts.provider?.trim(); + if (!rawProvider) throw new Error("Missing --provider."); + const provider = normalizeProviderId(rawProvider); + + const cfg = loadConfig(); + const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const order = describeOrder(store, provider); + + if (opts.json) { + runtime.log( + JSON.stringify( + { + agentId, + agentDir, + provider, + authStorePath: shortenHomePath(`${agentDir}/auth-profiles.json`), + order: order.length > 0 ? order : null, + }, + null, + 2, + ), + ); + return; + } + + runtime.log(`Agent: ${agentId}`); + runtime.log(`Provider: ${provider}`); + runtime.log(`Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`); + runtime.log( + order.length > 0 ? `Order override: ${order.join(", ")}` : "Order override: (none)", + ); +} + +export async function modelsAuthOrderClearCommand( + opts: { provider: string; agent?: string }, + runtime: RuntimeEnv, +) { + const rawProvider = opts.provider?.trim(); + if (!rawProvider) throw new Error("Missing --provider."); + const provider = normalizeProviderId(rawProvider); + + const cfg = loadConfig(); + const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + const updated = await setAuthProfileOrder({ agentDir, provider, order: null }); + if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?)."); + + runtime.log(`Agent: ${agentId}`); + runtime.log(`Provider: ${provider}`); + runtime.log("Cleared per-agent order override."); +} + +export async function modelsAuthOrderSetCommand( + opts: { provider: string; agent?: string; order: string[] }, + runtime: RuntimeEnv, +) { + const rawProvider = opts.provider?.trim(); + if (!rawProvider) throw new Error("Missing --provider."); + const provider = normalizeProviderId(rawProvider); + + const cfg = loadConfig(); + const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const providerKey = normalizeProviderId(provider); + const requested = (opts.order ?? []) + .map((entry) => String(entry).trim()) + .filter(Boolean); + if (requested.length === 0) { + throw new Error("Missing profile ids. Provide one or more profile ids."); + } + + for (const profileId of requested) { + const cred = store.profiles[profileId]; + if (!cred) { + throw new Error(`Auth profile "${profileId}" not found in ${agentDir}.`); + } + if (normalizeProviderId(cred.provider) !== providerKey) { + throw new Error( + `Auth profile "${profileId}" is for ${cred.provider}, not ${provider}.`, + ); + } + } + + const updated = await setAuthProfileOrder({ + agentDir, + provider, + order: requested, + }); + if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?)."); + + runtime.log(`Agent: ${agentId}`); + runtime.log(`Provider: ${provider}`); + runtime.log(`Order override: ${describeOrder(updated, provider).join(", ")}`); +} +