fix(providers): recognize configured media credentials across provider boundaries (#118761)

* fix(providers): honor configured media credentials across owner boundaries

* test(providers): align configured auth fixtures and derived SDK surfaces

* fix(providers): enforce shared DashScope media credential policy
This commit is contained in:
Peter Steinberger
2026-08-03 13:26:31 -07:00
committed by GitHub
parent 25aa29d7a1
commit 990fdcf6fd
41 changed files with 1255 additions and 178 deletions
@@ -63,7 +63,7 @@ dea96213010cbc816345b1229534f1acb8b0bcfdbd7814c62eefc77030fa9cd8 module/core
f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap
68c726280f6585af96c071758ba383e55480b4fc913eae7c26aea1af331dedf3 module/diagnostic-runtime
ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime
e8adcff47c1b677cd2c01a2130fe4d226ac30ab3c88a3ceb4df5a8660316c4a9 module/discord
086bb46f97cb1f4a040b602444daf2c47ac2c24aca150a0d238933956a8100fc module/discord
f65408d85477bb362ebe6ed9148c1bb6b9eb7839733e5e3119f4ff8f1cfd0567 module/error-runtime
b013053a61e7d9be3d0c683c02baf57fa7e4393ec54e0df6a46ab0f2fe2348fd module/extension-shared
ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime
@@ -100,7 +100,7 @@ ea56ea0455c62f292e1c7b3e56cac6eb3fea00e548f9ab2f4a69e0b41b0a2d96 module/memory-
987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime
d7dd3c82a4b1df9e4144b078caf0e6ab95e7b11c5046b53a38f01143a7fa0eec module/plugin-entry
8bc85c9f7df434c87cd7d35cc33080e2577a1893353f92bff8a0cc63d2c4eb0e module/plugin-runtime
9c9670596f6dee29ec7cdcb0616cefbcd50facbf71413bec4f7c63de8f4d5469 module/provider-auth
de508df6d9cfa9d21c4524989dd8bd142fb60cb4ae980193e0907767d76e6022 module/provider-auth
71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime
56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture
aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/question-gateway-runtime
@@ -1,4 +1,11 @@
// Alibaba tests cover video generation provider plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
@@ -13,9 +20,10 @@ import {
DASHSCOPE_WAN_VIDEO_MODELS,
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
} from "openclaw/plugin-sdk/video-generation";
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
fetchWithTimeoutGuardedMock,
@@ -31,6 +39,17 @@ beforeAll(async () => {
installProviderHttpMockCleanup();
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
vi.unstubAllEnvs();
});
function clearAlibabaAuthEnvironment(): void {
for (const name of ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "QWEN_API_KEY"]) {
vi.stubEnv(name, "");
}
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
@@ -57,6 +76,188 @@ describe("alibaba video generation provider", () => {
});
});
it.each(["sk-ws-alibaba-standard-key", "sk-alibaba-legacy-standard-key"])(
"advertises Wan video generation with config-only Standard API key %s",
(apiKey) => {
clearAlibabaAuthEnvironment();
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
alibaba: {
apiKey,
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
},
},
},
}),
).toBe(true);
},
);
it("does not use Qwen Coding Plan credentials for Alibaba video discovery", () => {
clearAlibabaAuthEnvironment();
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
qwen: {
apiKey: "qwen-coding-plan-key",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
models: [],
},
},
},
},
}),
).toBe(false);
});
it.each(["", "oauth:alibaba", "custom-local", "secretref-managed"])(
"does not advertise a non-secret Alibaba credential marker %j",
(apiKey) => {
clearAlibabaAuthEnvironment();
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
alibaba: {
apiKey,
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
},
},
},
}),
).toBe(false);
},
);
it("tracks whether an allowed Alibaba API-key SecretRef resolves", () => {
clearAlibabaAuthEnvironment();
vi.stubEnv("ALIBABA_QA_CONFIG_KEY", "resolved-alibaba-config-key");
const cfg = {
models: {
providers: {
alibaba: {
apiKey: {
source: "env" as const,
provider: "alibaba-test-env",
id: "ALIBABA_QA_CONFIG_KEY",
},
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
},
},
secrets: {
defaults: { env: "alibaba-test-env" },
providers: {
"alibaba-test-env": {
source: "env" as const,
allowlist: ["ALIBABA_QA_CONFIG_KEY"],
},
},
},
};
expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg })).toBe(true);
vi.stubEnv("ALIBABA_QA_CONFIG_KEY", "");
expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg })).toBe(false);
});
it("preserves Alibaba environment API-key discovery", () => {
clearAlibabaAuthEnvironment();
vi.stubEnv("MODELSTUDIO_API_KEY", "alibaba-environment-key");
expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg: {} })).toBe(true);
});
it("does not advertise an inherited Qwen Coding Plan API key", () => {
clearAlibabaAuthEnvironment();
vi.stubEnv("QWEN_API_KEY", "sk-sp-qwen-coding-plan-key");
expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg: {} })).toBe(false);
});
it("keeps explicit Standard config above an inherited Coding Plan environment key", () => {
clearAlibabaAuthEnvironment();
vi.stubEnv("QWEN_API_KEY", "sk-sp-qwen-coding-plan-key");
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
alibaba: {
auth: "api-key",
apiKey: "sk-ws-alibaba-standard-key",
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
},
},
},
}),
).toBe(true);
});
it.each([
["sk-ws-alibaba-profile", "sk-sp-qwen-environment", true],
["sk-sp-alibaba-profile", "sk-ws-qwen-environment", false],
])("preserves actual profile precedence for %s", async (profileKey, envKey, expected) => {
clearAlibabaAuthEnvironment();
vi.stubEnv("QWEN_API_KEY", envKey);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-alibaba-wan-auth-"));
try {
saveAuthProfileStore(
{
version: 1,
profiles: {
"alibaba:standard": {
type: "api_key",
provider: "alibaba",
key: profileKey,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg: {}, agentDir })).toBe(expected);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { force: true, recursive: true });
}
});
it("rejects a resolved Coding Plan API key before submitting a Wan request", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: "sk-sp-qwen-coding-plan-key" });
await expect(
alibabaVideoGenerationProvider.generateVideo({
provider: "alibaba",
model: "wan2.6-t2v",
prompt: "animate this shot",
cfg: {},
}),
).rejects.toThrow(/Standard DashScope endpoint.*same-region Standard API key/i);
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
@@ -1,9 +1,28 @@
import { buildDashscopeVideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
function isAlibabaVideoEndpointSupported(baseUrl: string | undefined): boolean {
try {
const hostname = new URL(baseUrl ?? DEFAULT_ALIBABA_VIDEO_BASE_URL).hostname;
return !/^(?:coding(?:-intl)?\.dashscope|token-plan\..+\.maas)\.aliyuncs\.com\.?$/iu.test(
hostname,
);
} catch {
return true;
}
}
export const alibabaVideoGenerationProvider = buildDashscopeVideoGenerationProvider({
providerId: "alibaba",
label: "Alibaba Model Studio",
taskLabel: "Alibaba Wan",
defaultBaseUrl: DEFAULT_ALIBABA_VIDEO_BASE_URL,
credentialPolicy: {
// Coding/Token Plan keys share Alibaba's env aliases but cannot authenticate Wan requests.
acceptsApiKey: (apiKey) => !apiKey.trim().startsWith("sk-sp-"),
acceptsBaseUrl: isAlibabaVideoEndpointSupported,
unsupportedMessage:
"Alibaba Wan video generation requires a Standard DashScope endpoint and a same-region Standard API key; Coding Plan and Token Plan credentials are not supported.",
},
});
@@ -231,11 +231,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
label: "BytePlus",
defaultModel: DEFAULT_BYTEPLUS_VIDEO_MODEL,
models: [DEFAULT_BYTEPLUS_VIDEO_MODEL, "seedance-1-5-pro-251215"],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "byteplus",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "byteplus", ...ctx }),
capabilities: {
providerOptions: {
seed: "number",
@@ -293,6 +293,65 @@ describe("comfy image-generation provider", () => {
).toBe(true);
});
it("uses provider-owned config auth for a complete Comfy Cloud workflow", () => {
const cfg = buildComfyConfig({
mode: "cloud",
image: {
workflow: { "6": { inputs: { text: "" } } },
promptNodeId: "6",
},
});
cfg.models = {
providers: {
comfy: {
apiKey: "comfy-provider-config-key",
baseUrl: "https://cloud.comfy.org",
models: [],
},
},
};
expect(buildComfyImageGenerationProvider().isConfigured?.({ cfg })).toBe(true);
});
it("does not let provider config auth bypass incomplete Comfy Cloud workflows", () => {
const cfg = buildComfyConfig({ mode: "cloud" });
cfg.models = {
providers: {
comfy: {
apiKey: "comfy-provider-config-key",
baseUrl: "https://cloud.comfy.org",
models: [],
},
},
};
expect(buildComfyImageGenerationProvider().isConfigured?.({ cfg })).toBe(false);
});
it("preserves an unavailable plugin-secret veto even with provider config auth", () => {
vi.stubEnv("COMFY_MISSING_PLUGIN_SECRET", "");
const cfg = buildComfyConfig({
mode: "cloud",
apiKey: { source: "env", provider: "default", id: "COMFY_MISSING_PLUGIN_SECRET" },
image: {
workflow: { "6": { inputs: { text: "" } } },
promptNodeId: "6",
},
});
cfg.models = {
providers: {
comfy: {
apiKey: "comfy-provider-config-key",
baseUrl: "https://cloud.comfy.org",
models: [],
},
},
};
expect(buildComfyImageGenerationProvider().isConfigured?.({ cfg })).toBe(false);
});
it("submits a local workflow, waits for history, and downloads images", async () => {
setComfyFetchGuardForTesting(fetchWithSsrFGuardMock);
fetchWithSsrFGuardMock
+1
View File
@@ -637,6 +637,7 @@ export function isComfyCapabilityConfigured(params: {
}
return isProviderApiKeyConfigured({
provider: "comfy",
cfg: params.cfg,
agentDir: params.agentDir,
});
}
@@ -182,11 +182,7 @@ export function buildDeepInfraVideoGenerationProvider(options?: {
defaultModel,
models: ids,
resolveModelCapabilities: resolveDeepInfraVideoModelCapabilities,
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "deepinfra",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "deepinfra", ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
+1 -5
View File
@@ -644,11 +644,7 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
FAL_KREA_2_MEDIUM_MODEL,
FAL_KREA_2_LARGE_MODEL,
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "fal",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "fal", ...ctx }),
capabilities: {
generate: {
maxCount: 4,
+1 -5
View File
@@ -106,11 +106,7 @@ export function buildFalMusicGenerationProvider(): MusicGenerationProvider {
label: "fal",
defaultModel: DEFAULT_FAL_MUSIC_MODEL,
models: [...FAL_MUSIC_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "fal",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "fal", ...ctx }),
capabilities: {
generate: {
maxTracks: 1,
+1 -5
View File
@@ -598,11 +598,7 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider {
"fal-ai/wan/v2.2-a14b/text-to-video",
"fal-ai/wan/v2.2-a14b/image-to-video",
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "fal",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "fal", ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
@@ -15,13 +15,8 @@ export const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
export const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
export const GOOGLE_VIDEO_MAX_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[2];
function isGoogleProviderConfigured(
ctx: { agentDir?: string } | VideoGenerationProviderConfiguredContext,
): boolean {
return isProviderApiKeyConfigured({
provider: "google",
agentDir: ctx.agentDir,
});
function isGoogleProviderConfigured(ctx: VideoGenerationProviderConfiguredContext): boolean {
return isProviderApiKeyConfigured({ provider: "google", ...ctx });
}
export function createGoogleMusicGenerationProviderMetadata(): Omit<
@@ -97,6 +97,7 @@ describe("Google image-generation provider", () => {
ssrfMock?.mockRestore();
ssrfMock = undefined;
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
@@ -572,8 +573,6 @@ describe("Google image-generation provider", () => {
});
it("reports configured from a config apiKey (gateway-routed gemini) with no env/profile creds", () => {
vi.spyOn(providerAuth, "isProviderApiKeyConfigured").mockReturnValue(false);
const provider = buildGoogleImageGenerationProvider();
expect(
provider.isConfigured?.({
@@ -593,6 +592,29 @@ describe("Google image-generation provider", () => {
).toBe(true);
});
it("does not advertise Gemini images for OAuth and managed-secret marker strings", () => {
vi.stubEnv("GEMINI_API_KEY", "");
vi.stubEnv("GOOGLE_API_KEY", "");
for (const apiKey of ["oauth:google", "secretref-managed", "gcp-vertex-credentials"]) {
expect(
buildGoogleImageGenerationProvider().isConfigured?.({
cfg: {
models: {
providers: {
google: {
apiKey,
baseUrl: "https://gateway.example.test/gemini/v1beta",
models: [],
},
},
},
},
}),
).toBe(false);
}
});
it("still reports not configured with a custom endpoint and no credentials", () => {
vi.spyOn(providerAuth, "isProviderApiKeyConfigured").mockReturnValue(false);
+2 -13
View File
@@ -7,10 +7,7 @@ import {
} from "openclaw/plugin-sdk/image-generation";
import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import {
hasConfiguredSecretInput,
isProviderApiKeyConfigured,
} from "openclaw/plugin-sdk/provider-auth";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
@@ -149,15 +146,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
label: "Google",
defaultModel: DEFAULT_GOOGLE_IMAGE_MODEL,
models: [DEFAULT_GOOGLE_IMAGE_MODEL, "gemini-3-pro-image"],
isConfigured: ({ cfg, agentDir }) =>
// generateImage already authenticates from a config apiKey; count a
// usable one (non-blank literal or secret ref) as configured here too,
// so image gen works from config alone, like chat.
hasConfiguredSecretInput(cfg?.models?.providers?.google?.apiKey) ||
isProviderApiKeyConfigured({
provider: "google",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "google", ...ctx }),
capabilities: {
generate: {
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
@@ -109,6 +109,24 @@ describe("google music generation provider", () => {
expectExplicitMusicGenerationCapabilities(buildGoogleMusicGenerationProvider());
});
it("advertises Gemini music generation with a config-only Google API key", () => {
expect(
buildGoogleMusicGenerationProvider().isConfigured?.({
cfg: {
models: {
providers: {
google: {
apiKey: "google-config-only-key",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [],
},
},
},
},
}),
).toBe(true);
});
it("submits generation and returns inline audio bytes plus lyrics", async () => {
mockGoogleAuth();
generateContentMock.mockResolvedValue({
@@ -159,6 +159,24 @@ describe("google video generation provider", () => {
expect(provider.capabilities.videoToVideo?.supportsAudio).toBe(false);
});
it("advertises Gemini video generation with a config-only Google API key", () => {
expect(
buildGoogleVideoGenerationProvider().isConfigured?.({
cfg: {
models: {
providers: {
google: {
apiKey: "google-config-only-key",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [],
},
},
},
},
}),
).toBe(true);
});
it("submits generation and returns inline video bytes", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-key",
@@ -175,6 +175,16 @@ describe("microsoft foundry image generation provider", () => {
});
});
it("passes provider-owned configuration to shared image-auth readiness", () => {
const cfg = buildConfig();
expect(buildMicrosoftFoundryImageGenerationProvider().isConfigured?.({ cfg })).toBe(true);
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({
provider: PROVIDER_ID,
cfg,
});
});
it("sends MAI image generation requests to the Foundry MAI endpoint with API-key auth", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
@@ -254,11 +254,7 @@ export function buildMicrosoftFoundryImageGenerationProvider(): ImageGenerationP
label: "Microsoft Foundry",
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
models: [],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: PROVIDER_ID,
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: PROVIDER_ID, ...ctx }),
capabilities: {
generate: {
maxCount: MAI_IMAGE_MAX_RESULTS,
@@ -79,6 +79,27 @@ describe("minimax image-generation provider", () => {
};
}
it.each([
["minimax", buildMinimaxImageGenerationProvider],
["minimax-portal", buildMinimaxPortalImageGenerationProvider],
])("advertises %s image generation using its own config-only credential", (providerId, build) => {
expect(
build().isConfigured?.({
cfg: {
models: {
providers: {
[providerId]: {
apiKey: "minimax-config-only-key",
baseUrl: "https://api.minimax.io/v1",
models: [],
},
},
},
},
}),
).toBe(true);
});
it("generates PNG buffers through the shared provider HTTP path", async () => {
mockMinimaxApiKey();
const fetchMock = mockSuccessfulMinimaxImageResponse();
@@ -85,11 +85,7 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider
label: "MiniMax",
defaultModel: DEFAULT_MODEL,
models: [DEFAULT_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: providerId,
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: providerId, ...ctx }),
capabilities: {
generate: {
maxCount: MINIMAX_MAX_IMAGE_RESULTS,
@@ -270,11 +270,7 @@ function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider
label: "MiniMax",
defaultModel: DEFAULT_MINIMAX_MUSIC_MODEL,
models: [DEFAULT_MINIMAX_MUSIC_MODEL, "music-2.6-free", "music-cover", "music-cover-free"],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: providerId,
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: providerId, ...ctx }),
capabilities: {
generate: {
maxTracks: 1,
@@ -401,11 +401,7 @@ function buildMinimaxVideoProvider(providerId: string): VideoGenerationProvider
"I2V-01-live",
"I2V-01",
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: providerId,
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: providerId, ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
@@ -123,6 +123,24 @@ describe("openai video generation provider", () => {
});
});
it("advertises OpenAI video for an actual config-only API key", () => {
expect(
buildOpenAIVideoGenerationProvider().isConfigured?.({
cfg: {
models: {
providers: {
openai: {
apiKey: "openai-video-config-key",
baseUrl: "https://api.openai.com/v1",
models: [],
},
},
},
},
}),
).toBe(true);
});
it("does not advertise video generation for OAuth-only OpenAI profiles", () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-openai-video-auth-"));
const previousOpenAIKey = process.env.OPENAI_API_KEY;
@@ -297,10 +297,10 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
label: "OpenAI",
defaultModel: DEFAULT_OPENAI_VIDEO_MODEL,
models: [DEFAULT_OPENAI_VIDEO_MODEL, "sora-2-pro"],
isConfigured: ({ agentDir }) =>
isConfigured: (ctx) =>
isProviderApiKeyConfigured({
provider: "openai",
agentDir,
...ctx,
profileTypes: ["api_key"],
}),
capabilities: {
@@ -221,8 +221,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
label: "OpenRouter",
defaultModel: DEFAULT_MODEL,
models: [...SUPPORTED_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({ provider: "openrouter", agentDir }),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "openrouter", ...ctx }),
capabilities: {
generate: {
maxCount: MAX_IMAGE_RESULTS,
@@ -344,11 +344,7 @@ export function buildOpenRouterMusicGenerationProvider(): MusicGenerationProvide
label: "OpenRouter",
defaultModel: DEFAULT_OPENROUTER_MUSIC_MODEL,
models: [...OPENROUTER_MUSIC_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "openrouter",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "openrouter", ...ctx }),
capabilities: {
generate: {
maxTracks: 1,
@@ -425,8 +425,7 @@ export function buildOpenRouterVideoGenerationProvider(): VideoGenerationProvide
label: "OpenRouter",
defaultModel: DEFAULT_MODEL,
models: [DEFAULT_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({ provider: "openrouter", agentDir }),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "openrouter", ...ctx }),
resolveModelCapabilities: resolveOpenRouterVideoModelCapabilities,
capabilities: {
providerOptions: {
@@ -348,11 +348,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
defaultModel: DEFAULT_PIXVERSE_MODEL_ID,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
models: [...PIXVERSE_VIDEO_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: PIXVERSE_PROVIDER_ID,
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: PIXVERSE_PROVIDER_ID, ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
+232 -38
View File
@@ -1,4 +1,11 @@
// Qwen tests cover video generation provider plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
@@ -13,9 +20,10 @@ import {
DASHSCOPE_WAN_VIDEO_MODELS,
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
} from "openclaw/plugin-sdk/video-generation";
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
fetchWithTimeoutGuardedMock,
@@ -31,6 +39,17 @@ beforeAll(async () => {
installProviderHttpMockCleanup();
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
vi.unstubAllEnvs();
});
function clearQwenAuthEnvironment(): void {
for (const name of ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"]) {
vi.stubEnv(name, "");
}
}
function expectPostJsonRequest(
call: unknown,
expected: {
@@ -87,6 +106,134 @@ describe("qwen video generation provider", () => {
});
});
it.each([
["sk-ws-qwen-standard-key", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"],
["sk-qwen-legacy-standard-key", "https://dashscope.aliyuncs.com/compatible-mode/v1"],
])("advertises Standard Qwen video credentials %s", (apiKey, baseUrl) => {
clearQwenAuthEnvironment();
expect(
qwenVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
qwen: {
apiKey,
baseUrl,
models: [],
},
},
},
},
}),
).toBe(true);
});
it.each([
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
])("does not advertise Wan video through subscription endpoint %s", (baseUrl) => {
clearQwenAuthEnvironment();
expect(
qwenVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
qwen: {
apiKey: "sk-ws-qwen-standard-key",
baseUrl,
models: [],
},
},
},
},
}),
).toBe(false);
});
it("does not advertise a Coding or Token Plan API key on a Standard endpoint", () => {
clearQwenAuthEnvironment();
expect(
qwenVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
qwen: {
apiKey: "sk-sp-qwen-subscription-key",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
models: [],
},
},
},
},
}),
).toBe(false);
});
it("does not advertise an inherited Coding Plan environment key", () => {
clearQwenAuthEnvironment();
vi.stubEnv("QWEN_API_KEY", "sk-sp-qwen-coding-plan-key");
expect(qwenVideoGenerationProvider.isConfigured?.({ cfg: {} })).toBe(false);
});
it("keeps explicit Standard config above an inherited Coding Plan environment key", () => {
clearQwenAuthEnvironment();
vi.stubEnv("QWEN_API_KEY", "sk-sp-qwen-coding-plan-key");
expect(
qwenVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
qwen: {
auth: "api-key",
apiKey: "sk-ws-qwen-standard-key",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
models: [],
},
},
},
},
}),
).toBe(true);
});
it.each([
["sk-ws-qwen-profile", "sk-sp-qwen-environment", true],
["sk-sp-qwen-profile", "sk-ws-qwen-environment", false],
])("preserves actual profile precedence for %s", async (profileKey, envKey, expected) => {
clearQwenAuthEnvironment();
vi.stubEnv("QWEN_API_KEY", envKey);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qwen-wan-auth-"));
try {
saveAuthProfileStore(
{
version: 1,
profiles: {
"qwen:standard": {
type: "api_key",
provider: "qwen",
key: profileKey,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
expect(qwenVideoGenerationProvider.isConfigured?.({ cfg: {}, agentDir })).toBe(expected);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { force: true, recursive: true });
}
});
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
@@ -238,6 +385,47 @@ describe("qwen video generation provider", () => {
).rejects.toThrow("Qwen generated video download exceeds 1 bytes");
});
it.each([
[
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com",
],
["https://dashscope.aliyuncs.com/compatible-mode/v1", "https://dashscope.aliyuncs.com"],
["https://dashscope-us.aliyuncs.com/compatible-mode/v1", "https://dashscope-us.aliyuncs.com"],
[
"https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1",
"https://cn-hongkong.dashscope.aliyuncs.com",
],
[
"https://workspace.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
"https://workspace.ap-southeast-1.maas.aliyuncs.com",
],
["https://proxy.example.test/vendor-prefix/", "https://proxy.example.test/vendor-prefix"],
])("routes Standard endpoint %s to its own regional AIGC host", async (baseUrl, aigcBaseUrl) => {
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
await qwenVideoGenerationProvider.generateVideo({
provider: "qwen",
model: "wan2.6-t2v",
prompt: "animate this shot",
cfg: {
models: {
providers: {
qwen: {
baseUrl,
models: [],
},
},
},
},
});
expect(postJsonRequestMock.mock.calls[0]?.[0]).toMatchObject({
url: `${aigcBaseUrl}/api/v1/services/aigc/video-generation/video-synthesis`,
});
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock, { baseUrl: aigcBaseUrl });
});
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
const provider = qwenVideoGenerationProvider;
@@ -255,48 +443,54 @@ describe("qwen video generation provider", () => {
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("preserves dedicated coding endpoints for dedicated API keys", async () => {
mockSuccessfulDashscopeVideoTask(
{
postJsonRequestMock,
fetchWithTimeoutMock,
},
{ requestId: "req-2", taskId: "task-2" },
);
const provider = qwenVideoGenerationProvider;
await provider.generateVideo({
provider: "qwen",
model: "wan2.6-t2v",
prompt: "animate this shot",
cfg: {
models: {
providers: {
qwen: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
models: [],
it.each([
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
])("rejects Wan generation through subscription endpoint %s", async (baseUrl) => {
await expect(
qwenVideoGenerationProvider.generateVideo({
provider: "qwen",
model: "wan2.6-t2v",
prompt: "animate this shot",
cfg: {
models: {
providers: {
qwen: {
baseUrl,
models: [],
},
},
},
},
},
});
}),
).rejects.toThrow(/Standard DashScope endpoint.*same-region Standard API key/i);
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
expectPostJsonRequest(postJsonRequestMock.mock.calls[0]?.[0], {
url: "https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
body: {
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("rejects a resolved Coding Plan API key before submitting a Wan request", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: "sk-sp-qwen-coding-plan-key" });
await expect(
qwenVideoGenerationProvider.generateVideo({
provider: "qwen",
model: "wan2.6-t2v",
input: {
prompt: "animate this shot",
prompt: "animate this shot",
cfg: {
models: {
providers: {
qwen: {
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
models: [],
},
},
},
},
parameters: {
duration: 5,
},
},
});
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock, {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com",
taskId: "task-2",
});
}),
).rejects.toThrow(/Standard DashScope endpoint.*same-region Standard API key/i);
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
});
+24 -4
View File
@@ -1,7 +1,21 @@
// Qwen provider module implements model/runtime integration.
import { buildDashscopeVideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
import { isQwenCodingPlanBaseUrl } from "./models.js";
const DEFAULT_QWEN_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
function isQwenVideoEndpointSupported(baseUrl: string | undefined): boolean {
if (isQwenCodingPlanBaseUrl(baseUrl)) {
return false;
}
try {
const hostname = new URL(baseUrl ?? DEFAULT_QWEN_VIDEO_BASE_URL).hostname;
return !/^token-plan\..+\.maas\.aliyuncs\.com\.?$/iu.test(hostname);
} catch {
return true;
}
}
function resolveQwenVideoBaseUrl(configuredBaseUrl: string | undefined): string {
const direct = configuredBaseUrl?.trim();
if (!direct) {
@@ -16,11 +30,10 @@ function resolveQwenVideoBaseUrl(configuredBaseUrl: string | undefined): string
function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
const url = new URL(baseUrl);
const hostname = url.hostname.toLowerCase().replace(/\.+$/u, "");
if (
url.hostname === "coding-intl.dashscope.aliyuncs.com" ||
url.hostname === "coding.dashscope.aliyuncs.com" ||
url.hostname === "dashscope-intl.aliyuncs.com" ||
url.hostname === "dashscope.aliyuncs.com"
/(?:^|\.)dashscope(?:-[^.]+)?\.aliyuncs\.com$/u.test(hostname) ||
hostname.endsWith(".maas.aliyuncs.com")
) {
return url.origin;
}
@@ -35,4 +48,11 @@ export const qwenVideoGenerationProvider = buildDashscopeVideoGenerationProvider
defaultBaseUrl: DEFAULT_QWEN_VIDEO_BASE_URL,
resolveRequestBaseUrl: resolveQwenVideoBaseUrl,
resolveAigcBaseUrl: resolveDashscopeAigcApiBaseUrl,
credentialPolicy: {
// Coding/Token Plan subscriptions use the same provider id but do not include Wan models.
acceptsApiKey: (apiKey) => !apiKey.trim().startsWith("sk-sp-"),
acceptsBaseUrl: isQwenVideoEndpointSupported,
unsupportedMessage:
"Qwen Wan video generation requires a Standard DashScope endpoint and a same-region Standard API key; Coding Plan and Token Plan credentials are not supported.",
},
});
@@ -352,11 +352,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
label: "Runway",
defaultModel: DEFAULT_RUNWAY_MODEL,
models: ["gen4.5", "gen4_turbo", "gen4_aleph", "gen3a_turbo", "veo3.1", "veo3.1_fast", "veo3"],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "runway",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "runway", ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
@@ -180,11 +180,7 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider
"minimax/hailuo-02",
"kwaivgI/kling-2.1-master",
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "together",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "together", ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
@@ -23,11 +23,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
label: "Vydra",
defaultModel: DEFAULT_VYDRA_IMAGE_MODEL,
models: [DEFAULT_VYDRA_IMAGE_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "vydra",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "vydra", ...ctx }),
capabilities: {
generate: {
maxCount: 1,
@@ -60,11 +60,7 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
label: "Vydra",
defaultModel: DEFAULT_VYDRA_VIDEO_MODEL,
models: [DEFAULT_VYDRA_VIDEO_MODEL, VYDRA_KLING_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "vydra",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "vydra", ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
+1 -5
View File
@@ -429,11 +429,7 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
modes: ["imageToVideo"],
},
},
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "xai",
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "xai", ...ctx }),
capabilities: {
generate: {
maxVideos: 1,
@@ -183,6 +183,32 @@ describe("OpenAI-compatible image provider helper", () => {
});
});
it("checks config-backed auth under the credential owner, not its HTTP config alias", () => {
const provider = createProvider({ providerConfigKey: "different-http-provider" });
const cfg = {
models: {
providers: {
sample: {
apiKey: "sample-config-key",
baseUrl: "https://sample.example/v1/",
models: [],
},
"different-http-provider": {
apiKey: "wrong-owner-key",
baseUrl: "https://different-http-provider.example/v1/",
models: [],
},
},
},
};
expect(provider.isConfigured?.({ cfg })).toBe(true);
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({
provider: "sample",
cfg,
});
});
it("posts JSON generation requests and parses OpenAI-compatible image data", async () => {
const release = mockGeneratedResponse();
const provider = createProvider();
@@ -162,11 +162,7 @@ export function createOpenAiCompatibleImageGenerationProvider(
? { defaultTimeoutMs: options.defaultTimeoutMs }
: {}),
models: [...options.models],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: options.id,
agentDir,
}),
isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: options.id, ...ctx }),
capabilities: options.capabilities,
async generateImage(req): Promise<ImageGenerationResult> {
const inputImages = req.inputImages ?? [];
@@ -98,6 +98,59 @@ describe("media-generation runtime shared candidates", () => {
]);
});
it("auto-detects config-only providers that do not implement custom readiness", () => {
const candidates = resolveCapabilityModelCandidates({
cfg: {
models: {
providers: {
"media-config-only": {
apiKey: "config-only-media-key",
baseUrl: "https://media.example.test/v1",
models: [],
},
},
},
} as OpenClawConfig,
modelConfig: undefined,
parseModelRef,
listProviders: () => [
{
id: "media-config-only",
defaultModel: "configured-video",
},
],
});
expect(candidates).toEqual([{ provider: "media-config-only", model: "configured-video" }]);
});
it("preserves an owner readiness veto even when generic config contains an API key", () => {
const candidates = resolveCapabilityModelCandidates({
cfg: {
models: {
providers: {
"media-config-only": {
apiKey: "config-only-media-key",
baseUrl: "https://media.example.test/v1",
models: [],
},
},
},
} as OpenClawConfig,
modelConfig: undefined,
parseModelRef,
listProviders: () => [
{
id: "media-config-only",
defaultModel: "configured-video",
isConfigured: () => false,
},
],
});
expect(candidates).toEqual([]);
});
it("orders auto-detected provider defaults by canonical aliases", () => {
const candidates = resolveCapabilityModelCandidates({
cfg: {
+5 -13
View File
@@ -3,11 +3,8 @@ import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercio
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveCapabilityModelRefForProviders } from "../../packages/media-generation-core/src/capability-model-ref.js";
import type { MediaGenerationNormalizationMetadataInput } from "../../packages/media-generation-core/src/normalization.js";
import { listProfilesForProvider } from "../agents/auth-profiles.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { describeFailoverError, isFailoverError } from "../agents/failover-error.js";
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
import type { FallbackAttempt } from "../agents/model-fallback.types.js";
import {
resolveAgentModelFallbackValues,
@@ -16,6 +13,7 @@ import {
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import type { OpenClawConfig } from "../config/types.js";
import { formatErrorMessage, toErrorObject } from "../infra/errors.js";
import { isProviderApiKeyConfigured } from "../plugin-sdk/provider-auth.js";
import { getProviderEnvVars as getDefaultProviderEnvVars } from "../secrets/provider-env-vars.js";
// Shared media-generation runtime helpers for provider fallback, request
@@ -111,17 +109,11 @@ function isCapabilityProviderConfigured(params: {
agentDir: params.agentDir,
});
}
if (resolveEnvApiKey(params.provider.id)?.apiKey) {
return true;
}
const agentDir = normalizeOptionalString(params.agentDir);
if (!agentDir) {
return false;
}
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
return isProviderApiKeyConfigured({
provider: params.provider.id,
cfg: params.cfg,
agentDir: params.agentDir,
});
return listProfilesForProvider(store, params.provider.id).length > 0;
}
function resolveAutoCapabilityFallbackRefs(params: {
+358 -1
View File
@@ -4,10 +4,17 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
} from "../agents/auth-profiles.js";
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles/types.js";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
COPILOT_INTEGRATION_ID,
deriveCopilotApiBaseUrlFromToken,
isProviderApiKeyConfigured,
normalizeGithubCopilotDomain,
resolveCopilotApiToken,
} from "./provider-auth.js";
@@ -111,6 +118,356 @@ async function runFallbackStoreCase(): Promise<FallbackStoreCaseResult> {
};
}
describe("provider API-key readiness", () => {
const provider = "media-readiness-provider";
afterEach(() => {
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
vi.unstubAllEnvs();
});
function configuredProvider(apiKey: unknown, providerId = provider): OpenClawConfig {
return {
models: {
providers: {
[providerId]: {
apiKey,
baseUrl: "https://media.example.test/v1",
models: [],
},
},
},
} as OpenClawConfig;
}
it.each([provider, ` ${provider.toUpperCase()} `])(
"recognizes usable config-only API keys for normalized provider entry %s",
(providerId) => {
expect(
isProviderApiKeyConfigured({
provider,
cfg: configuredProvider("media-secret", providerId),
}),
).toBe(true);
},
);
it.each([
"",
" ",
"oauth:media-readiness-provider",
"custom-local",
"gcp-vertex-credentials",
"secretref-managed",
"GOOGLE_API_KEY",
])("does not mistake non-secret marker %j for a usable configured API key", (apiKey) => {
vi.stubEnv("GOOGLE_API_KEY", "");
expect(isProviderApiKeyConfigured({ provider, cfg: configuredProvider(apiKey) })).toBe(false);
});
it("recognizes allowed env SecretRefs through their configured provider alias", () => {
vi.stubEnv("MEDIA_READINESS_TEST_KEY", "resolved-media-secret");
const cfg = configuredProvider({
source: "env",
provider: "team-env",
id: "MEDIA_READINESS_TEST_KEY",
});
cfg.secrets = {
defaults: { env: "team-env" },
providers: {
"team-env": { source: "env", allowlist: ["MEDIA_READINESS_TEST_KEY"] },
},
};
expect(isProviderApiKeyConfigured({ provider, cfg })).toBe(true);
});
it.each(["file", "exec"] as const)(
"keeps an unresolved %s SecretRef unavailable until its managed runtime snapshot resolves it",
(source) => {
const sourceConfig = configuredProvider({ source, provider: "managed", id: "media-key" });
expect(isProviderApiKeyConfigured({ provider, cfg: sourceConfig })).toBe(false);
const runtimeConfig = configuredProvider("resolved-managed-media-secret");
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
expect(isProviderApiKeyConfigured({ provider, cfg: sourceConfig })).toBe(true);
},
);
it("does not advertise missing or provider-disallowed env SecretRefs", () => {
vi.stubEnv("MEDIA_READINESS_TEST_KEY", "resolved-media-secret");
const cfg = configuredProvider({
source: "env",
provider: "team-env",
id: "MEDIA_READINESS_TEST_KEY",
});
cfg.secrets = {
providers: { "team-env": { source: "env", allowlist: ["OTHER_MEDIA_KEY"] } },
};
expect(isProviderApiKeyConfigured({ provider, cfg })).toBe(false);
vi.stubEnv("MEDIA_READINESS_TEST_KEY", "");
cfg.secrets.providers!["team-env"] = { source: "env" };
expect(isProviderApiKeyConfigured({ provider, cfg })).toBe(false);
});
it("preserves existing behavior when callers omit runtime configuration", () => {
expect(isProviderApiKeyConfigured({ provider })).toBe(false);
});
it("applies provider-owned credential acceptance only when explicitly requested", () => {
const cfg = configuredProvider("blocked-provider-key");
expect(isProviderApiKeyConfigured({ provider, cfg })).toBe(true);
expect(
isProviderApiKeyConfigured({
provider,
cfg,
acceptsApiKey: (apiKey) => !apiKey.startsWith("blocked-"),
}),
).toBe(false);
expect(
isProviderApiKeyConfigured({
provider,
cfg: configuredProvider("allowed-provider-key"),
acceptsApiKey: (apiKey) => !apiKey.startsWith("blocked-"),
}),
).toBe(true);
});
it.each([
["allowed-profile-key", "blocked-environment-key", true],
["blocked-profile-key", "allowed-environment-key", false],
])(
"applies credential acceptance to the higher-priority auth profile %s",
async (profileKey, envKey, expected) => {
vi.stubEnv("GOOGLE_API_KEY", envKey);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-key-policy-"));
try {
saveAuthProfileStore(
{
version: 1,
profiles: {
"google:selected": {
type: "api_key",
provider: "google",
key: profileKey,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
expect(
isProviderApiKeyConfigured({
provider: "google",
agentDir,
profileTypes: ["api_key"],
acceptsApiKey: (apiKey) => !apiKey.startsWith("blocked-"),
}),
).toBe(expected);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { force: true, recursive: true });
}
},
);
it("keeps an explicit config credential above rejected environment credentials", () => {
vi.stubEnv("GOOGLE_API_KEY", "blocked-environment-key");
const cfg = configuredProvider("allowed-config-key", "google");
const google = cfg.models?.providers?.google;
if (!google) {
throw new Error("missing configured Google provider");
}
google.auth = "api-key";
expect(
isProviderApiKeyConfigured({
provider: "google",
cfg,
acceptsApiKey: (apiKey) => !apiKey.startsWith("blocked-"),
}),
).toBe(true);
});
it.each([
["oauth", ["api_key"], false],
["token", ["api_key"], false],
["oauth", ["oauth"], true],
["token", ["token"], true],
["api-key", ["api_key"], true],
] as const)(
"honors configured %s credential mode for allowed profile types %j",
(auth, profileTypes, expected) => {
const cfg = configuredProvider("media-api-key");
const entry = cfg.models?.providers?.[provider];
if (!entry) {
throw new Error("missing configured media provider");
}
entry.auth = auth;
expect(isProviderApiKeyConfigured({ provider, cfg, profileTypes })).toBe(expected);
},
);
it("honors hydrated managed-SecretRef credential modes for API-key-only consumers", () => {
const sourceConfig = configuredProvider({
source: "file",
provider: "managed",
id: "media-key",
});
const runtimeConfig = configuredProvider("resolved-managed-media-secret");
const sourceProvider = sourceConfig.models?.providers?.[provider];
const runtimeProvider = runtimeConfig.models?.providers?.[provider];
if (!sourceProvider || !runtimeProvider) {
throw new Error("missing managed media provider configuration");
}
sourceProvider.auth = "oauth";
runtimeProvider.auth = "oauth";
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
expect(
isProviderApiKeyConfigured({ provider, cfg: sourceConfig, profileTypes: ["api_key"] }),
).toBe(false);
expect(
isProviderApiKeyConfigured({ provider, cfg: sourceConfig, profileTypes: ["oauth"] }),
).toBe(true);
});
it.each([
{
label: "compatible API-key profile",
credential: { type: "api_key", provider, key: "profile-api-key" },
profileTypes: ["api_key"],
expected: true,
},
{
label: "OAuth profile rejected by provider-entry credential policy",
credential: {
type: "oauth",
provider,
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
profileTypes: ["api_key"],
expected: false,
},
{
label: "token profile rejected by an API-key-only consumer",
credential: { type: "token", provider, token: "profile-token" },
profileTypes: ["api_key"],
expected: false,
},
{
label: "token profile accepted by a token consumer",
credential: { type: "token", provider, token: "profile-token" },
profileTypes: ["token"],
expected: true,
},
{
label: "API-key profile owned by a different provider",
credential: { type: "api_key", provider: "unrelated-provider", key: "wrong-provider-key" },
profileTypes: ["api_key"],
expected: false,
},
{
label: "API-key profile with missing credential material",
credential: { type: "api_key", provider },
profileTypes: ["api_key"],
expected: false,
},
{
label: "API-key profile with an unresolved env SecretRef",
credential: {
type: "api_key",
provider,
keyRef: { source: "env", provider: "default", id: "MEDIA_PROFILE_MISSING_SECRET" },
},
profileTypes: ["api_key"],
expected: false,
},
] satisfies Array<{
label: string;
credential: AuthProfileCredential;
profileTypes: AuthProfileCredential["type"][];
expected: boolean;
}>)(
"classifies configured profile references: $label",
async ({ credential, expected, profileTypes }) => {
vi.stubEnv("MEDIA_PROFILE_MISSING_SECRET", "");
const profileId = `${provider}:selected`;
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-profile-binding-"));
try {
saveAuthProfileStore({ version: 1, profiles: { [profileId]: credential } }, agentDir, {
filterExternalAuthProfiles: false,
syncExternalCli: false,
});
expect(
isProviderApiKeyConfigured({
provider,
agentDir,
cfg: configuredProvider(profileId),
profileTypes,
}),
).toBe(expected);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { force: true, recursive: true });
}
},
);
it("preserves API-key-only profile filters while accepting actual config API keys", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-auth-readiness-"));
try {
saveAuthProfileStore(
{
version: 1,
profiles: {
[`${provider}:oauth`]: {
type: "oauth",
provider,
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
expect(isProviderApiKeyConfigured({ provider, agentDir })).toBe(true);
expect(
isProviderApiKeyConfigured({
provider,
agentDir,
cfg: configuredProvider(`oauth:${provider}`),
profileTypes: ["api_key"],
}),
).toBe(false);
expect(
isProviderApiKeyConfigured({
provider,
agentDir,
cfg: configuredProvider("media-api-key"),
profileTypes: ["api_key"],
}),
).toBe(true);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { force: true, recursive: true });
}
});
});
describe("provider auth profile helpers", () => {
let fallbackStoreCase: FallbackStoreCaseResult;
+116 -3
View File
@@ -9,8 +9,10 @@ import { externalCliDiscoveryForProviderAuth } from "../agents/auth-profiles/ext
import { resolveApiKeyForProfile } from "../agents/auth-profiles/oauth.js";
import { resolveAuthProfileOrder } from "../agents/auth-profiles/order.js";
import { listProfilesForProvider } from "../agents/auth-profiles/profiles.js";
import { resolveStoredCredentialReadOnlyAvailability } from "../agents/auth-profiles/read-only-availability.js";
import {
ensureAuthProfileStore,
findPersistedAuthProfileCredential,
loadAuthProfileStoreForSecretsRuntime,
loadAuthProfileStoreWithoutExternalProfiles,
} from "../agents/auth-profiles/store.js";
@@ -21,6 +23,15 @@ import {
buildCopilotIdeHeaders,
} from "../agents/copilot-dynamic-headers.js";
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import {
profileTypeToAuthMode,
resolveDirectProviderCredentialMode,
resolveProviderConfig,
resolveProviderEntryApiKeyProfileReference,
resolveUsableCustomProviderApiKey,
} from "../agents/model-auth-provider-config.js";
import { resolveManagedSecretRefRuntimeProviderAuth } from "../agents/model-auth-runtime-config.js";
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import type { OpenClawConfig } from "../config/config.js";
import { logWarn } from "../logger.js";
@@ -398,20 +409,122 @@ export async function resolveCopilotApiToken(params: {
}
/**
* Checks whether a provider has either env auth or matching local auth profiles configured.
* Checks whether a provider has usable config/env auth or matching local auth profiles.
*/
export function isProviderApiKeyConfigured(params: {
/** Provider id to check for env auth or local auth profiles. */
/** Provider id to check for config/env auth or local auth profiles. */
provider: string;
/** Optional runtime config used to resolve provider-owned API-key credentials. */
cfg?: OpenClawConfig;
/** Agent directory containing auth profiles. */
agentDir?: string;
/** Optional allowed profile credential types. */
profileTypes?: readonly AuthProfileCredential["type"][];
/** Optional provider-owned acceptance predicate for a known selected credential. */
acceptsApiKey?: (apiKey: string) => boolean;
}): boolean {
const agentDir = params.agentDir?.trim();
if (params.acceptsApiKey) {
const { acceptsApiKey, ...availability } = params;
if (!isProviderApiKeyConfigured(availability)) {
return false;
}
const providerConfig = resolveProviderConfig(params.cfg, params.provider);
const authoredApiKey = providerConfig?.apiKey;
const store = agentDir
? ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false })
: undefined;
let profile =
typeof authoredApiKey === "string" ? store?.profiles[authoredApiKey.trim()] : undefined;
if (!profile && store && providerConfig?.auth !== "api-key") {
const [profileId] = listUsableProviderAuthProfileIds(availability).profileIds;
profile = profileId ? store.profiles[profileId] : undefined;
}
if (profile) {
const credential =
profile.type === "oauth"
? profile.access
: profile.type === "token"
? (profile.token ??
(profile.tokenRef?.source === "env" ? process.env[profile.tokenRef.id] : undefined))
: (profile.key ??
(profile.keyRef?.source === "env" ? process.env[profile.keyRef.id] : undefined));
// Opaque managed profile refs are validated after canonical async auth resolution.
return credential === undefined || acceptsApiKey(credential);
}
const configParams = { cfg: params.cfg, provider: params.provider };
const configKey =
resolveManagedSecretRefRuntimeProviderAuth(configParams)?.apiKey ??
resolveUsableCustomProviderApiKey(configParams)?.apiKey;
const selectedKey =
providerConfig?.auth === "api-key" && authoredApiKey !== undefined
? configKey
: (resolveEnvApiKey(params.provider, process.env, { config: params.cfg })?.apiKey ??
configKey);
return selectedKey === undefined || acceptsApiKey(selectedKey);
}
if (params.cfg) {
// Capability discovery must reject synthetic auth markers and unresolved
// SecretRefs that the provider's runtime cannot actually authenticate with.
const allowsCredentialMode = (mode: ReturnType<typeof profileTypeToAuthMode>) =>
!params.profileTypes?.length ||
params.profileTypes.some((profileType) => profileTypeToAuthMode(profileType) === mode);
const authoredApiKey = resolveProviderConfig(params.cfg, params.provider)?.apiKey;
const profileId = typeof authoredApiKey === "string" ? authoredApiKey.trim() : undefined;
if (agentDir && profileId) {
const credential = findPersistedAuthProfileCredential({ agentDir, profileId });
if (credential) {
const binding = resolveProviderEntryApiKeyProfileReference({
cfg: params.cfg,
provider: params.provider,
store: { version: 1, profiles: { [profileId]: credential } },
});
if (binding.kind === "profile-incompatible") {
return false;
}
if (binding.kind === "profile") {
return (
allowsCredentialMode(binding.mode) &&
resolveStoredCredentialReadOnlyAvailability({
credential: binding.credential,
cfg: params.cfg,
env: process.env,
}) === true
);
}
}
}
const configured = resolveUsableCustomProviderApiKey({
cfg: params.cfg,
provider: params.provider,
});
if (
configured?.apiKey &&
!isNonSecretApiKeyMarker(configured.apiKey) &&
allowsCredentialMode(
resolveDirectProviderCredentialMode({
cfg: params.cfg,
provider: params.provider,
inferredMode: "api-key",
}),
)
) {
return true;
}
const managed = resolveManagedSecretRefRuntimeProviderAuth({
cfg: params.cfg,
provider: params.provider,
});
if (managed?.apiKey && allowsCredentialMode(managed.mode)) {
return true;
}
}
if (resolveEnvApiKey(params.provider)?.apiKey) {
return true;
}
const agentDir = params.agentDir?.trim();
if (!agentDir) {
return false;
}
+24 -3
View File
@@ -266,6 +266,11 @@ export type DashscopeVideoGenerationProviderOptions = {
defaultBaseUrl: string;
resolveRequestBaseUrl?: (configuredBaseUrl: string | undefined) => string;
resolveAigcBaseUrl?: (baseUrl: string) => string;
credentialPolicy?: {
acceptsApiKey: (apiKey: string) => boolean;
acceptsBaseUrl?: (configuredBaseUrl: string | undefined) => boolean;
unsupportedMessage: string;
};
};
/** Builds one provider descriptor for the shared DashScope async video task protocol. */
@@ -283,10 +288,24 @@ export function buildDashscopeVideoGenerationProvider(
label: options.label,
defaultModel: DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({ provider: options.providerId, agentDir }),
isConfigured: (ctx) => {
const baseUrl = ctx.cfg?.models?.providers?.[options.providerId]?.baseUrl;
if (options.credentialPolicy?.acceptsBaseUrl?.(baseUrl) === false) {
return false;
}
return isProviderApiKeyConfigured({
provider: options.providerId,
...ctx,
profileTypes: options.credentialPolicy ? ["api_key"] : undefined,
acceptsApiKey: options.credentialPolicy?.acceptsApiKey,
});
},
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
async generateVideo(req): Promise<VideoGenerationResult> {
const providerConfig = req.cfg?.models?.providers?.[options.providerId];
if (options.credentialPolicy?.acceptsBaseUrl?.(providerConfig?.baseUrl) === false) {
throw new Error(options.credentialPolicy.unsupportedMessage);
}
const auth = await resolveApiKeyForProvider({
provider: options.providerId,
cfg: req.cfg,
@@ -296,8 +315,10 @@ export function buildDashscopeVideoGenerationProvider(
if (!auth.apiKey) {
throw new Error(`${options.apiKeyLabel ?? options.label} API key missing`);
}
if (options.credentialPolicy?.acceptsApiKey(auth.apiKey) === false) {
throw new Error(options.credentialPolicy.unsupportedMessage);
}
const providerConfig = req.cfg?.models?.providers?.[options.providerId];
const requestBaseUrl = resolveRequestBaseUrl(providerConfig?.baseUrl);
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({