mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* feat(media): Adobe Firefly image + video generation provider
Add unofficial adobe-firefly media provider with full OpenAI-compatible
image and video generation: Nano Banana / GPT Image families, Sora 2,
Veo 3.1 (standard/fast/reference), and Kling 3.0.
Supports browser session cookies (auto IMS token exchange) or direct
IMS access tokens, async submit-and-poll against Firefly 3P endpoints,
aspect-ratio and resolution controls, and multi-account web-session UX.
Chat completions are intentionally rejected (media-only surface).
Includes unit coverage for registry wiring, payload builders, auth
resolution, and mocked generate happy-paths.
* fix(adobe-firefly): clio auth, discovery fallback, credits balance
Root-cause 401 invalid token against live firefly.adobe.com captures:
generate/discovery use x-api-key + IMS client_id clio-playground-web
(not projectx_webapp). Align headers/origin, dual cookie to IMS exchange
(clio first, Express fallback), BKS poll rewrite for /jobs/result.
Models: parse POST /v2/models/discovery + static fallback catalog from
adobe/get_models.txt; expand image/video registries.
Limits: GET firefly.adobe.io/v1/credits/balance (SunbreakWebUI1) with
total/remaining + free/plan detail quotas. Clarify cookie vs JWT UX.
Unit tests: 27/27 pass.
* fix(adobe-firefly): reject guest tokens from page-only cookies
Live repro with firefly.adobe.com Cookie export: IMS check with
guest_allowed=true returns account_type=guest (no AdobeID). That token
fails generate (401 invalid token) and credits/balance (403
ErrMismatchOauthToken). guest_allowed=false needs adobelogin.com IMS
session cookies which are not present in a page-only Cookie paste.
- Detect/reject guest JWTs; clear error tells user to paste Bearer JWT
- Prefer user JWT from HAR/mixed paste; improve credential extraction
- Update web-cookie + credential UX to recommend Authorization Bearer
Unit tests 29/29.
* fix(adobe-firefly): production auth, Limits, and 408 load handling
Live validation against firefly.adobe.com + packaged VibeProxy:
Auth / credentials
- Prefer IMS user JWT (Bearer from firefly-3p); reject guest tokens from
page-only cookies with an actionable error
- Extract JWT from Bearer, access_token=, IMS sessionStorage tokenValue,
and mixed HAR pastes; prefer non-guest tokens
- Strip JWT from Cookie header (undici Headers.append crash on mixed paste)
- Keep sherlockToken → x-arp-session-id + sanitized Cookie for generate
Limits
- credits/balance → Record quotas (firefly_total / free / plan) so
providerLimits caches them (arrays were ignored)
- Allowlist adobe-firefly + firefly in USAGE_SUPPORTED + APIKEY limits
- Live: 10000 plan credits parsed end-to-end after refresh
Generate
- Browser-shaped gpt-image body (size auto, no extra top-level size)
- Exponential 408 "system under load" retries (8 attempts) with clear
client message that 408 is Adobe capacity, not invalid token
- Live: generate returns proper 408 under load; balance/models stay 200
Tests: adobe-firefly unit suite 33/33 pass.
* fix(adobe-firefly): match live capture headers; add gpt-image-2
- Do not send firefly.adobe.com Cookie to firefly-3p (wrong-origin; soft 408)
- Lift sherlockToken only into x-arp-session-id
- Poll headers match status_check.txt (Bearer + accept, no x-api-key)
- Catalog gpt-image-2 alias → upstream modelVersion "2" (GPT Image 2)
- Shorter 408 retry budget so clients fail fast with clear message
- Unit suite 34/34
* fix(adobe-firefly): always send x-arp-session-id on generate (fixes 408)
Root cause of Bearer JWT → HTTP 408 colligo "system under load":
submit only set x-arp-session-id when sherlockToken was present in a
cookie paste. JWT-only credentials never sent the header, and Adobe
soft-blocks those requests with instant 408 (x-colligo-timeout:0.0).
A/B against a real user IMS token:
- det nonce + synthetic ARP → 200
- random nonce + synthetic ARP → 200
- det nonce without ARP → 408
Match adobe2api / GPT2Image-Pro:
- buildAdobeSubmitNonce = sha256(user_id + prompt[:256])
- buildAdobeArpSessionId = base64({sid, ftr}) synthetic session
- buildAdobeSubmitHeaders always sets both headers
Live adobeFireflyGenerateImage end-to-end: submit + poll → S3 presigned URL.
* fix(adobe-firefly): drop literal cred fallbacks + type-clean tests
Addresses pre-merge review feedback on #8006:
- Removes the `|| "literal"` fallback after resolvePublicCred() in
adobeFireflyApiKey()/adobeFireflyExpressClientId()/adobeFireflyBalanceApiKey()
(open-sse/services/adobeFireflyClient.ts). resolvePublicCred() already
always returns the decoded embedded default, so the literal fallback
was dead code that reproduced the exact env-or-literal anti-pattern
docs/security/PUBLIC_CREDS.md documents as BAD (Hard Rule #11).
- Replaces the 11 `@typescript-eslint/no-explicit-any` casts in
tests/unit/adobe-firefly.test.ts with concrete types
(Record<string, unknown>, Headers, Error-narrowing on the
assert.rejects predicate), matching the pattern already used
elsewhere in this suite. `no-explicit-any` is a hard ESLint error
under tests/ in this repo.
- Freezes file-size baseline entries for the new
open-sse/services/adobeFireflyClient.ts (1958 LOC, new-file cap 800,
mirrors the qoderCli.ts precedent for a legitimately large new
provider client), open-sse/config/imageRegistry.ts (800->821, new
adobe-firefly registry entry) and the +3 LOC growth in
src/lib/usage/providerLimits.ts (1000->1003).
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
329 lines
9.2 KiB
TypeScript
329 lines
9.2 KiB
TypeScript
/**
|
|
* Adobe Firefly model catalog: live discovery + static fallback from browser capture.
|
|
*
|
|
* Live: POST firefly-3p.ff.adobe.io/v2/models/discovery (needs valid IMS token).
|
|
* Fallback: curated rows from adobe/get_models.txt (2026-07 Firefly SPA capture) so
|
|
* Media/Models still list usable ids when discovery fails or credentials are missing.
|
|
*/
|
|
|
|
import {
|
|
type AdobeFireflyDiscoveredModel,
|
|
discoverAdobeFireflyModels,
|
|
resolveAdobeAccessToken,
|
|
} from "./adobeFireflyClient.ts";
|
|
|
|
export interface AdobeFireflyCatalogModel {
|
|
/** OpenAI-style id without provider prefix, e.g. nano-banana-pro or flux-fluxPro */
|
|
id: string;
|
|
name: string;
|
|
modality: "image" | "video";
|
|
/** Upstream wire modelId for generate-async */
|
|
upstreamModelId: string;
|
|
/** Upstream wire modelVersion for generate-async */
|
|
upstreamModelVersion: string;
|
|
inputModalities?: string[];
|
|
}
|
|
|
|
/**
|
|
* Static fallback built from adobe/get_models.txt discovery response.
|
|
* Friendly aliases first (Media page defaults), then popular upstream families.
|
|
*/
|
|
export const ADOBE_FIREFLY_FALLBACK_MODELS: AdobeFireflyCatalogModel[] = [
|
|
// ── Friendly aliases (handler resolveAdobeImageModel / resolveAdobeVideoModel) ──
|
|
{
|
|
id: "nano-banana-pro",
|
|
name: "Gemini 3.0 (Nano Banana Pro)",
|
|
modality: "image",
|
|
upstreamModelId: "gemini-flash",
|
|
upstreamModelVersion: "nano-banana-2",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "nano-banana",
|
|
name: "Gemini 2.5 (Nano Banana)",
|
|
modality: "image",
|
|
upstreamModelId: "gemini-flash",
|
|
upstreamModelVersion: "nano-banana",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "nano-banana-2",
|
|
name: "Gemini 3.1 (Nano Banana 2)",
|
|
modality: "image",
|
|
upstreamModelId: "gemini-flash",
|
|
upstreamModelVersion: "nano-banana-3",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "gpt-image-2",
|
|
name: "GPT Image 2",
|
|
modality: "image",
|
|
upstreamModelId: "gpt-image",
|
|
upstreamModelVersion: "2",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "gpt-image",
|
|
name: "GPT Image 2",
|
|
modality: "image",
|
|
upstreamModelId: "gpt-image",
|
|
upstreamModelVersion: "2",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "gpt-image-1.5",
|
|
name: "GPT Image 1.5",
|
|
modality: "image",
|
|
upstreamModelId: "gpt-image",
|
|
upstreamModelVersion: "1.5",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "sora-2",
|
|
name: "Sora 2",
|
|
modality: "video",
|
|
upstreamModelId: "sora",
|
|
upstreamModelVersion: "sora-2",
|
|
},
|
|
{
|
|
id: "sora-2-pro",
|
|
name: "Sora 2 Pro",
|
|
modality: "video",
|
|
upstreamModelId: "sora",
|
|
upstreamModelVersion: "sora-2-pro",
|
|
},
|
|
{
|
|
id: "veo-3.1",
|
|
name: "Veo 3.1",
|
|
modality: "video",
|
|
upstreamModelId: "veo",
|
|
upstreamModelVersion: "3.1-generate",
|
|
},
|
|
{
|
|
id: "veo-3.1-fast",
|
|
name: "Veo 3.1 Fast",
|
|
modality: "video",
|
|
upstreamModelId: "veo",
|
|
upstreamModelVersion: "3.1-fast-generate",
|
|
},
|
|
{
|
|
id: "veo-3.1-ref",
|
|
name: "Veo 3.1 Reference",
|
|
modality: "video",
|
|
upstreamModelId: "veo",
|
|
upstreamModelVersion: "3.1-generate",
|
|
},
|
|
{
|
|
id: "kling-3",
|
|
name: "Kling Video v3 Standard Image to Video",
|
|
modality: "video",
|
|
upstreamModelId: "kling",
|
|
upstreamModelVersion: "kling_v3_standard_i2v",
|
|
},
|
|
// ── Additional image families from discovery capture ──
|
|
{
|
|
id: "flux-2",
|
|
name: "Flux 2",
|
|
modality: "image",
|
|
upstreamModelId: "flux",
|
|
upstreamModelVersion: "2",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "flux-pro",
|
|
name: "Flux 1.1 Pro",
|
|
modality: "image",
|
|
upstreamModelId: "flux",
|
|
upstreamModelVersion: "fluxPro",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "flux-ultra",
|
|
name: "Flux 1.1 Ultra",
|
|
modality: "image",
|
|
upstreamModelId: "flux",
|
|
upstreamModelVersion: "fluxUltra",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "seedream-4",
|
|
name: "Seedream 4.0",
|
|
modality: "image",
|
|
upstreamModelId: "seedream",
|
|
upstreamModelVersion: "seedream_v4",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "seedream-5-lite",
|
|
name: "Seedream 5.0 Lite",
|
|
modality: "image",
|
|
upstreamModelId: "seedream",
|
|
upstreamModelVersion: "seedream_v5_lite",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
{
|
|
id: "runway-gen4-image",
|
|
name: "Runway Gen-4 Image",
|
|
modality: "image",
|
|
upstreamModelId: "runway-gen4-image",
|
|
upstreamModelVersion: "gen4_image",
|
|
inputModalities: ["text", "image"],
|
|
},
|
|
// ── Additional video families ──
|
|
{
|
|
id: "kling-v3-t2v",
|
|
name: "Kling Video v3 Standard Text to Video",
|
|
modality: "video",
|
|
upstreamModelId: "kling",
|
|
upstreamModelVersion: "kling_v3_standard_t2v",
|
|
},
|
|
{
|
|
id: "kling-v3-pro-i2v",
|
|
name: "Kling Video v3 Pro Image to Video",
|
|
modality: "video",
|
|
upstreamModelId: "kling",
|
|
upstreamModelVersion: "kling_v3_pro_i2v",
|
|
},
|
|
{
|
|
id: "luma-ray3",
|
|
name: "Ray3",
|
|
modality: "video",
|
|
upstreamModelId: "luma",
|
|
upstreamModelVersion: "3.0-ray",
|
|
},
|
|
{
|
|
id: "runway-gen4-turbo",
|
|
name: "Runway Gen-4 Video",
|
|
modality: "video",
|
|
upstreamModelId: "runway",
|
|
upstreamModelVersion: "gen4_turbo",
|
|
},
|
|
];
|
|
|
|
/** Stable slug for upstream modelId + modelVersion (catalog id when not a friendly alias). */
|
|
export function slugifyAdobeModel(modelId: string, modelVersion: string): string {
|
|
const mid = String(modelId || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
const ver = String(modelVersion || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9.]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
if (!ver || ver === "default" || ver === mid) return mid || "model";
|
|
return `${mid}-${ver}`;
|
|
}
|
|
|
|
/** Map discovery rows → catalog entries (image/video only). */
|
|
export function mapDiscoveredToCatalog(
|
|
rows: AdobeFireflyDiscoveredModel[]
|
|
): AdobeFireflyCatalogModel[] {
|
|
const out: AdobeFireflyCatalogModel[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
// Prefer friendly aliases when upstream matches known fallback rows.
|
|
for (const fb of ADOBE_FIREFLY_FALLBACK_MODELS) {
|
|
const hit = rows.find(
|
|
(r) =>
|
|
r.modelId === fb.upstreamModelId &&
|
|
r.modelVersion === fb.upstreamModelVersion &&
|
|
(r.modality === fb.modality || r.modality === "unknown")
|
|
);
|
|
if (hit && !seen.has(fb.id)) {
|
|
seen.add(fb.id);
|
|
out.push({
|
|
...fb,
|
|
name: hit.displayName || fb.name,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const r of rows) {
|
|
if (r.modality !== "image" && r.modality !== "video") continue;
|
|
const id = slugifyAdobeModel(r.modelId, r.modelVersion);
|
|
if (seen.has(id)) continue;
|
|
// Skip if already covered by a friendly alias with same upstream
|
|
if (
|
|
out.some(
|
|
(o) =>
|
|
o.upstreamModelId === r.modelId && o.upstreamModelVersion === r.modelVersion
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
seen.add(id);
|
|
out.push({
|
|
id,
|
|
name: r.displayName || id,
|
|
modality: r.modality,
|
|
upstreamModelId: r.modelId,
|
|
upstreamModelVersion: r.modelVersion,
|
|
inputModalities: r.modality === "image" ? ["text", "image"] : ["text"],
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export function getAdobeFireflyFallbackCatalog(modality?: "image" | "video"): AdobeFireflyCatalogModel[] {
|
|
if (!modality) return [...ADOBE_FIREFLY_FALLBACK_MODELS];
|
|
return ADOBE_FIREFLY_FALLBACK_MODELS.filter((m) => m.modality === modality);
|
|
}
|
|
|
|
/**
|
|
* Live discovery when credentials resolve; otherwise static fallback from get_models capture.
|
|
*/
|
|
export async function resolveAdobeFireflyCatalog(opts: {
|
|
credentials?: {
|
|
apiKey?: string;
|
|
accessToken?: string;
|
|
providerSpecificData?: Record<string, unknown> | null;
|
|
} | null;
|
|
modality?: "image" | "video";
|
|
fetchImpl?: typeof fetch;
|
|
}): Promise<{ models: AdobeFireflyCatalogModel[]; source: "api" | "fallback" }> {
|
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
try {
|
|
if (opts.credentials) {
|
|
const token = await resolveAdobeAccessToken(opts.credentials, fetchImpl);
|
|
const discovered = await discoverAdobeFireflyModels(token, fetchImpl);
|
|
let catalog = mapDiscoveredToCatalog(discovered);
|
|
if (opts.modality) catalog = catalog.filter((m) => m.modality === opts.modality);
|
|
if (catalog.length > 0) return { models: catalog, source: "api" };
|
|
}
|
|
} catch {
|
|
// fall through to static catalog
|
|
}
|
|
|
|
return {
|
|
models: getAdobeFireflyFallbackCatalog(opts.modality),
|
|
source: "fallback",
|
|
};
|
|
}
|
|
|
|
/** Registry-shaped models for imageRegistry / videoRegistry. */
|
|
export function toRegistryImageModels(
|
|
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("image")
|
|
): Array<{ id: string; name: string; inputModalities?: string[] }> {
|
|
return models
|
|
.filter((m) => m.modality === "image")
|
|
.map((m) => ({
|
|
id: m.id,
|
|
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
|
|
inputModalities: m.inputModalities || ["text", "image"],
|
|
}));
|
|
}
|
|
|
|
export function toRegistryVideoModels(
|
|
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("video")
|
|
): Array<{ id: string; name: string }> {
|
|
return models
|
|
.filter((m) => m.modality === "video")
|
|
.map((m) => ({
|
|
id: m.id,
|
|
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
|
|
}));
|
|
}
|