fix: local updates for PR #4780

Co-authored-by: jlowin <jlowin@users.noreply.github.com>
This commit is contained in:
Gustavo Madeira Santana
2026-01-30 15:39:05 -05:00
committed by Gustavo Madeira Santana
parent dd4715a2c4
commit f24e3cdae5
8 changed files with 250 additions and 57 deletions

View File

@@ -27,6 +27,9 @@ When provider usage snapshots are available, the OAuth/token status section incl
provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Use `--agent <id>` to inspect a configured agents model/auth state. When omitted,
the command uses `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR` if set, otherwise the
configured default agent.
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
@@ -44,6 +47,7 @@ Options:
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
- `--agent <id>` (configured agent id; overrides `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR`)
## Aliases + fallbacks

View File

@@ -1,3 +1,5 @@
import type { Command } from "commander";
export type ManagerLookupResult<T> = {
manager: T | null;
error?: string;
@@ -46,3 +48,16 @@ export async function runCommandWithRuntime(
runtime.exit(1);
}
}
export function resolveOptionFromCommand<T>(
command: Command | undefined,
key: string,
): T | undefined {
let current: Command | null | undefined = command;
while (current) {
const opts = (current.opts?.() ?? {}) as Record<string, T | undefined>;
if (opts[key] !== undefined) return opts[key];
current = current.parent ?? undefined;
}
return undefined;
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const githubCopilotLoginCommand = vi.fn();
const modelsStatusCommand = vi.fn().mockResolvedValue(undefined);
vi.mock("../commands/models.js", async () => {
const actual = (await vi.importActual<typeof import("../commands/models.js")>(
@@ -10,10 +11,16 @@ vi.mock("../commands/models.js", async () => {
return {
...actual,
githubCopilotLoginCommand,
modelsStatusCommand,
};
});
describe("models cli", () => {
beforeEach(() => {
githubCopilotLoginCommand.mockClear();
modelsStatusCommand.mockClear();
});
it("registers github-copilot login command", { timeout: 60_000 }, async () => {
const { Command } = await import("commander");
const { registerModelsCli } = await import("./models-cli.js");
@@ -40,4 +47,51 @@ describe("models cli", () => {
expect.any(Object),
);
});
it("passes --agent to models status", async () => {
const { Command } = await import("commander");
const { registerModelsCli } = await import("./models-cli.js");
const program = new Command();
registerModelsCli(program);
await program.parseAsync(["models", "status", "--agent", "poe"], { from: "user" });
expect(modelsStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({ agent: "poe" }),
expect.any(Object),
);
});
it("passes parent --agent to models status", async () => {
const { Command } = await import("commander");
const { registerModelsCli } = await import("./models-cli.js");
const program = new Command();
registerModelsCli(program);
await program.parseAsync(["models", "--agent", "poe", "status"], { from: "user" });
expect(modelsStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({ agent: "poe" }),
expect.any(Object),
);
});
it("shows help for models auth without error exit", async () => {
const { Command } = await import("commander");
const { registerModelsCli } = await import("./models-cli.js");
const program = new Command();
program.exitOverride();
registerModelsCli(program);
try {
await program.parseAsync(["models", "auth"], { from: "user" });
expect.fail("expected help to exit");
} catch (err) {
const error = err as { exitCode?: number };
expect(error.exitCode).toBe(0);
}
});
});

View File

@@ -29,7 +29,7 @@ import {
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { resolveOptionFromCommand, runCommandWithRuntime } from "./cli-utils.js";
function runModelsCommand(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action);
@@ -41,7 +41,10 @@ export function registerModelsCli(program: Command) {
.description("Model discovery, scanning, and configuration")
.option("--status-json", "Output JSON (alias for `models status --json`)", false)
.option("--status-plain", "Plain output (alias for `models status --plain`)", false)
.option("--agent <id>", "Agent id (default: configured default agent)")
.option(
"--agent <id>",
"Agent id to inspect (overrides OPENCLAW_AGENT_DIR/PI_CODING_AGENT_DIR)",
)
.addHelpText(
"after",
() =>
@@ -86,8 +89,13 @@ export function registerModelsCli(program: Command) {
.option("--probe-timeout <ms>", "Per-probe timeout in ms")
.option("--probe-concurrency <n>", "Concurrent probes")
.option("--probe-max-tokens <n>", "Probe max tokens (best-effort)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.action(async (opts) => {
.option(
"--agent <id>",
"Agent id to inspect (overrides OPENCLAW_AGENT_DIR/PI_CODING_AGENT_DIR)",
)
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(command, "agent") ?? (opts.agent as string | undefined);
await runModelsCommand(async () => {
await modelsStatusCommand(
{
@@ -100,7 +108,7 @@ export function registerModelsCli(program: Command) {
probeTimeout: opts.probeTimeout as string | undefined,
probeConcurrency: opts.probeConcurrency as string | undefined,
probeMaxTokens: opts.probeMaxTokens as string | undefined,
agent: opts.agent as string | undefined,
agent,
},
defaultRuntime,
);
@@ -282,6 +290,10 @@ export function registerModelsCli(program: Command) {
});
const auth = models.command("auth").description("Manage model auth profiles");
auth.option("--agent <id>", "Agent id for auth order get/set/clear");
auth.action(() => {
auth.help();
});
auth
.command("add")
@@ -375,12 +387,14 @@ export function registerModelsCli(program: Command) {
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(command, "agent") ?? (opts.agent as string | undefined);
await runModelsCommand(async () => {
await modelsAuthOrderGetCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
agent,
json: Boolean(opts.json),
},
defaultRuntime,
@@ -394,12 +408,14 @@ export function registerModelsCli(program: Command) {
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:default)")
.action(async (profileIds: string[], opts) => {
.action(async (profileIds: string[], opts, command) => {
const agent =
resolveOptionFromCommand<string>(command, "agent") ?? (opts.agent as string | undefined);
await runModelsCommand(async () => {
await modelsAuthOrderSetCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
agent,
order: profileIds,
},
defaultRuntime,
@@ -412,12 +428,14 @@ export function registerModelsCli(program: Command) {
.description("Clear per-agent auth order override (fall back to config/round-robin)")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.action(async (opts) => {
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(command, "agent") ?? (opts.agent as string | undefined);
await runModelsCommand(async () => {
await modelsAuthOrderClearCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
agent,
},
defaultRuntime,
);

View File

@@ -6,9 +6,9 @@ import {
} from "../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { loadConfig } from "../../config/config.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { RuntimeEnv } from "../../runtime.js";
import { shortenHomePath } from "../../utils.js";
import { resolveKnownAgentId } from "./shared.js";
function resolveTargetAgent(
cfg: ReturnType<typeof loadConfig>,
@@ -17,7 +17,7 @@ function resolveTargetAgent(
agentId: string;
agentDir: string;
} {
const agentId = raw?.trim() ? normalizeAgentId(raw.trim()) : resolveDefaultAgentId(cfg);
const agentId = resolveKnownAgentId({ cfg, rawAgentId: raw }) ?? resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, agentId);
return { agentId, agentDir };
}

View File

@@ -1,5 +1,10 @@
import path from "node:path";
import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
import {
resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "../../agents/agent-scope.js";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
@@ -15,6 +20,7 @@ import {
buildModelAliasIndex,
parseModelRef,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { CONFIG_PATH, loadConfig } from "../../config/config.js";
@@ -40,8 +46,12 @@ import {
sortProbeResults,
type AuthProbeSummary,
} from "./list.probe.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
ensureFlagCompatibility,
resolveKnownAgentId,
} from "./shared.js";
export async function modelsStatusCommand(
opts: {
@@ -63,11 +73,19 @@ export async function modelsStatusCommand(
throw new Error("--probe cannot be used with --plain output.");
}
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const agentId = resolveKnownAgentId({ cfg, rawAgentId: opts.agent });
const agentDir = agentId ? resolveAgentDir(cfg, agentId) : resolveOpenClawAgentDir();
const agentModelPrimary = agentId ? resolveAgentModelPrimary(cfg, agentId) : undefined;
const agentFallbacksOverride = agentId
? resolveAgentModelFallbacksOverride(cfg, agentId)
: undefined;
const resolved = agentId
? resolveDefaultModelForAgent({ cfg, agentId })
: resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const modelConfig = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
@@ -77,11 +95,13 @@ export async function modelsStatusCommand(
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const rawModel =
const rawDefaultsModel =
typeof modelConfig === "string" ? modelConfig.trim() : (modelConfig?.primary?.trim() ?? "");
const rawModel = agentModelPrimary ?? rawDefaultsModel;
const resolvedLabel = `${resolved.provider}/${resolved.model}`;
const defaultLabel = rawModel || resolvedLabel;
const fallbacks = typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
const defaultsFallbacks = typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
const fallbacks = agentFallbacksOverride ?? defaultsFallbacks;
const imageModel =
typeof imageConfig === "string" ? imageConfig.trim() : (imageConfig?.primary?.trim() ?? "");
const imageFallbacks = typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : [];
@@ -95,10 +115,6 @@ export async function modelsStatusCommand(
);
const allowed = Object.keys(cfg.agents?.defaults?.models ?? {});
const agentId = opts.agent?.trim()
? normalizeAgentId(opts.agent.trim())
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, agentId);
const store = ensureAuthProfileStore(agentDir);
const modelsPath = path.join(agentDir, "models.json");
@@ -300,12 +316,21 @@ export async function modelsStatusCommand(
JSON.stringify(
{
configPath: CONFIG_PATH,
...(agentId ? { agentId } : {}),
agentDir,
defaultModel: defaultLabel,
resolvedDefault: resolvedLabel,
fallbacks,
imageModel: imageModel || null,
imageFallbacks,
...(agentId
? {
modelConfig: {
defaultSource: agentModelPrimary ? "agent" : "defaults",
fallbacksSource: agentFallbacksOverride !== undefined ? "agent" : "defaults",
},
}
: {}),
aliases,
allowed,
auth: {
@@ -341,7 +366,10 @@ export async function modelsStatusCommand(
}
const rich = isRich(opts);
type ModelConfigSource = "agent" | "defaults";
const label = (value: string) => colorize(rich, theme.accent, value.padEnd(14));
const labelWithSource = (value: string, source?: ModelConfigSource) =>
label(source ? `${value} (${source})` : value);
const displayDefault =
rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel;
@@ -356,32 +384,34 @@ export async function modelsStatusCommand(
)}`,
);
runtime.log(
`${label("Default")}${colorize(rich, theme.muted, ":")} ${colorize(
`${labelWithSource("Default", agentId ? (agentModelPrimary ? "agent" : "defaults") : undefined)}${colorize(
rich,
theme.success,
displayDefault,
)}`,
theme.muted,
":",
)} ${colorize(rich, theme.success, displayDefault)}`,
);
runtime.log(
`${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
`${labelWithSource(
`Fallbacks (${fallbacks.length || 0})`,
agentId ? (agentFallbacksOverride !== undefined ? "agent" : "defaults") : undefined,
)}${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(
`${labelWithSource("Image model", agentId ? "defaults" : undefined)}${colorize(
rich,
theme.muted,
":",
)} ${colorize(
)} ${colorize(rich, imageModel ? theme.accentBright : theme.muted, imageModel || "-")}`,
);
runtime.log(
`${labelWithSource(
`Image fallbacks (${imageFallbacks.length || 0})`,
agentId ? "defaults" : undefined,
)}${colorize(rich, theme.muted, ":")} ${colorize(
rich,
imageFallbacks.length ? theme.accentBright : theme.muted,
imageFallbacks.length ? imageFallbacks.join(", ") : "-",

View File

@@ -29,8 +29,11 @@ const mocks = vi.hoisted(() => {
return {
store,
resolveOpenClawAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"),
resolveAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"),
resolveDefaultAgentId: vi.fn().mockReturnValue("main"),
resolveAgentModelPrimary: vi.fn().mockReturnValue(undefined),
resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined),
listAgentIds: vi.fn().mockReturnValue(["main", "jeremiah"]),
ensureAuthProfileStore: vi.fn().mockReturnValue(store),
listProfilesForProvider: vi.fn((s: typeof store, provider: string) => {
return Object.entries(s.profiles)
@@ -72,18 +75,16 @@ const mocks = vi.hoisted(() => {
};
});
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: mocks.resolveAgentDir,
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
vi.mock("../../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
}));
vi.mock("../../routing/session-key.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
return {
...actual,
normalizeAgentId: (id: string) => id.toLowerCase().replace(/\s+/g, "-"),
};
});
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: mocks.resolveAgentDir,
resolveAgentModelPrimary: mocks.resolveAgentModelPrimary,
resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride,
listAgentIds: mocks.listAgentIds,
}));
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/auth-profiles.js")>();
@@ -127,6 +128,7 @@ describe("modelsStatusCommand auth overview", () => {
await modelsStatusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String((runtime.log as vi.Mock).mock.calls[0][0]));
expect(mocks.resolveOpenClawAgentDir).toHaveBeenCalled();
expect(payload.defaultModel).toBe("anthropic/claude-opus-4-5");
expect(payload.auth.storePath).toBe("/tmp/openclaw-agent/auth-profiles.json");
expect(payload.auth.shellEnvFallback.enabled).toBe(true);
@@ -157,23 +159,74 @@ describe("modelsStatusCommand auth overview", () => {
).toBe(true);
});
it("resolves agent dir from --agent flag", async () => {
mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw-agent-custom");
it("uses agent overrides and reports sources", async () => {
const localRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const originalPrimary = mocks.resolveAgentModelPrimary.getMockImplementation();
const originalFallbacks = mocks.resolveAgentModelFallbacksOverride.getMockImplementation();
const originalAgentDir = mocks.resolveAgentDir.getMockImplementation();
mocks.resolveAgentModelPrimary.mockReturnValue("openai/gpt-4");
mocks.resolveAgentModelFallbacksOverride.mockReturnValue(["openai/gpt-3.5"]);
mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw-agent-custom");
try {
await modelsStatusCommand({ json: true, agent: "jeremiah" }, localRuntime as never);
await modelsStatusCommand({ json: true, agent: "Jeremiah" }, localRuntime as never);
expect(mocks.resolveAgentDir).toHaveBeenCalledWith(expect.anything(), "jeremiah");
const payload = JSON.parse(String((localRuntime.log as vi.Mock).mock.calls[0][0]));
expect(payload.agentId).toBe("jeremiah");
expect(payload.agentDir).toBe("/tmp/openclaw-agent-custom");
expect(payload.defaultModel).toBe("openai/gpt-4");
expect(payload.fallbacks).toEqual(["openai/gpt-3.5"]);
expect(payload.modelConfig).toEqual({
defaultSource: "agent",
fallbacksSource: "agent",
});
} finally {
mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw-agent");
mocks.resolveAgentModelPrimary.mockImplementation(originalPrimary);
mocks.resolveAgentModelFallbacksOverride.mockImplementation(originalFallbacks);
mocks.resolveAgentDir.mockImplementation(originalAgentDir);
}
});
it("labels defaults when --agent has no overrides", async () => {
const localRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const originalPrimary = mocks.resolveAgentModelPrimary.getMockImplementation();
const originalFallbacks = mocks.resolveAgentModelFallbacksOverride.getMockImplementation();
mocks.resolveAgentModelPrimary.mockReturnValue(undefined);
mocks.resolveAgentModelFallbacksOverride.mockReturnValue(undefined);
try {
await modelsStatusCommand({ agent: "main" }, localRuntime as never);
const output = (localRuntime.log as vi.Mock).mock.calls
.map((call) => String(call[0]))
.join("\n");
expect(output).toContain("Default (defaults)");
expect(output).toContain("Fallbacks (0) (defaults)");
} finally {
mocks.resolveAgentModelPrimary.mockImplementation(originalPrimary);
mocks.resolveAgentModelFallbacksOverride.mockImplementation(originalFallbacks);
}
});
it("throws when agent id is unknown", async () => {
const localRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(modelsStatusCommand({ agent: "unknown" }, localRuntime as never)).rejects.toThrow(
'Unknown agent id "unknown".',
);
});
it("exits non-zero when auth is missing", async () => {
const originalProfiles = { ...mocks.store.profiles };
mocks.store.profiles = {};

View File

@@ -5,11 +5,14 @@ import {
parseModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { listAgentIds } from "../../agents/agent-scope.js";
import { formatCliCommand } from "../../cli/command-format.js";
import {
type OpenClawConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { normalizeAgentId } from "../../routing/session-key.js";
export const ensureFlagCompatibility = (opts: { json?: boolean; plain?: boolean }) => {
if (opts.json && opts.plain) {
@@ -82,6 +85,22 @@ export function normalizeAlias(alias: string): string {
return trimmed;
}
export function resolveKnownAgentId(params: {
cfg: OpenClawConfig;
rawAgentId?: string | null;
}): string | undefined {
const raw = params.rawAgentId?.trim();
if (!raw) return undefined;
const agentId = normalizeAgentId(raw);
const knownAgents = listAgentIds(params.cfg);
if (!knownAgents.includes(agentId)) {
throw new Error(
`Unknown agent id "${raw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`,
);
}
return agentId;
}
export { modelKey };
export { DEFAULT_MODEL, DEFAULT_PROVIDER };