From 9c64a0ca23d6703244597d7d8358139e4ff45765 Mon Sep 17 00:00:00 2001 From: Yunsu Date: Sat, 25 Apr 2026 18:45:38 +0900 Subject: [PATCH 1/8] fix(google): avoid doubled media generation API version Strip configured trailing /v1beta from Google music/video generation base URLs before calling the Google GenAI SDK.\n\nFixes #63240.\n\nThanks @Hybirdss. --- CHANGELOG.md | 1 + .../google/music-generation-provider.test.ts | 165 ++++++++++++++++++ .../google/music-generation-provider.ts | 4 +- .../google/video-generation-provider.test.ts | 115 ++++++++++++ .../google/video-generation-provider.ts | 4 +- 5 files changed, 285 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b88967bdc..e19d315fa35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss. - Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. - Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. diff --git a/extensions/google/music-generation-provider.test.ts b/extensions/google/music-generation-provider.test.ts index 2c0dbc451fe..746197fb617 100644 --- a/extensions/google/music-generation-provider.test.ts +++ b/extensions/google/music-generation-provider.test.ts @@ -82,6 +82,171 @@ describe("google music generation provider", () => { ); }); + it("strips /v1beta suffix from configured baseUrl before passing to GoogleGenAI SDK", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + data: Buffer.from("mp3-bytes").toString("base64"), + mimeType: "audio/mpeg", + }, + }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "ambient ocean", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", models: [] }, + }, + }, + }, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + + it("does NOT strip /v1beta when it appears mid-path (end-anchor proof)", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { inlineData: { data: Buffer.from("x").toString("base64"), mimeType: "audio/mpeg" } }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "test", + cfg: { + models: { + providers: { google: { baseUrl: "https://proxy.example.com/v1beta/route", models: [] } }, + }, + }, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://proxy.example.com/v1beta/route", + }), + }), + ); + }); + + it("passes baseUrl unchanged when no /v1beta suffix is present", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { inlineData: { data: Buffer.from("x").toString("base64"), mimeType: "audio/mpeg" } }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "test", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com", models: [] }, + }, + }, + }, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + + it("does not set baseUrl when none is configured", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { inlineData: { data: Buffer.from("x").toString("base64"), mimeType: "audio/mpeg" } }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "test", + cfg: {}, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.not.objectContaining({ + baseUrl: expect.anything(), + }), + }), + ); + }); + it("rejects unsupported wav output on clip model", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", diff --git a/extensions/google/music-generation-provider.ts b/extensions/google/music-generation-provider.ts index e5b53e50e2a..8c1da4d89df 100644 --- a/extensions/google/music-generation-provider.ts +++ b/extensions/google/music-generation-provider.ts @@ -6,7 +6,7 @@ import type { } from "openclaw/plugin-sdk/music-generation"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { normalizeGoogleApiBaseUrl } from "./api.js"; +import { resolveGoogleGenerativeAiApiOrigin } from "./api.js"; import { createGoogleMusicGenerationProviderMetadata, DEFAULT_GOOGLE_MUSIC_MODEL, @@ -37,7 +37,7 @@ type GoogleGenerateMusicResponse = { function resolveConfiguredGoogleMusicBaseUrl(req: MusicGenerationRequest): string | undefined { const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl); - return configured ? normalizeGoogleApiBaseUrl(configured) : undefined; + return configured ? resolveGoogleGenerativeAiApiOrigin(configured) : undefined; } function buildMusicPrompt(req: MusicGenerationRequest): string { diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 5b0ad93c0d4..15a16c6da5b 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -100,6 +100,121 @@ describe("google video generation provider", () => { ); }); + it("strips /v1beta suffix from configured baseUrl before passing to GoogleGenAI SDK", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { video: { videoBytes: Buffer.from("mp4").toString("base64"), mimeType: "video/mp4" } }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", models: [] }, + }, + }, + }, + durationSeconds: 3, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + + it("does NOT strip /v1beta when it appears mid-path (end-anchor proof)", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { video: { videoBytes: Buffer.from("mp4").toString("base64"), mimeType: "video/mp4" } }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "test", + cfg: { + models: { + providers: { google: { baseUrl: "https://proxy.example.com/v1beta/route", models: [] } }, + }, + }, + durationSeconds: 3, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://proxy.example.com/v1beta/route", + }), + }), + ); + }); + + it("passes baseUrl unchanged when no /v1beta suffix is present", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { video: { videoBytes: Buffer.from("mp4").toString("base64"), mimeType: "video/mp4" } }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "test", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com", models: [] }, + }, + }, + }, + durationSeconds: 3, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + it("rejects mixed image and video inputs", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index 365a0fb5c03..07c34f0ff86 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -13,7 +13,7 @@ import type { VideoGenerationProvider, VideoGenerationRequest, } from "openclaw/plugin-sdk/video-generation"; -import { normalizeGoogleApiBaseUrl } from "./api.js"; +import { resolveGoogleGenerativeAiApiOrigin } from "./api.js"; import { createGoogleVideoGenerationProviderMetadata, DEFAULT_GOOGLE_VIDEO_MODEL, @@ -29,7 +29,7 @@ const MAX_POLL_ATTEMPTS = 90; function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): string | undefined { const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl); - return configured ? normalizeGoogleApiBaseUrl(configured) : undefined; + return configured ? resolveGoogleGenerativeAiApiOrigin(configured) : undefined; } function parseVideoSize(size: string | undefined): { width: number; height: number } | undefined { From da2c61fe6e02a67f7cbb0d040a9b2b3ac806ba15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:46:14 +0100 Subject: [PATCH 2/8] fix: render authenticated control ui avatars --- CHANGELOG.md | 1 + docs/web/control-ui.md | 5 ++- src/gateway/control-ui-csp.test.ts | 6 +-- src/gateway/control-ui-csp.ts | 2 +- ui/src/ui/app-chat.test.ts | 62 +++++++++++++++++++++++++++--- ui/src/ui/app-chat.ts | 4 +- 6 files changed, 67 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e19d315fa35..68383b7c69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai - Diagnostics: emit structured tool execution diagnostic events with trace context, timing, and redacted error metadata. Thanks @vincentkoc. - Diagnostics: emit structured run and model-call diagnostic events with trace context, duration, and non-message error metadata. Thanks @vincentkoc. - Control UI/chat: add a Steer action on queued messages so a browser follow-up can be injected into the active run without retyping it. Thanks @steipete. +- Control UI/avatars: render authenticated assistant avatar routes through local blob URLs and allow those managed blobs in the dashboard CSP, restoring configured chat avatars. Fixes #71422. Thanks @blaspat. - Control UI/Talk: add browser WebRTC realtime voice sessions backed by OpenAI Realtime, with Gateway-minted ephemeral client secrets and `openclaw_agent_consult` handoff to the full OpenClaw agent. Thanks @steipete. - Plugin SDK/Codex harness: add provider-owned transport/auth/follow-up seams and harness result classification so Codex-style runtimes can participate in fallback policy without core special-casing. (#70772) Thanks @100yenadmin. - Codex harness: bridge Codex-native tool hooks into OpenClaw plugin hooks and approvals, with bounded relay payloads and approval spam protection. (#71008) Thanks @pashpashpash. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 2a54872c27c..82521d904d9 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -324,12 +324,13 @@ See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. ## Content Security Policy -The Control UI ships with a tight `img-src` policy: only **same-origin** assets and `data:` URLs are allowed. Remote `http(s)` and protocol-relative image URLs are rejected by the browser and do not issue network fetches. +The Control UI ships with a tight `img-src` policy: only **same-origin** assets, `data:` URLs, and locally generated `blob:` URLs are allowed. Remote `http(s)` and protocol-relative image URLs are rejected by the browser and do not issue network fetches. What this means in practice: -- Avatars and images served under relative paths (for example `/avatars/`) still render. +- Avatars and images served under relative paths (for example `/avatars/`) still render, including authenticated avatar routes that the UI fetches and converts into local `blob:` URLs. - Inline `data:image/...` URLs still render (useful for in-protocol payloads). +- Local `blob:` URLs created by the Control UI still render. - Remote avatar URLs emitted by channel metadata are stripped at the Control UI's avatar helpers and replaced with the built-in logo/badge, so a compromised or malicious channel cannot force arbitrary remote image fetches from an operator browser. You do not need to change anything to get this behavior — it is always on and not configurable. diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index c2cd0364a82..0a8cd209ed2 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -17,10 +17,10 @@ describe("buildControlUiCspHeader", () => { expect(csp).toContain("font-src 'self' https://fonts.gstatic.com"); }); - it("limits image loading to same-origin and data URLs", () => { + it("limits image loading to same-origin, data, and managed blob URLs", () => { const csp = buildControlUiCspHeader(); - expect(csp).toContain("img-src 'self' data:"); - expect(csp).not.toContain("img-src 'self' data: https:"); + expect(csp).toContain("img-src 'self' data: blob:"); + expect(csp).not.toContain("img-src 'self' data: blob: https:"); }); it("includes inline script hashes in script-src when provided", () => { diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 5f153176a13..1131e95d41a 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -44,7 +44,7 @@ export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] } "frame-ancestors 'none'", scriptSrc, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", - "img-src 'self' data:", + "img-src 'self' data: blob:", "font-src 'self' https://fonts.gstatic.com", "connect-src 'self' ws: wss:", ].join("; "); diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index a9516ae44fe..69d4efa8a96 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -104,9 +104,30 @@ describe("refreshChatAvatar", () => { }); it("uses a route-relative avatar endpoint before basePath bootstrap finishes", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ avatarUrl: "/avatar/main" }), + const createObjectURL = vi.fn(() => "blob:local-avatar"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input); + if (url === "/avatar/main?meta=1") { + return Promise.resolve({ + ok: true, + json: async () => ({ avatarUrl: "/avatar/main" }), + }); + } + if (url === "/avatar/main") { + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["avatar"]), + }); + } + throw new Error(`Unexpected avatar URL: ${url}`); }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -117,7 +138,17 @@ describe("refreshChatAvatar", () => { "/avatar/main?meta=1", expect.objectContaining({ method: "GET" }), ); - expect(host.chatAvatarUrl).toBe("/avatar/main"); + expect(fetchMock).toHaveBeenCalledWith( + "/avatar/main", + expect.objectContaining({ method: "GET" }), + ); + const avatarFetchInit = ( + fetchMock.mock.calls as Array<[string | URL | Request, RequestInit?]> + )[1]?.[1]; + expect(avatarFetchInit).not.toHaveProperty("headers"); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).not.toHaveBeenCalled(); + expect(host.chatAvatarUrl).toBe("blob:local-avatar"); }); it("prefers the paired device token for avatar metadata and local avatar URLs", async () => { @@ -261,6 +292,15 @@ describe("refreshChatAvatar", () => { }); it("ignores stale avatar responses after switching sessions", async () => { + const createObjectURL = vi.fn(() => "blob:ops-avatar"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); const mainRequest = createDeferred<{ avatarUrl?: string }>(); const opsRequest = createDeferred<{ avatarUrl?: string }>(); const fetchMock = vi.fn((input: string | URL | Request) => { @@ -277,6 +317,12 @@ describe("refreshChatAvatar", () => { json: async () => opsRequest.promise, }); } + if (url === "/avatar/ops") { + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["avatar"]), + }); + } throw new Error(`Unexpected avatar URL: ${url}`); }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -294,7 +340,8 @@ describe("refreshChatAvatar", () => { opsRequest.resolve({ avatarUrl: "/avatar/ops" }); await secondRefresh; - expect(host.chatAvatarUrl).toBe("/avatar/ops"); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(host.chatAvatarUrl).toBe("blob:ops-avatar"); expect(fetchMock).toHaveBeenNthCalledWith( 1, "/avatar/main?meta=1", @@ -305,6 +352,11 @@ describe("refreshChatAvatar", () => { "/avatar/ops?meta=1", expect.objectContaining({ method: "GET" }), ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/avatar/ops", + expect.objectContaining({ method: "GET" }), + ); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c40872e2518..55eefff0a1a 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -653,13 +653,13 @@ export async function refreshChatAvatar(host: ChatHost) { clearChatAvatarUrl(host); return; } - if (!authHeader || !isLocalControlUiAvatarUrl(avatarUrl)) { + if (!isLocalControlUiAvatarUrl(avatarUrl)) { setChatAvatarUrl(host, avatarUrl); return; } const avatarRes = await fetch(avatarUrl, { method: "GET", - headers: { Authorization: authHeader }, + ...(headers ? { headers } : {}), }); if (!avatarRes.ok) { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { From 815e9b493c7122cf931db86a687f853f0f7923fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:45:39 +0100 Subject: [PATCH 3/8] fix: improve openrouter model scan fallback --- CHANGELOG.md | 1 + docs/cli/models.md | 29 ++++++ docs/concepts/models.md | 17 ++-- src/agents/model-scan.test.ts | 22 +++++ src/agents/model-scan.ts | 20 +++-- src/commands/models/scan.test.ts | 147 +++++++++++++++++++++++++++++++ src/commands/models/scan.ts | 74 ++++++++++++---- 7 files changed, 282 insertions(+), 28 deletions(-) create mode 100644 src/commands/models/scan.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68383b7c69a..7db74176a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss. - Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. - Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. +- CLI/models: make `openclaw models scan` fall back to public OpenRouter free-model metadata when no `OPENROUTER_API_KEY` is configured, avoid config secret resolution for explicit `--no-probe` scans, and apply the scan timeout to the OpenRouter catalog request. - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. - Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev. - OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. diff --git a/docs/cli/models.md b/docs/cli/models.md index 1fd734a505c..71a46a8c0d3 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -66,6 +66,35 @@ Notes: stale removed-provider default. - `models status` may show `marker()` in auth output for non-secret placeholders (for example `OPENAI_API_KEY`, `secretref-managed`, `minimax-oauth`, `oauth:chutes`, `ollama-local`) instead of masking them as secrets. +### `models scan` + +`models scan` reads OpenRouter's public `:free` catalog and ranks candidates for +fallback use. The catalog itself is public, so metadata-only scans do not need +an OpenRouter key. + +By default OpenClaw tries to probe tool and image support with live model calls. +If no OpenRouter key is configured, the command falls back to metadata-only +output and explains that `:free` models still require `OPENROUTER_API_KEY` for +probes and inference. + +Options: + +- `--no-probe` (metadata only; no config/secrets lookup) +- `--min-params ` +- `--max-age-days ` +- `--provider ` +- `--max-candidates ` +- `--timeout ` (catalog request and per-probe timeout) +- `--concurrency ` +- `--yes` +- `--no-input` +- `--set-default` +- `--set-image` +- `--json` + +`--set-default` and `--set-image` require live probes; metadata-only scan +results are informational and are not applied to config. + ### `models status` Options: diff --git a/docs/concepts/models.md b/docs/concepts/models.md index d70dad3a4f2..123f13f9ecf 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -242,8 +242,11 @@ Key flags: - `--set-default`: set `agents.defaults.model.primary` to the first selection - `--set-image`: set `agents.defaults.imageModel.primary` to the first image selection -Probing requires an OpenRouter API key (from auth profiles or -`OPENROUTER_API_KEY`). Without a key, use `--no-probe` to list candidates only. +The OpenRouter `/models` catalog is public, so metadata-only scans can list +free candidates without a key. Probing and inference still require an +OpenRouter API key (from auth profiles or `OPENROUTER_API_KEY`). If no key is +available, `openclaw models scan` falls back to metadata-only output and leaves +config unchanged. Use `--no-probe` to request metadata-only mode explicitly. Scan results are ranked by: @@ -255,12 +258,14 @@ Scan results are ranked by: Input - OpenRouter `/models` list (filter `:free`) -- Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY` (see [/environment](/help/environment)) +- Live probes require OpenRouter API key from auth profiles or `OPENROUTER_API_KEY` (see [/environment](/help/environment)) - Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates` -- Probe controls: `--timeout`, `--concurrency` +- Request/probe controls: `--timeout`, `--concurrency` -When run in a TTY, you can select fallbacks interactively. In non‑interactive -mode, pass `--yes` to accept defaults. +When live probes run in a TTY, you can select fallbacks interactively. In +non‑interactive mode, pass `--yes` to accept defaults. Metadata-only results are +informational; `--set-default` and `--set-image` require live probes so +OpenClaw does not configure an unusable keyless OpenRouter model. ## Models registry (`models.json`) diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index b8540af6fb8..501b96cd970 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -81,6 +81,28 @@ describe("scanOpenRouterModels", () => { }); }); + it("applies the scan timeout to the OpenRouter catalog request", async () => { + const fetchImpl: typeof fetch = async (_input, init) => + await new Promise((_resolve, reject) => { + const signal = typeof init === "object" && init ? init.signal : undefined; + if (signal?.aborted) { + reject(new Error("catalog aborted")); + return; + } + signal?.addEventListener("abort", () => reject(new Error("catalog aborted")), { + once: true, + }); + }); + + await expect( + scanOpenRouterModels({ + fetchImpl, + probe: false, + timeoutMs: 1, + }), + ).rejects.toThrow(/catalog aborted/); + }); + it("matches provider filters across canonical provider aliases", async () => { const fetchImpl = createFetchFixture({ data: [ diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 484810b914d..053e8a91b86 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -180,10 +180,16 @@ async function withTimeout( } } -async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise { - const res = await fetchImpl(OPENROUTER_MODELS_URL, { - headers: { Accept: "application/json" }, - }); +async function fetchOpenRouterModels( + fetchImpl: typeof fetch, + timeoutMs: number, +): Promise { + const res = await withTimeout(timeoutMs, (signal) => + fetchImpl(OPENROUTER_MODELS_URL, { + headers: { Accept: "application/json" }, + signal, + }), + ); if (!res.ok) { throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); } @@ -407,7 +413,9 @@ export async function scanOpenRouterModels( const probe = options.probe ?? true; const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || ""; if (probe && !apiKey) { - throw new Error("Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan."); + throw new Error( + "Missing OpenRouter API key. Free OpenRouter models still require OPENROUTER_API_KEY for live probes and inference; call with probe:false to list public catalog metadata.", + ); } const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)); @@ -416,7 +424,7 @@ export async function scanOpenRouterModels( const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0)); const providerFilter = normalizeProviderId(options.providerFilter ?? ""); - const catalog = await fetchOpenRouterModels(fetchImpl); + const catalog = await fetchOpenRouterModels(fetchImpl, timeoutMs); const now = Date.now(); const filtered = catalog.filter((entry) => { diff --git a/src/commands/models/scan.test.ts b/src/commands/models/scan.test.ts new file mode 100644 index 00000000000..38a07d472e7 --- /dev/null +++ b/src/commands/models/scan.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelScanResult } from "../../agents/model-scan.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + loadModelsConfig: vi.fn(), + resolveApiKeyForProvider: vi.fn(), + scanOpenRouterModels: vi.fn(), +})); + +vi.mock("./load-config.js", () => ({ + loadModelsConfig: mocks.loadModelsConfig, +})); + +vi.mock("../../agents/model-auth.js", () => ({ + resolveApiKeyForProvider: mocks.resolveApiKeyForProvider, +})); + +vi.mock("../../agents/model-scan.js", () => ({ + scanOpenRouterModels: mocks.scanOpenRouterModels, +})); + +const { modelsScanCommand } = await import("./scan.js"); + +function createRuntime(): RuntimeEnv & { lines: string[] } { + const lines: string[] = []; + return { + lines, + log: (...args: unknown[]) => lines.push(args.join(" ")), + error: (...args: unknown[]) => lines.push(args.join(" ")), + exit: (code?: number) => { + throw new Error(`exit ${code ?? 0}`); + }, + } as RuntimeEnv & { lines: string[] }; +} + +function scanResult(overrides: Partial = {}): ModelScanResult { + return { + id: "acme/free:free", + name: "ACME Free", + provider: "openrouter", + modelRef: "openrouter/acme/free:free", + contextLength: 128_000, + maxCompletionTokens: 8192, + supportedParametersCount: 2, + supportsToolsMeta: true, + modality: "text", + inferredParamB: 70, + createdAtMs: 1_700_000_000_000, + pricing: { prompt: 0, completion: 0, request: 0, image: 0, webSearch: 0, internalReasoning: 0 }, + isFree: true, + tool: { ok: false, latencyMs: null, skipped: true }, + image: { ok: false, latencyMs: null, skipped: true }, + ...overrides, + }; +} + +describe("models scan command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not load config or resolve secrets for metadata-only scans", async () => { + const runtime = createRuntime(); + mocks.scanOpenRouterModels.mockResolvedValue([scanResult()]); + + await modelsScanCommand({ probe: false }, runtime); + + expect(mocks.loadModelsConfig).not.toHaveBeenCalled(); + expect(mocks.resolveApiKeyForProvider).not.toHaveBeenCalled(); + expect(mocks.scanOpenRouterModels).toHaveBeenCalledWith( + expect.objectContaining({ probe: false }), + ); + expect(runtime.lines.join("\n")).toContain("metadata only"); + expect(runtime.lines.join("\n")).toContain("Tool"); + expect(runtime.lines.join("\n")).toContain("skip"); + }); + + it("downgrades to metadata-only scan when no OpenRouter key is configured", async () => { + const runtime = createRuntime(); + vi.stubEnv("OPENROUTER_API_KEY", undefined); + mocks.loadModelsConfig.mockResolvedValue({}); + mocks.resolveApiKeyForProvider.mockResolvedValue({ apiKey: "" }); + mocks.scanOpenRouterModels.mockResolvedValue([scanResult()]); + + await modelsScanCommand({}, runtime); + + expect(mocks.loadModelsConfig).toHaveBeenCalledTimes(1); + expect(mocks.resolveApiKeyForProvider).toHaveBeenCalledWith({ + provider: "openrouter", + cfg: {}, + }); + expect(mocks.scanOpenRouterModels).toHaveBeenCalledWith( + expect.objectContaining({ probe: false }), + ); + expect(runtime.lines.join("\n")).toContain("still require OPENROUTER_API_KEY"); + }); + + it("uses OPENROUTER_API_KEY directly without loading model config", async () => { + const runtime = createRuntime(); + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test"); + mocks.scanOpenRouterModels.mockResolvedValue([ + scanResult({ tool: { ok: false, latencyMs: null, skipped: false } }), + ]); + + await expect(modelsScanCommand({ json: true }, runtime)).rejects.toThrow( + /No tool-capable OpenRouter free models found/, + ); + + expect(mocks.loadModelsConfig).not.toHaveBeenCalled(); + expect(mocks.resolveApiKeyForProvider).not.toHaveBeenCalled(); + expect(mocks.scanOpenRouterModels).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "sk-or-test", + probe: true, + }), + ); + }); + + it("rejects applying metadata-only scan results", async () => { + const runtime = createRuntime(); + vi.stubEnv("OPENROUTER_API_KEY", undefined); + + await expect(modelsScanCommand({ probe: false, setDefault: true }, runtime)).rejects.toThrow( + /Cannot apply metadata-only OpenRouter scan results/, + ); + + expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled(); + }); + + it("rejects applying auto-downgraded metadata-only scan results before scanning", async () => { + const runtime = createRuntime(); + vi.stubEnv("OPENROUTER_API_KEY", undefined); + mocks.loadModelsConfig.mockResolvedValue({}); + mocks.resolveApiKeyForProvider.mockResolvedValue({ apiKey: "" }); + + await expect(modelsScanCommand({ setDefault: true }, runtime)).rejects.toThrow( + /Cannot apply metadata-only OpenRouter scan results/, + ); + + expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 47d56517a9f..f5d72215093 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,4 +1,5 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompts"; +import { getEnvApiKey } from "@mariozechner/pi-ai"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js"; import { withProgressTotals } from "../../cli/progress.js"; @@ -82,7 +83,11 @@ function compareScanMetadata(a: ModelScanResult, b: ModelScanResult): number { } function buildScanHint(result: ModelScanResult): string { - const toolLabel = result.tool.ok ? `tool ${formatMs(result.tool.latencyMs)}` : "tool fail"; + const toolLabel = result.tool.skipped + ? "tool skip" + : result.tool.ok + ? `tool ${formatMs(result.tool.latencyMs)}` + : "tool fail"; const imageLabel = result.image.skipped ? "img skip" : result.image.ok @@ -103,6 +108,21 @@ function printScanSummary(results: ModelScanResult[], runtime: RuntimeEnv) { ); } +function printMetadataOnlyNotice(params: { + results: ModelScanResult[]; + runtime: RuntimeEnv; + autoDowngraded: boolean; +}) { + if (params.autoDowngraded) { + params.runtime.log( + "OpenRouter free models still require OPENROUTER_API_KEY for live probes and inference. Listing public catalog metadata only.", + ); + } + params.runtime.log( + `Found ${params.results.length} OpenRouter free models (metadata only; configure OPENROUTER_API_KEY to test tools/images).`, + ); +} + function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) { const header = [ pad("Model", MODEL_PAD), @@ -116,7 +136,10 @@ function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) { for (const entry of results) { const modelLabel = pad(truncate(entry.modelRef, MODEL_PAD), MODEL_PAD); - const toolLabel = pad(entry.tool.ok ? formatMs(entry.tool.latencyMs) : "fail", 10); + const toolLabel = pad( + entry.tool.skipped ? "skip" : entry.tool.ok ? formatMs(entry.tool.latencyMs) : "fail", + 10, + ); const imageLabel = pad( entry.image.ok ? formatMs(entry.image.latencyMs) : entry.image.skipped ? "skip" : "fail", 10, @@ -167,18 +190,35 @@ export async function modelsScanCommand( throw new Error("--concurrency must be > 0"); } - const cfg = await loadModelsConfig({ commandName: "models scan", runtime }); - const probe = opts.probe ?? true; + const requestedProbe = opts.probe ?? true; + if (!requestedProbe && (opts.setDefault || opts.setImage)) { + throw new Error( + "Cannot apply metadata-only OpenRouter scan results. Remove --no-probe or configure OPENROUTER_API_KEY and rerun with probes before changing defaults.", + ); + } + let probe = requestedProbe; let storedKey: string | undefined; - if (probe) { - try { - const resolved = await resolveApiKeyForProvider({ - provider: "openrouter", - cfg, - }); - storedKey = resolved.apiKey; - } catch { - storedKey = undefined; + if (requestedProbe) { + storedKey = getEnvApiKey("openrouter")?.trim() || undefined; + if (!storedKey) { + try { + const cfg = await loadModelsConfig({ commandName: "models scan" }); + const resolved = await resolveApiKeyForProvider({ + provider: "openrouter", + cfg, + }); + storedKey = resolved.apiKey?.trim() || undefined; + } catch { + storedKey = undefined; + } + } + if (!storedKey) { + if (opts.setDefault || opts.setImage) { + throw new Error( + "Cannot apply metadata-only OpenRouter scan results. Configure OPENROUTER_API_KEY and rerun with probes before changing defaults.", + ); + } + probe = false; } } const results = await withProgressTotals( @@ -212,9 +252,11 @@ export async function modelsScanCommand( if (!probe) { if (!opts.json) { - runtime.log( - `Found ${results.length} OpenRouter free models (metadata only; pass --probe to test tools/images).`, - ); + printMetadataOnlyNotice({ + results, + runtime, + autoDowngraded: requestedProbe, + }); printScanTable(sortScanResults(results), runtime); } else { writeRuntimeJson(runtime, results); From 678d2c327c73b4cc811d0db28a1b57bc9b067102 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 02:48:57 -0700 Subject: [PATCH 4/8] docs(changelog): backfill missing PR refs and reporter credits in top Unreleased Three of my (vincentkoc) entries were missing closing PR refs, and several maintainer-fix entries were missing credit for the user who reported the underlying issue: - Diagnostics/OTEL outbound delivery: add (#71471) and credit @jlapenna whose #70424 framed the broader tracing work. - Cron malformed legacy jobs: add (#71509). - OpenAI/Codex OAuth region failures: add (#71501) and credit reporter @wulala-xjj (#51175). - Telegram duplicate pollers: credit reporter @Co-Messi (#56230). - MCP/CLI one-shot retire: credit reporter @spartoviMD (#71457). - OpenAI/Codex image baseUrl canonicalize: credit reporter @GodsBoy (#71460). - Feishu TTS Ogg/Opus: credit reporters @sg1416-zg (#61249) and @ycjlb2023-peteryi (#37868). - MiniMax TTS portal OAuth: credit reporter @zx15210404690-hash (#55017). - MCP config reload disposal: credit reporter @xieyuanqing (#60656). --- CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db74176a88..40225a1808a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes -- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. Thanks @vincentkoc. +- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna. - Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev. @@ -18,11 +18,11 @@ Docs: https://docs.openclaw.ai - Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss. - Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. -- Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. +- Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. (#71509) Thanks @vincentkoc. - CLI/models: make `openclaw models scan` fall back to public OpenRouter free-model metadata when no `OPENROUTER_API_KEY` is configured, avoid config secret resolution for explicit `--no-probe` scans, and apply the scan timeout to the OpenRouter catalog request. - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. - Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev. -- OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. +- OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. (#71501) Thanks @vincentkoc and @wulala-xjj. - Telegram: remove the startup persisted-offset `getUpdates` preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar. - Telegram: keep the polling stall watchdog active even when grammY reports the runner as not running while its task is still pending, so a rebuilt transport cannot leave `getUpdates` silent until a manual gateway restart. Fixes #69064. Thanks @LDLoeb. - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. @@ -30,22 +30,22 @@ Docs: https://docs.openclaw.ai - Browser/Chrome: stop passing redundant `--disable-setuid-sandbox` when `browser.noSandbox` is enabled; `--no-sandbox` remains the effective sandbox opt-out. (#67939) Thanks @sebykrueger. - Browser/client: stop telling agents to permanently avoid the browser after transient timeout or cancellation failures; keep the no-retry hint for persistent unavailable/rate-limit cases. (#46505) Thanks @jriff. - Browser/aria snapshots: bind `format=aria` `axN` refs to live DOM nodes through backend DOM ids when Playwright is available, so follow-up browser actions can use those refs without timing out. (#62434) Thanks @MrKipler. -- Telegram: prevent duplicate in-process long pollers for the same bot token and add clearer `getUpdates` conflict diagnostics for external duplicate pollers. Fixes #56230. +- Telegram: prevent duplicate in-process long pollers for the same bot token and add clearer `getUpdates` conflict diagnostics for external duplicate pollers. Fixes #56230. Thanks @Co-Messi. - Browser/Linux: detect Chromium-based installs under `/opt/google`, `/opt/brave.com`, `/usr/lib/chromium`, and `/usr/lib/chromium-browser` before asking users to set `browser.executablePath`. (#48563) Thanks @lupuletic. - Sessions/browser: close tracked browser tabs when idle, daily, `/new`, or `/reset` session rollover archives the previous transcript, preventing tabs from leaking past the old session. Thanks @jakozloski. - Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. Thanks @jalehman. - OpenAI/Codex: send Codex Responses system prompts through top-level `instructions` while preserving the existing native Codex payload controls. -- MCP/CLI: retire bundled MCP runtimes at the end of one-shot `openclaw agent` and `openclaw infer model run` gateway/local executions, so repeated scripted runs do not accumulate stdio MCP child processes. Fixes #71457. -- OpenAI/Codex image generation: canonicalize legacy `openai-codex.baseUrl` values such as `https://chatgpt.com/backend-api` to the Codex Responses backend before calling `gpt-image-2`, matching the chat transport. Fixes #71460. +- MCP/CLI: retire bundled MCP runtimes at the end of one-shot `openclaw agent` and `openclaw infer model run` gateway/local executions, so repeated scripted runs do not accumulate stdio MCP child processes. Fixes #71457. Thanks @spartoviMD. +- OpenAI/Codex image generation: canonicalize legacy `openai-codex.baseUrl` values such as `https://chatgpt.com/backend-api` to the Codex Responses backend before calling `gpt-image-2`, matching the chat transport. Fixes #71460. Thanks @GodsBoy. - Control UI: make `/usage` use the fresh context snapshot for context percentage, and include cache-write tokens in the Usage overview cache-hit denominator. Fixes #47885. Thanks @imwyvern and @Ante042. - GitHub Copilot: preserve encrypted Responses reasoning item IDs during replay so Copilot can validate encrypted reasoning payloads across requests. (#71448) Thanks @a410979729-sys. - Agents/replies: recover final-answer text when streamed assistant chunks contain only whitespace, preventing completed turns from surfacing as empty-payload errors. Fixes #71454. (#71467) Thanks @Sanjays2402. -- Feishu/TTS: transcode voice-intent MP3 and other audio replies to Ogg/Opus before sending native Feishu audio bubbles, while keeping ordinary MP3 attachments as files. Fixes #61249 and #37868. -- Providers/MiniMax: let TTS use MiniMax portal OAuth and Token Plan credentials before falling back to `MINIMAX_API_KEY`, and include current TTS HD model ids. Fixes #55017. +- Feishu/TTS: transcode voice-intent MP3 and other audio replies to Ogg/Opus before sending native Feishu audio bubbles, while keeping ordinary MP3 attachments as files. Fixes #61249 and #37868. Thanks @sg1416-zg and @ycjlb2023-peteryi. +- Providers/MiniMax: let TTS use MiniMax portal OAuth and Token Plan credentials before falling back to `MINIMAX_API_KEY`, and include current TTS HD model ids. Fixes #55017. Thanks @zx15210404690-hash. - Telegram/webhook: acknowledge validated webhook updates before running bot middleware, keeping slow agent turns from tripping Telegram delivery retries while preserving per-chat processing lanes. Fixes #71392. Thanks @joelforsberg46-source. - MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106, #71110, #70389, and #70808. -- MCP/config reload: hot-apply `mcp.*` changes by disposing cached session MCP runtimes, and dispose bundled MCP runtimes during gateway shutdown so removed `mcp.servers` entries reap child processes promptly. Fixes #60656. +- MCP/config reload: hot-apply `mcp.*` changes by disposing cached session MCP runtimes, and dispose bundled MCP runtimes during gateway shutdown so removed `mcp.servers` entries reap child processes promptly. Fixes #60656. Thanks @xieyuanqing. - Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. - Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed `{ type: "text" }` blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek, @alvinttang, and @coffeexcoin. - Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring `NIX_PROFILES` right-to-left precedence and falling back to `~/.nix-profile/bin` when unset. Fixes #44402. (#59935) Thanks @jerome-benoit. From c1f359c2761a4da98d5e51af91eaf219bcafa897 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 02:45:29 -0700 Subject: [PATCH 5/8] fix(test): reuse heavy-check lock in boundary prep --- package.json | 2 +- ...e-extension-package-boundary-artifacts.mjs | 33 ++++++++----- scripts/run-tsgo.mjs | 4 +- ...tension-package-boundary-artifacts.test.ts | 47 +++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 73e968568cb..3fafa7673ce 100644 --- a/package.json +++ b/package.json @@ -1299,7 +1299,7 @@ "build": "node scripts/build-all.mjs", "build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts", "build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:plugin-sdk:dts": "tsgo -p tsconfig.plugin-sdk.dts.json", + "build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true", "build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", "canon:check": "node scripts/canon.mjs check", diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 8f62cf329d4..4e87448c6b7 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -1,14 +1,10 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; -import { createRequire } from "node:module"; import path, { resolve } from "node:path"; +import { isLocalCheckEnabled } from "./lib/local-heavy-check-runtime.mjs"; -const require = createRequire(import.meta.url); const repoRoot = resolve(import.meta.dirname, ".."); -const tsgoBin = path.join( - path.dirname(require.resolve("@typescript/native-preview/package.json")), - "bin/tsgo.js", -); +const runTsgoScript = path.join(repoRoot, "scripts/run-tsgo.mjs"); const TYPE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".d.ts", ".js", ".mjs", ".json"]); const VALID_MODES = new Set(["all", "package-boundary"]); @@ -167,7 +163,7 @@ export function runNodeStep(label, args, timeoutMs, params = {}) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(process.execPath, args, { cwd: repoRoot, - env: process.env, + env: params.env ? { ...process.env, ...params.env } : process.env, signal: abortController?.signal, stdio: ["ignore", "pipe", "pipe"], }); @@ -231,7 +227,9 @@ export function runNodeStep(label, args, timeoutMs, params = {}) { export async function runNodeStepsInParallel(steps) { const abortController = new AbortController(); const results = await Promise.allSettled( - steps.map((step) => runNodeStep(step.label, step.args, step.timeoutMs, { abortController })), + steps.map((step) => + runNodeStep(step.label, step.args, step.timeoutMs, { abortController, env: step.env }), + ), ); const firstFailure = results.find((result) => result.status === "rejected"); if (firstFailure) { @@ -239,6 +237,17 @@ export async function runNodeStepsInParallel(steps) { } } +export async function runNodeSteps(steps, env = process.env) { + if (!isLocalCheckEnabled(env)) { + await runNodeStepsInParallel(steps); + return; + } + + for (const step of steps) { + await runNodeStep(step.label, step.args, step.timeoutMs, { env: step.env }); + } +} + export async function main(argv = process.argv.slice(2)) { try { const mode = parseMode(argv); @@ -272,7 +281,8 @@ export async function main(argv = process.argv.slice(2)) { }); pendingSteps.push({ label: "plugin-sdk boundary dts", - args: [tsgoBin, "-p", "tsconfig.plugin-sdk.dts.json"], + args: [runTsgoScript, "-p", "tsconfig.plugin-sdk.dts.json", "--declaration", "true"], + env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" }, timeoutMs: 300_000, stampPath: ROOT_DTS_STAMP, }); @@ -287,7 +297,8 @@ export async function main(argv = process.argv.slice(2)) { }); pendingSteps.push({ label: "plugin-sdk package boundary dts", - args: [tsgoBin, "-p", "packages/plugin-sdk/tsconfig.json"], + args: [runTsgoScript, "-p", "packages/plugin-sdk/tsconfig.json", "--declaration", "true"], + env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" }, timeoutMs: 300_000, stampPath: PACKAGE_DTS_STAMP, }); @@ -296,7 +307,7 @@ export async function main(argv = process.argv.slice(2)) { } if (pendingSteps.length > 0) { - await runNodeStepsInParallel(pendingSteps); + await runNodeSteps(pendingSteps); for (const step of pendingSteps) { if (step.stampPath) { writeStampFile(step.stampPath); diff --git a/scripts/run-tsgo.mjs b/scripts/run-tsgo.mjs index 2f8c7fe741f..5bbcbdaa599 100644 --- a/scripts/run-tsgo.mjs +++ b/scripts/run-tsgo.mjs @@ -21,7 +21,9 @@ if (tsBuildInfoFile) { } const sparseGuardError = getSparseTsgoGuardError(finalArgs, { cwd: process.cwd() }); const releaseLock = - sparseGuardError || !shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env) + sparseGuardError || + env.OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD === "1" || + !shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env) ? () => {} : acquireLocalHeavyCheckLockSync({ cwd: process.cwd(), diff --git a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts index 95e5e12aa86..d7ba87fa11d 100644 --- a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts +++ b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts @@ -6,6 +6,7 @@ import { createPrefixedOutputWriter, isArtifactSetFresh, parseMode, + runNodeSteps, runNodeStepsInParallel, } from "../../scripts/prepare-extension-package-boundary-artifacts.mjs"; @@ -57,6 +58,52 @@ describe("prepare-extension-package-boundary-artifacts", () => { expect(Date.now() - startedAt).toBeLessThan(abortBudgetMs); }, 45_000); + it("runs boundary prep steps serially for local checks", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-serial-")); + tempRoots.add(rootDir); + const logPath = path.join(rootDir, "steps.log"); + const appendScript = (label: string) => + `const fs=require("node:fs");` + + `const log=${JSON.stringify(logPath)};` + + `fs.appendFileSync(log, ${JSON.stringify(`${label}-start\n`)});` + + `setTimeout(()=>{fs.appendFileSync(log, ${JSON.stringify(`${label}-end\n`)});}, 50);`; + + await runNodeSteps( + [ + { label: "first", args: ["--eval", appendScript("first")], timeoutMs: 5_000 }, + { label: "second", args: ["--eval", appendScript("second")], timeoutMs: 5_000 }, + ], + { OPENCLAW_LOCAL_CHECK: "1" }, + ); + + expect(fs.readFileSync(logPath, "utf8").trim().split("\n")).toEqual([ + "first-start", + "first-end", + "second-start", + "second-end", + ]); + }); + + it("passes step-specific environment overrides to child steps", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-env-")); + tempRoots.add(rootDir); + const outputPath = path.join(rootDir, "env.txt"); + const writeEnvScript = + `const fs=require("node:fs");` + + `fs.writeFileSync(${JSON.stringify(outputPath)}, process.env.OPENCLAW_TEST_ENV || "", "utf8");`; + + await runNodeStepsInParallel([ + { + label: "env-step", + args: ["--eval", writeEnvScript], + env: { OPENCLAW_TEST_ENV: "passed" }, + timeoutMs: 5_000, + }, + ]); + + expect(fs.readFileSync(outputPath, "utf8")).toBe("passed"); + }); + it("treats artifacts as fresh only when outputs are newer than inputs", () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-prep-")); tempRoots.add(rootDir); From ed8384d32d31bdd08ef703a821818e80c821eaf5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:51:39 +0100 Subject: [PATCH 6/8] fix(minimax): default music generation to music 2.6 --- CHANGELOG.md | 1 + docs/gateway/config-agents.md | 2 +- docs/help/testing-live.md | 2 +- docs/providers/minimax.md | 8 ++++---- docs/tools/music-generation.md | 4 ++-- .../minimax/music-generation-provider.test.ts | 11 +++++------ .../minimax/music-generation-provider.ts | 4 ++-- src/agents/tools/music-generate-tool.test.ts | 18 +++++++++--------- src/music-generation/live-test-helpers.ts | 2 +- src/music-generation/runtime.test.ts | 14 +++++++------- 10 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40225a1808a..d53c28181f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and #62315. Thanks @noahclanman and @edwardzheng1. - Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss. - Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. - Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. (#71509) Thanks @vincentkoc. diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 61447670c6b..31f4a583f4a 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -347,7 +347,7 @@ Time format in system prompt. Default: `auto` (OS preference). - If omitted, `image_generate` can still infer an auth-backed provider default. It tries the current default provider first, then the remaining registered image-generation providers in provider-id order. - `musicGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared music-generation capability and the built-in `music_generate` tool. - - Typical values: `google/lyria-3-clip-preview`, `google/lyria-3-pro-preview`, or `minimax/music-2.5+`. + - Typical values: `google/lyria-3-clip-preview`, `google/lyria-3-pro-preview`, or `minimax/music-2.6`. - If omitted, `music_generate` can still infer an auth-backed provider default. It tries the current default provider first, then the remaining registered music-generation providers in provider-id order. - If you select a provider/model directly, configure the matching provider auth/API key too. - `videoGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 21c4f00f6f6..30e098b3480 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -492,7 +492,7 @@ image-generation runtime, and the live provider request. - `comfy`: separate Comfy live file, not this shared sweep - Optional narrowing: - `OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS="google,minimax"` - - `OPENCLAW_LIVE_MUSIC_GENERATION_MODELS="google/lyria-3-clip-preview,minimax/music-2.5+"` + - `OPENCLAW_LIVE_MUSIC_GENERATION_MODELS="google/lyria-3-clip-preview,minimax/music-2.6"` - Optional auth behavior: - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 88bc78ff027..b752e9f0cff 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -12,7 +12,7 @@ MiniMax also provides: - Bundled speech synthesis via T2A v2 - Bundled image understanding via `MiniMax-VL-01` -- Bundled music generation via `music-2.5+` +- Bundled music generation via `music-2.6` - Bundled `web_search` through the MiniMax Coding Plan search API Provider split: @@ -30,7 +30,7 @@ Provider split: | `MiniMax-M2.7-highspeed` | Chat (reasoning) | Faster M2.7 reasoning tier | | `MiniMax-VL-01` | Vision | Image understanding model | | `image-01` | Image generation | Text-to-image and image-to-image editing | -| `music-2.5+` | Music generation | Default music model | +| `music-2.6` | Music generation | Default music model | | `music-2.5` | Music generation | Previous music generation tier | | `music-2.0` | Music generation | Legacy music generation tier | | `MiniMax-Hailuo-2.3` | Video generation | Text-to-video and image reference flows | @@ -282,7 +282,7 @@ The bundled `minimax` plugin registers MiniMax T2A v2 as a speech provider for The bundled `minimax` plugin also registers music generation through the shared `music_generate` tool. -- Default music model: `minimax/music-2.5+` +- Default music model: `minimax/music-2.6` - Also supports `minimax/music-2.5` and `minimax/music-2.0` - Prompt controls: `lyrics`, `instrumental`, `durationSeconds` - Output format: `mp3` @@ -295,7 +295,7 @@ To use MiniMax as the default music provider: agents: { defaults: { musicGenerationModel: { - primary: "minimax/music-2.5+", + primary: "minimax/music-2.6", }, }, }, diff --git a/docs/tools/music-generation.md b/docs/tools/music-generation.md index 54f860cda15..47aa4e42e87 100644 --- a/docs/tools/music-generation.md +++ b/docs/tools/music-generation.md @@ -81,7 +81,7 @@ Example: | -------- | ---------------------- | ---------------- | --------------------------------------------------------- | -------------------------------------- | | ComfyUI | `workflow` | Up to 1 image | Workflow-defined music or audio | `COMFY_API_KEY`, `COMFY_CLOUD_API_KEY` | | Google | `lyria-3-clip-preview` | Up to 10 images | `lyrics`, `instrumental`, `format` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | -| MiniMax | `music-2.5+` | None | `lyrics`, `instrumental`, `durationSeconds`, `format=mp3` | `MINIMAX_API_KEY` | +| MiniMax | `music-2.6` | None | `lyrics`, `instrumental`, `durationSeconds`, `format=mp3` | `MINIMAX_API_KEY` | ### Declared capability matrix @@ -176,7 +176,7 @@ Duplicate prevention: if a music task is already `queued` or `running` for the c defaults: { musicGenerationModel: { primary: "google/lyria-3-clip-preview", - fallbacks: ["minimax/music-2.5+"], + fallbacks: ["minimax/music-2.6"], }, }, }, diff --git a/extensions/minimax/music-generation-provider.test.ts b/extensions/minimax/music-generation-provider.test.ts index 7e9c5c51f1d..d015d102b6e 100644 --- a/extensions/minimax/music-generation-provider.test.ts +++ b/extensions/minimax/music-generation-provider.test.ts @@ -47,7 +47,6 @@ describe("minimax music generation provider", () => { const provider = buildMinimaxMusicGenerationProvider(); const result = await provider.generateMusic({ provider: "minimax", - model: "music-2.5+", prompt: "upbeat dance-pop with female vocals", cfg: {}, lyrics: "our city wakes", @@ -61,7 +60,7 @@ describe("minimax music generation provider", () => { get: expect.any(Function), }), body: expect.objectContaining({ - model: "music-2.5+", + model: "music-2.6", lyrics: "our city wakes", output_format: "url", audio_setting: { @@ -95,7 +94,7 @@ describe("minimax music generation provider", () => { const provider = buildMinimaxMusicGenerationProvider(); const result = await provider.generateMusic({ provider: "minimax", - model: "music-2.5+", + model: "music-2.6", prompt: "upbeat dance-pop with female vocals", cfg: {}, lyrics: "our city wakes", @@ -116,7 +115,7 @@ describe("minimax music generation provider", () => { await expect( provider.generateMusic({ provider: "minimax", - model: "music-2.5+", + model: "music-2.6", prompt: "driving techno", cfg: {}, instrumental: true, @@ -135,7 +134,7 @@ describe("minimax music generation provider", () => { const provider = buildMinimaxMusicGenerationProvider(); await provider.generateMusic({ provider: "minimax", - model: "music-2.5+", + model: "music-2.6", prompt: "upbeat dance-pop", cfg: {}, }); @@ -143,7 +142,7 @@ describe("minimax music generation provider", () => { expect(postJsonRequestMock).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ - model: "music-2.5+", + model: "music-2.6", lyrics_optimizer: true, }), }), diff --git a/extensions/minimax/music-generation-provider.ts b/extensions/minimax/music-generation-provider.ts index 58ff8c9cbba..1b94f16f2a6 100644 --- a/extensions/minimax/music-generation-provider.ts +++ b/extensions/minimax/music-generation-provider.ts @@ -15,7 +15,7 @@ import { import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; const DEFAULT_MINIMAX_MUSIC_BASE_URL = "https://api.minimax.io"; -const DEFAULT_MINIMAX_MUSIC_MODEL = "music-2.5+"; +const DEFAULT_MINIMAX_MUSIC_MODEL = "music-2.6"; const DEFAULT_TIMEOUT_MS = 120_000; type MinimaxBaseResp = { @@ -125,7 +125,7 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider { id: "minimax", label: "MiniMax", defaultModel: DEFAULT_MINIMAX_MUSIC_MODEL, - models: [DEFAULT_MINIMAX_MUSIC_MODEL, "music-2.5", "music-2.0"], + models: [DEFAULT_MINIMAX_MUSIC_MODEL, "music-2.6-free", "music-cover", "music-cover-free"], isConfigured: ({ agentDir }) => isProviderApiKeyConfigured({ provider: "minimax", diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index fe3de1fd365..6a683685014 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -333,8 +333,8 @@ describe("createMusicGenerateTool", () => { vi.spyOn(musicGenerationRuntime, "listRuntimeMusicGenerationProviders").mockReturnValue([ { id: "minimax", - defaultModel: "music-2.5+", - models: ["music-2.5+"], + defaultModel: "music-2.6", + models: ["music-2.6"], capabilities: { generate: { maxTracks: 1, @@ -355,7 +355,7 @@ describe("createMusicGenerateTool", () => { config: asConfig({ agents: { defaults: { - musicGenerationModel: { primary: "minimax/music-2.5+" }, + musicGenerationModel: { primary: "minimax/music-2.6" }, }, }, }), @@ -457,7 +457,7 @@ describe("createMusicGenerateTool", () => { it("surfaces normalized durations from runtime metadata", async () => { vi.spyOn(musicGenerationRuntime, "generateMusic").mockResolvedValue({ provider: "minimax", - model: "music-2.5+", + model: "music-2.6", attempts: [], ignoredOverrides: [], tracks: [ @@ -489,7 +489,7 @@ describe("createMusicGenerateTool", () => { config: asConfig({ agents: { defaults: { - musicGenerationModel: { primary: "minimax/music-2.5+" }, + musicGenerationModel: { primary: "minimax/music-2.6" }, }, }, }), @@ -521,8 +521,8 @@ describe("createMusicGenerateTool", () => { vi.spyOn(musicGenerationRuntime, "listRuntimeMusicGenerationProviders").mockReturnValue([ { id: "minimax", - defaultModel: "music-2.5+", - models: ["music-2.5+"], + defaultModel: "music-2.6", + models: ["music-2.6"], capabilities: { edit: { enabled: true, maxInputImages: 1 }, }, @@ -538,7 +538,7 @@ describe("createMusicGenerateTool", () => { }); vi.spyOn(musicGenerationRuntime, "generateMusic").mockResolvedValue({ provider: "minimax", - model: "music-2.5+", + model: "music-2.6", attempts: [], ignoredOverrides: [], tracks: [{ buffer: Buffer.from("music"), mimeType: "audio/mpeg" }], @@ -553,7 +553,7 @@ describe("createMusicGenerateTool", () => { config: asConfig({ agents: { defaults: { - musicGenerationModel: { primary: "minimax/music-2.5+" }, + musicGenerationModel: { primary: "minimax/music-2.6" }, }, }, tools: { web: { fetch: { ssrfPolicy: { allowRfc2544BenchmarkRange: true } } } }, diff --git a/src/music-generation/live-test-helpers.ts b/src/music-generation/live-test-helpers.ts index db861ebf3e2..a0034c6072b 100644 --- a/src/music-generation/live-test-helpers.ts +++ b/src/music-generation/live-test-helpers.ts @@ -11,7 +11,7 @@ export { parseProviderModelMap, redactLiveApiKey }; export const DEFAULT_LIVE_MUSIC_MODELS: Record = { google: "google/lyria-3-clip-preview", - minimax: "minimax/music-2.5+", + minimax: "minimax/music-2.6", }; export function parseCsvFilter(raw?: string): Set | null { diff --git a/src/music-generation/runtime.test.ts b/src/music-generation/runtime.test.ts index 6c9248cef86..99197050fd2 100644 --- a/src/music-generation/runtime.test.ts +++ b/src/music-generation/runtime.test.ts @@ -93,13 +93,13 @@ describe("music-generation runtime", () => { if (providerId === "minimax") { return { id: "minimax", - defaultModel: "music-2.5+", + defaultModel: "music-2.6", capabilities: {}, isConfigured: () => true, async generateMusic() { return { tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], - model: "music-2.5+", + model: "music-2.6", }; }, }; @@ -116,7 +116,7 @@ describe("music-generation runtime", () => { }, { id: "minimax", - defaultModel: "music-2.5+", + defaultModel: "music-2.6", capabilities: {}, isConfigured: () => true, generateMusic: async () => ({ tracks: [] }), @@ -129,7 +129,7 @@ describe("music-generation runtime", () => { }); expect(result.provider).toBe("minimax"); - expect(result.model).toBe("music-2.5+"); + expect(result.model).toBe("music-2.6"); expect(result.attempts).toEqual([ { provider: "google", @@ -302,7 +302,7 @@ describe("music-generation runtime", () => { durationSeconds?: number; } | undefined; - mocks.resolveAgentModelPrimaryValue.mockReturnValue("minimax/music-2.5+"); + mocks.resolveAgentModelPrimaryValue.mockReturnValue("minimax/music-2.6"); mocks.getMusicGenerationProvider.mockReturnValue({ id: "minimax", capabilities: { @@ -317,7 +317,7 @@ describe("music-generation runtime", () => { }; return { tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], - model: "music-2.5+", + model: "music-2.6", }; }, }); @@ -326,7 +326,7 @@ describe("music-generation runtime", () => { cfg: { agents: { defaults: { - musicGenerationModel: { primary: "minimax/music-2.5+" }, + musicGenerationModel: { primary: "minimax/music-2.6" }, }, }, } as OpenClawConfig, From e6713c0a6123cd29c986eaac48a484838bb09b1b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:52:33 +0100 Subject: [PATCH 7/8] test(minimax): cover default music model normalization --- extensions/minimax/music-generation-provider.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/minimax/music-generation-provider.test.ts b/extensions/minimax/music-generation-provider.test.ts index d015d102b6e..5f90a0febda 100644 --- a/extensions/minimax/music-generation-provider.test.ts +++ b/extensions/minimax/music-generation-provider.test.ts @@ -47,6 +47,7 @@ describe("minimax music generation provider", () => { const provider = buildMinimaxMusicGenerationProvider(); const result = await provider.generateMusic({ provider: "minimax", + model: "", prompt: "upbeat dance-pop with female vocals", cfg: {}, lyrics: "our city wakes", From 936f27dcab0969192f66d8f580106c60e5e1a33e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:54:43 +0100 Subject: [PATCH 8/8] docs: clarify minimax music changelog scope --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d53c28181f5..7bb4c3d2cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and #62315. Thanks @noahclanman and @edwardzheng1. +- MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and addresses the music default from #62315. Thanks @noahclanman and @edwardzheng1. - Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss. - Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. - Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. (#71509) Thanks @vincentkoc.