From 990fdcf6fd0eea74f425a729f426ba709d28469b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 13:26:31 -0700 Subject: [PATCH] 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 --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../alibaba/video-generation-provider.test.ts | 203 +++++++++- .../alibaba/video-generation-provider.ts | 19 + .../byteplus/video-generation-provider.ts | 6 +- .../comfy/image-generation-provider.test.ts | 59 +++ extensions/comfy/workflow-runtime.ts | 1 + .../deepinfra/video-generation-provider.ts | 6 +- extensions/fal/image-generation-provider.ts | 6 +- extensions/fal/music-generation-provider.ts | 6 +- extensions/fal/video-generation-provider.ts | 6 +- .../google/generation-provider-metadata.ts | 9 +- .../google/image-generation-provider.test.ts | 26 +- .../google/image-generation-provider.ts | 15 +- .../google/music-generation-provider.test.ts | 18 + .../google/video-generation-provider.test.ts | 18 + .../image-generation-provider.test.ts | 10 + .../image-generation-provider.ts | 6 +- .../minimax/image-generation-provider.test.ts | 21 + .../minimax/image-generation-provider.ts | 6 +- .../minimax/music-generation-provider.ts | 6 +- .../minimax/video-generation-provider.ts | 6 +- .../openai/video-generation-provider.test.ts | 18 + .../openai/video-generation-provider.ts | 4 +- .../openrouter/image-generation-provider.ts | 3 +- .../openrouter/music-generation-provider.ts | 6 +- .../openrouter/video-generation-provider.ts | 3 +- .../pixverse/video-generation-provider.ts | 6 +- .../qwen/video-generation-provider.test.ts | 270 +++++++++++-- extensions/qwen/video-generation-provider.ts | 28 +- .../runway/video-generation-provider.ts | 6 +- .../together/video-generation-provider.ts | 6 +- extensions/vydra/image-generation-provider.ts | 6 +- extensions/vydra/video-generation-provider.ts | 6 +- extensions/xai/video-generation-provider.ts | 6 +- .../openai-compatible-image-provider.test.ts | 26 ++ .../openai-compatible-image-provider.ts | 6 +- src/media-generation/runtime-shared.test.ts | 53 +++ src/media-generation/runtime-shared.ts | 18 +- src/plugin-sdk/provider-auth.test.ts | 359 +++++++++++++++++- src/plugin-sdk/provider-auth.ts | 119 +++++- src/plugin-sdk/video-generation.ts | 27 +- 41 files changed, 1255 insertions(+), 178 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index fa704e9344ad..5e8a1df368f2 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/alibaba/video-generation-provider.test.ts b/extensions/alibaba/video-generation-provider.test.ts index 5e33754b7691..86f9aeb9ea91 100644 --- a/extensions/alibaba/video-generation-provider.test.ts +++ b/extensions/alibaba/video-generation-provider.test.ts @@ -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 { 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 }); diff --git a/extensions/alibaba/video-generation-provider.ts b/extensions/alibaba/video-generation-provider.ts index 28a2bc736525..17713de6b7f8 100644 --- a/extensions/alibaba/video-generation-provider.ts +++ b/extensions/alibaba/video-generation-provider.ts @@ -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.", + }, }); diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index 1b75e105a5f1..7aeaa700d80a 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -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", diff --git a/extensions/comfy/image-generation-provider.test.ts b/extensions/comfy/image-generation-provider.test.ts index cc3a0aa7f759..7ec2f4443ddd 100644 --- a/extensions/comfy/image-generation-provider.test.ts +++ b/extensions/comfy/image-generation-provider.test.ts @@ -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 diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index 3bb330cd98c1..aac59549650d 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -637,6 +637,7 @@ export function isComfyCapabilityConfigured(params: { } return isProviderApiKeyConfigured({ provider: "comfy", + cfg: params.cfg, agentDir: params.agentDir, }); } diff --git a/extensions/deepinfra/video-generation-provider.ts b/extensions/deepinfra/video-generation-provider.ts index 5a3676d678f8..4a5664148244 100644 --- a/extensions/deepinfra/video-generation-provider.ts +++ b/extensions/deepinfra/video-generation-provider.ts @@ -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, diff --git a/extensions/fal/image-generation-provider.ts b/extensions/fal/image-generation-provider.ts index 5144101c0c20..910e663f76ff 100644 --- a/extensions/fal/image-generation-provider.ts +++ b/extensions/fal/image-generation-provider.ts @@ -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, diff --git a/extensions/fal/music-generation-provider.ts b/extensions/fal/music-generation-provider.ts index cbd508f7b920..4699339a2c19 100644 --- a/extensions/fal/music-generation-provider.ts +++ b/extensions/fal/music-generation-provider.ts @@ -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, diff --git a/extensions/fal/video-generation-provider.ts b/extensions/fal/video-generation-provider.ts index dac58edccddf..1c965808f069 100644 --- a/extensions/fal/video-generation-provider.ts +++ b/extensions/fal/video-generation-provider.ts @@ -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, diff --git a/extensions/google/generation-provider-metadata.ts b/extensions/google/generation-provider-metadata.ts index 9daf4f135578..3babaa2463f1 100644 --- a/extensions/google/generation-provider-metadata.ts +++ b/extensions/google/generation-provider-metadata.ts @@ -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< diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 066346b6795a..e4825051c916 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -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); diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index 0065f8cc6e03..962a8263f32c 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -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, diff --git a/extensions/google/music-generation-provider.test.ts b/extensions/google/music-generation-provider.test.ts index edcb67f0e976..b94b9eb23824 100644 --- a/extensions/google/music-generation-provider.test.ts +++ b/extensions/google/music-generation-provider.test.ts @@ -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({ diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 167e9414da7b..94a49f8c46a5 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -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", diff --git a/extensions/microsoft-foundry/image-generation-provider.test.ts b/extensions/microsoft-foundry/image-generation-provider.test.ts index b35e1c94b114..6b67b74733c8 100644 --- a/extensions/microsoft-foundry/image-generation-provider.test.ts +++ b/extensions/microsoft-foundry/image-generation-provider.test.ts @@ -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({ diff --git a/extensions/microsoft-foundry/image-generation-provider.ts b/extensions/microsoft-foundry/image-generation-provider.ts index fd685c91adaf..8e329ba24a37 100644 --- a/extensions/microsoft-foundry/image-generation-provider.ts +++ b/extensions/microsoft-foundry/image-generation-provider.ts @@ -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, diff --git a/extensions/minimax/image-generation-provider.test.ts b/extensions/minimax/image-generation-provider.test.ts index d12eddcab606..b97e2cbd7a22 100644 --- a/extensions/minimax/image-generation-provider.test.ts +++ b/extensions/minimax/image-generation-provider.test.ts @@ -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(); diff --git a/extensions/minimax/image-generation-provider.ts b/extensions/minimax/image-generation-provider.ts index b54f120dc036..80804c95c799 100644 --- a/extensions/minimax/image-generation-provider.ts +++ b/extensions/minimax/image-generation-provider.ts @@ -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, diff --git a/extensions/minimax/music-generation-provider.ts b/extensions/minimax/music-generation-provider.ts index ce6e181e0fe0..243d7eb564b5 100644 --- a/extensions/minimax/music-generation-provider.ts +++ b/extensions/minimax/music-generation-provider.ts @@ -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, diff --git a/extensions/minimax/video-generation-provider.ts b/extensions/minimax/video-generation-provider.ts index e2f6b596057c..428c7e3739bf 100644 --- a/extensions/minimax/video-generation-provider.ts +++ b/extensions/minimax/video-generation-provider.ts @@ -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, diff --git a/extensions/openai/video-generation-provider.test.ts b/extensions/openai/video-generation-provider.test.ts index 9e05cf08cf98..d50989b5cbcc 100644 --- a/extensions/openai/video-generation-provider.test.ts +++ b/extensions/openai/video-generation-provider.test.ts @@ -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; diff --git a/extensions/openai/video-generation-provider.ts b/extensions/openai/video-generation-provider.ts index c9977a9093d3..39f014a1db40 100644 --- a/extensions/openai/video-generation-provider.ts +++ b/extensions/openai/video-generation-provider.ts @@ -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: { diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index b19f3bd98b5e..c9b0565ca780 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -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, diff --git a/extensions/openrouter/music-generation-provider.ts b/extensions/openrouter/music-generation-provider.ts index 4fb1ede82977..648d67567a76 100644 --- a/extensions/openrouter/music-generation-provider.ts +++ b/extensions/openrouter/music-generation-provider.ts @@ -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, diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts index ab9cba4dadd5..06d9c2f09cf2 100644 --- a/extensions/openrouter/video-generation-provider.ts +++ b/extensions/openrouter/video-generation-provider.ts @@ -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: { diff --git a/extensions/pixverse/video-generation-provider.ts b/extensions/pixverse/video-generation-provider.ts index 18f92a961127..b2ecc95454de 100644 --- a/extensions/pixverse/video-generation-provider.ts +++ b/extensions/pixverse/video-generation-provider.ts @@ -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, diff --git a/extensions/qwen/video-generation-provider.test.ts b/extensions/qwen/video-generation-provider.test.ts index 38ff236d90c4..4910f9a83d69 100644 --- a/extensions/qwen/video-generation-provider.test.ts +++ b/extensions/qwen/video-generation-provider.test.ts @@ -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(); }); }); diff --git a/extensions/qwen/video-generation-provider.ts b/extensions/qwen/video-generation-provider.ts index 7f226b1c219f..87644aa4946a 100644 --- a/extensions/qwen/video-generation-provider.ts +++ b/extensions/qwen/video-generation-provider.ts @@ -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.", + }, }); diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index ed43f5a695f8..97998748eb65 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -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, diff --git a/extensions/together/video-generation-provider.ts b/extensions/together/video-generation-provider.ts index 09ec5429710a..35c416ccceb5 100644 --- a/extensions/together/video-generation-provider.ts +++ b/extensions/together/video-generation-provider.ts @@ -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, diff --git a/extensions/vydra/image-generation-provider.ts b/extensions/vydra/image-generation-provider.ts index b9b183fe5c8b..bdd43b494075 100644 --- a/extensions/vydra/image-generation-provider.ts +++ b/extensions/vydra/image-generation-provider.ts @@ -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, diff --git a/extensions/vydra/video-generation-provider.ts b/extensions/vydra/video-generation-provider.ts index a0f93657cd8a..8928aee17a2e 100644 --- a/extensions/vydra/video-generation-provider.ts +++ b/extensions/vydra/video-generation-provider.ts @@ -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, diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index 65d79ca2020c..279640d1a6e3 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -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, diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 17d14085eee5..3bcdfe79fd21 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -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(); diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts index 87065b1d5558..6a398afa826c 100644 --- a/src/image-generation/openai-compatible-image-provider.ts +++ b/src/image-generation/openai-compatible-image-provider.ts @@ -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 { const inputImages = req.inputImages ?? []; diff --git a/src/media-generation/runtime-shared.test.ts b/src/media-generation/runtime-shared.test.ts index 3d5273f0c289..e37177a41687 100644 --- a/src/media-generation/runtime-shared.test.ts +++ b/src/media-generation/runtime-shared.test.ts @@ -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: { diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index 9ede5656b862..0a2b733cfa08 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -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: { diff --git a/src/plugin-sdk/provider-auth.test.ts b/src/plugin-sdk/provider-auth.test.ts index 1b072960cd04..b80c47d78892 100644 --- a/src/plugin-sdk/provider-auth.test.ts +++ b/src/plugin-sdk/provider-auth.test.ts @@ -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 { }; } +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; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 76edb6ca81ba..60608bad4d03 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -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) => + !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; } diff --git a/src/plugin-sdk/video-generation.ts b/src/plugin-sdk/video-generation.ts index 9302bd1af10b..bb2015befa82 100644 --- a/src/plugin-sdk/video-generation.ts +++ b/src/plugin-sdk/video-generation.ts @@ -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 { + 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({