fix: normalize provider aliases in auth order

This commit is contained in:
Peter Steinberger
2026-01-07 05:43:32 +01:00
parent 8187baab18
commit 12d57da53a
5 changed files with 493 additions and 39 deletions

View File

@@ -70,6 +70,7 @@
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running. - 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. - 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: 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. - 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. - 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. - Docs: add group chat participation guidance to the AGENTS template.

View File

@@ -120,6 +120,37 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toEqual(["zai:work", "zai:default"]); 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", () => { it("normalizes z.ai aliases in auth.profiles", () => {
const order = resolveAuthProfileOrder({ const order = resolveAuthProfileOrder({
cfg: { cfg: {

View File

@@ -542,12 +542,14 @@ export function resolveAuthProfileOrder(params: {
}): string[] { }): string[] {
const { cfg, store, provider, preferredProfile } = params; const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider); const providerKey = normalizeProviderId(provider);
const configuredOrder = const configuredOrder = (() => {
cfg?.auth?.order?.[providerKey] ?? const order = cfg?.auth?.order;
cfg?.auth?.order?.[provider] ?? if (!order) return undefined;
(providerKey === "zai" for (const [key, value] of Object.entries(order)) {
? (cfg?.auth?.order?.["z.ai"] ?? cfg?.auth?.order?.["z-ai"]) if (normalizeProviderId(key) === providerKey) return value;
: undefined); }
return undefined;
})();
const explicitProfiles = cfg?.auth?.profiles const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles) ? Object.entries(cfg.auth.profiles)
.filter( .filter(
@@ -565,7 +567,7 @@ export function resolveAuthProfileOrder(params: {
const filtered = baseOrder.filter((profileId) => { const filtered = baseOrder.filter((profileId) => {
const cred = store.profiles[profileId]; const cred = store.profiles[profileId];
return cred ? cred.provider === provider : true; return cred ? normalizeProviderId(cred.provider) === providerKey : true;
}); });
const deduped: string[] = []; const deduped: string[] = [];
for (const entry of filtered) { for (const entry of filtered) {

View File

@@ -3,8 +3,16 @@ import { describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn(); const loadConfig = vi.fn();
const ensureClawdbotModelsJson = vi.fn().mockResolvedValue(undefined); const ensureClawdbotModelsJson = vi.fn().mockResolvedValue(undefined);
const resolveClawdbotAgentDir = vi.fn().mockReturnValue("/tmp/clawdbot-agent"); 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 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 resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined); const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const discoverAuthStorage = vi.fn().mockReturnValue({}); const discoverAuthStorage = vi.fn().mockReturnValue({});
@@ -26,6 +34,8 @@ vi.mock("../agents/agent-paths.js", () => ({
vi.mock("../agents/auth-profiles.js", () => ({ vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
})); }));
vi.mock("../agents/model-auth.js", () => ({ vi.mock("../agents/model-auth.js", () => ({

View File

@@ -1,3 +1,5 @@
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai"; import type { Api, Model } from "@mariozechner/pi-ai";
import { import {
discoverAuthStorage, discoverAuthStorage,
@@ -10,6 +12,8 @@ import {
type AuthProfileStore, type AuthProfileStore,
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
} from "../../agents/auth-profiles.js"; } from "../../agents/auth-profiles.js";
import { import {
getCustomProviderApiKey, getCustomProviderApiKey,
@@ -27,8 +31,12 @@ import {
CONFIG_PATH_CLAWDBOT, CONFIG_PATH_CLAWDBOT,
loadConfig, loadConfig,
} from "../../config/config.js"; } from "../../config/config.js";
import { info } from "../../globals.js"; import {
getShellEnvAppliedKeys,
shouldEnableShellEnvFallback,
} from "../../infra/shell-env.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { shortenHomePath } from "../../utils.js";
import { import {
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
@@ -50,12 +58,52 @@ const isRich = (opts?: { json?: boolean; plain?: boolean }) =>
const pad = (value: string, size: number) => value.padEnd(size); 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) => { const truncate = (value: string, max: number) => {
if (value.length <= max) return value; if (value.length <= max) return value;
if (max <= 3) return value.slice(0, max); if (max <= 3) return value.slice(0, max);
return `${value.slice(0, max - 3)}...`; 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 = { type ConfiguredEntry = {
key: string; key: string;
ref: { provider: string; model: string }; ref: { provider: string; model: string };
@@ -101,6 +149,109 @@ const hasAuthForProvider = (
return false; 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 resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
const resolvedDefault = resolveConfiguredModelRef({ const resolvedDefault = resolveConfiguredModelRef({
cfg, cfg,
@@ -305,23 +456,45 @@ function printModelTable(
const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD); const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD);
const inputLabel = pad(row.input || "-", INPUT_PAD); const inputLabel = pad(row.input || "-", INPUT_PAD);
const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD); const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD);
const localLabel = pad( const localText = row.local === null ? "-" : row.local ? "yes" : "no";
row.local === null ? "-" : row.local ? "yes" : "no", const localLabel = pad(localText, LOCAL_PAD);
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( const coloredLocal = colorize(
row.available === null ? "-" : row.available ? "yes" : "no", rich,
AUTH_PAD, 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 = [ const line = [
rich ? chalk.cyan(keyLabel) : keyLabel, rich ? chalk.cyan(keyLabel) : keyLabel,
inputLabel, coloredInput,
ctxLabel, ctxLabel,
localLabel, coloredLocal,
authLabel, coloredAuth,
rich ? chalk.gray(tagsLabel) : tagsLabel, tagsLabel,
].join(" "); ].join(" ");
runtime.log(line); runtime.log(line);
} }
@@ -468,18 +641,113 @@ export async function modelsStatusCommand(
}, {}); }, {});
const allowed = Object.keys(cfg.agent?.models ?? {}); 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<string>();
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<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",
];
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) { if (opts.json) {
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
configPath: CONFIG_PATH_CLAWDBOT, configPath: CONFIG_PATH_CLAWDBOT,
defaultModel: rawModel || resolvedLabel, agentDir,
resolvedDefault: `${resolved.provider}/${resolved.model}`, defaultModel: defaultLabel,
resolvedDefault: resolvedLabel,
fallbacks, fallbacks,
imageModel: imageModel || null, imageModel: imageModel || null,
imageFallbacks, imageFallbacks,
aliases, aliases,
allowed, allowed,
auth: {
storePath: resolveAuthStorePathForDisplay(),
shellEnvFallback: {
enabled: shellFallbackEnabled,
appliedKeys: applied,
},
providersWithOAuth: providersWithOauth,
providers: providerAuth,
},
}, },
null, null,
2, 2,
@@ -493,33 +761,175 @@ export async function modelsStatusCommand(
return; 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( runtime.log(
`Default: ${defaultLabel}${ `${label("Config")}${colorize(rich, chalk.gray, ":")} ${colorize(rich, chalk.white, CONFIG_PATH_CLAWDBOT)}`,
rawModel && rawModel !== resolvedLabel ? ` (from ${rawModel})` : ""
}`,
); );
runtime.log( runtime.log(
`Fallbacks (${fallbacks.length || 0}): ${fallbacks.join(", ") || "-"}`, `${label("Agent dir")}${colorize(rich, chalk.gray, ":")} ${colorize(
); rich,
runtime.log(`Image model: ${imageModel || "-"}`); chalk.white,
runtime.log( shortenHomePath(agentDir),
`Image fallbacks (${imageFallbacks.length || 0}): ${ )}`,
imageFallbacks.length ? imageFallbacks.join(", ") : "-"
}`,
); );
runtime.log( 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.keys(aliases).length
? Object.entries(aliases) ? Object.entries(aliases)
.map(([alias, target]) => `${alias} -> ${target}`) .map(([alias, target]) =>
rich
? `${chalk.blue(alias)} ${chalk.gray("->")} ${chalk.white(
target,
)}`
: `${alias} -> ${target}`,
)
.join(", ") .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( runtime.log(
`Configured models (${allowed.length || 0}): ${ `${label(
allowed.length ? allowed.join(", ") : "all" `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)}`);
}
} }