Files
open-design/apps/daemon/tests/media-adapters.test.ts
chenxueandGitHub b1fd529eea Feat/aihubmix byok provider (#3583)
* feat(media): integrate AIHubMix as full-stack BYOK provider

Add AIHubMix (https://aihubmix.com) as a BYOK provider at the same layer as
SenseAudio — both a chat protocol and a media-generation provider — so users
can paste one key and get chat + image + speech, with the same key auto-mirrored
into media-config for the `od media` CLI path.

AIHubMix is an OpenAI-wire-compatible aggregator. Its distinguishing trait is the
fixed APP-Code attribution header (discount), funnelled through a single
aihubmixHeaders() helper so every outbound call point (chat proxy, connection
test, model discovery, media renderers, memory extractor) injects it in one place.

Chat protocol layer:
- Extend ApiProtocol / ConnectionTestProtocol / MemoryExtractionProvider unions.
- New web provider client + dispatch; mirror across apiProtocols, config,
  utils, Settings/ChatComposer/HomeHero UI surfaces.
- Refactor the SenseAudio tool-loop chat proxy into a shared
  registerByokToolChatProxy() factory; register senseaudio + aihubmix through it
  (senseaudio behaviour byte-preserved). connectionTest + providerModels treat
  aihubmix as OpenAI-shaped with APP-Code headers.

Media layer:
- Register provider + image (aihubmix-gpt-image-1 / -dall-e-3) and speech
  (aihubmix-tts-1) models; prefixed ids stay unique and map to wire names.
- OpenAI-compatible renderers (/v1/images/generations, /v1/audio/speech) and
  chat tool executors; ENV_KEYS + media-picker allowlists updated.

Note: AIHUBMIX_APP_CODE in apps/daemon/src/aihubmix.ts is an empty placeholder —
fill it with the code from https://aihubmix.com/appstore to enable the discount
header (the integration works without it).

Tests: media-aihubmix.test.ts (wire-name mapping, APP-Code invariant, image +
speech renderers) and an AIHubMix proxy-route case; verify:media-models, guard,
and typecheck all green.

* feat(media): source AIHubMix model lists from live catalog endpoints

Chat LLM models and media image models now come from AIHubMix's live catalogue
(GET /api/v1/models?type=llm | image_generation, public, returns model_id /
model_name) instead of only the hand-curated static lists.

Chat: the existing /api/provider/models discovery, when protocol=aihubmix, now
hits the type=llm catalogue and parses the AIHubMix envelope (342 models live).
The static SUGGESTED_MODELS remain as the pre-fetch default.

Media image: new GET /api/media/providers/aihubmix/models endpoint proxies the
type=image_generation catalogue (53 models), prefixes ids `aihubmix-` for
uniqueness, and caches for 5 min with stale-on-error fallback. generateMedia
synthesizes a def for any unregistered `aihubmix-*` id (like the fal-ai/* path)
so every catalogue model renders through the OpenAI-compatible image endpoint.

Helpers (aihubmixCatalogUrl / parseAIHubMixCatalog) + tests for url building,
envelope parsing, and rendering a dynamic catalogue id. The web media pickers
still read the static seed list; wiring them to the live endpoint is a
follow-up UI change.

* feat(web): show live AIHubMix image catalog in all media pickers

Wire the AIHubMix image-generation catalogue (53 models, fetched from the new
daemon endpoint) into every image-model picker. A shared hook
(useAIHubMixImageModels) fetches once per session and caches; mergeAihubmixImageModels
replaces the two static aihubmix seeds with the live list (and falls back to the
seeds when the fetch hasn't resolved or fails, so the picker is never empty).

Surfaces:
- NewProjectPanel image picker — merged list passed to supportedModels('image').
- Home media composer — buildHomeMediaComposer / fieldsForSurface /
  normalizeHomeMediaInputs now accept an injected imageModels list; HomeView
  fetches and threads it through all three composer call sites.

Saved/template image-model ids starting with `aihubmix-` are accepted even
though they're absent from the static IMAGE_MODELS, since they render
dynamically daemon-side.

* feat(byok): load AIHubMix chat model list without an API key

The BYOK chat model dropdown already merges live-fetched models with the static
suggestions, but the fetch was gated on a non-empty API key (every provider hits
/v1/models behind its key). AIHubMix's catalogue is public, so its 342-model
list now loads as soon as the AIHubMix tab is selected — no key required.

- Daemon /api/provider/models accepts an empty apiKey when protocol=aihubmix
  (baseUrl still required); providerModelsHeaders sends no Bearer when the key
  is blank.
- Web: missingByokModelFetchFields treats apiKey as optional for aihubmix, and
  the auto-fetch effect skips the commit-on-blur gate for aihubmix (base URL is
  prefilled and there's no key to debounce), so the list populates on tab select.

* feat(byok): default AIHubMix chat model to claude-opus-4-8

* feat(byok): memory model picker uses live searchable model list

The Memory model picker was a plain <select> hard-wired to the 8 static
SUGGESTED_MODELS. Swap it to the same SearchableModelSelect the chat picker
above uses, fed by the parent's merged fetched+suggested model options
(apiModelOptions). For AIHubMix that means the full live 342-model catalogue
shows as a searchable dropdown (keyless), matching the chat picker; other
protocols get their live /v1/models list once the chat key is set. 'Same as
chat' leads the list and 'Custom…' is pinned last.

* feat(byok): BYOK chat image-model picker lists live AIHubMix catalog

The Settings BYOK 'image generation model' dropdown (byokImageModel, used by
the chat-injected generate_image tool) listed only the 2 static aihubmix seeds.
Feed it the live image catalogue (useAIHubMixImageModels) for aihubmix so it
shows all ~53 models; SenseAudio keeps its static registry list. Relax the
daemon's isAIHubMixImageModel to accept any aihubmix-* id (they all render via
the OpenAI-compatible endpoint with prefix-strip), so a dynamically-chosen
default passes validation instead of being dropped back to the seed default.

* refactor(byok): share one hook for BYOK image-model pickers; wire composer

The chat composer's inline 'image generation model' picker still listed only
the static aihubmix seeds. Rather than re-derive the merge+filter a third time,
extract useByokImageModelOptions(provider) into the shared aihubmix-image-models
module (live AIHubMix catalogue for aihubmix, static registry otherwise) and use
it from both the Settings image picker and the composer picker. One cached fetch
feeds all sites.

* feat(byok): route AIHubMix chat by model name to native protocol wire

Per the AIHubMix integration guide §4.3, route the BYOK chat by model name to
each family's native endpoint on the AIHubMix origin instead of forcing every
model through /v1/chat/completions:
  claude*           → Anthropic /v1/messages (x-api-key + anthropic-version)
  gemini*/imagen*   → Gemini :streamGenerateContent (x-goog-api-key)
  everything else   → OpenAI /v1/chat/completions (keeps the tool loop)
All three carry the APP-Code attribution header.

The claude/gemini routes run base chat only (no in-chat generate_image yet) —
each protocol has a pluggable tool slot for a later Anthropic tool_use / Gemini
functionCall adapter. The OpenAI family keeps in-chat image/speech generation.

Reuse: extracted runAnthropicChatStream / runGeminiChatStream (+ payload
builders) from the existing /api/proxy/{anthropic,google}/stream handlers; those
routes now delegate to the same functions (behaviour preserved, 79 proxy tests
green). New helpers classifyAIHubMixModel / aihubmixOriginFromBase /
aihubmixAppCodeHeader in aihubmix.ts. No SDK added.

Default AIHubMix chat model is now gpt-5.5 (an OpenAI-family model) so the
default keeps in-chat generate_image working after routing.

Tests: claude→/v1/messages and gemini→/gemini/.../streamGenerateContent routing
assertions in proxy-routes.test.ts.

* feat(byok): AIHubMix in-chat image/video/audio model selection + media-adapters

Bring the BYOK chat media surface to parity across image, video and audio:

- Composer + Settings dropdowns for image/video/speech models and TTS voice,
  threaded as byokVideoModel / byokSpeechModel / byokSpeechVoice. The user's
  explicit pick is authoritative over the LLM's tool `model` arg (regression
  the LLM kept overriding the composer choice). "Default" resolves to the first
  live-catalogue model (honest label) with the daemon const as final fallback.
- New apps/daemon/src/media-adapters: injection-style capability registry +
  family-aware video request builder (seedance content[] array vs generic
  input_reference), capability shape aligned with OpenRouter /videos/models.
  The chat video executor now builds requests through it.
- Per-family request shaping:
  * video: duration snapped to each family's allowed set (veo 4/6/8, sora
    4/8/12, wan 5/10); i2v reference resolved to a data URL.
  * image: gemini/nano-banana models routed to gemini-native generateContent
    (the /v1/images/generations shape 400s for them); others stay OpenAI-shaped.
  * speech: gemini TTS via generateContent with PCM->WAV wrapping and Gemini
    voice fallback; others use /v1/audio/speech (mp3).
  * download: same-origin AIHubMix asset URLs re-send Bearer + APP-Code.
- Clear, actionable errors for unsupported i2v / "params dropped" models, plus
  per-call submit logging (model + endpoint) for multi-model verification.
- i18n keys across 19 locales for the new pickers and the neutral default label.

Tests: byok-tools + media-adapters specs (85 passing); guard + daemon/web
typecheck green.

* feat(byok): media tool loops on claude/gemini chat + wan i2v wire + seedance resolution fix

Make AIHubMix in-chat media generation work regardless of which chat model is
selected, and fix two image-to-video wire-shape bugs surfaced by live testing.

chat-routes: the aihubmix routeByModel divert sent claude -> Anthropic
/v1/messages and gemini -> Gemini generateContent BEFORE the OpenAI tool loop,
so those chat models had no generate_image/video/speech tools ("没有音频工具").
Add per-protocol tool loops inside registerByokToolChatProxy (reusing the shared
executeOneTool): runAnthropicToolTurn/Chat parse tool_use content blocks +
input_json_delta; runGeminiToolTurn/Chat parse functionCall parts. The shared
single-pass runAnthropicChatStream/runGeminiChatStream and the direct
/api/proxy/{anthropic,google}/stream routes are untouched. Factor the per-kind
tool-result hint into buildToolResultContent (shared by all three protocols) and
add openaiToolsToAnthropic / openaiToolsToGemini schema converters (Gemini schema
sanitized to the OpenAPI subset). Gemini 3.x attaches a thoughtSignature to each
functionCall part that must be echoed back verbatim or the follow-up 400s, so
replay the raw functionCall part instead of reconstructing {name,args}.

media-adapters: seedance accepts resolution only as a token (480p/720p/1080p),
never a WxH pixel string — passing the aspect-derived 1280x720 made it 400
("resolution ... not valid ... in i2v"). Add snapResolutionToken to normalise.
Add a third request family `wan` for wan*/happyhorse*: verified against a working
happyhorse-1.0-i2v call, these are DashScope/wanx-backed and use
{ input:{ prompt, media:[{type:first_frame,url}] }, parameters:{ resolution, duration, prompt_extend, watermark } },
NOT the flat input_reference shape we were sending. Update the i2v failure-advice
message accordingly (the remaining risk is wanx needing a public image URL while
we send an inline data URL; seedance accepts the inline image).

Tests: anthropic+gemini in-chat tool loops (incl. thoughtSignature round-trip),
snapResolutionToken, seedance token resolution, and the wan input.media shape.

* feat(byok): add veo video family + default to seedance-2.0-fast

veo* models go through the gateway's OpenAI→Gemini predictLongRunning shim,
which wants `seconds` as a NUMBER and only honours `size` (no aspect_ratio) —
distinct enough from the generic flat shape to warrant its own `veo` family in
the media-adapters builder. Verified against a working veo-3.1-lite call.

Also default the BYOK chat video model to doubao-seedance-2-0-fast-260128 (the
broadly-available fast i2v/t2v path) and extend the media-adapters tests to
cover the veo family routing + duration snapping (4/6/8).

* feat(byok): hide account-source model labels for AIHubMix

AIHubMix's live model fetch returns its full account catalogue, so every
picker option carried an identical "from your account" badge that added
noise without distinguishing anything. Introduce a reusable
ACCOUNT_MODEL_SOURCE_LABEL_HIDDEN set + hidesAccountModelSourceLabel()
predicate: for listed protocols the per-option source label is dropped and
the loaded-models hint shows a plain count (new settings.modelsLoadedCount
key) instead of the account wording. Other providers are unaffected; add a
protocol to the set to opt it in.

* fix(byok): close AIHubMix catalogue SSRF + share gemini image branch

Two review fixes on the AIHubMix BYOK provider:

- SSRF: the public `/api/media/providers/aihubmix/models` GET took a
  caller-controlled `baseUrl` and fetched it server-side, so
  `?baseUrl=http://169.254.169.254/` could reach a private/metadata host.
  The catalogue lives at one fixed origin, so the route now ignores any
  `baseUrl` query and hard-codes the official AIHubMix origin — no
  attacker-chooseable fetch target remains. A focused route test asserts a
  malicious `baseUrl` is ignored and only aihubmix.com is contacted.

- Gemini image parity: the media renderer always sent AIHubMix image models
  to the OpenAI `/images/generations` endpoint, which 400s for
  gemini/imagen-family models. Extract the chat tool's Gemini-native branch
  into a shared `aihubmixGeminiImageBytes` helper and route those models
  through it from both the chat tool and the Home/New Project/CLI media path,
  so the catalogue the picker exposes is fully renderable. Added a media-path
  test that asserts a gemini-family id hits the generateContent wire.

* feat(byok): hide redundant gateway preset for single-origin AIHubMix

The "gateway preset" dropdown only ever offered AIHubMix itself (plus the
generic custom-endpoint option) because AIHubMix is a fixed-origin gateway
with exactly one preset — redundant UI. Gate it behind a reusable
GATEWAY_PRESET_HIDDEN set + hidesGatewayPreset() predicate (mirroring the
existing ACCOUNT_MODEL_SOURCE_LABEL_HIDDEN pattern) so another single-origin
gateway can opt in by adding its protocol, rather than hardcoding a check.
The Base URL field still shows the resolved origin; multi-preset and
custom-endpoint-reliant protocols (azure/google/senseaudio) are unaffected.

* feat(byok): hide Base URL field too for fixed-origin AIHubMix gateway

AIHubMix resolves its Base URL automatically, so the editable field was as
redundant as the gateway preset. Generalize the just-added GATEWAY_PRESET_HIDDEN
into one semantic concept — FIXED_ORIGIN_GATEWAYS + isFixedOriginGateway() — and
drive BOTH the preset dropdown and the Base URL field off that single predicate.
Adding a fixed-origin gateway protocol now hides both surfaces in one place; the
user only supplies an API key. azure/google/senseaudio are unaffected.

* fix(byok): close AIHubMix asset-download SSRF redirect bypass

The AIHubMix asset downloads validated the literal URL via
assertExternalAssetUrl but then fetched with the default redirect:'follow',
so a validated public URL could 302 the daemon into 169.254.169.254 / RFC1918
space (and leak Bearer/APP-Code to the hop target). Add a shared
assertAndFetchExternalAsset(url, init) helper that validates and pins
redirect:'error', and route every external asset download through it:
chat video/image (byok-tools.ts) and media image/video + SenseAudio image
(media.ts). Also add the missing isLocalSameOrigin guard to the AIHubMix
catalogue route so it matches its sibling media routes.

Regression tests lock the invariant: helper-level (blocks literal metadata
host, forces redirect:'error') plus chat video/image executors asserting the
download fetch carries redirect:'error', and a cross-origin rejection test on
the catalogue route.

Also point the AIHubMix API-key console link at the referral URL.

* feat(byok): carry New Project media-model pick into BYOK conversation

The model chosen in New Project → Media was stored on project.metadata but
dropped for BYOK providers (AIHubMix): media is produced by the generate_*
chat tools whose default comes from the per-request byok*Model field, which
ProjectView only seeded from Settings. Seed those overrides from the project's
creation-time pick instead, gated to the active BYOK provider so a cross-provider
model (e.g. a SenseAudio run whose image project defaulted to gpt-image-2) can't
clobber the user's Settings default. mediaModelProviderId resolves AIHubMix's
`aihubmix-` live ids by prefix so the seed survives before the async catalogue
loads.

* refactor(byok): remove in-chat media model selector pending unified picker

Drop the in-conversation BYOK image/video/speech model dropdowns from the chat
composer. Media model selection now comes from the project's creation-time pick
(seeded into the byok*Model overrides), and a unified selection surface will
replace this later. Also removes the now-dead option hooks/imports so the
composer no longer fires AIHubMix catalogue fetches it doesn't render. The
byok* props/handlers are retained as plumbing for the future unified picker.

* fix(byok): resolve AIHubMix fixed-origin base URL so model lists load

Hiding the Base URL field for the fixed-origin AIHubMix gateway left
config.baseUrl empty, which blocked the live model-list fetch (it gates on a
valid base URL) so the Settings dropdown and the top-bar switcher showed only
the static suggested models. Centralise the fixed-origin concept in
state/apiProtocols (FIXED_ORIGIN_GATEWAYS / isFixedOriginGateway /
resolveFixedOriginBaseUrl) and resolve the canonical origin in loadConfig so
every consumer sees it — including configs already persisted with an empty
baseUrl. SettingsDialog now imports the shared helpers instead of redefining
them.
2026-06-04 09:00:20 +00:00

380 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
buildVideoRequest,
createCapabilityRegistry,
deriveVideoFamily,
normalizeModelId,
snapDuration,
snapResolutionToken,
snapVeoSize,
snapSizeToSupported,
aihubmixMediaRegistry,
type ModelCapability,
} from '../src/media-adapters/index.js';
const SEEDANCE: ModelCapability = {
id: 'doubao-seedance-2-0-260128',
apiModel: 'doubao-seedance-2-0-260128',
mediaType: 'video',
family: 'seedance',
caps: ['t2v', 'i2v'],
};
const VEO: ModelCapability = {
id: 'veo-3.1-generate-preview',
apiModel: 'veo-3.1-generate-preview',
mediaType: 'video',
family: 'veo',
caps: ['t2v'], // veo is text-to-video only on the AIHubMix gateway
supportedDurations: [4, 6, 8],
};
const SORA: ModelCapability = {
id: 'sora-2',
apiModel: 'sora-2',
mediaType: 'video',
family: 'generic',
caps: ['t2v', 'i2v'],
supportedDurations: [4, 8, 12],
supportedSizes: ['720x1280', '1280x720'],
};
const DATA_URL = 'data:image/png;base64,aGVsbG8=';
describe('media-adapters registry', () => {
it('normalizes the aihubmix- prefix on lookup', () => {
expect(normalizeModelId('aihubmix-doubao-seedance-2-0-260128')).toBe('doubao-seedance-2-0-260128');
const reg = createCapabilityRegistry([SEEDANCE]);
expect(reg.get('aihubmix-doubao-seedance-2-0-260128')?.id).toBe(SEEDANCE.id);
expect(reg.get('doubao-seedance-2-0-260128')?.id).toBe(SEEDANCE.id);
});
it('default registry is seeded with known video models', () => {
expect(aihubmixMediaRegistry.get('doubao-seedance-2-0-260128')?.family).toBe('seedance');
expect(aihubmixMediaRegistry.get('sora-2')?.family).toBe('generic');
expect(aihubmixMediaRegistry.get('veo-3.1-generate-preview')?.family).toBe('veo');
expect(aihubmixMediaRegistry.get('veo-3.1-lite-generate-preview')?.family).toBe('veo');
});
});
describe('deriveVideoFamily', () => {
it('routes seedance / wan / veo / generic by wire name', () => {
expect(deriveVideoFamily('doubao-seedance-2-0-260128')).toBe('seedance');
expect(deriveVideoFamily('wan2.5-i2v-preview')).toBe('wan');
expect(deriveVideoFamily('happyhorse-1.0-i2v')).toBe('wan');
expect(deriveVideoFamily('veo-3.1-generate-preview')).toBe('veo');
expect(deriveVideoFamily('veo-3.1-lite-generate-preview')).toBe('veo');
expect(deriveVideoFamily('sora-2')).toBe('generic');
});
});
describe('snapDuration', () => {
it('snaps to the family allowed set (ties → shorter), clamps when unconstrained', () => {
expect(snapDuration(VEO, 5)).toBe(4); // veo 4/6/8, tie→shorter
expect(snapDuration(VEO, 7)).toBe(6);
expect(snapDuration(VEO, 10)).toBe(8);
expect(snapDuration(SEEDANCE, 5)).toBe(5); // no constraint → clamp 3-12
expect(snapDuration(SEEDANCE, 99)).toBe(12);
});
});
describe('buildVideoRequest — seedance family', () => {
it('t2v: builds a multimodal content array with the prompt', () => {
const built = buildVideoRequest(SEEDANCE, { prompt: 'a panda', durationSeconds: 5 });
expect(built.family).toBe('seedance');
expect(built.pathSuffix).toBe('/videos');
expect(built.contentType).toBe('application/json');
expect(built.hasReference).toBe(false);
expect(built.body.model).toBe('doubao-seedance-2-0-260128');
expect(built.body.duration).toBe(5);
expect(built.body.content).toEqual([{ type: 'text', text: 'a panda' }]);
expect(built.body.input_reference).toBeUndefined();
});
it('i2v: adds the reference image as image_url with role first_frame', () => {
const built = buildVideoRequest(SEEDANCE, {
prompt: 'animate the panda',
durationSeconds: 5,
imageRef: { dataUrl: DATA_URL },
});
expect(built.hasReference).toBe(true);
const content = built.body.content as any[];
expect(content[0]).toEqual({ type: 'text', text: 'animate the panda' });
expect(content[1]).toEqual({
type: 'image_url',
image_url: { url: DATA_URL },
role: 'first_frame',
});
});
it('i2v: never sends a WxH pixel size as resolution (Seedance 400s on that)', () => {
// Regression: the aihubmix video tool passes size=1280x720 (aspect-derived)
// but no resolution token. Seedance rejected "1280x720" with
// "the parameter resolution ... is not valid for model ... in i2v".
const built = buildVideoRequest(SEEDANCE, {
prompt: 'animate it',
durationSeconds: 5,
size: '1280x720',
imageRef: { dataUrl: DATA_URL },
});
expect(built.body.resolution).toBe('720p'); // token, NOT "1280x720"
});
it('i2v: an explicit resolution token wins over size', () => {
const built = buildVideoRequest(SEEDANCE, {
prompt: 'animate it',
resolution: '1080p',
size: '1280x720',
});
expect(built.body.resolution).toBe('1080p');
});
});
describe('snapVeoSize', () => {
it('passes through known-good Veo sizes', () => {
expect(snapVeoSize('1280x720')).toBe('1280x720');
expect(snapVeoSize('720x1280')).toBe('720x1280');
expect(snapVeoSize('1920x1080')).toBe('1920x1080');
});
it('maps out-of-range sizes to the nearest orientation at 720p', () => {
expect(snapVeoSize('1024x1024')).toBe('1280x720'); // square → landscape
expect(snapVeoSize('960x720')).toBe('1280x720'); // 4:3 → landscape
expect(snapVeoSize('720x960')).toBe('720x1280'); // 3:4 → portrait
});
it('defaults to landscape 720p when nothing usable is supplied', () => {
expect(snapVeoSize(undefined)).toBe('1280x720');
expect(snapVeoSize('garbage')).toBe('1280x720');
});
});
describe('snapSizeToSupported', () => {
const SUP = ['720x1280', '1280x720'];
it('returns the size unchanged when the model declares no constraint', () => {
expect(snapSizeToSupported('1024x1024', undefined)).toBe('1024x1024');
expect(snapSizeToSupported('1024x1024', [])).toBe('1024x1024');
});
it('passes through an exact supported value (case/×-insensitive)', () => {
expect(snapSizeToSupported('1280x720', SUP)).toBe('1280x720');
expect(snapSizeToSupported('1280X720', SUP)).toBe('1280x720');
expect(snapSizeToSupported('720×1280', SUP)).toBe('720x1280');
});
it('maps an unsupported size to a supported one of the same orientation', () => {
expect(snapSizeToSupported('1024x1024', SUP)).toBe('1280x720'); // square → landscape
expect(snapSizeToSupported('960x720', SUP)).toBe('1280x720'); // 4:3 landscape
expect(snapSizeToSupported('720x960', SUP)).toBe('720x1280'); // 3:4 portrait
});
it('falls back to the first supported size when input is missing/unparseable', () => {
expect(snapSizeToSupported(undefined, SUP)).toBe('1280x720');
expect(snapSizeToSupported('garbage', SUP)).toBe('1280x720');
});
});
describe('snapResolutionToken', () => {
it('passes through valid tokens (case-insensitive)', () => {
expect(snapResolutionToken('720p', undefined)).toBe('720p');
expect(snapResolutionToken('1080P', undefined)).toBe('1080p');
expect(snapResolutionToken('480p', '1920x1080')).toBe('480p'); // token wins
});
it('maps a WxH pixel size to the nearest token by short side', () => {
expect(snapResolutionToken(undefined, '1280x720')).toBe('720p');
expect(snapResolutionToken(undefined, '1920x1080')).toBe('1080p');
expect(snapResolutionToken(undefined, '854x480')).toBe('480p');
expect(snapResolutionToken(undefined, '720x1280')).toBe('720p'); // portrait
});
it('defaults to 720p when nothing usable is supplied', () => {
expect(snapResolutionToken(undefined, undefined)).toBe('720p');
expect(snapResolutionToken('garbage', 'nope')).toBe('720p');
});
});
describe('buildVideoRequest — wan family (happyhorse / wan*)', () => {
const HAPPYHORSE: ModelCapability = {
id: 'happyhorse-1.0-i2v',
apiModel: 'happyhorse-1.0-i2v',
mediaType: 'video',
family: 'wan',
caps: ['i2v'],
supportedFrameImages: ['first_frame'],
supportedResolutions: ['480P', '720P', '1080P'],
};
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo=';
it('routes happyhorse* / wan* to the wan family by name', () => {
expect(deriveVideoFamily('happyhorse-1.0-i2v')).toBe('wan');
expect(deriveVideoFamily('wan2.6-i2v')).toBe('wan');
expect(deriveVideoFamily('wan2.5-t2v-preview')).toBe('wan');
});
it('i2v: builds the DashScope input.media + parameters shape', () => {
const built = buildVideoRequest(HAPPYHORSE, {
prompt: 'cat stretches',
durationSeconds: 5,
size: '1280x720',
imageRef: { dataUrl: DATA_URL },
});
expect(built.family).toBe('wan');
expect(built.hasReference).toBe(true);
// No flat top-level prompt / input_reference — those were the broken shape.
expect(built.body.prompt).toBeUndefined();
expect(built.body.input_reference).toBeUndefined();
expect(built.body).toMatchObject({
model: 'happyhorse-1.0-i2v',
input: {
prompt: 'cat stretches',
media: [{ type: 'first_frame', url: DATA_URL }],
},
parameters: {
resolution: '720P', // uppercase token, NOT "1280x720"
duration: 5,
prompt_extend: true,
watermark: false,
},
});
});
it('t2v: omits media when there is no reference image', () => {
const built = buildVideoRequest(
{ ...HAPPYHORSE, id: 'wan2.5-t2v-preview', apiModel: 'wan2.5-t2v-preview', caps: ['t2v'] },
{ prompt: 'a sunset', durationSeconds: 5 },
);
expect((built.body.input as any).media).toBeUndefined();
expect((built.body.input as any).prompt).toBe('a sunset');
expect((built.body.parameters as any).resolution).toBe('720P');
});
});
describe('buildVideoRequest — veo family', () => {
it('t2v: seconds is a NUMBER and size only — no aspect_ratio (Gemini shim 400s otherwise)', () => {
// Regression: the chat video tool always derives an aspect-based size AND an
// aspect_ratio, and the generic branch sent seconds as the string "8". The
// gateway's OpenAI→Gemini predictLongRunning shim wants an integer and only
// honours `size`; the string + extra aspect_ratio made veo fail.
const built = buildVideoRequest(VEO, {
prompt: 'a sunset',
durationSeconds: 5, // veo → snapped to 4
aspectRatio: '16:9',
size: '1280x720',
});
expect(built.family).toBe('veo');
expect(built.body).toMatchObject({
model: 'veo-3.1-generate-preview',
prompt: 'a sunset',
seconds: 4, // NUMBER, not "4"
size: '1280x720',
});
expect(typeof built.body.seconds).toBe('number');
expect(built.body.aspect_ratio).toBeUndefined();
expect(built.body.resolution).toBeUndefined(); // never an explicit resolution
expect(built.body.input_reference).toBeUndefined();
});
it('snaps an out-of-range size (1:1) to a valid landscape Veo size (Gemini shim 400s on 1024p)', () => {
const built = buildVideoRequest(VEO, {
prompt: 'square clip',
durationSeconds: 8,
size: '1024x1024', // 1:1 → gateway would derive an invalid 1024p
});
expect(built.body.size).toBe('1280x720');
});
it('t2v-only: never emits input_reference even if a reference image is passed', () => {
// veo rejects every reference form on the gateway; the builder must not put
// one on the wire. (The caller gates i2v out via caps before reaching here.)
const built = buildVideoRequest(VEO, {
prompt: 'clip',
durationSeconds: 6,
imageRef: { dataUrl: DATA_URL },
});
expect(built.body.input_reference).toBeUndefined();
expect(built.body.seconds).toBe(6);
});
});
describe('buildVideoRequest — generic family (sora)', () => {
it('t2v: flat body with seconds string + size, NO aspect_ratio (Sora rejects it), no input_reference', () => {
const built = buildVideoRequest(SORA, {
prompt: 'a sunset',
durationSeconds: 5, // sora 4/8/12 → snapped to 4
aspectRatio: '16:9', // supplied by the caller but must NOT reach the wire
size: '1280x720',
});
expect(built.family).toBe('generic');
expect(built.body).toMatchObject({
model: 'sora-2',
prompt: 'a sunset',
seconds: '4', // generic keeps the string shape sora expects
size: '1280x720',
});
// Regression: Sora 400s with "Unknown parameter: 'aspect_ratio'".
expect(built.body.aspect_ratio).toBeUndefined();
expect(built.body.input_reference).toBeUndefined();
});
it('snaps an unsupported size (1:1) to a supported one (Sora 400s on 1024x1024)', () => {
const built = buildVideoRequest(SORA, {
prompt: 'square',
durationSeconds: 8,
size: '1024x1024', // not in Sora's supported set
});
expect(built.body.size).toBe('1280x720'); // square → landscape
});
it('snaps a portrait-ish size to the supported portrait size', () => {
const built = buildVideoRequest(SORA, {
prompt: 'tall',
durationSeconds: 8,
size: '720x960', // 3:4 portrait, unsupported
});
expect(built.body.size).toBe('720x1280');
});
it('i2v: attaches input_reference as the data URL', () => {
const built = buildVideoRequest(SORA, {
prompt: 'clip',
durationSeconds: 8,
imageRef: { dataUrl: DATA_URL },
});
expect(built.hasReference).toBe(true);
expect(built.body.input_reference).toBe(DATA_URL);
expect(built.body.seconds).toBe('8');
});
});
describe('buildVideoRequest — apiModelI2V + passthrough', () => {
it('switches to apiModelI2V when a reference image is present', () => {
const cap: ModelCapability = {
id: 'wan2.5',
apiModel: 'wan2.5-t2v-preview',
apiModelI2V: 'wan2.5-i2v-preview',
mediaType: 'video',
family: 'generic',
caps: ['t2v', 'i2v'],
};
expect(buildVideoRequest(cap, { prompt: 'x' }).wireModel).toBe('wan2.5-t2v-preview');
expect(buildVideoRequest(cap, { prompt: 'x', imageRef: { dataUrl: DATA_URL } }).wireModel).toBe(
'wan2.5-i2v-preview',
);
});
it('merges extraBodyDefaults and only whitelisted passthrough keys', () => {
const cap: ModelCapability = {
id: 'm',
apiModel: 'm',
mediaType: 'video',
family: 'generic',
caps: ['t2v'],
allowedPassthroughParameters: ['mode'],
extraBodyDefaults: [{ name: 'mode', type: 'string', default: 'std' }],
};
const built = buildVideoRequest(cap, {
prompt: 'x',
passthrough: { mode: 'pro', not_allowed: 'drop-me' },
});
expect(built.body.mode).toBe('pro'); // passthrough overrides default
expect(built.body.not_allowed).toBeUndefined(); // non-whitelisted dropped
});
});