mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(media): keep deferred audio and extraction providers discoverable (#139231)
Keep enabled audio and structured-extraction providers discoverable after another media plugin loads, and when shared models select only images. Merge eligible manifest owners at the shared catalog; leave execution selection with the runner and preserve active instances, policy filtering, and selected image loading. Resolve capability metadata only for normalized, deduplicated untagged provider entries. Pair synthetic audio fixtures with their matching runtime/metadata generation to avoid unrelated cold plugin imports. Verified the final integration with 69 focused tests, formatting/lint/core typecheck, a fresh independent review, a sealed build, 12 audio/extraction Gateway cases, and three paired full-inventory discovery cases. Exact-head CI 34060063771 succeeded (141 successful jobs, 16 skipped). Production delta: -20 lines. Related #119772 remains open; generic extraction fallback #119773 is outside scope. Thanks to @rodrigo-fonseca-oliveira for the original diagnosis and fix. Co-authored-by: Rodrigo Oliveira <rodrfons@hotmail.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
bd6f54b8ab
commit
1873328511
10 changed files with 435 additions and 85 deletions
|
|
@ -3325,7 +3325,7 @@ src/plugins/bundle-mcp.ts 2
|
|||
src/plugins/bundled-channel-config-metadata.ts 4
|
||||
src/plugins/bundled-plugin-scan.ts 1
|
||||
src/plugins/candidate-install-owner.ts 2
|
||||
src/plugins/capability-provider-runtime.ts 15
|
||||
src/plugins/capability-provider-runtime.ts 14
|
||||
src/plugins/captured-registration.ts 2
|
||||
src/plugins/channel-presence-policy.ts 2
|
||||
src/plugins/channel-validation.ts 1
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ Per-capability (`image`/`audio`/`video`) keys:
|
|||
| `echoTranscript` | `boolean` | `false` | Audio only: echo the transcript before agent processing |
|
||||
| `echoFormat` | `string` | `'📝 "{transcript}"'` | Audio only: format for the echoed transcript |
|
||||
|
||||
Prompts, limits, language hints, request overrides, and provider options can be set as capability defaults or overridden on individual `tools.media.models[]` entries. Capability defaults also cover auto-detected providers when no explicit model is configured.
|
||||
Prompts, limits, language hints, request overrides, and provider options can be set as capability defaults or overridden on individual `tools.media.models[]` entries. Capability defaults also cover auto-detected providers when no explicit model is configured. A model list selects which entries run; it does not hide other enabled providers from provider discovery.
|
||||
|
||||
### Model entries
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
|
||||
import { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { pluginLoaderCacheState } from "../plugins/registry-lifecycle.js";
|
||||
import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
|
||||
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
|
||||
import { createColdPluginFixture } from "../plugins/test-helpers/cold-plugin-fixtures.js";
|
||||
import { buildMediaUnderstandingCapabilityRegistry } from "./provider-capability-registry.js";
|
||||
|
||||
let root: string;
|
||||
|
||||
function resetFixtureState() {
|
||||
resetPluginRuntimeStateForTest();
|
||||
pluginLoaderCacheState.clear();
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
}
|
||||
|
||||
function createMediaOwner(pluginId: string, providerId: string) {
|
||||
const rootDir = path.join(root, pluginId);
|
||||
fs.mkdirSync(rootDir);
|
||||
const fixture = createColdPluginFixture({
|
||||
rootDir,
|
||||
pluginId,
|
||||
providerId,
|
||||
manifest: {
|
||||
channels: [],
|
||||
providers: [],
|
||||
activation: { onStartup: false },
|
||||
contracts: { mediaUnderstandingProviders: [providerId] },
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(
|
||||
fixture.runtimeSource,
|
||||
`require("node:fs").appendFileSync(${JSON.stringify(fixture.runtimeMarker)}, ${JSON.stringify("import\n")});
|
||||
module.exports = {
|
||||
id: ${JSON.stringify(pluginId)},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider({
|
||||
id: ${JSON.stringify(providerId)}, capabilities: ["audio"]
|
||||
});
|
||||
}
|
||||
};
|
||||
`,
|
||||
);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
describe("media capability inference owner loading", () => {
|
||||
beforeEach(() => {
|
||||
resetFixtureState();
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-media-inference-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetFixtureState();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "direct active provider",
|
||||
active: true,
|
||||
providerId: "qa-audio",
|
||||
configuredId: "qa-audio",
|
||||
imageFallback: false,
|
||||
},
|
||||
{
|
||||
name: "cold runtime-only provider",
|
||||
active: false,
|
||||
providerId: "qa-audio",
|
||||
configuredId: "qa-audio",
|
||||
imageFallback: false,
|
||||
},
|
||||
{
|
||||
name: "normalized media alias",
|
||||
active: true,
|
||||
providerId: "google",
|
||||
configuredId: " GEMINI ",
|
||||
imageFallback: false,
|
||||
},
|
||||
{
|
||||
name: "plugin capability ahead of config image fallback",
|
||||
active: true,
|
||||
providerId: "qa-audio",
|
||||
configuredId: "qa-audio",
|
||||
imageFallback: true,
|
||||
},
|
||||
{
|
||||
name: "provider alongside explicitly tagged shared models",
|
||||
active: true,
|
||||
providerId: "qa-audio",
|
||||
configuredId: "qa-audio",
|
||||
imageFallback: false,
|
||||
taggedPeers: true,
|
||||
},
|
||||
])(
|
||||
"infers a $name without importing unrelated owners",
|
||||
({ active, providerId, configuredId, imageFallback, taggedPeers }) => {
|
||||
const selected = createMediaOwner("qa-selected-owner", providerId);
|
||||
const unrelated = createMediaOwner("qa-unrelated-owner", "qa-unrelated");
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
allow: [selected.pluginId, unrelated.pluginId],
|
||||
load: { paths: [selected.rootDir, unrelated.rootDir] },
|
||||
entries: {
|
||||
[selected.pluginId]: { enabled: true },
|
||||
[unrelated.pluginId]: { enabled: true },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
media: {
|
||||
models: [
|
||||
...(taggedPeers
|
||||
? [
|
||||
{ provider: configuredId, capabilities: ["image" as const] },
|
||||
{ provider: unrelated.providerId, capabilities: ["audio" as const] },
|
||||
]
|
||||
: []),
|
||||
{ provider: configuredId },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
if (imageFallback) {
|
||||
config.models = {
|
||||
providers: {
|
||||
[providerId]: {
|
||||
baseUrl: "https://example.invalid",
|
||||
models: [
|
||||
{
|
||||
id: "fixture-image",
|
||||
name: "Fixture image",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
maxTokens: 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const registry = createEmptyPluginRegistry();
|
||||
if (active) {
|
||||
registry.mediaUnderstandingProviders.push({
|
||||
pluginId: selected.pluginId,
|
||||
pluginName: "Selected fixture",
|
||||
source: selected.runtimeSource,
|
||||
provider: { id: providerId, capabilities: ["audio"] },
|
||||
});
|
||||
}
|
||||
|
||||
const metadataSnapshot = createPluginMetadataSnapshotFixture({
|
||||
plugins: [selected, unrelated].map((fixture) => ({
|
||||
id: fixture.pluginId,
|
||||
origin: "global",
|
||||
rootDir: fixture.rootDir,
|
||||
source: fixture.runtimeSource,
|
||||
configSchema: { type: "object" },
|
||||
contracts: { mediaUnderstandingProviders: [fixture.providerId] },
|
||||
})),
|
||||
});
|
||||
const capabilities = withPluginRuntimeGenerationScope(
|
||||
{ metadataSnapshot, pluginRegistry: registry },
|
||||
() => buildMediaUnderstandingCapabilityRegistry(config),
|
||||
);
|
||||
expect(capabilities.get(providerId)?.capabilities).toEqual(["audio"]);
|
||||
expect(fs.existsSync(unrelated.runtimeMarker)).toBe(false);
|
||||
expect(fs.existsSync(selected.runtimeMarker)).toBe(!active);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,22 +1,23 @@
|
|||
// Capability registry tests cover plugin-owned capability precedence and
|
||||
// config-derived image-provider fallback registration.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePluginCapabilityProviders } from "../plugins/capability-provider-runtime.js";
|
||||
import { resolvePluginCapabilityProvider } from "../plugins/capability-provider-runtime.js";
|
||||
import { buildMediaUnderstandingCapabilityRegistry } from "./provider-capability-registry.js";
|
||||
|
||||
vi.mock("../plugins/capability-provider-runtime.js", () => ({
|
||||
resolvePluginCapabilityProviders: vi.fn(() => []),
|
||||
resolvePluginCapabilityProvider: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
const resolveProviders = vi.mocked(resolvePluginCapabilityProviders);
|
||||
const resolveProviders = vi.mocked(resolvePluginCapabilityProvider);
|
||||
|
||||
describe("media-understanding capability registry", () => {
|
||||
beforeEach(() => {
|
||||
resolveProviders.mockReturnValue([]);
|
||||
resolveProviders.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("auto-registers config providers with image-capable models", () => {
|
||||
const registry = buildMediaUnderstandingCapabilityRegistry({
|
||||
tools: { media: { models: [{ provider: "glm" }, { provider: "textOnly" }] } },
|
||||
models: {
|
||||
providers: {
|
||||
glm: {
|
||||
|
|
@ -34,9 +35,10 @@ describe("media-understanding capability registry", () => {
|
|||
});
|
||||
|
||||
it("keeps plugin-owned capabilities ahead of config auto-registration", () => {
|
||||
resolveProviders.mockReturnValue([{ id: "google", capabilities: ["audio"] } as never]);
|
||||
resolveProviders.mockReturnValue({ id: "google", capabilities: ["audio"] });
|
||||
|
||||
const registry = buildMediaUnderstandingCapabilityRegistry({
|
||||
tools: { media: { models: [{ provider: "google" }] } },
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
|
|
|
|||
|
|
@ -1,44 +1,45 @@
|
|||
// Capability registry used to decide which shared media model entries are
|
||||
// eligible for image/audio/video understanding.
|
||||
// Capability metadata for the configured shared media model entries.
|
||||
import { normalizeMediaProviderId } from "../../packages/media-understanding-common/src/provider-id.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { resolvePluginCapabilityProviders } from "../plugins/capability-provider-runtime.js";
|
||||
import { resolvePluginCapabilityProvider } from "../plugins/capability-provider-runtime.js";
|
||||
import { resolveImageCapableConfigProviderIds } from "./config-provider-models.js";
|
||||
import type { MediaUnderstandingCapabilityRegistry, MediaUnderstandingProvider } from "./types.js";
|
||||
import { resolveConfiguredMediaEntryCapabilities } from "./entry-capabilities.js";
|
||||
import type { MediaUnderstandingCapabilityRegistry } from "./types.js";
|
||||
|
||||
function mergeProviderCapabilities(
|
||||
registry: MediaUnderstandingCapabilityRegistry,
|
||||
provider: Pick<MediaUnderstandingProvider, "id" | "capabilities">,
|
||||
) {
|
||||
const normalizedKey = normalizeMediaProviderId(provider.id);
|
||||
const existing = registry.get(normalizedKey);
|
||||
registry.set(normalizedKey, {
|
||||
capabilities: provider.capabilities ?? existing?.capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds provider capability metadata used to filter shared media model entries. */
|
||||
/** Resolves capability metadata for configured shared media model providers. */
|
||||
export function buildMediaUnderstandingCapabilityRegistry(
|
||||
cfg?: OpenClawConfig,
|
||||
): MediaUnderstandingCapabilityRegistry {
|
||||
const registry: MediaUnderstandingCapabilityRegistry = new Map();
|
||||
|
||||
for (const provider of resolvePluginCapabilityProviders({
|
||||
key: "mediaUnderstandingProviders",
|
||||
cfg,
|
||||
})) {
|
||||
mergeProviderCapabilities(registry, provider);
|
||||
}
|
||||
|
||||
for (const normalizedKey of resolveImageCapableConfigProviderIds(cfg)) {
|
||||
// Plugin declarations own provider capability truth; config auto-registration only fills gaps.
|
||||
if (!registry.has(normalizedKey)) {
|
||||
mergeProviderCapabilities(registry, {
|
||||
id: normalizedKey,
|
||||
capabilities: ["image"],
|
||||
});
|
||||
const providerIds = new Set<string>();
|
||||
for (const entry of cfg?.tools?.media?.models ?? []) {
|
||||
if (
|
||||
typeof entry?.provider === "string" &&
|
||||
(entry.type ?? (entry.command ? "cli" : "provider")) === "provider" &&
|
||||
!resolveConfiguredMediaEntryCapabilities(entry)
|
||||
) {
|
||||
const providerId = normalizeMediaProviderId(entry.provider);
|
||||
if (providerId) {
|
||||
providerIds.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const providerId of providerIds) {
|
||||
const provider = resolvePluginCapabilityProvider({
|
||||
key: "mediaUnderstandingProviders",
|
||||
providerId,
|
||||
cfg,
|
||||
});
|
||||
if (provider) {
|
||||
// Keep canonical keys: runtime aliases do not independently opt entries into inference.
|
||||
registry.set(normalizeMediaProviderId(provider.id), { capabilities: provider.capabilities });
|
||||
}
|
||||
}
|
||||
for (const providerId of resolveImageCapableConfigProviderIds(cfg)) {
|
||||
// A runtime declaration owns capability truth, including an explicitly absent capability set.
|
||||
if (providerIds.has(providerId) && !registry.has(providerId)) {
|
||||
registry.set(providerId, { capabilities: ["image"] });
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { MediaUnderstandingModelConfig } from "../config/types.tools.js";
|
||||
import { resolveMediaRuntimeTimeoutMs, resolveModelEntries, resolveTimeoutMs } from "./resolve.js";
|
||||
import type { MediaUnderstandingCapability } from "./types.js";
|
||||
|
||||
|
|
@ -23,6 +24,43 @@ describe("media timeout resolution", () => {
|
|||
});
|
||||
|
||||
describe("resolveModelEntries", () => {
|
||||
it.each<{
|
||||
name: string;
|
||||
models: MediaUnderstandingModelConfig[];
|
||||
expected: MediaUnderstandingModelConfig[];
|
||||
}>([
|
||||
{
|
||||
name: "explicit provider",
|
||||
models: [{ provider: "groq", model: "selected-audio", capabilities: ["audio"] }],
|
||||
expected: [{ provider: "groq", model: "selected-audio", capabilities: ["audio"] }],
|
||||
},
|
||||
{
|
||||
name: "explicit CLI",
|
||||
models: [{ type: "cli", command: "fixture-transcribe", capabilities: ["audio"] }],
|
||||
expected: [{ type: "cli", command: "fixture-transcribe", capabilities: ["audio"] }],
|
||||
},
|
||||
{
|
||||
name: "image-only selection leaves audio automatic",
|
||||
models: [{ provider: "openai", model: "selected-image", capabilities: ["image"] }],
|
||||
expected: [],
|
||||
},
|
||||
])("preserves $name with a complete provider catalog", ({ models, expected }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: { media: { models } },
|
||||
};
|
||||
const completeRegistry = new Map<string, { capabilities: MediaUnderstandingCapability[] }>([
|
||||
...providerRegistry,
|
||||
["unselected-audio", { capabilities: ["audio"] }],
|
||||
["unselected-video", { capabilities: ["video"] }],
|
||||
]);
|
||||
const entries = resolveModelEntries({
|
||||
cfg,
|
||||
capability: "audio",
|
||||
providerRegistry: completeRegistry,
|
||||
});
|
||||
expect(entries.map(({ entry }) => entry)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("uses provider capabilities for shared entries without explicit caps", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MediaUnderstandingModelConfig } from "../config/types.tools.js";
|
||||
import { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
|
||||
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
|
||||
import { withAudioFixture } from "./runner.test-utils.js";
|
||||
import { transcribeAudioFile } from "./runtime.js";
|
||||
|
||||
|
|
@ -24,6 +25,15 @@ describe("audio processing disposition", () => {
|
|||
])("records completed processing for $name", async ({ name, entries, text, handled }) => {
|
||||
cli.mockReset().mockResolvedValue({ stdout: text, stderr: "" });
|
||||
const api = vi.fn(async () => ({ text }));
|
||||
// File APIs enumerate metadata before dispatch, so the fixture must own both views.
|
||||
const metadataSnapshot = createPluginMetadataSnapshotFixture({
|
||||
plugins: [
|
||||
{
|
||||
id: "synthetic-audio",
|
||||
contracts: { mediaUnderstandingProviders: ["synthetic-audio"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.mediaUnderstandingProviders.push({
|
||||
pluginId: "synthetic-audio",
|
||||
|
|
@ -41,23 +51,25 @@ describe("audio processing disposition", () => {
|
|||
},
|
||||
);
|
||||
await withAudioFixture(`disposition-${name.replaceAll(" ", "-")}`, async ({ mediaPath }) => {
|
||||
const result = await withPluginRuntimeRegistryScope(registry, () =>
|
||||
transcribeAudioFile({
|
||||
filePath: mediaPath,
|
||||
mime: "audio/wav",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
"synthetic-audio": {
|
||||
baseUrl: "https://unused.invalid",
|
||||
apiKey: "synthetic-fixture-key",
|
||||
models: [],
|
||||
const result = await withPluginRuntimeGenerationScope(
|
||||
{ metadataSnapshot, pluginRegistry: registry },
|
||||
() =>
|
||||
transcribeAudioFile({
|
||||
filePath: mediaPath,
|
||||
mime: "audio/wav",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
"synthetic-audio": {
|
||||
baseUrl: "https://unused.invalid",
|
||||
apiKey: "synthetic-fixture-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: { media: { models } },
|
||||
},
|
||||
tools: { media: { models } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(cli).toHaveBeenCalledTimes(entries.includes("cli") ? 1 : 0);
|
||||
expect(api).toHaveBeenCalledTimes(entries.includes("api") ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
|||
import { clearBundledDiscoveryModeMemo } from "./bundled-discovery-state.js";
|
||||
import { removeBundledDiscoveryStateRoot } from "./bundled-discovery.test-support.js";
|
||||
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
|
||||
import { createPluginMetadataSnapshotFixture } from "./plugin-metadata.test-support.js";
|
||||
import { createEmptyPluginRegistry } from "./registry.js";
|
||||
import { createPluginRecord } from "./status.test-helpers.js";
|
||||
|
||||
// Real machine state instead of a module mock: the plugins project runs every
|
||||
// file in one shared worker (isolate=false), so a mocked bundled-discovery
|
||||
|
|
@ -1275,7 +1277,7 @@ describe("resolvePluginCapabilityProviders", () => {
|
|||
expect(mocks.loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses active non-speech capability providers even when cfg has explicit plugin entries", () => {
|
||||
it("preserves active media providers while checking the complete family", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.mediaUnderstandingProviders.push({
|
||||
pluginId: "deepgram",
|
||||
|
|
@ -1287,6 +1289,9 @@ describe("resolvePluginCapabilityProviders", () => {
|
|||
},
|
||||
} as never);
|
||||
mocks.resolveRuntimePluginRegistry.mockReturnValue(active);
|
||||
setCapabilityManifestPlugins([
|
||||
{ id: "deepgram", contracts: { mediaUnderstandingProviders: ["deepgram"] } },
|
||||
]);
|
||||
|
||||
const providers = resolvePluginCapabilityProviders({
|
||||
key: "mediaUnderstandingProviders",
|
||||
|
|
@ -1301,11 +1306,134 @@ describe("resolvePluginCapabilityProviders", () => {
|
|||
});
|
||||
|
||||
expectResolvedCapabilityProviderIds(providers, ["deepgram"]);
|
||||
expect(mocks.loadPluginManifestRegistryCore).not.toHaveBeenCalled();
|
||||
expect(providers[0]).toBe(active.mediaUnderstandingProviders[0]?.provider);
|
||||
expectInitialRuntimeRegistryLookup();
|
||||
});
|
||||
|
||||
it("merges configured media-understanding providers missing from the active registry", () => {
|
||||
it.each([
|
||||
{ name: "automatic partial registry", active: "partial", expected: ["qa-image", "qa-audio"] },
|
||||
{ name: "automatic cold registry", active: "cold", expected: ["qa-image", "qa-audio"] },
|
||||
{ name: "automatic complete registry", active: "full", expected: ["qa-image", "qa-audio"] },
|
||||
{
|
||||
name: "explicit media selection",
|
||||
active: "partial",
|
||||
explicit: true,
|
||||
expected: ["qa-image", "qa-audio"],
|
||||
},
|
||||
{
|
||||
name: "disabled audio owner",
|
||||
active: "partial",
|
||||
disabledOwner: true,
|
||||
expected: ["qa-image"],
|
||||
},
|
||||
{ name: "denied audio owner", active: "partial", deniedOwner: true, expected: ["qa-image"] },
|
||||
{
|
||||
name: "excluded audio owner",
|
||||
active: "partial",
|
||||
excludedOwner: true,
|
||||
expected: ["qa-image"],
|
||||
},
|
||||
{ name: "globally disabled plugins", active: "full", globalDisabled: true, expected: [] },
|
||||
])("keeps media discovery complete for $name", (testCase) => {
|
||||
const image = { id: "qa-image", capabilities: ["image"], describeImage: vi.fn() };
|
||||
const audio = {
|
||||
id: "qa-audio",
|
||||
capabilities: ["audio"],
|
||||
defaultModels: { audio: "fixture-audio" },
|
||||
autoPriority: { audio: 1 },
|
||||
transcribeAudioWithContext: vi.fn(),
|
||||
};
|
||||
const registry = (providers: Array<typeof image | typeof audio>) => {
|
||||
const value = createEmptyPluginRegistry();
|
||||
for (const provider of providers) {
|
||||
addCapabilityProvider(value, "mediaUnderstandingProviders", {
|
||||
id: provider.id,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const active = registry(
|
||||
testCase.active === "cold" ? [] : testCase.active === "full" ? [image, audio] : [image],
|
||||
);
|
||||
const loaded = registry([image, audio]);
|
||||
const activeImage = active.mediaUnderstandingProviders.find(
|
||||
(entry) => entry.provider.id === image.id,
|
||||
)?.provider;
|
||||
mocks.resolveRuntimePluginRegistry.mockImplementation((options?: unknown) =>
|
||||
options === undefined ? active : loaded,
|
||||
);
|
||||
setCapabilityManifestPlugins([
|
||||
{ id: "qa-image", contracts: { mediaUnderstandingProviders: ["qa-image"] } },
|
||||
{ id: "qa-audio", contracts: { mediaUnderstandingProviders: ["qa-audio"] } },
|
||||
]);
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
enabled: !testCase.globalDisabled,
|
||||
allow: testCase.excludedOwner ? ["qa-image"] : ["qa-image", "qa-audio"],
|
||||
deny: testCase.deniedOwner ? ["qa-audio"] : [],
|
||||
entries: {
|
||||
"qa-image": { enabled: true },
|
||||
"qa-audio": { enabled: !testCase.disabledOwner },
|
||||
},
|
||||
},
|
||||
...(testCase.explicit
|
||||
? { tools: { media: { models: [{ provider: "qa-audio", capabilities: ["audio"] }] } } }
|
||||
: {}),
|
||||
};
|
||||
const resolve = () =>
|
||||
resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders", cfg });
|
||||
const first = resolve();
|
||||
expect(first.map(({ id }) => id).toSorted()).toEqual([...testCase.expected].toSorted());
|
||||
if (activeImage && !testCase.globalDisabled) {
|
||||
expect(first.find(({ id }) => id === image.id)).toBe(activeImage);
|
||||
}
|
||||
expect(
|
||||
resolve()
|
||||
.map(({ id }) => id)
|
||||
.toSorted(),
|
||||
).toEqual([...testCase.expected].toSorted());
|
||||
expect(image.describeImage).not.toHaveBeenCalled();
|
||||
expect(audio.transcribeAudioWithContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "complete", complete: true, disabled: false },
|
||||
{ name: "partial", complete: false, disabled: false },
|
||||
{ name: "disabled", complete: true, disabled: true },
|
||||
])(
|
||||
"keeps $name prepared media facts distinct from unresolved discovery",
|
||||
({ complete, disabled }) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const ids = complete ? ["qa-image", "qa-audio"] : ["qa-image"];
|
||||
for (const id of ids) {
|
||||
addCapabilityProvider(registry, "mediaUnderstandingProviders", { id });
|
||||
registry.plugins.push(createPluginRecord({ id, origin: "bundled" }));
|
||||
}
|
||||
const prepared = prepareMediaCapabilityProviders({
|
||||
cfg: { plugins: { enabled: !disabled, allow: ["qa-image", "qa-audio"] } },
|
||||
registry,
|
||||
pluginMetadataSnapshot: createPluginMetadataSnapshotFixture({
|
||||
plugins: ["qa-image", "qa-audio"].map((id) => ({
|
||||
id,
|
||||
contracts: { mediaUnderstandingProviders: [id] },
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (disabled) {
|
||||
expect(prepared.mediaUnderstandingProviders).toEqual([]);
|
||||
} else if (!complete) {
|
||||
expect(prepared.mediaUnderstandingProviders).toBeUndefined();
|
||||
} else {
|
||||
expect(prepared.mediaUnderstandingProviders?.map(({ id }) => id).toSorted()).toEqual([
|
||||
"qa-audio",
|
||||
"qa-image",
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the full media provider family available with explicit models", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.mediaUnderstandingProviders.push({
|
||||
pluginId: "openai",
|
||||
|
|
@ -1369,7 +1497,8 @@ describe("resolvePluginCapabilityProviders", () => {
|
|||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expectResolvedCapabilityProviderIds(providers, ["openai", "deepgram"]);
|
||||
expectResolvedCapabilityProviderIds(providers, ["openai", "deepgram", "google"]);
|
||||
expect(providers[0]).toBe(active.mediaUnderstandingProviders[0]?.provider);
|
||||
expectInitialRuntimeRegistryLookup();
|
||||
expectActiveRegistryLookup(["deepgram", "google"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ type CapabilityPluginResolution = {
|
|||
|
||||
function shouldMergeManifestProvidersWhenActive(key: CapabilityProviderRegistryKey): boolean {
|
||||
return (
|
||||
key === "mediaUnderstandingProviders" ||
|
||||
key === "imageGenerationProviders" ||
|
||||
key === "videoGenerationProviders" ||
|
||||
key === "musicGenerationProviders"
|
||||
|
|
@ -290,26 +291,6 @@ function collectRequestedVoiceModelProviderIds(cfg: OpenClawConfig | undefined):
|
|||
return requested;
|
||||
}
|
||||
|
||||
function addMediaModelProviders(target: Set<string>, value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
for (const entry of value) {
|
||||
if (typeof entry === "object" && entry !== null) {
|
||||
addStringValue(target, (entry as { provider?: unknown }).provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectRequestedMediaUnderstandingProviderIds(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
): Set<string> {
|
||||
const requested = new Set<string>();
|
||||
const media = cfg?.tools?.media;
|
||||
addMediaModelProviders(requested, media?.models);
|
||||
return requested;
|
||||
}
|
||||
|
||||
function collectRequestedCapabilityProviderIds(params: {
|
||||
key: CapabilityProviderRegistryKey;
|
||||
cfg?: OpenClawConfig;
|
||||
|
|
@ -331,8 +312,6 @@ function collectRequestedCapabilityProviderIds(params: {
|
|||
addStringValue(requested, talk.resolveConfiguredTalkRealtimeProviderId(params.cfg ?? {}));
|
||||
return requested.size > 0 ? requested : undefined;
|
||||
}
|
||||
case "mediaUnderstandingProviders":
|
||||
return collectRequestedMediaUnderstandingProviderIds(params.cfg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -565,8 +544,8 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
|
|||
includeVoiceModel: activeProviders.length > 0,
|
||||
}) ?? new Set<string>();
|
||||
const mergeManifestProviders = shouldMergeManifestProvidersWhenActive(params.key);
|
||||
// Broaden an existing scope; cold and generation catalogs already include all
|
||||
// eligible owners. A caller's default config map must never narrow those catalogs.
|
||||
// Media/generation catalogs include every eligible owner; their execution owners
|
||||
// select models later. Additional ids must not narrow an unscoped catalog.
|
||||
if (requested.size > 0 || (activeProviders.length > 0 && !mergeManifestProviders)) {
|
||||
for (const providerId of params.additionalProviderIds ?? []) {
|
||||
addStringValue(requested, providerId);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { transcribeAudioFile } from "openclaw/plugin-sdk/media-understanding-run
|
|||
import { vi } from "vitest";
|
||||
import { loadDiscordVoiceTestHarness } from "../extensions/discord/test-api.js";
|
||||
import type { MediaUnderstandingModelConfig } from "../src/config/types.tools.js";
|
||||
import { createPluginMetadataSnapshotFixture } from "../src/plugins/plugin-metadata.test-support.js";
|
||||
import { createEmptyPluginRegistry } from "../src/plugins/registry-empty.js";
|
||||
import { withPluginRuntimeRegistryScope } from "../src/plugins/runtime/gateway-request-scope.js";
|
||||
import { withPluginRuntimeGenerationScope } from "../src/plugins/runtime/generation-scope.js";
|
||||
|
||||
const { defineDiscordVoiceTests } = await loadDiscordVoiceTestHarness();
|
||||
const cli = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -55,6 +56,16 @@ defineDiscordVoiceTests(
|
|||
source: "test/transcripts-discord-audio.integration.test.ts",
|
||||
provider: { id: "synthetic-audio", capabilities: ["audio"], transcribeAudio: provider },
|
||||
});
|
||||
// Keep the synthetic runtime and discovery inventory in one generation;
|
||||
// transcription must not materialize unrelated bundled plugin owners.
|
||||
const metadataSnapshot = createPluginMetadataSnapshotFixture({
|
||||
plugins: [
|
||||
{
|
||||
id: "synthetic-audio",
|
||||
contracts: { mediaUnderstandingProviders: ["synthetic-audio"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const apiModel: MediaUnderstandingModelConfig = {
|
||||
provider: "synthetic-audio",
|
||||
model: "synthetic-stt",
|
||||
|
|
@ -122,8 +133,9 @@ defineDiscordVoiceTests(
|
|||
transcribeAudioFileMock.mockImplementation(async (params) => {
|
||||
wavPaths.push(params.filePath);
|
||||
wavSizes.push((await fs.stat(params.filePath)).size);
|
||||
const result = await withPluginRuntimeRegistryScope(registry, () =>
|
||||
transcribeAudioFile(params),
|
||||
const result = await withPluginRuntimeGenerationScope(
|
||||
{ metadataSnapshot, pluginRegistry: registry },
|
||||
() => transcribeAudioFile(params),
|
||||
);
|
||||
results.push(result);
|
||||
return result;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue