diff --git a/src/media-understanding/runner.entries.guards.test.ts b/src/media-understanding/runner.entries.guards.test.ts index 66dfe4c655f3..ca8104f7ed0a 100644 --- a/src/media-understanding/runner.entries.guards.test.ts +++ b/src/media-understanding/runner.entries.guards.test.ts @@ -1,7 +1,7 @@ // Runner entry guard tests cover malformed decision data formatting without // depending on provider execution. import { describe, expect, it } from "vitest"; -import { formatDecisionSummary } from "./runner.entries.js"; +import { formatDecisionSummary, formatMissingProviderHint } from "./runner.entries.js"; import type { MediaUnderstandingDecision } from "./types.js"; describe("media-understanding formatDecisionSummary guards", () => { @@ -45,3 +45,61 @@ describe("media-understanding formatDecisionSummary guards", () => { ).toBe("audio: failed (0/1)"); }); }); + +describe("media-understanding formatMissingProviderHint", () => { + it("returns the catalog hint for a provider with mediaUnderstandingProviders contract (groq)", () => { + const hint = formatMissingProviderHint("groq"); + expect(hint).toContain("openclaw plugins install @openclaw/groq-provider"); + expect(hint).toContain("openclaw plugins registry --refresh"); + expect(hint).toContain("stop and start the gateway service"); + expect(hint).toContain("openclaw doctor --fix"); + expect(hint).toContain("official external plugin"); + }); + + it("returns empty string for a provider with only generic providers[] entry but no mediaUnderstandingProviders contract (amazon-bedrock)", () => { + const hint = formatMissingProviderHint("amazon-bedrock"); + expect(hint).toBe(""); + }); + + it("returns empty string for a non-cataloged id (no convention fallback)", () => { + const hint = formatMissingProviderHint("mystery-provider"); + expect(hint).toBe(""); + }); + + it("returns empty string for an empty/whitespace id", () => { + expect(formatMissingProviderHint("")).toBe(""); + expect(formatMissingProviderHint(" ")).toBe(""); + }); + + it("returns empty string for an id that does not look like a plugin id", () => { + expect(formatMissingProviderHint("bad/id")).toBe(""); + expect(formatMissingProviderHint("a")).toBe(""); + expect(formatMissingProviderHint("some/long/path")).toBe(""); + }); + + it("preserves the legacy prefix when hint is appended (catalog-known id)", () => { + const hint = formatMissingProviderHint("groq"); + const composed = `Media provider not available: groq${hint}`; + expect(composed).toMatch(/^Media provider not available: groq .*openclaw plugins install/); + expect(composed).toMatch(/official external plugin/); + expect(composed).toMatch(/stop and start the gateway service/); + }); + + it("preserves the legacy message verbatim when the id is not cataloged", () => { + const hint = formatMissingProviderHint("mystery-provider"); + expect(`Media provider not available: mystery-provider${hint}`).toBe( + "Media provider not available: mystery-provider", + ); + }); + + it("returns empty string for a channel-only id (feishu)", () => { + expect(formatMissingProviderHint("feishu")).toBe(""); + }); + + it("returns empty string for a catalog provider without mediaUnderstandingProviders contract (amazon-bedrock legacy prefix)", () => { + const hint = formatMissingProviderHint("amazon-bedrock"); + expect(`Media provider not available: amazon-bedrock${hint}`).toBe( + "Media provider not available: amazon-bedrock", + ); + }); +}); diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 8f6e2569cf83..d1541195f2e0 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -7,6 +7,12 @@ import { normalizeNullableString, } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js"; +import { extractGeminiResponse } from "../../packages/media-understanding-common/src/output-extract.js"; +import { + estimateBase64Size, + resolveVideoMaxBase64Bytes, +} from "../../packages/media-understanding-common/src/video.js"; import { collectProviderApiKeysForExecution, executeWithApiKeyRotation, @@ -19,6 +25,7 @@ import { } from "../agents/provider-request-config.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { applyTemplate } from "../auto-reply/templating.js"; +import { formatCliCommand } from "../cli/command-format.js"; import type { ModelProviderConfig, OpenClawConfig } from "../config/types.js"; import type { MediaUnderstandingConfig, @@ -29,14 +36,13 @@ import { writeExternalFileWithinRoot } from "../infra/fs-safe.js"; import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runFfmpeg } from "../media/media-services.js"; +import { + getOfficialExternalPluginCatalogManifest, + listOfficialExternalProviderCatalogEntries, +} from "../plugins/official-external-plugin-catalog.js"; +import { resolveOfficialExternalPluginRepairHint } from "../plugins/official-external-plugin-repair-hints.js"; import { runExec } from "../process/exec.js"; import { providerOperationRetryConfig } from "../provider-runtime/operation-retry.js"; -import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js"; -import { extractGeminiResponse } from "../../packages/media-understanding-common/src/output-extract.js"; -import { - estimateBase64Size, - resolveVideoMaxBase64Bytes, -} from "../../packages/media-understanding-common/src/video.js"; import { MediaAttachmentCache } from "./attachments.js"; import { CLI_OUTPUT_MAX_BUFFER, @@ -666,6 +672,53 @@ function assertMinAudioSize(params: { size: number; attachmentIndex: number }): ); } +/** + * Build an actionable hint suffix for "provider not available" errors. + * + * Restricts the hint to ids that are owned by the official external + * provider catalog — NOT the combined channel/plugin catalog — so a media + * provider id like `feishu` (an official channel, not a media provider) + * never emits a misleading install hint from a media-provider error. + * + * Tier 1: provider id is owned by an official external provider entry that + * declares a `contracts.mediaUnderstandingProviders` block listing the + * id — emit the catalog-backed install + registry refresh + doctor fix + * commands. + * Tier 2: empty string — keeps the legacy message verbatim for ids that + * are not in the provider catalog (channel ids, plugin ids, unknown + * ids, internal ids, etc.). Newly externalized media providers must + * register with the official external provider catalog to receive the + * actionable hint. + */ +export function formatMissingProviderHint(providerId: string): string { + const trimmed = providerId.trim(); + if (!trimmed) { + return ""; + } + // Look up the id only in catalog entries that declare + // `contracts.mediaUnderstandingProviders`. This ensures the install hint + // only fires for provider packages that actually own the missing + // media-understanding capability. Providers that have a generic `providers[]` + // catalog entry but no media-understanding contract (e.g. Amazon Bedrock) + // will not emit misleading hints. + const providerEntry = listOfficialExternalProviderCatalogEntries().find((entry) => { + const manifest = getOfficialExternalPluginCatalogManifest(entry); + const mediaProviders = manifest?.contracts?.mediaUnderstandingProviders ?? []; + return mediaProviders.some((mediaId) => mediaId === trimmed); + }); + if (!providerEntry) { + return ""; + } + // `resolveOfficialExternalPluginRepairHint` is contract-agnostic but we + // already validated ownership via the provider-only catalog, so the + // returned hint is for the correct provider entry. + const catalogHint = resolveOfficialExternalPluginRepairHint(trimmed); + if (!catalogHint) { + return ""; + } + return ` Install the official external plugin with: ${formatCliCommand(catalogHint.installCommand)}, then run ${formatCliCommand("openclaw plugins registry --refresh")} and stop and start the gateway service, or run ${formatCliCommand(catalogHint.doctorFixCommand)} to repair automatically.`; +} + /** Executes one provider-backed media-understanding entry for one attachment. */ export async function runProviderEntry(params: { capability: MediaUnderstandingCapability; @@ -741,7 +794,9 @@ export async function runProviderEntry(params: { const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry); if (!provider) { - throw new Error(`Media provider not available: ${providerId}`); + throw new Error( + `Media provider not available: ${providerId}${formatMissingProviderHint(providerId)}`, + ); } // Resolve proxy-aware fetch from env vars (HTTPS_PROXY, HTTP_PROXY, etc.)