fix(tools): honor config apiKey in media tool preflight (#85570)

Summary:
- The branch adds a config-aware tool auth helper, routes image/PDF/media generation preflight and list selection through it, threads `workspaceDir`, and adds focused regression tests plus a changelog entry.
- Reproducibility: yes. by source inspection. Current main gates affected media/PDF/generation preflight paths on env/profile auth while the runtime auth contract already accepts usable `models.providers.*.apiKey`.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(tools): fall back to config apiKey in capability preflight
- PR branch already contained follow-up commit before automerge: fix(tools): honor config apiKey in media tool preflight
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8557…

Validation:
- ClawSweeper review passed for head b8c9242d77.
- Required merge gates passed before the squash merge.

Prepared head SHA: b8c9242d77
Review: https://github.com/openclaw/openclaw/pull/85570#issuecomment-4523770355

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
This commit is contained in:
Mason Huang
2026-05-23 14:27:03 +08:00
committed by GitHub
parent b4f62c9afc
commit 31c269f0ed
16 changed files with 247 additions and 14 deletions
+1
View File
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
- Windows installer: fail Git checkout installs when `pnpm install` or `pnpm build` fails instead of writing a wrapper to a missing CLI build.
- Sessions: surface previous-transcript archive failures during `/new` rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.
- TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in `openclaw tui`. Fixes #85538. Thanks @danpolasek.
@@ -64,6 +64,7 @@ export function summarizeImageGenerationCapabilities(provider: ImageGenerationPr
export function createImageGenerateListActionResult(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
}): ImageGenerateActionResult {
@@ -73,6 +74,7 @@ export function createImageGenerateListActionResult(params: {
providers,
emptyText: "No image-generation providers are registered.",
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
listModes: listSupportedImageGenerationModes,
+4
View File
@@ -208,11 +208,13 @@ const ImageGenerateToolSchema = Type.Object({
export function resolveImageGenerationModelConfigForTool(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
}): ToolModelConfig | null {
return resolveCapabilityModelConfigForTool({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
modelConfig: params.cfg?.agents?.defaults?.imageGenerationModel,
@@ -806,6 +808,7 @@ export function createImageGenerateTool(options?: {
if (action === "list") {
return createImageGenerateListActionResult({
cfg,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authProfileStore,
});
@@ -816,6 +819,7 @@ export function createImageGenerateTool(options?: {
const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({
cfg,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authProfileStore,
});
+29
View File
@@ -121,6 +121,11 @@ vi.mock("../auth-profiles.js", () => ({
}));
vi.mock("../model-auth.js", () => ({
hasUsableCustomProviderApiKey: (cfg?: OpenClawConfig, provider?: string) => {
const providerConfig = cfg?.models?.providers?.[provider ?? ""];
const apiKey = providerConfig?.apiKey;
return typeof apiKey === "string" && apiKey.trim().length > 0;
},
resolveEnvApiKey: (provider: string) => {
const envVarByProvider: Record<string, string[]> = {
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
@@ -1060,6 +1065,30 @@ describe("image tool implicit imageModel config", () => {
});
});
it("pairs a custom provider when config declares its api key", async () => {
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "hatchery-qwen3.6-plus/text-1" } } },
models: {
providers: {
"hatchery-qwen3.6-plus": {
baseUrl: "https://example.com",
apiKey: "sk-configured", // pragma: allowlist secret
models: [
makeModelDefinition("text-1", ["text"]),
makeModelDefinition("qwen3.6-plus", ["text", "image"]),
],
},
},
},
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: "hatchery-qwen3.6-plus/qwen3.6-plus",
});
expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function");
});
});
it("does not double-prefix custom provider model IDs that already include the provider", async () => {
await withTempAgentDir(async (agentDir) => {
await writeAuthProfiles(agentDir, {
+2
View File
@@ -239,6 +239,8 @@ export function resolveImageModelConfigForTool(params: {
return buildToolModelConfigFromCandidates({
explicit,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
candidates: [...primaryAliasCandidates, ...primaryCandidates, ...remainingAutoCandidates],
@@ -45,6 +45,7 @@ export function createMediaGenerateProviderListActionResult<
providers: TProvider[];
emptyText: string;
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
listModes: (provider: TProvider) => string[];
@@ -72,6 +73,7 @@ export function createMediaGenerateProviderListActionResult<
providers: params.providers,
provider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
}),
@@ -3,6 +3,8 @@ import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasGenerationToolAvailability,
isCapabilityProviderConfigured,
resolveCapabilityModelConfigForTool,
resolveMediaToolLocalRoots,
resolveModelFromRegistry,
} from "./media-tool-shared.js";
@@ -104,6 +106,61 @@ describe("resolveModelFromRegistry", () => {
});
describe("hasGenerationToolAvailability", () => {
it("accepts config-backed custom provider auth for generation providers", () => {
const cfg = {
models: {
providers: {
"custom-image": {
baseUrl: "https://example.com/v1",
apiKey: "sk-configured", // pragma: allowlist secret
models: [],
},
},
},
};
expect(
hasGenerationToolAvailability({
providerKey: "imageGenerationProviders",
cfg,
providers: [{ id: "custom-image", defaultModel: "workflow" }],
}),
).toBe(true);
});
it("preserves a provider-specific not-configured result over generic config auth", () => {
const cfg = {
models: {
providers: {
"workflow-image": {
baseUrl: "https://example.com/v1",
apiKey: "sk-configured", // pragma: allowlist secret
models: [],
},
},
},
};
const provider = {
id: "workflow-image",
defaultModel: "workflow",
isConfigured: () => false,
};
expect(
isCapabilityProviderConfigured({
providers: [provider],
provider,
cfg,
}),
).toBe(false);
expect(
resolveCapabilityModelConfigForTool({
cfg,
providers: [provider],
}),
).toBeNull();
});
it("allows generation tools for runtime providers configured without auth", () => {
expect(
hasGenerationToolAvailability({
+19 -4
View File
@@ -27,7 +27,7 @@ import {
import {
buildToolModelConfigFromCandidates,
coerceToolModelConfig,
hasAuthForProvider,
hasProviderAuthForTool,
hasToolModelConfig,
resolveDefaultModelRef,
type ToolModelConfig,
@@ -192,6 +192,7 @@ export function isCapabilityProviderConfigured<T extends CapabilityProvider>(par
provider?: T;
providerId?: string;
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
}): boolean {
@@ -203,8 +204,10 @@ export function isCapabilityProviderConfigured<T extends CapabilityProvider>(par
});
if (!provider) {
return params.providerId
? hasAuthForProvider({
? hasProviderAuthForTool({
provider: params.providerId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
})
@@ -216,8 +219,10 @@ export function isCapabilityProviderConfigured<T extends CapabilityProvider>(par
agentDir: params.agentDir,
});
}
return hasAuthForProvider({
return hasProviderAuthForTool({
provider: provider.id,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
});
@@ -251,6 +256,7 @@ export function resolveSelectedCapabilityProvider<T extends CapabilityProvider>(
function resolveCapabilityModelCandidatesForTool(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
providers: CapabilityProvider[];
@@ -267,6 +273,7 @@ function resolveCapabilityModelCandidatesForTool(params: {
providers: params.providers,
provider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
})
@@ -309,6 +316,7 @@ function resolveCapabilityModelCandidatesForTool(params: {
export function resolveCapabilityModelConfigForTool(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
modelConfig?: AgentModelConfig;
@@ -326,10 +334,13 @@ export function resolveCapabilityModelConfigForTool(params: {
};
return buildToolModelConfigFromCandidates({
explicit,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
candidates: resolveCapabilityModelCandidatesForTool({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
providers: getProviders(),
@@ -339,6 +350,7 @@ export function resolveCapabilityModelConfigForTool(params: {
providers: getProviders(),
providerId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
}),
@@ -367,6 +379,7 @@ export function hasGenerationToolAvailability(params: {
providers,
provider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
}),
@@ -396,8 +409,10 @@ export function hasGenerationToolAvailability(params: {
contract: params.providerKey,
config: params.cfg,
}).some((providerId) =>
hasAuthForProvider({
hasProviderAuthForTool({
provider: providerId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
}),
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { hasProviderAuthForTool } from "./model-config.helpers.js";
describe("hasProviderAuthForTool", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("accepts config-backed custom provider auth", () => {
const cfg = {
models: {
providers: {
hatchery: {
baseUrl: "https://example.com/v1",
apiKey: "sk-configured", // pragma: allowlist secret
models: [],
},
},
},
} as OpenClawConfig;
expect(hasProviderAuthForTool({ provider: "hatchery", cfg })).toBe(true);
});
it("keeps auth-store profiles as valid tool auth", () => {
expect(
hasProviderAuthForTool({
provider: "hatchery",
authStore: {
version: 1,
profiles: {
"hatchery:default": {
provider: "hatchery",
type: "api_key",
key: "sk-profile", // pragma: allowlist secret
},
},
},
}),
).toBe(true);
});
it("rejects providers without config, env, or profile auth", () => {
expect(hasProviderAuthForTool({ provider: "unconfigured-provider" })).toBe(false);
});
});
+25 -2
View File
@@ -14,7 +14,7 @@ import {
} from "../auth-profiles.js";
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { resolveEnvApiKey } from "../model-auth.js";
import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../model-auth.js";
import { resolveConfiguredModelRef } from "../model-selection.js";
export type ToolModelConfig = { primary?: string; fallbacks?: string[]; timeoutMs?: number };
@@ -79,6 +79,25 @@ export function hasAuthProfileForProvider(params: {
return profileIds.some((profileId) => store.profiles[profileId]?.type === params.type);
}
export function hasProviderAuthForTool(params: {
provider: string;
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
}): boolean {
if (
hasAuthForProvider({
provider: params.provider,
agentDir: params.agentDir,
authStore: params.authStore,
})
) {
return true;
}
return hasUsableCustomProviderApiKey(params.cfg, params.provider);
}
export function coerceToolModelConfig(model?: AgentToolModelConfig): ToolModelConfig {
const primary = resolveAgentModelPrimaryValue(model);
const fallbacks = resolveAgentModelFallbackValues(model);
@@ -92,6 +111,8 @@ export function coerceToolModelConfig(model?: AgentToolModelConfig): ToolModelCo
export function buildToolModelConfigFromCandidates(params: {
explicit: ToolModelConfig;
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
candidates: Array<string | null | undefined>;
@@ -110,8 +131,10 @@ export function buildToolModelConfigFromCandidates(params: {
const provider = trimmed.slice(0, trimmed.indexOf("/")).trim();
const providerConfigured =
params.isProviderConfigured?.(provider) ??
hasAuthForProvider({
hasProviderAuthForTool({
provider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
});
@@ -58,7 +58,7 @@ function summarizeMusicGenerationCapabilities(
export function createMusicGenerateListActionResult(
config?: OpenClawConfig,
options?: { agentDir?: string; authStore?: AuthProfileStore },
options?: { workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore },
): MusicGenerateActionResult {
const providers = listRuntimeMusicGenerationProviders({ config });
return createMediaGenerateProviderListActionResult({
@@ -66,6 +66,7 @@ export function createMusicGenerateListActionResult(
providers,
emptyText: "No music-generation providers are registered.",
cfg: config,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authStore,
listModes: listSupportedMusicGenerationModes,
+4
View File
@@ -143,11 +143,13 @@ const MusicGenerateToolSchema = Type.Object({
function resolveMusicGenerationModelConfigForTool(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
}): ToolModelConfig | null {
return resolveCapabilityModelConfigForTool({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
modelConfig: params.cfg?.agents?.defaults?.musicGenerationModel,
@@ -614,6 +616,7 @@ export function createMusicGenerateTool(options?: {
if (action === "list") {
return createMusicGenerateListActionResult(cfg, {
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authProfileStore,
});
@@ -625,6 +628,7 @@ export function createMusicGenerateTool(options?: {
const musicGenerationModelConfig = resolveMusicGenerationModelConfigForTool({
cfg,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authProfileStore,
});
+34 -1
View File
@@ -18,7 +18,11 @@ vi.mock("./model-config.helpers.js", () => ({
...(objectModel?.fallbacks?.length ? { fallbacks: objectModel.fallbacks } : {}),
};
},
hasAuthForProvider: ({ provider }: { provider: string }) => {
hasProviderAuthForTool: ({ provider, cfg }: { provider: string; cfg?: OpenClawConfig }) => {
const providerCfg = cfg?.models?.providers?.[provider] as { apiKey?: string } | undefined;
if (providerCfg?.apiKey?.trim()) {
return true;
}
if (provider === "anthropic") {
return Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN);
}
@@ -137,4 +141,33 @@ describe("resolvePdfModelConfigForTool", () => {
primary: "minimax/MiniMax-VL-01",
});
});
it("uses a config-authenticated custom provider image model as a PDF fallback", () => {
const cfg = {
...withDefaultModel("hatchery/text-1"),
models: {
providers: {
hatchery: {
baseUrl: "https://example.com/v1",
apiKey: "sk-configured", // pragma: allowlist secret
models: [
{
id: "vision-1",
name: "Vision 1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_000,
maxTokens: 4_096,
},
],
},
},
},
} as OpenClawConfig;
expect(resolvePdfModelConfigForTool({ cfg, agentDir: TEST_AGENT_DIR })).toEqual({
primary: "hatchery/vision-1",
});
});
});
+13 -5
View File
@@ -12,7 +12,7 @@ import {
resolveConfiguredImageModelRefs,
resolveProviderVisionModelFromConfig,
} from "./image-tool.helpers.js";
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
import { hasProviderAuthForTool, resolveDefaultModelRef } from "./model-config.helpers.js";
import { coercePdfModelConfig } from "./pdf-tool.helpers.js";
function resolveImageCandidateRefs(params: {
@@ -29,8 +29,10 @@ function resolveImageCandidateRefs(params: {
})
.filter((providerId) => !params.filter || params.filter(providerId))
.filter((providerId) =>
hasAuthForProvider({
hasProviderAuthForTool({
provider: providerId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
}),
@@ -76,8 +78,10 @@ export function resolvePdfModelConfigForTool(params: {
}
const primary = resolveDefaultModelRef(params.cfg);
const googleOk = hasAuthForProvider({
const googleOk = hasProviderAuthForTool({
provider: "google",
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
});
@@ -92,8 +96,10 @@ export function resolvePdfModelConfigForTool(params: {
let preferred: string | null = null;
const providerOk = hasAuthForProvider({
const providerOk = hasProviderAuthForTool({
provider: primary.provider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
});
@@ -140,8 +146,10 @@ export function resolvePdfModelConfigForTool(params: {
if (
!providerId ||
isMinimaxVlmProvider(providerId) ||
!hasAuthForProvider({
!hasProviderAuthForTool({
provider: providerId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
})
@@ -81,7 +81,7 @@ function summarizeVideoGenerationCapabilities(
export function createVideoGenerateListActionResult(
config?: OpenClawConfig,
options?: { agentDir?: string; authStore?: AuthProfileStore },
options?: { workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore },
): VideoGenerateActionResult {
const providers = listRuntimeVideoGenerationProviders({ config });
return createMediaGenerateProviderListActionResult({
@@ -89,6 +89,7 @@ export function createVideoGenerateListActionResult(
providers,
emptyText: "No video-generation providers are registered.",
cfg: config,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authStore,
listModes: listSupportedVideoGenerationModes,
+4
View File
@@ -222,11 +222,13 @@ function createVideoGenerateToolSchema(params: { includeAudioReferences: boolean
export function resolveVideoGenerationModelConfigForTool(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
}): ToolModelConfig | null {
return resolveCapabilityModelConfigForTool({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
modelConfig: params.cfg?.agents?.defaults?.videoGenerationModel,
@@ -958,6 +960,7 @@ export function createVideoGenerateTool(options?: {
if (action === "list") {
return createVideoGenerateListActionResult(cfg, {
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authProfileStore,
});
@@ -969,6 +972,7 @@ export function createVideoGenerateTool(options?: {
const videoGenerationModelConfig = resolveVideoGenerationModelConfigForTool({
cfg,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
authStore: options?.authProfileStore,
});