Skip to content

Commit 12aaef9

Browse files
Spolen23Takhoffman
andauthored
Fix infer CLI reliability gaps (#63263)
Verified: - pnpm install --frozen-lockfile - git diff --check - pnpm test src/media-understanding/defaults.test.ts src/media-understanding/runner.vision-skip.test.ts src/media-understanding/runner.cli-audio.test.ts src/web-search/runtime.test.ts - pnpm tsgo:test:src Co-authored-by: Spolen23 <215900770+Spolen23@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
1 parent bdb75bd commit 12aaef9

8 files changed

Lines changed: 145 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
- Plugins/media: auto-enable provider plugins referenced by `agents.defaults.imageGenerationModel`, `videoGenerationModel`, and `musicGenerationModel` primary/fallback refs, so configured Google and MiniMax media providers do not stay disabled behind a restrictive plugin allowlist. Thanks @vincentkoc.
2020
- Memory-core/dreaming: retry managed dreaming cron registration after startup when the cron service is not reachable yet, so the scheduled Memory Dreaming Promotion sweep recovers without waiting for heartbeat traffic. Fixes #72841. Thanks @amknight.
2121
- Acpx/runtime: validate the runtime session mode at the `AcpxRuntime.ensureSession` wrapper boundary so callers that pass anything other than `persistent` or `oneshot` get a clear `ACP_INVALID_RUNTIME_OPTION` error instead of silently round-tripping through the encoded handle as a default `persistent` mode and later throwing `SessionResumeRequiredError`. Investigation context: #73071. (#73548) Thanks @amknight.
22+
- CLI/infer: keep web-search fallback on missing provider API keys, preserve structured validation errors from the selected provider, and let per-request image describe prompts override configured media-entry prompts. (#63263) Thanks @Spolen23.
2223

2324
## 2026.4.27
2425

extensions/openai/media-understanding-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const openaiCodexMediaUnderstandingProvider: MediaUnderstandingProvider =
3535
id: "openai-codex",
3636
capabilities: ["image"],
3737
defaultModels: { image: "gpt-5.5" },
38+
autoPriority: { image: 20 },
3839
describeImage: describeImageWithModel,
3940
describeImages: describeImagesWithModel,
4041
};

src/config/types.tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,14 @@ export type MediaUnderstandingConfig = MediaProviderRequestConfig & {
9090
maxChars?: number;
9191
/** Default prompt. */
9292
prompt?: string;
93+
/** Internal request-scoped prompt override injected by CLI/runtime wrappers. */
94+
_requestPromptOverride?: string;
9395
/** Default timeout (seconds). */
9496
timeoutSeconds?: number;
9597
/** Default language hint (audio). */
9698
language?: string;
99+
/** Internal request-scoped language override injected by CLI/runtime wrappers. */
100+
_requestLanguageOverride?: string;
97101
/** Attachment selection policy. */
98102
attachments?: MediaUnderstandingAttachmentsConfig;
99103
/** Ordered model list (fallbacks in order). */

src/media-understanding/defaults.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ const mediaMetadataPlugins = vi.hoisted(() => [
5757
defaultModels: { image: "gpt-5.4-mini", audio: "gpt-4o-transcribe" },
5858
autoPriority: { image: 10, audio: 10 },
5959
},
60-
"openai-codex": { capabilities: ["image"], defaultModels: { image: "gpt-5.5" } },
60+
"openai-codex": {
61+
capabilities: ["image"],
62+
defaultModels: { image: "gpt-5.5" },
63+
autoPriority: { image: 20 },
64+
},
6165
opencode: { capabilities: ["image"], defaultModels: { image: "gpt-5-nano" } },
6266
"opencode-go": { capabilities: ["image"], defaultModels: { image: "kimi-k2.6" } },
6367
openrouter: { capabilities: ["image"], defaultModels: { image: "auto" } },
@@ -124,6 +128,7 @@ describe("resolveAutoMediaKeyProviders", () => {
124128
expect(resolveAutoMediaKeyProviders({ capability: "image" })).toEqual([
125129
"openai",
126130
"anthropic",
131+
"openai-codex",
127132
"google",
128133
"minimax",
129134
"minimax-portal",

src/media-understanding/runner.entries.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ function resolveEntryRunOptions(params: {
393393
return { maxBytes, maxChars, timeoutMs, prompt };
394394
}
395395

396-
function resolveAudioRequestOverrides(config: MediaUnderstandingConfig | undefined): {
396+
function resolveMediaRequestOverrides(config: MediaUnderstandingConfig | undefined): {
397397
prompt?: string;
398398
language?: string;
399399
} {
@@ -571,14 +571,15 @@ export async function runProviderEntry(params: {
571571
maxBytes,
572572
timeoutMs,
573573
});
574+
const requestOverrides = resolveMediaRequestOverrides(params.config);
574575
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
575576
const imageInput = {
576577
buffer: media.buffer,
577578
fileName: media.fileName,
578579
mime: media.mime,
579580
model: modelId,
580581
provider: providerId,
581-
prompt,
582+
prompt: requestOverrides.prompt ?? prompt,
582583
timeoutMs,
583584
profile: entry.profile,
584585
preferredProfile: entry.preferredProfile,
@@ -610,7 +611,7 @@ export async function runProviderEntry(params: {
610611
throw new Error(`Audio transcription provider "${providerId}" not available.`);
611612
}
612613
const transcribeAudio = provider.transcribeAudio;
613-
const requestOverrides = resolveAudioRequestOverrides(params.config);
614+
const requestOverrides = resolveMediaRequestOverrides(params.config);
614615
const media = await params.cache.getBuffer({
615616
attachmentIndex: params.attachmentIndex,
616617
maxBytes,
@@ -736,7 +737,7 @@ export async function runCliEntry(params: {
736737
if (!command) {
737738
throw new Error(`CLI entry missing command for ${capability}`);
738739
}
739-
const requestOverrides = resolveAudioRequestOverrides(params.config);
740+
const requestOverrides = resolveMediaRequestOverrides(params.config);
740741
const { maxBytes, maxChars, timeoutMs, prompt } = resolveEntryRunOptions({
741742
capability,
742743
entry,

src/media-understanding/runner.vision-skip.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,57 @@ describe("runCapability image skip", () => {
192192
);
193193
});
194194

195+
it("lets per-request image prompts override entry prompts", async () => {
196+
await withMediaFixture(
197+
{
198+
filePrefix: "openclaw-image-request-prompt",
199+
extension: "png",
200+
mediaType: "image/png",
201+
fileContents: Buffer.from("image"),
202+
},
203+
async ({ ctx, media, cache }) => {
204+
let seenPrompt: string | undefined;
205+
const cfg = {} as OpenClawConfig;
206+
207+
const result = await runCapability({
208+
capability: "image",
209+
cfg,
210+
ctx,
211+
attachments: cache,
212+
media,
213+
agentDir: "/tmp",
214+
providerRegistry: new Map([
215+
[
216+
"openrouter",
217+
{
218+
id: "openrouter",
219+
capabilities: ["image"],
220+
describeImage: async (req) => {
221+
seenPrompt = req.prompt;
222+
return { text: "request prompt ok", model: req.model };
223+
},
224+
},
225+
],
226+
]),
227+
config: {
228+
_requestPromptOverride: "Use this request prompt",
229+
models: [
230+
{
231+
provider: "openrouter",
232+
model: "google/gemini-2.5-flash",
233+
prompt: "entry prompt",
234+
},
235+
],
236+
},
237+
activeModel: { provider: "openai", model: "gpt-4.1" },
238+
});
239+
240+
expect(result.decision.outcome).toBe("success");
241+
expect(seenPrompt).toBe("Use this request prompt");
242+
},
243+
);
244+
});
245+
195246
it("prefers agents.defaults.imageModel over the active model for auto image resolution", async () => {
196247
const cfg = {
197248
agents: {

src/web-search/runtime.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ describe("web search runtime", () => {
318318
it("falls back to another provider when auto-selected search execution fails", async () => {
319319
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
320320
createGoogleSearchProvider({
321+
requiresCredential: false,
321322
createTool: () => ({
322323
description: "google",
323324
parameters: {},
@@ -340,6 +341,63 @@ describe("web search runtime", () => {
340341
});
341342
});
342343

344+
it("falls back when an auto-selected provider returns a structured error payload", async () => {
345+
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
346+
createGoogleSearchProvider({
347+
requiresCredential: false,
348+
createTool: () => ({
349+
description: "google",
350+
parameters: {},
351+
execute: async () => ({
352+
error: "missing_google_api_key",
353+
message: "google key missing",
354+
}),
355+
}),
356+
}),
357+
createDuckDuckGoSearchProvider(),
358+
]);
359+
360+
await expect(
361+
runWebSearch({
362+
config: {},
363+
args: { query: "fallback-structured-error" },
364+
}),
365+
).resolves.toEqual({
366+
provider: "duckduckgo",
367+
result: { query: "fallback-structured-error", provider: "duckduckgo" },
368+
});
369+
});
370+
371+
it("does not fall back when an auto-selected provider returns a validation error payload", async () => {
372+
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
373+
createGoogleSearchProvider({
374+
requiresCredential: false,
375+
createTool: () => ({
376+
description: "google",
377+
parameters: {},
378+
execute: async () => ({
379+
error: "invalid_freshness",
380+
message: "freshness must be day, week, month, or year.",
381+
}),
382+
}),
383+
}),
384+
createDuckDuckGoSearchProvider(),
385+
]);
386+
387+
await expect(
388+
runWebSearch({
389+
config: {},
390+
args: { query: "fallback-validation-error", freshness: "forever" },
391+
}),
392+
).resolves.toEqual({
393+
provider: "google",
394+
result: {
395+
error: "invalid_freshness",
396+
message: "freshness must be day, week, month, or year.",
397+
},
398+
});
399+
});
400+
343401
it("does not prebuild fallback provider tools before attempting the selected provider", async () => {
344402
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
345403
createGoogleSearchProvider(),

src/web-search/runtime.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { logVerbose } from "../globals.js";
88
import type {
99
PluginWebSearchProviderEntry,
1010
WebSearchProviderToolDefinition,
11-
} from "../plugins/web-provider-types.js";
12-
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
13-
import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
11+
} from "../plugins/types.js";
12+
import {
13+
resolvePluginWebSearchProviders,
14+
resolveRuntimeWebSearchProviders,
15+
} from "../plugins/web-search-providers.runtime.js";
1416
import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js";
1517
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime-web-tools-state.js";
1618
import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js";
@@ -311,6 +313,14 @@ function hasExplicitWebSearchSelection(params: {
311313
return false;
312314
}
313315

316+
function isStructuredAvailabilityError(result: unknown): result is { error: string } {
317+
if (!result || typeof result !== "object" || !("error" in result)) {
318+
return false;
319+
}
320+
const error = (result as { error?: unknown }).error;
321+
return typeof error === "string" && /^missing_[a-z0-9_]*api_key$/i.test(error);
322+
}
323+
314324
export async function runWebSearch(params: RunWebSearchParams): Promise<RunWebSearchResult> {
315325
const config = resolveWebSearchRuntimeConfig(params.config);
316326
const search = resolveSearchConfig(config);
@@ -347,9 +357,14 @@ export async function runWebSearch(params: RunWebSearchParams): Promise<RunWebSe
347357
sawUnavailableProvider = true;
348358
continue;
349359
}
360+
const executed = await definition.execute(params.args);
361+
if (allowFallback && isStructuredAvailabilityError(executed)) {
362+
lastError = new Error(`web_search provider "${candidate.id}" returned ${executed.error}`);
363+
continue;
364+
}
350365
return {
351366
provider: candidate.id,
352-
result: await definition.execute(params.args),
367+
result: executed,
353368
};
354369
} catch (error) {
355370
lastError = error;

0 commit comments

Comments
 (0)