Merge branch 'main' into feat/pwa-web-push

This commit is contained in:
Val Alexander
2026-04-25 04:57:46 -05:00
committed by GitHub
29 changed files with 750 additions and 100 deletions

View File

@@ -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.
@@ -16,11 +16,14 @@ 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 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.
- 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.
@@ -28,22 +31,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.
@@ -97,6 +100,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.

View File

@@ -66,6 +66,35 @@ Notes:
stale removed-provider default.
- `models status` may show `marker(<value>)` 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 <b>`
- `--max-age-days <days>`
- `--provider <name>`
- `--max-candidates <n>`
- `--timeout <ms>` (catalog request and per-probe timeout)
- `--concurrency <n>`
- `--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:

View File

@@ -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 noninteractive
mode, pass `--yes` to accept defaults.
When live probes run in a TTY, you can select fallbacks interactively. In
noninteractive 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`)

View File

@@ -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 }`).

View File

@@ -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

View File

@@ -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",
},
},
},

View File

@@ -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"],
},
},
},

View File

@@ -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/<id>`) still render.
- Avatars and images served under relative paths (for example `/avatars/<id>`) 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.

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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 {

View File

@@ -47,7 +47,7 @@ describe("minimax music generation provider", () => {
const provider = buildMinimaxMusicGenerationProvider();
const result = await provider.generateMusic({
provider: "minimax",
model: "music-2.5+",
model: "",
prompt: "upbeat dance-pop with female vocals",
cfg: {},
lyrics: "our city wakes",
@@ -61,7 +61,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 +95,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 +116,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 +135,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 +143,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,
}),
}),

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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<Response>((_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: [

View File

@@ -180,10 +180,16 @@ async function withTimeout<T>(
}
}
async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise<OpenRouterModelMeta[]> {
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
});
async function fetchOpenRouterModels(
fetchImpl: typeof fetch,
timeoutMs: number,
): Promise<OpenRouterModelMeta[]> {
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) => {

View File

@@ -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 } } } },

View File

@@ -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> = {}): 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();
});
});

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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",
"worker-src 'self'",
"connect-src 'self' ws: wss:",

View File

@@ -11,7 +11,7 @@ export { parseProviderModelMap, redactLiveApiKey };
export const DEFAULT_LIVE_MUSIC_MODELS: Record<string, string> = {
google: "google/lyria-3-clip-preview",
minimax: "minimax/music-2.5+",
minimax: "minimax/music-2.6",
};
export function parseCsvFilter(raw?: string): Set<string> | null {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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" }),
);
});
});

View File

@@ -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)) {