mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
Merge branch 'main' into feat/pwa-web-push
This commit is contained in:
22
CHANGELOG.md
22
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 }`).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 } } } },
|
||||
|
||||
147
src/commands/models/scan.test.ts
Normal file
147
src/commands/models/scan.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user