Skip to content

Commit 2e5e5e5

Browse files
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 <[email protected]>
1 parent 4a0cd56 commit 2e5e5e5

2 files changed

Lines changed: 121 additions & 8 deletions

File tree

src/media-understanding/runner.entries.guards.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Runner entry guard tests cover malformed decision data formatting without
22
// depending on provider execution.
33
import { describe, expect, it } from "vitest";
4-
import { formatDecisionSummary } from "./runner.entries.js";
4+
import { formatDecisionSummary, formatMissingProviderHint } from "./runner.entries.js";
55
import type { MediaUnderstandingDecision } from "./types.js";
66

77
describe("media-understanding formatDecisionSummary guards", () => {
@@ -45,3 +45,61 @@ describe("media-understanding formatDecisionSummary guards", () => {
4545
).toBe("audio: failed (0/1)");
4646
});
4747
});
48+
49+
describe("media-understanding formatMissingProviderHint", () => {
50+
it("returns the catalog hint for a provider with mediaUnderstandingProviders contract (groq)", () => {
51+
const hint = formatMissingProviderHint("groq");
52+
expect(hint).toContain("openclaw plugins install @openclaw/groq-provider");
53+
expect(hint).toContain("openclaw plugins registry --refresh");
54+
expect(hint).toContain("stop and start the gateway service");
55+
expect(hint).toContain("openclaw doctor --fix");
56+
expect(hint).toContain("official external plugin");
57+
});
58+
59+
it("returns empty string for a provider with only generic providers[] entry but no mediaUnderstandingProviders contract (amazon-bedrock)", () => {
60+
const hint = formatMissingProviderHint("amazon-bedrock");
61+
expect(hint).toBe("");
62+
});
63+
64+
it("returns empty string for a non-cataloged id (no convention fallback)", () => {
65+
const hint = formatMissingProviderHint("mystery-provider");
66+
expect(hint).toBe("");
67+
});
68+
69+
it("returns empty string for an empty/whitespace id", () => {
70+
expect(formatMissingProviderHint("")).toBe("");
71+
expect(formatMissingProviderHint(" ")).toBe("");
72+
});
73+
74+
it("returns empty string for an id that does not look like a plugin id", () => {
75+
expect(formatMissingProviderHint("bad/id")).toBe("");
76+
expect(formatMissingProviderHint("a")).toBe("");
77+
expect(formatMissingProviderHint("some/long/path")).toBe("");
78+
});
79+
80+
it("preserves the legacy prefix when hint is appended (catalog-known id)", () => {
81+
const hint = formatMissingProviderHint("groq");
82+
const composed = `Media provider not available: groq${hint}`;
83+
expect(composed).toMatch(/^Media provider not available: groq .*openclaw plugins install/);
84+
expect(composed).toMatch(/official external plugin/);
85+
expect(composed).toMatch(/stop and start the gateway service/);
86+
});
87+
88+
it("preserves the legacy message verbatim when the id is not cataloged", () => {
89+
const hint = formatMissingProviderHint("mystery-provider");
90+
expect(`Media provider not available: mystery-provider${hint}`).toBe(
91+
"Media provider not available: mystery-provider",
92+
);
93+
});
94+
95+
it("returns empty string for a channel-only id (feishu)", () => {
96+
expect(formatMissingProviderHint("feishu")).toBe("");
97+
});
98+
99+
it("returns empty string for a catalog provider without mediaUnderstandingProviders contract (amazon-bedrock legacy prefix)", () => {
100+
const hint = formatMissingProviderHint("amazon-bedrock");
101+
expect(`Media provider not available: amazon-bedrock${hint}`).toBe(
102+
"Media provider not available: amazon-bedrock",
103+
);
104+
});
105+
});

src/media-understanding/runner.entries.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import {
77
normalizeNullableString,
88
} from "@openclaw/normalization-core/string-coerce";
99
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
10+
import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js";
11+
import { extractGeminiResponse } from "../../packages/media-understanding-common/src/output-extract.js";
12+
import {
13+
estimateBase64Size,
14+
resolveVideoMaxBase64Bytes,
15+
} from "../../packages/media-understanding-common/src/video.js";
1016
import {
1117
collectProviderApiKeysForExecution,
1218
executeWithApiKeyRotation,
@@ -19,6 +25,7 @@ import {
1925
} from "../agents/provider-request-config.js";
2026
import type { MsgContext } from "../auto-reply/templating.js";
2127
import { applyTemplate } from "../auto-reply/templating.js";
28+
import { formatCliCommand } from "../cli/command-format.js";
2229
import type { ModelProviderConfig, OpenClawConfig } from "../config/types.js";
2330
import type {
2431
MediaUnderstandingConfig,
@@ -29,14 +36,13 @@ import { writeExternalFileWithinRoot } from "../infra/fs-safe.js";
2936
import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js";
3037
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
3138
import { runFfmpeg } from "../media/media-services.js";
39+
import {
40+
getOfficialExternalPluginCatalogManifest,
41+
listOfficialExternalProviderCatalogEntries,
42+
} from "../plugins/official-external-plugin-catalog.js";
43+
import { resolveOfficialExternalPluginRepairHint } from "../plugins/official-external-plugin-repair-hints.js";
3244
import { runExec } from "../process/exec.js";
3345
import { providerOperationRetryConfig } from "../provider-runtime/operation-retry.js";
34-
import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js";
35-
import { extractGeminiResponse } from "../../packages/media-understanding-common/src/output-extract.js";
36-
import {
37-
estimateBase64Size,
38-
resolveVideoMaxBase64Bytes,
39-
} from "../../packages/media-understanding-common/src/video.js";
4046
import { MediaAttachmentCache } from "./attachments.js";
4147
import {
4248
CLI_OUTPUT_MAX_BUFFER,
@@ -666,6 +672,53 @@ function assertMinAudioSize(params: { size: number; attachmentIndex: number }):
666672
);
667673
}
668674

675+
/**
676+
* Build an actionable hint suffix for "provider not available" errors.
677+
*
678+
* Restricts the hint to ids that are owned by the official external
679+
* provider catalog — NOT the combined channel/plugin catalog — so a media
680+
* provider id like `feishu` (an official channel, not a media provider)
681+
* never emits a misleading install hint from a media-provider error.
682+
*
683+
* Tier 1: provider id is owned by an official external provider entry that
684+
* declares a `contracts.mediaUnderstandingProviders` block listing the
685+
* id — emit the catalog-backed install + registry refresh + doctor fix
686+
* commands.
687+
* Tier 2: empty string — keeps the legacy message verbatim for ids that
688+
* are not in the provider catalog (channel ids, plugin ids, unknown
689+
* ids, internal ids, etc.). Newly externalized media providers must
690+
* register with the official external provider catalog to receive the
691+
* actionable hint.
692+
*/
693+
export function formatMissingProviderHint(providerId: string): string {
694+
const trimmed = providerId.trim();
695+
if (!trimmed) {
696+
return "";
697+
}
698+
// Look up the id only in catalog entries that declare
699+
// `contracts.mediaUnderstandingProviders`. This ensures the install hint
700+
// only fires for provider packages that actually own the missing
701+
// media-understanding capability. Providers that have a generic `providers[]`
702+
// catalog entry but no media-understanding contract (e.g. Amazon Bedrock)
703+
// will not emit misleading hints.
704+
const providerEntry = listOfficialExternalProviderCatalogEntries().find((entry) => {
705+
const manifest = getOfficialExternalPluginCatalogManifest(entry);
706+
const mediaProviders = manifest?.contracts?.mediaUnderstandingProviders ?? [];
707+
return mediaProviders.some((mediaId) => mediaId === trimmed);
708+
});
709+
if (!providerEntry) {
710+
return "";
711+
}
712+
// `resolveOfficialExternalPluginRepairHint` is contract-agnostic but we
713+
// already validated ownership via the provider-only catalog, so the
714+
// returned hint is for the correct provider entry.
715+
const catalogHint = resolveOfficialExternalPluginRepairHint(trimmed);
716+
if (!catalogHint) {
717+
return "";
718+
}
719+
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.`;
720+
}
721+
669722
/** Executes one provider-backed media-understanding entry for one attachment. */
670723
export async function runProviderEntry(params: {
671724
capability: MediaUnderstandingCapability;
@@ -741,7 +794,9 @@ export async function runProviderEntry(params: {
741794

742795
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
743796
if (!provider) {
744-
throw new Error(`Media provider not available: ${providerId}`);
797+
throw new Error(
798+
`Media provider not available: ${providerId}${formatMissingProviderHint(providerId)}`,
799+
);
745800
}
746801

747802
// Resolve proxy-aware fetch from env vars (HTTPS_PROXY, HTTP_PROXY, etc.)

0 commit comments

Comments
 (0)