fix: align model auth display with workspace evidence

This commit is contained in:
Shakker
2026-04-29 21:12:16 +01:00
parent 2fe3e779ff
commit b4ecc814c5
10 changed files with 260 additions and 21 deletions

View File

@@ -16,9 +16,16 @@ const modelCatalogMocks = vi.hoisted(() => ({
const modelAuthLabelMocks = vi.hoisted(() => ({
resolveModelAuthLabel: vi.fn<(params: unknown) => string | undefined>(() => undefined),
}));
const modelProviderAuthMocks = vi.hoisted(() => ({
authenticatedProviders: new Set(["anthropic", "google", "openai"]),
}));
const modelProviderAuthMocks = vi.hoisted(() => {
const state = {
authenticatedProviders: new Set(["anthropic", "google", "openai"]),
createProviderAuthChecker: vi.fn(),
};
state.createProviderAuthChecker.mockImplementation(
() => (provider: string) => state.authenticatedProviders.has(provider),
);
return state;
});
const MODELS_ADD_DEPRECATED_TEXT =
"⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models.";
@@ -32,8 +39,7 @@ vi.mock("../../agents/model-auth-label.js", () => ({
}));
vi.mock("../../agents/model-provider-auth.js", () => ({
createProviderAuthChecker: () => (provider: string) =>
modelProviderAuthMocks.authenticatedProviders.has(provider),
createProviderAuthChecker: modelProviderAuthMocks.createProviderAuthChecker,
hasAuthForModelProvider: ({ provider }: { provider: string }) =>
modelProviderAuthMocks.authenticatedProviders.has(provider),
}));
@@ -104,6 +110,7 @@ beforeEach(() => {
modelAuthLabelMocks.resolveModelAuthLabel.mockReset();
modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined);
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]);
modelProviderAuthMocks.createProviderAuthChecker.mockClear();
setActivePluginRegistry(
createTestRegistry([
...textSurfaceModelsTestPlugins,
@@ -179,6 +186,9 @@ describe("handleModelsCommand", () => {
expect(result?.reply?.text).toContain("Use: /models <provider>");
expect(result?.reply?.text).toContain("Switch: /model <provider/model>");
expect(result?.reply?.text).not.toContain("Add: /models add");
expect(modelProviderAuthMocks.createProviderAuthChecker).toHaveBeenCalledWith(
expect.objectContaining({ workspaceDir: "/tmp" }),
);
});
it("hides unauthenticated providers by default and keeps all as explicit browse", async () => {

View File

@@ -1,4 +1,8 @@
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
@@ -11,6 +15,7 @@ import {
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -63,7 +68,7 @@ type ParsedModelsCommand =
export async function buildModelsProviderData(
cfg: OpenClawConfig,
agentId?: string,
options: { view?: "default" | "all" } = {},
options: { view?: "default" | "all"; workspaceDir?: string } = {},
): Promise<ModelsProviderData> {
const resolvedDefault = resolveDefaultModelForAgent({
cfg,
@@ -77,6 +82,10 @@ export async function buildModelsProviderData(
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId,
workspaceDir:
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir(),
view: options.view,
});
@@ -329,6 +338,7 @@ export async function resolveModelsCommandReply(params: {
currentModel?: string;
agentId?: string;
agentDir?: string;
workspaceDir?: string;
sessionEntry?: ModelsCommandSessionEntry;
}): Promise<ReplyPayload | null> {
const body = params.commandBodyNormalized.trim();
@@ -342,7 +352,10 @@ export async function resolveModelsCommandReply(params: {
const { byProvider, providers, modelNames } = await buildModelsProviderData(
params.cfg,
params.agentId,
parsed.action === "list" && parsed.all ? { view: "all" } : undefined,
{
...(parsed.action === "list" && parsed.all ? { view: "all" as const } : {}),
workspaceDir: params.workspaceDir,
},
);
const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null;
const providerInfos = buildProviderInfos({ providers, byProvider });
@@ -523,6 +536,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
agentId: modelsAgentId,
agentDir: modelsAgentDir,
workspaceDir: modelsAgentId === currentAgentId ? params.workspaceDir : undefined,
sessionEntry: targetSessionEntry,
});
if (!reply) {

View File

@@ -1,4 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
let modelsListCommand: typeof import("./models/list.list-command.js").modelsListCommand;
let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegistry;
@@ -333,6 +337,35 @@ describe("models list/status", () => {
modelRegistryState.available = available ? [ZAI_MODEL, OPENAI_MODEL] : [];
}
async function writeWorkspaceAuthEvidencePlugin(workspaceDir: string) {
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-cloud");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "index.ts"), "export default {}\n", "utf8");
await fs.writeFile(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: "workspace-cloud",
configSchema: { type: "object" },
setup: {
providers: [
{
id: "workspace-cloud",
authEvidence: [
{
type: "local-file-with-env",
fileEnvVar: "WORKSPACE_CLOUD_CREDENTIALS",
credentialMarker: "workspace-cloud-local-credentials",
source: "workspace cloud credentials",
},
],
},
],
},
}),
"utf8",
);
}
beforeAll(async () => {
({ modelsListCommand } = await import("./models/list.list-command.js"));
({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js"));
@@ -410,6 +443,67 @@ describe("models list/status", () => {
expect(payload.models[0]?.available).toBe(false);
});
it("models list uses trusted workspace plugin auth evidence for configured rows", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-models-list-auth-"));
const workspaceDir = path.join(tempRoot, "workspace");
const bundledDir = path.join(tempRoot, "bundled");
const stateDir = path.join(tempRoot, "state");
const credentialsPath = path.join(tempRoot, "credentials.json");
await fs.mkdir(bundledDir, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(credentialsPath, "{}", "utf8");
await writeWorkspaceAuthEvidencePlugin(workspaceDir);
getRuntimeConfig.mockReturnValue({
agents: {
defaults: {
workspace: workspaceDir,
model: "workspace-cloud/model-a",
},
},
plugins: { allow: ["workspace-cloud"] },
models: {
providers: {
"workspace-cloud": {
baseUrl: "https://workspace-cloud.example/v1",
api: "openai-responses",
models: [
{
id: "model-a",
name: "Workspace Cloud Model A",
input: ["text"],
contextWindow: 8192,
maxTokens: 4096,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
});
const runtime = makeRuntime();
try {
await withEnvAsync(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
OPENCLAW_STATE_DIR: stateDir,
WORKSPACE_CLOUD_CREDENTIALS: credentialsPath,
},
() => modelsListCommand({ all: true, provider: "workspace-cloud", json: true }, runtime),
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
const payload = parseJsonLog(runtime);
expect(payload.models).toEqual([
expect.objectContaining({
key: "workspace-cloud/model-a",
available: true,
}),
]);
});
it("models list all includes unauthenticated provider catalog rows", async () => {
setDefaultZaiRegistry({ available: false });
hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);

View File

@@ -1,5 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { createModelListAuthIndex } from "./list.auth-index.js";
type PluginSnapshotResult = {
@@ -61,6 +65,35 @@ function modelConfig(id: string) {
};
}
async function writeWorkspaceAuthEvidencePlugin(workspaceDir: string) {
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-cloud");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "index.ts"), "export default {}\n", "utf8");
await fs.writeFile(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: "workspace-cloud",
configSchema: { type: "object" },
setup: {
providers: [
{
id: "workspace-cloud",
authEvidence: [
{
type: "local-file-with-env",
fileEnvVar: "WORKSPACE_CLOUD_CREDENTIALS",
credentialMarker: "workspace-cloud-local-credentials",
source: "workspace cloud credentials",
},
],
},
],
},
}),
"utf8",
);
}
describe("createModelListAuthIndex", () => {
beforeEach(() => {
envCandidateMocks.resolveProviderEnvApiKeyCandidates.mockClear();
@@ -113,6 +146,47 @@ describe("createModelListAuthIndex", () => {
expect(index.hasProviderAuth("google-vertex")).toBe(true);
});
it("uses trusted workspace plugin auth evidence when workspace scope is supplied", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-list-auth-index-"));
const workspaceDir = path.join(tempRoot, "workspace");
const bundledDir = path.join(tempRoot, "bundled");
const stateDir = path.join(tempRoot, "state");
const credentialsPath = path.join(tempRoot, "credentials.json");
await fs.mkdir(bundledDir, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(credentialsPath, "{}", "utf8");
await writeWorkspaceAuthEvidencePlugin(workspaceDir);
try {
await withEnvAsync(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
OPENCLAW_STATE_DIR: stateDir,
WORKSPACE_CLOUD_CREDENTIALS: credentialsPath,
},
async () => {
const cfg = { plugins: { allow: ["workspace-cloud"] } };
const withoutWorkspace = createModelListAuthIndex({
cfg,
authStore: emptyStore,
env: process.env,
});
const withWorkspace = createModelListAuthIndex({
cfg,
authStore: emptyStore,
workspaceDir,
env: process.env,
});
expect(withoutWorkspace.hasProviderAuth("workspace-cloud")).toBe(false);
expect(withWorkspace.hasProviderAuth("workspace-cloud")).toBe(true);
},
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("records configured provider API keys", () => {
const index = createModelListAuthIndex({
cfg: {

View File

@@ -21,6 +21,7 @@ export type ModelListAuthIndex = {
export type CreateModelListAuthIndexParams = {
cfg: OpenClawConfig;
authStore: AuthProfileStore;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
syntheticAuthProviderRefs?: readonly string[];
};
@@ -39,10 +40,12 @@ function normalizeAuthProvider(
function listValidatedSyntheticAuthProviderRefs(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): readonly string[] {
const result = loadPluginRegistrySnapshotWithMetadata({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (result.source !== "persisted" && result.source !== "provided") {
@@ -57,9 +60,14 @@ export function createModelListAuthIndex(
params: CreateModelListAuthIndexParams,
): ModelListAuthIndex {
const env = params.env ?? process.env;
const aliasMap = resolveProviderAuthAliasMap({ config: params.cfg, env });
const envCandidateMap = resolveProviderEnvApiKeyCandidates({ config: params.cfg, env });
const authEvidenceMap = resolveProviderEnvAuthEvidence({ config: params.cfg, env });
const lookupParams = {
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
};
const aliasMap = resolveProviderAuthAliasMap(lookupParams);
const envCandidateMap = resolveProviderEnvApiKeyCandidates(lookupParams);
const authEvidenceMap = resolveProviderEnvAuthEvidence(lookupParams);
const authenticatedProviders = new Set<string>();
const syntheticAuthProviders = new Set<string>();
const envProviderAuthCache = new Map<string, boolean>();
@@ -90,6 +98,7 @@ export function createModelListAuthIndex(
aliasMap,
candidateMap: envCandidateMap,
authEvidenceMap,
workspaceDir: params.workspaceDir,
})
) {
addProvider(provider);
@@ -110,7 +119,11 @@ export function createModelListAuthIndex(
}
for (const provider of params.syntheticAuthProviderRefs ??
listValidatedSyntheticAuthProviderRefs({ cfg: params.cfg, env })) {
listValidatedSyntheticAuthProviderRefs({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
env,
})) {
addSyntheticProvider(provider);
}

View File

@@ -66,6 +66,7 @@ export function resolveProviderAuthOverview(params: {
store: AuthProfileStore;
modelsPath: string;
agentDir?: string;
workspaceDir?: string;
syntheticAuth?: { value: string; source: string };
}): ProviderAuthOverview {
const { provider, cfg, store } = params;
@@ -123,7 +124,10 @@ export function resolveProviderAuthOverview(params: {
const tokenCount = profiles.filter((id) => store.profiles[id]?.type === "token").length;
const apiKeyCount = profiles.filter((id) => store.profiles[id]?.type === "api_key").length;
const envKey = resolveEnvApiKey(provider);
const envKey = resolveEnvApiKey(provider, process.env, {
config: cfg,
workspaceDir: params.workspaceDir,
});
const customKey = getCustomProviderApiKey(cfg, provider);
const usableCustomKey = resolveUsableCustomProviderApiKey({ cfg, provider });

View File

@@ -65,18 +65,26 @@ export async function modelsListCommand(
if (providerFilter === null) {
return;
}
const [{ loadAuthProfileStoreWithoutExternalProfiles }, { resolveOpenClawAgentDir }] =
await Promise.all([
import("../../agents/auth-profiles/store.js"),
import("../../agents/agent-paths.js"),
]);
const [
{ loadAuthProfileStoreWithoutExternalProfiles },
{ resolveOpenClawAgentDir },
{ resolveAgentWorkspaceDir, resolveDefaultAgentId },
{ resolveDefaultAgentWorkspaceDir },
] = await Promise.all([
import("../../agents/auth-profiles/store.js"),
import("../../agents/agent-paths.js"),
import("../../agents/agent-scope.js"),
import("../../agents/workspace.js"),
]);
const { resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",
runtime,
});
const authStore = loadAuthProfileStoreWithoutExternalProfiles();
const agentDir = resolveOpenClawAgentDir();
const authIndex = createModelListAuthIndex({ cfg, authStore });
const workspaceDir =
resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? resolveDefaultAgentWorkspaceDir();
const authIndex = createModelListAuthIndex({ cfg, authStore, workspaceDir });
let modelRegistry: ModelRegistry | undefined;
let registryModels: Model<Api>[] = [];

View File

@@ -4,6 +4,8 @@ import {
resolveAgentDir,
resolveAgentExplicitModelPrimary,
resolveAgentModelFallbacksOverride,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import {
buildAuthHealthSummary,
@@ -24,6 +26,7 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { createConfigIO } from "../../config/config.js";
import {
resolveAgentModelFallbackValues,
@@ -161,6 +164,9 @@ export async function modelsStatusCommand(
const cfg = await loadModelsConfig({ commandName: "models status", runtime });
const agentId = resolveKnownAgentId({ cfg, rawAgentId: opts.agent });
const agentDir = agentId ? resolveAgentDir(cfg, agentId) : resolveOpenClawAgentDir();
const workspaceAgentId = agentId ?? resolveDefaultAgentId(cfg);
const workspaceDir =
resolveAgentWorkspaceDir(cfg, workspaceAgentId) ?? resolveDefaultAgentWorkspaceDir();
const agentModelPrimary = agentId ? resolveAgentExplicitModelPrimary(cfg, agentId) : undefined;
const agentFallbacksOverride = agentId
? resolveAgentModelFallbacksOverride(cfg, agentId)
@@ -241,8 +247,12 @@ export async function modelsStatusCommand(
const providersFromEnv = new Set<string>();
// Use the shared provider-env registry so `models status` stays aligned with
// env-backed providers beyond the text-model defaults (for example image-gen).
for (const provider of Object.keys(resolveProviderEnvApiKeyCandidates()).toSorted()) {
if (resolveEnvApiKey(provider)) {
const envCandidateMap = resolveProviderEnvApiKeyCandidates({
config: cfg,
workspaceDir,
});
for (const provider of Object.keys(envCandidateMap).toSorted()) {
if (resolveEnvApiKey(provider, process.env, { config: cfg, workspaceDir })) {
providersFromEnv.add(provider);
}
}
@@ -296,6 +306,7 @@ export async function modelsStatusCommand(
store,
modelsPath,
agentDir,
workspaceDir,
syntheticAuth: syntheticAuthByProvider.get(provider),
}),
)

View File

@@ -38,6 +38,7 @@ const mocks = vi.hoisted(() => {
resolveOpenClawAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"),
resolveAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"),
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/openclaw-agent/workspace"),
resolveDefaultAgentId: vi.fn().mockReturnValue("main"),
resolveAgentExplicitModelPrimary: vi.fn().mockReturnValue(undefined),
resolveAgentEffectiveModelPrimary: vi.fn().mockReturnValue(undefined),
resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined),
@@ -132,11 +133,15 @@ vi.mock("../../agents/agent-paths.js", () => ({
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: mocks.resolveAgentDir,
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary,
resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary,
resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride,
listAgentIds: mocks.listAgentIds,
}));
vi.mock("../../agents/workspace.js", () => ({
resolveDefaultAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/openclaw-agent/workspace"),
}));
vi.mock("../../agents/auth-profiles/display.js", () => ({
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
}));

View File

@@ -1,5 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import {
ErrorCodes,
errorShape,
@@ -30,6 +32,9 @@ export const modelsHandlers: GatewayRequestHandlers = {
try {
const catalog = await context.loadGatewayModelCatalog();
const cfg = context.getRuntimeConfig();
const workspaceDir =
resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ??
resolveDefaultAgentWorkspaceDir();
const view = resolveModelsListView(params);
if (view === "all") {
respond(true, { models: catalog }, undefined);
@@ -39,6 +44,7 @@ export const modelsHandlers: GatewayRequestHandlers = {
cfg,
catalog,
defaultProvider: DEFAULT_PROVIDER,
workspaceDir,
view,
});
respond(true, { models }, undefined);