From 72e67904be8b17985b18893ece68fccaea50cb59 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 16:45:37 -0700 Subject: [PATCH] fix(cli): infer provider lists respect selected agent (#123884) * fix(cli): require inference provider owners * test(cli): type provider owner fixtures * chore: leave infer release note to release --- docs/cli/infer.md | 11 +- src/agents/agent-scope-config.ts | 12 +- src/cli/capability-cli.test.ts | 152 ++++++++++++++++++++++++-- src/cli/capability-cli/audio.ts | 4 + src/cli/capability-cli/embedding.ts | 7 +- src/cli/capability-cli/image.ts | 5 +- src/cli/capability-cli/metadata.ts | 14 +-- src/cli/capability-cli/model.ts | 23 ++-- src/cli/capability-cli/shared.ts | 37 ++++++- src/cli/capability-cli/tts-runtime.ts | 10 +- src/cli/capability-cli/tts.ts | 24 ++-- src/cli/capability-cli/video.ts | 7 +- src/cli/capability-cli/web.ts | 7 +- src/web-search/runtime.ts | 3 +- 14 files changed, 264 insertions(+), 52 deletions(-) diff --git a/docs/cli/infer.md b/docs/cli/infer.md index 244b3a17a9ca..29e7d85c2ba1 100644 --- a/docs/cli/infer.md +++ b/docs/cli/infer.md @@ -106,6 +106,11 @@ A good infer-based skill maps common user intents to the right subcommand, inclu - For `image describe`, `--file` accepts local paths and HTTP(S) URLs; remote URLs go through the normal media-fetch SSRF policy. - Stateless execution commands (`model run`, `image *`, `audio *`, `video *`, `web *`, `embedding *`) default to local. Gateway-managed state commands (`tts status`) default to gateway. - The local path never requires the gateway to be running. +- Provider inventory commands whose `configured` state can come from saved agent auth accept + `--agent `. Without it, they use `agents.defaults.systemAgent.agentId` or the sole configured + agent; explicit multi-agent fleets with no system owner must pass `--agent`. The provider catalog + remains aggregate; `--agent` scopes saved-auth and per-agent selection facts. Gateway-owned TTS + provider state remains Gateway-global, so `tts providers --gateway` does not accept `--agent`. - Generated image and video `--output` files are staged beside the destination and replace it only after the complete buffer is written; a failed write leaves an existing destination unchanged. - Local `model run` is a lean one-shot provider completion: it resolves the configured agent model and auth but does not start a chat-agent turn, load tools, or open bundled MCP servers. - `model run --file` attaches image files (auto-detected MIME type) to the prompt; repeat `--file` for multiple images. Non-image files are rejected — use `infer audio transcribe` or `infer video describe` instead. @@ -121,7 +126,7 @@ openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json openclaw infer model run --prompt "Summarize this changelog entry" --model openai/gpt-5.4 --json openclaw infer model run --prompt "Describe this image in one sentence" --file ./photo.jpg --model google/gemini-2.5-flash --json openclaw infer model run --prompt "Use more reasoning here" --thinking high --json -openclaw infer model providers --json +openclaw infer model providers --agent --json openclaw infer model inspect --model gpt-5.6-sol --json ``` @@ -254,7 +259,7 @@ Search and fetch. openclaw infer web search --query "OpenClaw docs" --json openclaw infer web search --query "OpenClaw infer web providers" --json openclaw infer web fetch --url https://docs.openclaw.ai/cli/infer --json -openclaw infer web providers --json +openclaw infer web providers --agent --json ``` `web providers` lists available, configured, and selected providers for search and fetch. @@ -266,7 +271,7 @@ Vector creation and embedding-provider inspection. ```bash openclaw infer embedding create --text "friendly lobster" --json openclaw infer embedding create --text "customer support ticket: delayed shipment" --model openai/text-embedding-3-large --json -openclaw infer embedding providers --json +openclaw infer embedding providers --agent --json ``` ## JSON output diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index aeb8e8cd853b..b4273af5f602 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -223,6 +223,7 @@ export function tryResolveLegacyCompatibilityAgentId(cfg: OpenClawConfig): strin export function resolveSystemAgentTargetAgentId( cfg: OpenClawConfig, requestedAgentId?: string, + context?: AgentSelectionContext, ): string { const configuredAgentId = normalizeOptionalString(requestedAgentId) ?? @@ -231,10 +232,13 @@ export function resolveSystemAgentTargetAgentId( return normalizeAgentId(configuredAgentId); } return normalizeAgentId( - resolveSoleAgentId(cfg, { - surface: "system-agent consult routing", - hint: "Set agents.defaults.systemAgent.agentId or pass an explicit consult agent id.", - }), + resolveSoleAgentId( + cfg, + context ?? { + surface: "system-agent consult routing", + hint: "Set agents.defaults.systemAgent.agentId or pass an explicit consult agent id.", + }, + ), ); } diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 724296e4872f..59dd81807d77 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -46,8 +46,12 @@ const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({})), getRuntimeConfigSourceSnapshot: vi.fn(() => null), setRuntimeConfigSnapshot: vi.fn(), - loadAuthProfileStoreForRuntime: vi.fn(() => ({ profiles: {}, order: {} })), - listProfilesForProvider: vi.fn(() => []), + loadAuthProfileStoreForRuntime: vi.fn< + typeof import("../agents/auth-profiles.js").loadAuthProfileStoreForRuntime + >(() => ({ version: 1, profiles: {}, order: {} })), + listProfilesForProvider: vi.fn< + typeof import("../agents/auth-profiles.js").listProfilesForProvider + >(() => []), resolveApiKeyForProviderCore: vi.fn(), loadManifestMetadataSnapshot: vi.fn(() => ({ manifestRegistry: { plugins: [] } })), planEffectiveModelCatalogRows: vi.fn< @@ -67,7 +71,9 @@ const mocks = vi.hoisted(() => ({ return store; }, ), - resolveMemorySearchConfig: vi.fn(() => null), + resolveMemorySearchConfig: vi.fn< + typeof import("../agents/memory-search.js").resolveMemorySearchConfig + >(() => null), loadModelCatalog: vi.fn(async () => []), prepareSimpleCompletionModelForAgent: vi.fn(async () => ({ selection: { @@ -184,7 +190,12 @@ const mocks = vi.hoisted(() => ({ entries: [], })), convertHeicToJpeg: vi.fn(async () => Buffer.from("jpeg-normalized")), - isWebSearchProviderConfigured: vi.fn(() => false), + listWebSearchProviders: vi.fn( + () => [], + ), + isWebSearchProviderConfigured: vi.fn< + typeof import("../web-search/runtime.js").isWebSearchProviderConfigured + >(() => false), isWebFetchProviderConfigured: vi.fn(() => false), getModelsCommandSecretTargetIds: vi.fn(() => new Set(["models.providers.*.apiKey"])), getMemoryEmbeddingCommandSecretTargetIds: vi.fn(() => new Set(["models.providers.*.apiKey"])), @@ -279,7 +290,15 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "main", resolveAgentDir: mocks.resolveAgentDir, resolveAgentConfig: () => ({}), - resolveAgentEffectiveModelPrimary: () => undefined, + resolveAgentEffectiveModelPrimary: ( + cfg: { + agents?: { + defaults?: { model?: string }; + entries?: Record; + }; + }, + agentId: string, + ) => cfg.agents?.entries?.[agentId]?.model ?? cfg.agents?.defaults?.model, resolveAgentModelFallbacksOverride: () => [], })); @@ -422,7 +441,7 @@ vi.mock("../tts/provider-registry.js", () => ({ })); vi.mock("../web-search/runtime.js", () => ({ - listWebSearchProviders: vi.fn(() => []), + listWebSearchProviders: mocks.listWebSearchProviders, isWebSearchProviderConfigured: mocks.isWebSearchProviderConfigured as typeof import("../web-search/runtime.js").isWebSearchProviderConfigured, runWebSearch: vi.fn(), @@ -520,13 +539,16 @@ describe("capability cli", () => { beforeEach(() => { vi.stubEnv("OPENAI_API_KEY", ""); + mocks.loadConfig.mockReset().mockReturnValue({}); mocks.runtime.log.mockClear(); mocks.runtime.error.mockClear(); mocks.runtime.writeJson.mockClear(); mocks.loadModelCatalog .mockReset() .mockResolvedValue([{ id: "gpt-5.4", provider: "openai", name: "GPT-5.4" }] as never); - mocks.loadAuthProfileStoreForRuntime.mockReset().mockReturnValue({ profiles: {}, order: {} }); + mocks.loadAuthProfileStoreForRuntime + .mockReset() + .mockReturnValue({ version: 1, profiles: {}, order: {} }); mocks.listProfilesForProvider.mockReset().mockReturnValue([]); mocks.resolveApiKeyForProviderCore.mockReset().mockRejectedValue(new Error("no auth profile")); mocks.loadManifestMetadataSnapshot @@ -607,6 +629,7 @@ describe("capability cli", () => { { id: "openai", defaultModel: "text-embedding-3-small", transport: "remote" }, ]); mocks.listEmbeddingProviders.mockReset().mockReturnValue([]); + mocks.listWebSearchProviders.mockReset().mockReturnValue([]); mocks.isWebSearchProviderConfigured.mockReset().mockReturnValue(false); mocks.isWebFetchProviderConfigured.mockReset().mockReturnValue(false); mocks.getModelsCommandSecretTargetIds.mockClear(); @@ -795,6 +818,7 @@ describe("capability cli", () => { expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ config: mocks.loadConfig(), + ...(command === "providers" ? { agentId: "main" } : {}), readOnly: true, }); }); @@ -814,6 +838,120 @@ describe("capability cli", () => { expect(mocks.getProviderEnvVars).toHaveBeenCalledWith("openai"); }); + it("scopes provider state and model selection to an explicit agent", async () => { + const cfg = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "beta" } }, + entries: { + alpha: { model: "anthropic/claude-sonnet-4-6" }, + beta: { model: "openai/gpt-5.4" }, + }, + }, + }; + mocks.loadConfig.mockReturnValue(cfg); + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-sonnet-4-6", provider: "anthropic", name: "Claude" }, + { id: "gpt-5.4", provider: "openai", name: "GPT" }, + ] as never); + mocks.loadAuthProfileStoreForRuntime.mockImplementation( + (agentDir) => + ({ + profiles: + agentDir === "/tmp/agent-alpha" + ? { "anthropic:alpha": { provider: "anthropic" } } + : { "openai:beta": { provider: "openai" } }, + order: {}, + }) as never, + ); + mocks.listProfilesForProvider.mockImplementation((store, provider) => + Object.entries(store.profiles as Record) + .filter(([, profile]) => profile.provider === provider) + .map(([id]) => id), + ); + + await runCapability("model", "providers", "--agent", "alpha", "--json"); + + expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ + config: cfg, + agentId: "alpha", + readOnly: true, + }); + expect(mocks.resolveAgentDir.mock.calls.map((call) => call[1])).toEqual(["alpha", "alpha"]); + expect(firstJsonOutput()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ provider: "anthropic", configured: true, selected: true }), + expect.objectContaining({ provider: "openai", configured: false, selected: false }), + ]), + ); + }); + + it("uses the configured system agent for embedding provider state", async () => { + const cfg = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "beta" } }, + entries: { alpha: {}, beta: {} }, + }, + }; + mocks.loadConfig.mockReturnValue(cfg); + mocks.resolveMemorySearchConfig.mockImplementation( + (_cfg, agentId) => + (agentId === "beta" + ? { provider: "openai", model: "text-embedding-3-small" } + : null) as never, + ); + + await runCapability("embedding", "providers", "--json"); + + expect(mocks.resolveMemorySearchConfig).toHaveBeenCalledWith(cfg, "beta"); + expect(mocks.resolveAgentDir.mock.calls.every((call) => call[1] === "beta")).toBe(true); + expect(firstJsonOutput()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "openai", configured: true, selected: true }), + ]), + ); + }); + + it("requires an explicit owner for agent-backed provider state in explicit fleets", async () => { + mocks.loadConfig.mockReturnValue({ + agents: { + ownership: "explicit", + entries: { alpha: {}, beta: {} }, + }, + }); + + await expect(runCapability("audio", "providers", "--json")).rejects.toThrow("exit 1"); + + expectRuntimeErrorContains("inference provider inspection has no explicit owner"); + expectRuntimeErrorContains("Pass --agent or set agents.defaults.systemAgent.agentId"); + expect(mocks.loadAuthProfileStoreForRuntime).not.toHaveBeenCalled(); + }); + + it("scopes web search auth inspection to the selected agent", async () => { + mocks.loadConfig.mockReturnValue({ + agents: { + ownership: "explicit", + entries: { alpha: {}, beta: {} }, + }, + }); + const provider = { + id: "openai", + envVars: ["OPENAI_API_KEY"], + requiresCredential: true, + }; + mocks.listWebSearchProviders.mockReturnValue([provider] as never); + + await runCapability("web", "providers", "--agent", "beta", "--json"); + + expect(firstJsonOutput()).toMatchObject({ search: [{ id: "openai" }], fetch: [] }); + expect(mocks.isWebSearchProviderConfigured).toHaveBeenCalledWith({ + provider, + config: mocks.loadConfig(), + agentDir: "/tmp/agent-beta", + }); + }); + it("inspects runtime-declared manifest models without live discovery", async () => { mocks.loadModelCatalog.mockResolvedValueOnce([] as never); mocks.planEffectiveModelCatalogRows.mockReturnValueOnce({ diff --git a/src/cli/capability-cli/audio.ts b/src/cli/capability-cli/audio.ts index 361f2848842e..a8f1c6c90c8c 100644 --- a/src/cli/capability-cli/audio.ts +++ b/src/cli/capability-cli/audio.ts @@ -16,6 +16,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, } from "./shared.js"; @@ -80,10 +81,12 @@ export function registerAudioCapabilityCommands(capability: Command): void { audio .command("providers") .description("List audio transcription providers") + .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); + const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); const remoteProviders = [...buildMediaUnderstandingRegistry(undefined, cfg).values()] .filter((provider) => provider.capabilities?.includes("audio")) .map((provider) => ({ @@ -91,6 +94,7 @@ export function registerAudioCapabilityCommands(capability: Command): void { configured: providerHasGenericConfig({ cfg, providerId: provider.id, + agentId, envVars: getProviderEnvVars(provider.id, { config: cfg, includeUntrustedWorkspacePlugins: false, diff --git a/src/cli/capability-cli/embedding.ts b/src/cli/capability-cli/embedding.ts index 28a39fe56d65..fb3d3222e8ed 100644 --- a/src/cli/capability-cli/embedding.ts +++ b/src/cli/capability-cli/embedding.ts @@ -17,6 +17,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, } from "./shared.js"; @@ -122,11 +123,12 @@ export function registerEmbeddingCapabilityCommands(capability: Command): void { embedding .command("providers") .description("List embedding providers") + .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); - const agentId = resolveDefaultAgentId(cfg); + const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); const selectedProvider = resolvedMemory?.provider; const providers = new Map( @@ -155,7 +157,7 @@ export function registerEmbeddingCapabilityCommands(capability: Command): void { providers.set(selectedProvider, { id: selectedProvider, defaultModel: resolvedMemory?.model || undefined, - transport: providerHasGenericConfig({ cfg, providerId: selectedProvider }) + transport: providerHasGenericConfig({ cfg, providerId: selectedProvider, agentId }) ? "remote" : undefined, autoSelectPriority: undefined, @@ -168,6 +170,7 @@ export function registerEmbeddingCapabilityCommands(capability: Command): void { providerHasGenericConfig({ cfg, providerId: provider.id, + agentId, }), selected: provider.id === selectedProvider, id: provider.id, diff --git a/src/cli/capability-cli/image.ts b/src/cli/capability-cli/image.ts index c3ecfae27b0d..a12ff604c914 100644 --- a/src/cli/capability-cli/image.ts +++ b/src/cli/capability-cli/image.ts @@ -40,6 +40,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, } from "./shared.js"; @@ -407,10 +408,12 @@ export function registerImageCapabilityCommands(capability: Command): void { image .command("providers") .description("List image generation providers") + .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); + const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); const selectedProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.mediaModels?.image), ); @@ -418,7 +421,7 @@ export function registerImageCapabilityCommands(capability: Command): void { available: true, configured: selectedProvider === provider.id || - providerHasGenericConfig({ cfg, providerId: provider.id }), + providerHasGenericConfig({ cfg, providerId: provider.id, agentId }), selected: selectedProvider === provider.id, id: provider.id, label: provider.label, diff --git a/src/cli/capability-cli/metadata.ts b/src/cli/capability-cli/metadata.ts index 384a1bb637bf..a7c719888a6f 100644 --- a/src/cli/capability-cli/metadata.ts +++ b/src/cli/capability-cli/metadata.ts @@ -47,7 +47,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "model.providers", description: "List model providers discovered from the catalog.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "provider ids with counts and defaults", }, { @@ -134,7 +134,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "image.providers", description: "List image generation providers.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "provider ids and defaults", }, { @@ -148,7 +148,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "audio.providers", description: "List audio transcription providers.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "provider ids and capabilities", }, { @@ -179,7 +179,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "tts.providers", description: "List speech providers.", transports: ["local", "gateway"], - flags: ["--local", "--gateway", "--json"], + flags: ["--agent", "--local", "--gateway", "--json"], resultShape: "provider ids, configured state, models, voices", }, { @@ -254,7 +254,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "video.providers", description: "List video generation and description providers.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "provider ids and defaults", }, { @@ -275,7 +275,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "web.providers", description: "List web search and fetch providers.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "provider ids grouped by family", }, { @@ -289,7 +289,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "embedding.providers", description: "List embedding providers.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "provider ids and default models", }, ]; diff --git a/src/cli/capability-cli/model.ts b/src/cli/capability-cli/model.ts index 7a55cb4de9c8..2123f0543b46 100644 --- a/src/cli/capability-cli/model.ts +++ b/src/cli/capability-cli/model.ts @@ -11,7 +11,11 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../../packages/gateway-protocol/src/client-info.js"; -import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + resolveAgentDir, + resolveAgentEffectiveModelPrimary, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import { listProfilesForProvider, loadAuthProfileStoreForRuntime, @@ -27,7 +31,6 @@ import { } from "../../agents/simple-completion-runtime.js"; import { normalizeThinkLevel, type ThinkLevel } from "../../auto-reply/thinking.js"; import { getRuntimeConfig } from "../../config/config.js"; -import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; import { ADMIN_SCOPE } from "../../gateway/operator-scopes.js"; @@ -46,6 +49,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, resolveTransport, @@ -54,8 +58,8 @@ import { const LOCAL_MODEL_RUN_SYSTEM_PROMPT = "You are a personal assistant running inside OpenClaw."; const HEIC_MODEL_RUN_MIMES = new Set(["image/heic", "image/heif"]); -async function loadModelCatalogForInspection(cfg: OpenClawConfig) { - const prepared = await loadPreparedModelCatalog({ config: cfg, readOnly: true }); +async function loadModelCatalogForInspection(cfg: OpenClawConfig, agentId?: string) { + const prepared = await loadPreparedModelCatalog({ config: cfg, agentId, readOnly: true }); const metadataSnapshot = loadManifestMetadataSnapshot({ config: cfg, env: process.env }); const manifest = planEffectiveModelCatalogRows({ registry: metadataSnapshot.manifestRegistry, @@ -342,11 +346,12 @@ async function runModelRun(params: { } satisfies CapabilityEnvelope; } -async function buildModelProviders() { +async function buildModelProviders(rawAgentId?: string) { const cfg = getRuntimeConfig(); - const catalog = await loadModelCatalogForInspection(cfg); + const agentId = resolveCapabilityProviderAgentId(cfg, rawAgentId); + const catalog = await loadModelCatalogForInspection(cfg, agentId); const selectedProvider = resolveSelectedProviderFromModelRef( - resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), + resolveAgentEffectiveModelPrimary(cfg, agentId), ); const grouped = new Map< string, @@ -368,6 +373,7 @@ async function buildModelProviders() { configured: providerHasGenericConfig({ cfg, providerId: entry.provider, + agentId, envVars: getProviderEnvVars(entry.provider), }), selected: selectedProvider === entry.provider, @@ -511,10 +517,11 @@ export function registerModelCapabilityCommands(capability: Command): void { model .command("providers") .description("List model providers from the catalog") + .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const result = await buildModelProviders(); + const result = await buildModelProviders(opts.agent as string | undefined); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, providerSummaryText); }); }); diff --git a/src/cli/capability-cli/shared.ts b/src/cli/capability-cli/shared.ts index 45572467e773..8426af514a54 100644 --- a/src/cli/capability-cli/shared.ts +++ b/src/cli/capability-cli/shared.ts @@ -2,7 +2,8 @@ import { parseStrictFiniteNumber, parseStrictPositiveInteger, } from "@openclaw/normalization-core/number-coercion"; -import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds, resolveSystemAgentTargetAgentId } from "../../agents/agent-scope-config.js"; +import { resolveAgentDir } from "../../agents/agent-scope.js"; import { listProfilesForProvider, loadAuthProfileStoreForRuntime, @@ -101,8 +102,32 @@ export function resolveSelectedProviderFromModelRef( return resolveModelRefOverride(modelRef).provider; } -function getAuthProfileIdsForProvider(cfg: OpenClawConfig, providerId: string): string[] { - const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg)); +export function resolveCapabilityProviderAgentId( + cfg: OpenClawConfig, + rawAgentId: string | undefined, +): string { + const requestedAgentId = rawAgentId?.trim(); + if (rawAgentId !== undefined && !requestedAgentId) { + throw new Error("--agent must not be blank"); + } + const agentId = resolveSystemAgentTargetAgentId(cfg, requestedAgentId, { + surface: "inference provider inspection", + hint: "Pass --agent or set agents.defaults.systemAgent.agentId.", + }); + if (!listAgentIds(cfg).includes(agentId)) { + throw new Error( + `Unknown agent id "${agentId}". Run \`openclaw agents list\` to see configured agents.`, + ); + } + return agentId; +} + +function getAuthProfileIdsForProvider( + cfg: OpenClawConfig, + providerId: string, + agentId: string, +): string[] { + const agentDir = resolveAgentDir(cfg, agentId); const store = loadAuthProfileStoreForRuntime(agentDir); return listProfilesForProvider(store, providerId); } @@ -110,6 +135,8 @@ function getAuthProfileIdsForProvider(cfg: OpenClawConfig, providerId: string): export function providerHasGenericConfig(params: { cfg: OpenClawConfig; providerId: string; + /** Omit only for aggregate/global callers that intentionally exclude agent auth stores. */ + agentId?: string; envVars?: string[]; }): boolean { const modelsProviders = (params.cfg.models?.providers ?? {}) as Record; @@ -123,7 +150,9 @@ export function providerHasGenericConfig(params: { }); const envConfigured = envVars.some((envVar) => Boolean(process.env[envVar]?.trim())); return ( - getAuthProfileIdsForProvider(params.cfg, params.providerId).length > 0 || + (params.agentId + ? getAuthProfileIdsForProvider(params.cfg, params.providerId, params.agentId).length > 0 + : false) || hasOwnKeys(modelsProviders[params.providerId]) || hasOwnKeys(pluginEntries[params.providerId]?.config) || hasOwnKeys(ttsProviders[params.providerId]) || diff --git a/src/cli/capability-cli/tts-runtime.ts b/src/cli/capability-cli/tts-runtime.ts index a9fe3abb17b6..2052e3f9e7c0 100644 --- a/src/cli/capability-cli/tts-runtime.ts +++ b/src/cli/capability-cli/tts-runtime.ts @@ -31,6 +31,7 @@ import type { CapabilityEnvelope, CapabilityTransport } from "./metadata.js"; import { pinRuntimeConfigSnapshot, providerHasGenericConfig, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, } from "./shared.js"; @@ -413,9 +414,12 @@ function resolvedTtsConfigHasProviderApiKey(config: unknown, providerId: string) return ttsProviderConfigHasApiKey(config.providerConfigs[providerId]); } -export async function runTtsProviders(transport: CapabilityTransport) { +export async function runTtsProviders(transport: CapabilityTransport, rawAgentId?: string) { const cfg = getRuntimeConfig(); if (transport === "gateway") { + if (rawAgentId !== undefined) { + throw new Error("--agent is only supported with local TTS provider inspection."); + } const payload: { providers?: Array>; active?: string; @@ -441,6 +445,7 @@ export async function runTtsProviders(transport: CapabilityTransport) { }), }; } + const agentId = resolveCapabilityProviderAgentId(cfg, rawAgentId); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); const active = getTtsProvider(config, prefsPath); @@ -448,7 +453,8 @@ export async function runTtsProviders(transport: CapabilityTransport) { providers: listSpeechProviders(cfg).map((provider) => ({ available: true, configured: - active === provider.id || providerHasGenericConfig({ cfg, providerId: provider.id }), + active === provider.id || + providerHasGenericConfig({ cfg, providerId: provider.id, agentId }), selected: active === provider.id, id: provider.id, name: provider.label, diff --git a/src/cli/capability-cli/tts.ts b/src/cli/capability-cli/tts.ts index 145434dd3796..8ffec0aa3a32 100644 --- a/src/cli/capability-cli/tts.ts +++ b/src/cli/capability-cli/tts.ts @@ -95,16 +95,20 @@ export function registerTtsCapabilityCommands(capability: Command): void { }); }); - for (const [name, description, run] of [ - ["providers", "List speech providers", runTtsProviders], - ["personas", "List TTS personas", runTtsPersonas], - ] as const) { - registerTransportTtsCommand( - tts.command(name).description(description), - "local", - (_, transport) => run(transport), - ); - } + registerTransportTtsCommand( + tts + .command("providers") + .description("List speech providers") + .option("--agent ", "Agent whose provider state should be inspected"), + "local", + (opts, transport) => runTtsProviders(transport, opts.agent as string | undefined), + ); + + registerTransportTtsCommand( + tts.command("personas").description("List TTS personas"), + "local", + (_, transport) => runTtsPersonas(transport), + ); tts .command("status") diff --git a/src/cli/capability-cli/video.ts b/src/cli/capability-cli/video.ts index 82b2fd52ce15..777b251af7e3 100644 --- a/src/cli/capability-cli/video.ts +++ b/src/cli/capability-cli/video.ts @@ -39,6 +39,7 @@ import { parseOptionalTimeoutMs, providerHasGenericConfig, requireProviderModelOverride, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, } from "./shared.js"; @@ -310,10 +311,12 @@ export function registerVideoCapabilityCommands(capability: Command): void { video .command("providers") .description("List video generation and description providers") + .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); + const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); const selectedGenerationProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.mediaModels?.video), ); @@ -322,7 +325,7 @@ export function registerVideoCapabilityCommands(capability: Command): void { available: true, configured: selectedGenerationProvider === provider.id || - providerHasGenericConfig({ cfg, providerId: provider.id }), + providerHasGenericConfig({ cfg, providerId: provider.id, agentId }), selected: selectedGenerationProvider === provider.id, id: provider.id, label: provider.label, @@ -334,7 +337,7 @@ export function registerVideoCapabilityCommands(capability: Command): void { .filter((provider) => provider.capabilities?.includes("video")) .map((provider) => ({ available: true, - configured: providerHasGenericConfig({ cfg, providerId: provider.id }), + configured: providerHasGenericConfig({ cfg, providerId: provider.id, agentId }), selected: false, id: provider.id, capabilities: provider.capabilities, diff --git a/src/cli/capability-cli/web.ts b/src/cli/capability-cli/web.ts index 805dd94e0633..19881cde2545 100644 --- a/src/cli/capability-cli/web.ts +++ b/src/cli/capability-cli/web.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; +import { resolveAgentDir } from "../../agents/agent-scope.js"; import { getRuntimeConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -22,6 +23,7 @@ import { emitJsonOrText, formatEnvelopeForText, parseOptionalPositiveInteger, + resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, } from "./shared.js"; @@ -178,10 +180,13 @@ export function registerWebCapabilityCommands(capability: Command): void { web .command("providers") .description("List web providers") + .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); + const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); + const agentDir = resolveAgentDir(cfg, agentId); const selectedSearchProvider = typeof cfg.tools?.web?.search?.provider === "string" ? normalizeLowercaseStringOrEmpty(cfg.tools.web.search.provider) @@ -193,7 +198,7 @@ export function registerWebCapabilityCommands(capability: Command): void { const result = { search: listWebSearchProviders({ config: cfg }).map((provider) => ({ available: true, - configured: isWebSearchProviderConfigured({ provider, config: cfg }), + configured: isWebSearchProviderConfigured({ provider, config: cfg, agentDir }), selected: provider.id === selectedSearchProvider, id: provider.id, envVars: provider.envVars, diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 04425afeb239..00cb6a6e3cae 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -137,9 +137,10 @@ export function isWebSearchProviderConfigured(params: { | "requiresCredential" >; config?: OpenClawConfig; + agentDir?: string; }): boolean { const config = resolveWebSearchRuntimeConfig({ config: params.config }); - return hasEntryCredential(params.provider, config, resolveSearchConfig(config)); + return hasEntryCredential(params.provider, config, resolveSearchConfig(config), params.agentDir); } /** Lists runtime web_search providers after applying runtime config snapshots. */