diff --git a/CHANGELOG.md b/CHANGELOG.md index 374778c2f..9cdb868b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ - Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running. - Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229. - Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order. +- Auth/CLI: normalize provider ids and Z.AI aliases across auth profile ordering and models list/status. Thanks @mneves75 for PR #303. - Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save. - Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256. - Docs: add group chat participation guidance to the AGENTS template. diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index dbddb27cc..d9bfe938c 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -120,6 +120,37 @@ describe("resolveAuthProfileOrder", () => { expect(order).toEqual(["zai:work", "zai:default"]); }); + it("normalizes provider casing in auth.order keys", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + order: { OpenAI: ["openai:work", "openai:default"] }, + profiles: { + "openai:default": { provider: "openai", mode: "api_key" }, + "openai:work": { provider: "openai", mode: "api_key" }, + }, + }, + }, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-default", + }, + "openai:work": { + type: "api_key", + provider: "openai", + key: "sk-work", + }, + }, + }, + provider: "openai", + }); + expect(order).toEqual(["openai:work", "openai:default"]); + }); + it("normalizes z.ai aliases in auth.profiles", () => { const order = resolveAuthProfileOrder({ cfg: { diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index b28f7f6ba..7640d36d6 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -542,12 +542,14 @@ export function resolveAuthProfileOrder(params: { }): string[] { const { cfg, store, provider, preferredProfile } = params; const providerKey = normalizeProviderId(provider); - const configuredOrder = - cfg?.auth?.order?.[providerKey] ?? - cfg?.auth?.order?.[provider] ?? - (providerKey === "zai" - ? (cfg?.auth?.order?.["z.ai"] ?? cfg?.auth?.order?.["z-ai"]) - : undefined); + const configuredOrder = (() => { + const order = cfg?.auth?.order; + if (!order) return undefined; + for (const [key, value] of Object.entries(order)) { + if (normalizeProviderId(key) === providerKey) return value; + } + return undefined; + })(); const explicitProfiles = cfg?.auth?.profiles ? Object.entries(cfg.auth.profiles) .filter( @@ -565,7 +567,7 @@ export function resolveAuthProfileOrder(params: { const filtered = baseOrder.filter((profileId) => { const cred = store.profiles[profileId]; - return cred ? cred.provider === provider : true; + return cred ? normalizeProviderId(cred.provider) === providerKey : true; }); const deduped: string[] = []; for (const entry of filtered) { diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 911ae7afa..84dc203c4 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -3,8 +3,16 @@ import { describe, expect, it, vi } from "vitest"; const loadConfig = vi.fn(); const ensureClawdbotModelsJson = vi.fn().mockResolvedValue(undefined); const resolveClawdbotAgentDir = vi.fn().mockReturnValue("/tmp/clawdbot-agent"); -const ensureAuthProfileStore = vi.fn().mockReturnValue({}); +const ensureAuthProfileStore = vi + .fn() + .mockReturnValue({ version: 1, profiles: {} }); const listProfilesForProvider = vi.fn().mockReturnValue([]); +const resolveAuthProfileDisplayLabel = vi.fn( + ({ profileId }: { profileId: string }) => profileId, +); +const resolveAuthStorePathForDisplay = vi + .fn() + .mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json"); const resolveEnvApiKey = vi.fn().mockReturnValue(undefined); const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined); const discoverAuthStorage = vi.fn().mockReturnValue({}); @@ -26,6 +34,8 @@ vi.mock("../agents/agent-paths.js", () => ({ vi.mock("../agents/auth-profiles.js", () => ({ ensureAuthProfileStore, listProfilesForProvider, + resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay, })); vi.mock("../agents/model-auth.js", () => ({ diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index 052b00ecf..fe5402c3e 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -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, @@ -27,8 +31,12 @@ import { CONFIG_PATH_CLAWDBOT, 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, @@ -50,12 +58,52 @@ const isRich = (opts?: { json?: boolean; plain?: boolean }) => const pad = (value: string, size: number) => value.padEnd(size); +const colorize = ( + rich: boolean, + color: (value: string) => string, + value: string, +) => (rich ? color(value) : value); + +const formatKey = (key: string, rich: boolean) => + colorize(rich, chalk.yellow, key); + +const formatValue = (value: string, rich: boolean) => + colorize(rich, chalk.white, value); + +const formatKeyValue = ( + key: string, + value: string, + rich: boolean, + valueColor: (value: string) => string = chalk.white, +) => `${formatKey(key, rich)}=${colorize(rich, valueColor, value)}`; + +const formatSeparator = (rich: boolean) => colorize(rich, chalk.gray, " | "); + +const formatTag = (tag: string, rich: boolean) => { + if (!rich) return tag; + if (tag === "default") return chalk.greenBright(tag); + if (tag === "image") return chalk.magentaBright(tag); + if (tag === "configured") return chalk.cyan(tag); + if (tag === "missing") return chalk.red(tag); + if (tag.startsWith("fallback#")) return chalk.yellow(tag); + if (tag.startsWith("img-fallback#")) return chalk.yellowBright(tag); + if (tag.startsWith("alias:")) return chalk.blue(tag); + return chalk.gray(tag); +}; + const truncate = (value: string, max: number) => { if (value.length <= max) return value; if (max <= 3) return value.slice(0, max); 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 +149,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, @@ -305,23 +456,45 @@ function printModelTable( const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD); const inputLabel = pad(row.input || "-", INPUT_PAD); const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD); - const localLabel = pad( - row.local === null ? "-" : row.local ? "yes" : "no", - LOCAL_PAD, + const localText = row.local === null ? "-" : row.local ? "yes" : "no"; + const localLabel = pad(localText, LOCAL_PAD); + const authText = + row.available === null ? "-" : row.available ? "yes" : "no"; + const authLabel = pad(authText, AUTH_PAD); + const tagsLabel = + row.tags.length > 0 + ? rich + ? row.tags.map((tag) => formatTag(tag, rich)).join(",") + : row.tags.join(",") + : ""; + + const coloredInput = colorize( + rich, + row.input.includes("image") ? chalk.magenta : chalk.white, + inputLabel, ); - const authLabel = pad( - row.available === null ? "-" : row.available ? "yes" : "no", - AUTH_PAD, + const coloredLocal = colorize( + rich, + row.local === null ? chalk.gray : row.local ? chalk.green : chalk.gray, + localLabel, + ); + const coloredAuth = colorize( + rich, + row.available === null + ? chalk.gray + : row.available + ? chalk.green + : chalk.red, + authLabel, ); - const tagsLabel = row.tags.length > 0 ? row.tags.join(",") : ""; const line = [ rich ? chalk.cyan(keyLabel) : keyLabel, - inputLabel, + coloredInput, ctxLabel, - localLabel, - authLabel, - rich ? chalk.gray(tagsLabel) : tagsLabel, + coloredLocal, + coloredAuth, + tagsLabel, ].join(" "); runtime.log(line); } @@ -468,18 +641,113 @@ 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(); + 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(); + // 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, - defaultModel: rawModel || resolvedLabel, - resolvedDefault: `${resolved.provider}/${resolved.model}`, + agentDir, + defaultModel: defaultLabel, + resolvedDefault: resolvedLabel, fallbacks, imageModel: imageModel || null, imageFallbacks, aliases, allowed, + auth: { + storePath: resolveAuthStorePathForDisplay(), + shellEnvFallback: { + enabled: shellFallbackEnabled, + appliedKeys: applied, + }, + providersWithOAuth: providersWithOauth, + providers: providerAuth, + }, }, null, 2, @@ -493,33 +761,175 @@ export async function modelsStatusCommand( return; } - runtime.log(info(`Config: ${CONFIG_PATH_CLAWDBOT}`)); + const rich = isRich(opts); + const label = (value: string) => colorize(rich, chalk.cyan, value.padEnd(14)); + const displayDefault = + rawModel && rawModel !== resolvedLabel + ? `${resolvedLabel} (from ${rawModel})` + : resolvedLabel; + runtime.log( - `Default: ${defaultLabel}${ - rawModel && rawModel !== resolvedLabel ? ` (from ${rawModel})` : "" - }`, + `${label("Config")}${colorize(rich, chalk.gray, ":")} ${colorize(rich, chalk.white, CONFIG_PATH_CLAWDBOT)}`, ); runtime.log( - `Fallbacks (${fallbacks.length || 0}): ${fallbacks.join(", ") || "-"}`, - ); - runtime.log(`Image model: ${imageModel || "-"}`); - runtime.log( - `Image fallbacks (${imageFallbacks.length || 0}): ${ - imageFallbacks.length ? imageFallbacks.join(", ") : "-" - }`, + `${label("Agent dir")}${colorize(rich, chalk.gray, ":")} ${colorize( + rich, + chalk.white, + shortenHomePath(agentDir), + )}`, ); runtime.log( - `Aliases (${Object.keys(aliases).length || 0}): ${ + `${label("Default")}${colorize(rich, chalk.gray, ":")} ${colorize( + rich, + chalk.green, + displayDefault, + )}`, + ); + runtime.log( + `${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize( + rich, + chalk.gray, + ":", + )} ${colorize( + rich, + fallbacks.length ? chalk.yellow : chalk.gray, + fallbacks.length ? fallbacks.join(", ") : "-", + )}`, + ); + runtime.log( + `${label("Image model")}${colorize(rich, chalk.gray, ":")} ${colorize( + rich, + imageModel ? chalk.magenta : chalk.gray, + imageModel || "-", + )}`, + ); + runtime.log( + `${label(`Image fallbacks (${imageFallbacks.length || 0})`)}${colorize( + rich, + chalk.gray, + ":", + )} ${colorize( + rich, + imageFallbacks.length ? chalk.magentaBright : chalk.gray, + imageFallbacks.length ? imageFallbacks.join(", ") : "-", + )}`, + ); + runtime.log( + `${label(`Aliases (${Object.keys(aliases).length || 0})`)}${colorize( + rich, + chalk.gray, + ":", + )} ${colorize( + rich, + Object.keys(aliases).length ? chalk.cyan : chalk.gray, Object.keys(aliases).length ? Object.entries(aliases) - .map(([alias, target]) => `${alias} -> ${target}`) + .map(([alias, target]) => + rich + ? `${chalk.blue(alias)} ${chalk.gray("->")} ${chalk.white( + target, + )}` + : `${alias} -> ${target}`, + ) .join(", ") - : "-" + : "-", + )}`, + ); + runtime.log( + `${label(`Configured models (${allowed.length || 0})`)}${colorize( + rich, + chalk.gray, + ":", + )} ${colorize( + rich, + allowed.length ? chalk.white : chalk.gray, + allowed.length ? allowed.join(", ") : "all", + )}`, + ); + + runtime.log(""); + runtime.log(colorize(rich, chalk.bold, "Auth overview")); + runtime.log( + `${label("Auth store")}${colorize(rich, chalk.gray, ":")} ${colorize( + rich, + chalk.white, + shortenHomePath(resolveAuthStorePathForDisplay()), + )}`, + ); + runtime.log( + `${label("Shell env")}${colorize(rich, chalk.gray, ":")} ${colorize( + rich, + shellFallbackEnabled ? chalk.green : chalk.gray, + shellFallbackEnabled ? "on" : "off", + )}${ + applied.length + ? colorize(rich, chalk.gray, ` (applied: ${applied.join(", ")})`) + : "" }`, ); runtime.log( - `Configured models (${allowed.length || 0}): ${ - allowed.length ? allowed.join(", ") : "all" - }`, + `${label( + `Providers w/ OAuth (${providersWithOauth.length || 0})`, + )}${colorize(rich, chalk.gray, ":")} ${colorize( + rich, + providersWithOauth.length ? chalk.white : chalk.gray, + providersWithOauth.length ? providersWithOauth.join(", ") : "-", + )}`, ); + + for (const entry of providerAuth) { + const separator = formatSeparator(rich); + const bits: string[] = []; + bits.push( + formatKeyValue( + "effective", + `${colorize(rich, chalk.magenta, entry.effective.kind)}:${colorize( + rich, + chalk.gray, + entry.effective.detail, + )}`, + rich, + (value) => value, + ), + ); + if (entry.profiles.count > 0) { + bits.push( + formatKeyValue( + "profiles", + `${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`, + rich, + ), + ); + if (entry.profiles.labels.length > 0) { + bits.push(formatValue(entry.profiles.labels.join(", "), rich)); + } + } + if (entry.env) { + bits.push( + formatKeyValue( + "env", + `${entry.env.value}${separator}${formatKeyValue( + "source", + entry.env.source, + rich, + )}`, + rich, + ), + ); + } + if (entry.modelsJson) { + bits.push( + formatKeyValue( + "models.json", + `${entry.modelsJson.value}${separator}${formatKeyValue( + "source", + entry.modelsJson.source, + rich, + )}`, + rich, + ), + ); + } + runtime.log(`- ${chalk.bold(entry.provider)} ${bits.join(separator)}`); + } }