Files
OpenMAIC/components/settings
a8726ec4f3 feat(media): add OpenRouter image and video providers (#1356)
* feat(media): add OpenRouter image and video providers

OpenMAIC ships six separate video providers (Veo, Kling, Seedance,
MiniMax, Grok, HappyHorse) and seven image providers, each needing its
own key. OpenRouter fronts those same model families behind one key and
one account, so this adds it as a provider on both sides.

Both use OpenRouter's dedicated media endpoints, not chat-completions:

- Image: POST /images -> { data: [{ b64_json }] }
- Video: POST /videos -> 202 { id, status }, poll GET /videos/{id},
  then GET /videos/{id}/content for the mp4 bytes

The model list is fetched live from GET /images/models and
GET /videos/models through /api/openrouter-models rather than pinned in
the registry: OpenRouter hosts 48 image and 28 video models today and
adds more, so a hardcoded shortlist would decide for the operator which
models exist. The registry keeps a three-entry seed as an offline
fallback, and the existing custom-model UI still accepts any model id.
Both catalogs answer unauthenticated, so the picker fills before a key
is pasted; a key is forwarded when present for proxied base URLs.

Adapter contracts are covered by stubbed-fetch tests (request shape,
empty-response handling, and the video job state machine including
terminal failure). No test performs a billable call.

Closes #1355

* fix(media): validate the key and tolerate a pasted endpoint URL

Three fixes found while configuring the new provider:

1. Both connectivity probes hit the model catalogs, which answer 200
   unauthenticated — so "Test Connection" reported success for any
   string, including an invalid key. Probe GET /key instead: equally
   cheap, and it actually rejects a bad key.

2. The settings field is labelled "Base URL" but the panel echoes it
   back as "Request URL", so pasting the full endpoint
   (https://openrouter.ai/api/v1/images) is the natural mistake. That
   built /api/v1/images/images and 404'd. Trim a trailing slash and a
   trailing /images or /videos so both forms work; a proxy path that
   merely contains the word is left alone.

3. The image and video settings panels read `data.message` on a failed
   test, but failures answer with `error` (apiError) and only successes
   carry `message`. Every failing connectivity test — for any provider,
   not just OpenRouter — rendered "connection failed: undefined" instead
   of the reason. Pre-existing; surfaced by 1 and 2 above.

Closes #1355

* fix(media): make every OpenRouter model selectable, and always select a provider

Two gaps found while configuring the new provider.

The settings Models list is a read-only catalog for every provider; the
actual model picker is the media popover. That picker built its groups
from the static registry array, so OpenRouter offered only the
three-entry seed while settings listed the full live catalog — the
models were visible but not choosable. Feed the same live catalog into
the popover, fetched only once the provider is usable so an
unconfigured install makes no request.

Separately, `imageProviderId`/`videoProviderId` are empty until a
provider is chosen (first-run auto-config leaves them blank when the
server reports no media provider). Opening the settings panel on an
empty id selected nothing: the header rendered the missing name key as
"settings.undefined", and Test Connection posted a blank
x-image-provider/x-video-provider, so it failed with "No image/video
provider configured" whatever key was typed. Fall back to the first
catalog entry so the panel always has a selection. Pre-existing and not
specific to OpenRouter.

Closes #1355

* fix(tts): request a browser-playable format from custom providers

`generateOpenAITTS` serves every custom OpenAI-compatible TTS provider but
never sent `response_format`, so it inherited whatever each provider
defaults to. OpenAI defaults to mp3; OpenRouter's /audio/speech defaults
to raw `pcm`. The unknown content type then fell through to the `'mp3'`
default below, the client built `data:audio/mp3;base64,…` from headerless
PCM samples, and playback failed with "no supported source was found" —
while the server logged a clean 200, because the audio really was
generated. Name the format instead of inheriting it.

Also stop mislabelling an unrecognised body: `pcm`/`l16` now raises a
message naming the cause, and `aac`/`opus` are recognised.

Two supporting fixes:

- /api/openrouter-models normalises its base URL the way the adapters do
  and falls back to the public catalog when a custom base URL fails, so a
  typo in a free-text settings field cannot empty the model picker. Also
  types the headers object so tsc accepts the conditional.
- provider-neutrality-guard pins exact per-vendor occurrence counts in
  lib/server/provider-config.ts. Adding the image and video env entries
  raises "openrouter" from 2 to 6 (each entry contributes both its key and
  its value); CI failed without the bump.

Closes #1355

* fix(security): never send the operator key to a client-chosen host

Review found `/api/openrouter-models` was an SSRF and key-exfiltration
path, and the finding is correct. The route took `x-base-url` from the
caller at highest precedence while preferring the *server* env key, so any
caller could make the server send the operator's OpenRouter credential as
an `Authorization: Bearer` header to an arbitrary URL. The route's own
comment claimed it followed `/api/verify-image-provider`; that pattern
runs `validateUrlForSSRF` on client base URLs, and this route did not.

The boundary is now explicit: the server key travels only to the
operator's own base URL. A client-supplied URL is SSRF-validated and
carries only that caller's own `x-api-key` — the server key is dropped —
and the unauthenticated public-catalog fallback never forwards a
credential chosen for a different host. Redirects are no longer followed
(`redirect: 'manual'`), since a redirect would carry the Authorization
header off-host and reopen the same hole, and upstream reads are bounded
by a timeout.

The per-URL cache is now keyed by destination *and* a hash of the
credential, and bounded to 64 entries with oldest-first eviction, so
client-supplied URLs cannot grow it without limit and one caller's
key-authorised catalog is never served to another.

Also from the review:

- The image adapter discarded the reported `media_type`. The
  orchestration layer wraps a bare `base64` as `data:image/png`
  unconditionally, so jpeg/webp results were mislabelled; the adapter now
  returns a data URL carrying the real type.
- Adapter generation and poll requests set `redirect: 'manual'`, matching
  the `/key` probe that already did.
- `runPolledTask` accepts an `AbortSignal` so the sleep between polls is
  cancellable; the video adapter passes the caller's signal. Without it a
  cancelled generation still slept out a full 10s interval.

Tests cover the highest-risk paths the review named: which credential
reaches which URL, that an SSRF-rejected destination is never contacted,
that the fallback is unauthenticated, cache isolation between callers,
and MIME preservation.

Findings 2 (base-URL normalisation) and 4 (neutrality-guard debt) were
already fixed in d553a08, pushed after the review was submitted; CI is
green on that commit.

Closes #1355

* ci: retry flaky voice clone timeout

* fix(vercel): keep OpenRouter catalogs within Hobby function limit

* fix(vercel): avoid tracing self-hosted sharp binaries

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 00:01:48 +08:00
..