From 2e5e5e5af90513243a0863a8cf511cd3edf3f84c Mon Sep 17 00:00:00 2001 From: wangmiao0668000666 Date: Mon, 29 Jun 2026 01:45:44 +0800 Subject: [PATCH] fix(media-understanding): append actionable install hint when media provider is missing (#97484) Gates the install hint on the official external provider catalog's `mediaUnderstandingProviders` contract so only true media-provider packages (e.g. groq) emit the actionable hint; channel-only ids (e.g. feishu) and providers without a media-understanding contract (e.g. amazon-bedrock) fall back to the legacy bare error message verbatim. Also tightens the gateway recovery wording from generic 'restart the gateway' to 'stop and start the gateway service' per the canonical issue reporter's actual recovery flow (full systemd stop/start, not hot-reload). Co-authored-by: wangmiao0668000666 --- .../runner.entries.guards.test.ts | 60 +++++++++++++++- src/media-understanding/runner.entries.ts | 69 +++++++++++++++++-- 2 files changed, 121 insertions(+), 8 deletions(-) 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.)