diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ebba1936b0..191de70e20 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.", "_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.", "_rebaseline_2026_07_21_7301_universal_cooldown_retry": "PR #7301 (ViFigueiredo, feat/universal-cooldown-retry) own growth, surfaced during rebase-onto-tip reconciliation (fast-gates PR->release do not run check:file-size): open-sse/services/combo.ts 3388->3479 (+91) generalizes the existing quota-share-only cooldown-aware retry (dispatchWithCooldownRetry) to ALL combo strategies (priority/weighted/round-robin/etc), gates it on the model lockout's REAL reason (not a hardcoded \"rate_limit\") via the existing getModelLockoutInfo/resolveComboCooldownWaitDecision chokepoint, and adds a global comboTimeoutMs guard + aggregated per-target error diagnostics on exhaustion. Companion leaves open-sse/services/combo/comboCooldownRetry.ts (+29), combo/autoStrategy.ts (+9, auto-strategy combo-ref guard so a combo cannot recursively reference itself as a candidate), combo/comboSetup.ts (+3), comboConfig.ts (+6) all stay under cap. Irreducible orchestration wiring at the existing dispatch chokepoint (mirrors the quota-share-only precedent this PR generalizes); not extractable without hiding the retry loop. Covered by tests/unit/combo-auto-candidate-expansion.test.ts (+61, combo-ref guard), tests/unit/combo-routing-engine.test.ts (+68, universal retry across strategies + comboTimeoutMs, no-explicit-any clean), tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts (+136, quota_exhausted vs rate_limit reason gating, disabled-flag passthrough). Structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_07_21_7935_vi_locale_residual_ui": "PR #7935 (nguyenha935, fix/vietnamese-locale-residual) own growth: 9 dashboard components gained `useTranslations()` wiring (import + hook call + a handful of `t(\"key\")` call-sites replacing hardcoded English strings) as part of restoring i18n coverage — ComboHealthTab.tsx 1028->1031 (+3), cloud-agents/page.tsx 922->931 (+9), PoolWizard.tsx 1007->1022 (+15), EndpointPageClient.tsx 2612->2615 (+3), health/page.tsx 1091->1095 (+4), ProviderOnboardingWizard.tsx 912->948 (+36, largest — several previously-hardcoded wizard step labels/descriptions), PricingTab.tsx 1012->1017 (+5), ProxyRegistryManager.tsx 1461->1464 (+3), BudgetTab.tsx 1016->1028 (+12). All additions are literal `t(...)`/`tc(...)` call-site swaps for existing UI text, verified byte-identical in intent against the corresponding new `src/i18n/messages/{en,vi}.json` keys (see tests/unit/dashboard-localization-contract.test.ts, tests/unit/i18n-vi-completeness.test.ts, tests/unit/gamification-display-contract.test.ts, tests/unit/cli-catalog-display-contract.test.ts added by the same PR). Fast-gates PR->release do not run check:file-size, so this surfaced only during rebase-onto-tip reconciliation.", @@ -157,6 +158,7 @@ "_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.", "open-sse/services/qoderCli.ts": 989, "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", + "open-sse/config/imageRegistry.ts": 821, "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1813, "open-sse/executors/base.ts": 1540, @@ -187,6 +189,7 @@ "open-sse/mcp-server/tools/advancedTools.ts": 1120, "_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.", "open-sse/services/accountFallback.ts": 1864, + "open-sse/services/adobeFireflyClient.ts": 1958, "open-sse/services/batchProcessor.ts": 915, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 45803f4122..f7b19cc8cd 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -639,6 +639,34 @@ export const IMAGE_PROVIDERS: Record = { models: LMARENA_DIRECT_IMAGE_MODELS, supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], }, + + // Adobe Firefly (unofficial) — IMS access_token (clio-playground-web) or browser + // Cookie from firefly.adobe.com. Async 3P image generate + poll. + // Model list = static fallback from models/discovery capture; live discovery + // refreshes via resolveAdobeFireflyCatalog when credentials work. + "adobe-firefly": { + id: "adobe-firefly", + alias: "firefly", + baseUrl: "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", + authType: "apikey", + authHeader: "bearer", + format: "adobe-firefly-image", + models: [ + { id: "nano-banana-pro", name: "Firefly Gemini 3.0 (Nano Banana Pro)", inputModalities: ["text", "image"] }, + { id: "nano-banana", name: "Firefly Gemini 2.5 (Nano Banana)", inputModalities: ["text", "image"] }, + { id: "nano-banana-2", name: "Firefly Gemini 3.1 (Nano Banana 2)", inputModalities: ["text", "image"] }, + { id: "gpt-image-2", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] }, + { id: "gpt-image", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] }, + { id: "gpt-image-1.5", name: "Firefly GPT Image 1.5", inputModalities: ["text", "image"] }, + { id: "flux-2", name: "Firefly Flux 2", inputModalities: ["text", "image"] }, + { id: "flux-pro", name: "Firefly Flux 1.1 Pro", inputModalities: ["text", "image"] }, + { id: "flux-ultra", name: "Firefly Flux 1.1 Ultra", inputModalities: ["text", "image"] }, + { id: "seedream-4", name: "Firefly Seedream 4.0", inputModalities: ["text", "image"] }, + { id: "seedream-5-lite", name: "Firefly Seedream 5.0 Lite", inputModalities: ["text", "image"] }, + { id: "runway-gen4-image", name: "Firefly Runway Gen-4 Image", inputModalities: ["text", "image"] }, + ], + supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"], + }, }; /** diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 1eb75f7f65..98e84772a2 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -264,6 +264,30 @@ export const VIDEO_PROVIDERS: Record = { format: "xai-video", models: [{ id: "grok-imagine-video", name: "Grok Imagine Video" }], }, + + // Adobe Firefly (unofficial) — same IMS/cookie credential as the image entry. + // Async 3P video generate + poll (Sora 2, Veo 3.1, Kling …). Fallback list + // from models/discovery capture (adobe/get_models.txt). + "adobe-firefly": { + id: "adobe-firefly", + alias: "firefly", + baseUrl: "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async", + authType: "apikey", + authHeader: "bearer", + format: "adobe-firefly-video", + models: [ + { id: "sora-2", name: "Firefly Sora 2" }, + { id: "sora-2-pro", name: "Firefly Sora 2 Pro" }, + { id: "veo-3.1", name: "Firefly Veo 3.1" }, + { id: "veo-3.1-fast", name: "Firefly Veo 3.1 Fast" }, + { id: "veo-3.1-ref", name: "Firefly Veo 3.1 Reference" }, + { id: "kling-3", name: "Firefly Kling v3 Standard I2V" }, + { id: "kling-v3-t2v", name: "Firefly Kling v3 Standard T2V" }, + { id: "kling-v3-pro-i2v", name: "Firefly Kling v3 Pro I2V" }, + { id: "luma-ray3", name: "Firefly Ray3" }, + { id: "runway-gen4-turbo", name: "Firefly Runway Gen-4 Video" }, + ], + }, }; /** diff --git a/open-sse/executors/adobe-firefly.ts b/open-sse/executors/adobe-firefly.ts new file mode 100644 index 0000000000..8b0ec194c3 --- /dev/null +++ b/open-sse/executors/adobe-firefly.ts @@ -0,0 +1,34 @@ +// AdobeFireflyExecutor — chat-completions guard for the adobe-firefly +// web-cookie media provider. +// +// Adobe Firefly is image/video generation only (Firefly 3P async APIs). There +// is no chat/completions surface. Real work lives in: +// open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +// open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +// +// Without this executor, getExecutor("adobe-firefly") would fall through to +// DefaultExecutor and mis-route the user's IMS token / cookie to api.openai.com. + +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult } from "../utils/error.ts"; + +const ADOBE_FIREFLY_BASE_URL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; + +export class AdobeFireflyExecutor extends BaseExecutor { + constructor() { + super("adobe-firefly", { id: "adobe-firefly", baseUrl: ADOBE_FIREFLY_BASE_URL }); + } + + async execute(_input: ExecuteInput) { + return makeExecutorErrorResult( + 400, + "adobe-firefly is a media-generation provider and does not support chat completions. " + + "Use POST /v1/images/generations (e.g. model \"adobe-firefly/nano-banana-pro\") " + + "or POST /v1/videos/generations (e.g. model \"adobe-firefly/sora-2\").", + _input.body, + ADOBE_FIREFLY_BASE_URL + ); + } +} + +export default AdobeFireflyExecutor; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 7c5305e1f3..0218a1eed2 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -38,6 +38,7 @@ import { ClaudeWebWithAutoRefresh } from "./claude-web-with-auto-refresh.ts"; import { CopilotWebExecutor } from "./copilot-web.ts"; import { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; import { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; +import { AdobeFireflyExecutor } from "./adobe-firefly.ts"; import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; import { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; import { FeloWebExecutor } from "./felo-web.ts"; @@ -134,6 +135,8 @@ const executors = { copilot: new CopilotWebExecutor(), // Alias "microsoft-designer-web": new MicrosoftDesignerWebExecutor(), msdesigner: new MicrosoftDesignerWebExecutor(), // Alias + "adobe-firefly": new AdobeFireflyExecutor(), + firefly: new AdobeFireflyExecutor(), // Alias "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias "duckduckgo-web": new DuckDuckGoWebExecutor(), @@ -257,6 +260,7 @@ export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; export { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; +export { AdobeFireflyExecutor } from "./adobe-firefly.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; export { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; export { FeloWebExecutor } from "./felo-web.ts"; diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index c0d3de0ec1..452dd12dcb 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -68,6 +68,7 @@ import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvid import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; +import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; interface KieImageOptions { @@ -498,6 +499,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "adobe-firefly-image") { + return handleAdobeFireflyImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "nanobanana") { return handleNanoBananaImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts new file mode 100644 index 0000000000..309be55838 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -0,0 +1,128 @@ +// Adobe Firefly (unofficial) image-generation handler. +// Family: adobe-firefly-image | Provider: adobe-firefly +// +// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full +// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with +// client_id clio-playground-web (Express projectx_webapp fallback). + +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; +import { + AdobeFireflyError, + adobeFireflyGenerateImage, + resolveAdobeAccessToken, +} from "../../../services/adobeFireflyClient.ts"; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +export async function handleAdobeFireflyImageGeneration({ + model, + provider, + body, + credentials, + log, + fetchImpl = fetch, +}: { + model: string; + provider: string; + providerConfig?: { baseUrl?: string }; + body: { + prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + aspectRatio?: unknown; + quality?: unknown; + seed?: unknown; + negative_prompt?: unknown; + timeout_ms?: unknown; + image?: unknown; + image_url?: unknown; + }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Adobe Firefly image generation", + }); + } + + try { + const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); + const seed = + typeof body.seed === "number" + ? body.seed + : typeof body.seed === "string" && body.seed.trim() + ? Number(body.seed) + : undefined; + + // Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id). + // JWT may be embedded in the same paste as cookies (HAR / multi-line). + const psd = (credentials as { providerSpecificData?: { cookie?: string } })?.providerSpecificData; + const sessionCookie = + (typeof psd?.cookie === "string" && psd.cookie.trim()) || + (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || + (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") + ? credentials.accessToken + : undefined); + + log?.info?.( + "IMAGE", + `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + ); + + const result = await adobeFireflyGenerateImage({ + accessToken, + prompt, + model, + size: body.size, + aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, + quality: body.quality, + seed: Number.isFinite(seed as number) ? (seed as number) : undefined, + negativePrompt: + typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, + sessionCookie, + timeoutMs, + fetchImpl, + log, + }); + + return saveImageSuccessResult({ + provider, + model, + startTime, + images: [{ url: result.url }], + }); + } catch (err) { + if (err instanceof AdobeFireflyError) { + log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: err.status, + startTime, + error: err.message, + }); + } + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`); + return saveImageErrorResult({ + provider, + model, + status: 500, + startTime, + error: errorText, + }); + } +} diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 7de016a261..2df27942a1 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -17,6 +17,7 @@ import { handleDashscopeVideoGeneration } from "./videoGeneration/dashscopeHandl import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"; import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; +import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -147,6 +148,16 @@ export async function handleVideoGeneration({ body, credentials, log }) { if (providerConfig.format === "xai-video") { return handleXaiVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } + if (providerConfig.format === "adobe-firefly-video") { + return handleAdobeFireflyVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } return { success: false, diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts new file mode 100644 index 0000000000..244879af90 --- /dev/null +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -0,0 +1,135 @@ +// Adobe Firefly (unofficial) video-generation handler. +// Family: adobe-firefly-video | Provider: adobe-firefly +// +// Credentials: IMS access_token (JWT) or full Cookie header from +// firefly.adobe.com / new.express.adobe.com. + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { + AdobeFireflyError, + adobeFireflyGenerateVideo, + resolveAdobeAccessToken, +} from "../../services/adobeFireflyClient.ts"; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +export async function handleAdobeFireflyVideoGeneration({ + model, + provider, + body, + credentials, + log, + fetchImpl = fetch, +}: { + model: string; + provider: string; + providerConfig?: { baseUrl?: string }; + body: Record; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return { + success: false, + status: 400, + error: "Prompt is required for Adobe Firefly video generation", + }; + } + + try { + const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000); + const seed = + typeof body.seed === "number" + ? body.seed + : typeof body.seed === "string" && String(body.seed).trim() + ? Number(body.seed) + : undefined; + // Keep raw paste for Cookie + sherlockToken (x-arp-session-id). + const psd = (credentials as { providerSpecificData?: { cookie?: string } })?.providerSpecificData; + const sessionCookie = + (typeof psd?.cookie === "string" && psd.cookie.trim()) || + (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || + (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") + ? credentials.accessToken + : undefined); + + log?.info?.( + "VIDEO", + `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + ); + + const result = await adobeFireflyGenerateVideo({ + accessToken, + prompt, + model, + size: body.size, + aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.ratio ?? body.size, + duration: body.duration ?? body.durationSeconds, + quality: body.quality, + resolution: body.resolution ?? body.quality, + seed: Number.isFinite(seed as number) ? (seed as number) : undefined, + negativePrompt: + typeof body.negative_prompt === "string" + ? body.negative_prompt + : typeof body.negativePrompt === "string" + ? body.negativePrompt + : undefined, + generateAudio: body.generate_audio !== false && body.generateAudio !== false, + sessionCookie, + timeoutMs, + fetchImpl, + log, + }); + + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: result.url, format: result.format || "mp4" }], + }, + }; + } catch (err) { + if (err instanceof AdobeFireflyError) { + log?.error?.("VIDEO", `${provider} adobe-firefly error ${err.status}: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: err.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message.slice(0, 500), + }).catch(() => {}); + return { success: false, status: err.status, error: err.message }; + } + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("VIDEO", `${provider} adobe-firefly exception: ${errorText}`); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 500, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: 500, error: errorText }; + } +} diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts new file mode 100644 index 0000000000..249c58700d --- /dev/null +++ b/open-sse/services/adobeFireflyClient.ts @@ -0,0 +1,1957 @@ +/** + * Adobe Firefly (unofficial) media client. + * + * Talks to the same Firefly 3P async APIs that firefly.adobe.com uses (live browser + * captures in repo `adobe/`): + * POST https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async + * POST https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async + * POST https://firefly-3p.ff.adobe.io/v2/models/discovery + * GET https://firefly.adobe.io/v1/credits/balance + * then polls BKS job result URLs rewritten from links.result. + * + * Auth is an Adobe IMS access token (Bearer, client_id = clio-playground-web). + * Callers may pass either: + * - a raw IMS access_token JWT (from Authorization: Bearer on Firefly), or + * - a browser Cookie header from firefly.adobe.com (exchanged via IMS check/v6/token + * with client_id clio-playground-web; Express projectx_webapp as fallback). + * + * x-api-key on generate/discovery MUST match the token's IMS client + * (`clio-playground-web`). Mismatch → HTTP 401 invalid token. + * + * Unofficial — tokens/cookies are short-lived; Adobe may change the wire contract. + */ + +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = + "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; +export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL = + "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"; +export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = + "https://firefly-3p.ff.adobe.io/v2/storage/image"; +export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL = + "https://firefly-3p.ff.adobe.io/v2/models/discovery"; +export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = + "https://firefly.adobe.io/v1/credits/balance"; +export const ADOBE_FIREFLY_IMS_REFRESH_URL = + "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"; +/** Scope set observed on live firefly.adobe.com IMS access tokens. */ +export const ADOBE_FIREFLY_IMS_SCOPE = + "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext," + + "additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations," + + "additional_info.roles,account_cluster.read,creative_production,tk_platform," + + "tk_platform_sync,profile"; + +const DEFAULT_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +const DEFAULT_SEC_CH_UA = + '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"'; +const DEFAULT_POLL_INTERVAL_MS = 3000; +const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; +const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; +const FIREFLY_ORIGIN = "https://firefly.adobe.com"; +const FIREFLY_REFERER = "https://firefly.adobe.com/"; + +export type AdobeFireflyImageModelId = + | "nano-banana-pro" + | "nano-banana" + | "nano-banana-2" + | "gpt-image" + | "gpt-image-2" + | "gpt-image-1.5" + | "flux-2" + | "flux-pro" + | "flux-ultra" + | "seedream-4" + | "seedream-5-lite" + | "runway-gen4-image"; + +export type AdobeFireflyVideoModelId = + | "sora-2" + | "sora-2-pro" + | "veo-3.1" + | "veo-3.1-fast" + | "veo-3.1-ref" + | "kling-3"; + +export interface AdobeFireflyImageModelSpec { + upstreamModelId: string; + upstreamModelVersion: string; + /** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */ + family: "nano" | "gpt-image" | "generic"; +} + +export interface AdobeFireflyVideoModelSpec { + engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3"; + upstreamModel: string; + modelId?: string; + modelVersion?: string; + referenceMode?: "frame" | "image"; + defaultDuration: number; + defaultResolution: string; +} + +/** + * Upstream modelId/modelVersion pairs from firefly-3p models/discovery + * (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here. + */ +export const ADOBE_FIREFLY_IMAGE_MODELS: Record = + { + // Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2 + "nano-banana-pro": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-2", + family: "nano", + }, + // Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana + "nano-banana": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana", + family: "nano", + }, + // Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3 + "nano-banana-2": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-3", + family: "nano", + }, + // GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2") + "gpt-image": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + // Explicit catalog alias so pickers show "gpt-image-2" distinctly + "gpt-image-2": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + "gpt-image-1.5": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "1.5", + family: "gpt-image", + }, + "flux-2": { + upstreamModelId: "flux", + upstreamModelVersion: "2", + family: "generic", + }, + "flux-pro": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxPro", + family: "generic", + }, + "flux-ultra": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxUltra", + family: "generic", + }, + "seedream-4": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v4", + family: "generic", + }, + "seedream-5-lite": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v5_lite", + family: "generic", + }, + "runway-gen4-image": { + upstreamModelId: "runway-gen4-image", + upstreamModelVersion: "gen4_image", + family: "generic", + }, + }; + +export const ADOBE_FIREFLY_VIDEO_MODELS: Record = + { + "sora-2": { + engine: "sora2", + upstreamModel: "openai:firefly:colligo:sora2", + defaultDuration: 8, + defaultResolution: "720p", + }, + "sora-2-pro": { + engine: "sora2-pro", + upstreamModel: "openai:firefly:colligo:sora2-pro", + defaultDuration: 8, + defaultResolution: "720p", + }, + "veo-3.1": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-fast": { + engine: "veo31-fast", + upstreamModel: "google:firefly:colligo:veo31-fast", + modelId: "veo", + modelVersion: "3.1-fast-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-ref": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + referenceMode: "image", + defaultDuration: 6, + defaultResolution: "720p", + }, + "kling-3": { + engine: "kling3", + upstreamModel: "kling:firefly:colligo:kling3", + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + defaultDuration: 5, + defaultResolution: "1080p", + }, + }; + +const NANO_SIZE_MAP: Record> = { + "1K": { + "1:1": { width: 1024, height: 1024 }, + "16:9": { width: 1360, height: 768 }, + "9:16": { width: 768, height: 1360 }, + "4:3": { width: 1152, height: 864 }, + "3:4": { width: 864, height: 1152 }, + "1:8": { width: 384, height: 3072 }, + "1:4": { width: 512, height: 2048 }, + "4:1": { width: 2048, height: 512 }, + "8:1": { width: 3072, height: 384 }, + }, + "2K": { + "1:1": { width: 2048, height: 2048 }, + "16:9": { width: 2752, height: 1536 }, + "9:16": { width: 1536, height: 2752 }, + "4:3": { width: 2048, height: 1536 }, + "3:4": { width: 1536, height: 2048 }, + "1:8": { width: 768, height: 6144 }, + "1:4": { width: 1024, height: 4096 }, + "4:1": { width: 4096, height: 1024 }, + "8:1": { width: 6144, height: 768 }, + }, + "4K": { + "1:1": { width: 4096, height: 4096 }, + "16:9": { width: 5504, height: 3072 }, + "9:16": { width: 3072, height: 5504 }, + "4:3": { width: 4096, height: 3072 }, + "3:4": { width: 3072, height: 4096 }, + "1:8": { width: 1536, height: 12288 }, + "1:4": { width: 2048, height: 8192 }, + "4:1": { width: 8192, height: 2048 }, + "8:1": { width: 12288, height: 1536 }, + }, +}; + +const GPT_SIZE_MAP: Record> = { + "1K": { + "1:1": { width: 1024, height: 1024 }, + "5:4": { width: 1120, height: 896 }, + "9:16": { width: 720, height: 1280 }, + "21:9": { width: 1456, height: 624 }, + "16:9": { width: 1280, height: 720 }, + "4:3": { width: 1152, height: 864 }, + "3:2": { width: 1248, height: 832 }, + "4:5": { width: 896, height: 1120 }, + "3:4": { width: 864, height: 1152 }, + "2:3": { width: 832, height: 1248 }, + }, + "2K": { + "1:1": { width: 2048, height: 2048 }, + "5:4": { width: 2240, height: 1792 }, + "9:16": { width: 1440, height: 2560 }, + "21:9": { width: 3024, height: 1296 }, + "16:9": { width: 2560, height: 1440 }, + "4:3": { width: 2304, height: 1728 }, + "3:2": { width: 2496, height: 1664 }, + "4:5": { width: 1792, height: 2240 }, + "3:4": { width: 1728, height: 2304 }, + "2:3": { width: 1664, height: 2496 }, + }, + "4K": { + "1:1": { width: 2880, height: 2880 }, + "5:4": { width: 3200, height: 2560 }, + "9:16": { width: 2160, height: 3840 }, + "21:9": { width: 3696, height: 1584 }, + "16:9": { width: 3840, height: 2160 }, + "4:3": { width: 3264, height: 2448 }, + "3:2": { width: 3504, height: 2336 }, + "4:5": { width: 2560, height: 3200 }, + "3:4": { width: 2448, height: 3264 }, + "2:3": { width: 2336, height: 3504 }, + }, +}; + +const PIXEL_SIZE_TO_RATIO: Record = { + "1024x1024": "1:1", + "1536x1536": "1:1", + "2048x2048": "1:1", + "1024x1792": "9:16", + "1536x2752": "9:16", + "1792x1024": "16:9", + "2752x1536": "16:9", + "2048x1536": "4:3", + "1536x2048": "3:4", + "1280x720": "16:9", + "720x1280": "9:16", + "1920x1080": "16:9", + "1080x1920": "9:16", +}; + +export class AdobeFireflyError extends Error { + status: number; + code?: string; + + constructor(message: string, status = 502, code?: string) { + super(message); + this.name = "AdobeFireflyError"; + this.status = status; + this.code = code; + } +} + +/** Public x-api-key + primary IMS client_id for firefly.adobe.com (`clio-playground-web`). */ +export function adobeFireflyApiKey(): string { + return resolvePublicCred("adobe_firefly_api_key", "ADOBE_FIREFLY_API_KEY"); +} + +/** Express IMS client_id fallback for cookie exchange (`projectx_webapp`). */ +export function adobeFireflyExpressClientId(): string { + return resolvePublicCred("adobe_firefly_express_client_id", "ADOBE_FIREFLY_EXPRESS_CLIENT_ID"); +} + +/** Public x-api-key for GET firefly.adobe.io/v1/credits/balance (`SunbreakWebUI1`). */ +export function adobeFireflyBalanceApiKey(): string { + return resolvePublicCred("adobe_firefly_balance_api_key", "ADOBE_FIREFLY_BALANCE_API_KEY"); +} + +/** Decode IMS JWT payload (no signature verification — client-side claim read only). */ +export function decodeAdobeJwtPayload(token: string): Record | null { + try { + // Do not call extractAdobeCredentialToken here (would recurse via guest checks). + let raw = String(token || "").trim().replace(/^bearer\s+/i, "").trim(); + // If a blob was passed, take the first JWT-shaped segment. + const m = raw.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); + if (m) raw = m[0]; + const part = raw.split(".")[1]; + if (!part) return null; + const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const obj = JSON.parse(json); + return obj && typeof obj === "object" ? (obj as Record) : null; + } catch { + return null; + } +} + +/** AdobeID subject for x-account-id on balance / account_cluster calls. */ +export function extractAdobeAccountIdFromToken(token: string): string { + const payload = decodeAdobeJwtPayload(token); + if (!payload) return ""; + const candidates = [payload.user_id, payload.aa_id, payload.sub, payload.id]; + for (const c of candidates) { + if (typeof c === "string" && c.includes("@")) return c.trim(); + } + for (const c of candidates) { + if (typeof c === "string" && c.trim()) return c.trim(); + } + return ""; +} + +export function looksLikeAdobeJwt(value: string): boolean { + const raw = value.trim(); + if (!raw) return false; + // Avoid treating cookie blobs that happen to have two dots as JWT. + if (raw.includes(";") || (raw.includes("=") && !raw.startsWith("eyJ"))) return false; + // Allow a single space after optional Bearer prefix (stripped earlier). + if (/\s/.test(raw) && !/^bearer\s+/i.test(raw)) return false; + const token = raw.replace(/^bearer\s+/i, "").trim(); + const parts = token.split("."); + if (parts.length !== 3) return false; + // Adobe IMS access tokens are sizable; reject tiny accidental 3-segment strings. + if (token.length < 80) return false; + return parts.every((p) => p.length > 0 && /^[A-Za-z0-9_-]+$/.test(p)); +} + +/** + * True when IMS issued a guest token (no signed-in AdobeID). + * Live repro: firefly.adobe.com page cookies alone → account_type=guest → generate 401 / + * balance 403 ErrMismatchOauthToken. + */ +export function isAdobeGuestAccessToken(token: string): boolean { + const payload = decodeAdobeJwtPayload(token); + if (!payload) return false; + const userId = typeof payload.user_id === "string" ? payload.user_id : ""; + const aaId = typeof payload.aa_id === "string" ? payload.aa_id : ""; + const type = typeof payload.type === "string" ? payload.type.toLowerCase() : ""; + // Authenticated Firefly tokens always carry an @AdobeID (or similar) subject. + if (userId.includes("@AdobeID") || aaId.includes("@AdobeID")) return false; + if (userId.includes("@GuestID") || aaId.includes("@GuestID")) return true; + if (type === "guest" || type.includes("guest")) return true; + // Guest tokens from ims/check often omit type/user_id entirely. + if (!userId && !aaId) return true; + return false; +} + +export function isAdobeUserAccessToken(token: string): boolean { + return looksLikeAdobeJwt(token) && !isAdobeGuestAccessToken(token); +} + +/** + * Pull an IMS JWT out of free-form paste: raw JWT, Bearer …, access_token=…, + * IMS sessionStorage JSON (`tokenValue`), multi-line Network/HAR dumps. + * Prefer the longest user (non-guest) eyJ… JWT found. + */ +export function extractAdobeCredentialToken(raw: string): string { + const value = String(raw || "").trim(); + if (!value) return ""; + + if (/^bearer\s+/i.test(value)) { + const bare = value.replace(/^bearer\s+/i, "").trim().split(/\s+/)[0] || ""; + if (looksLikeAdobeJwt(bare)) return bare; + } + + // access_token=... in cookie-ish or form paste + const accessMatch = value.match(/(?:^|[;\s&])access_token=([^;\s&]+)/i); + if (accessMatch?.[1]) { + const t = decodeURIComponent(accessMatch[1].trim()); + if (looksLikeAdobeJwt(t)) return t; + } + + // IMS sessionStorage / localStorage JSON: "tokenValue":"eyJ..." + const tokenValueMatch = value.match(/"tokenValue"\s*:\s*"(eyJ[^"]+)"/i); + if (tokenValueMatch?.[1] && looksLikeAdobeJwt(tokenValueMatch[1])) { + return tokenValueMatch[1]; + } + + // Authorization: Bearer eyJ... + const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i); + if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; + + // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. + const jwtMatches = value.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g); + if (jwtMatches && jwtMatches.length > 0) { + const sorted = [...jwtMatches].sort((a, b) => b.length - a.length); + const user = sorted.find((t) => looksLikeAdobeJwt(t) && isAdobeUserAccessToken(t)); + if (user) return user; + const best = sorted[0]; + if (looksLikeAdobeJwt(best)) return best; + } + + // Pure JWT + if (looksLikeAdobeJwt(value)) return value.replace(/^bearer\s+/i, "").trim(); + + // Cookie / other blob unchanged for IMS exchange + return value; +} + +/** + * True when the paste still looks like a Cookie header (not a bare JWT). + * Used to attach Cookie + sherlockToken → x-arp-session-id on generate. + */ +export function looksLikeAdobeCookieBlob(value: string): boolean { + const raw = String(value || "").trim(); + if (!raw || looksLikeAdobeJwt(raw)) return false; + if (raw.includes(";") && raw.includes("=")) return true; + if (/(?:^|[;\s])(?:aux_sid|ff_session|sherlockToken|forterToken|arkose)=/i.test(raw)) { + return true; + } + return false; +} + +/** + * Strip JWTs / Authorization lines from a mixed paste so only Cookie pairs remain. + * Undici Headers.append rejects multi-line Cookie values (throws Headers.append: "eyJ…"). + */ +export function extractAdobeCookieHeader(raw: string): string { + const value = String(raw || "").trim(); + if (!value) return ""; + if (looksLikeAdobeJwt(value)) return ""; + + // Drop pure JWT lines and Authorization: Bearer lines + const cleaned = value + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter((line) => { + if (!line) return false; + if (/^authorization\s*:/i.test(line)) return false; + if (/^bearer\s+/i.test(line)) return false; + if (looksLikeAdobeJwt(line)) return false; + // Drop standalone eyJ… segments + if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(line)) return false; + return true; + }) + .join("; "); + + // Also strip inline eyJ JWT tokens that may sit inside a cookie string + const noJwt = cleaned + .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "") + .replace(/;\s*;/g, ";") + .replace(/^;\s*|\s*;$/g, "") + .trim(); + + if (!noJwt || !looksLikeAdobeCookieBlob(noJwt)) return ""; + // Final safety: Cookie header must be single-line + return noJwt.replace(/[\r\n]+/g, "; ").trim(); +} + +const GUEST_COOKIE_HELP = + "Firefly page cookies alone only mint a GUEST IMS token (no AdobeID) — generate returns 401 and Limits 403. " + + "Fix: open firefly.adobe.com signed-in → F12 → Network → click a request to firefly-3p.ff.adobe.io " + + "(generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' " + + "(starts with eyJ…). Paste that JWT as the credential. " + + "Cookie-only works only if you also export IMS session cookies from adobelogin.com / adobeid-na1 " + + "(Cookie-Editor → export all Adobe domains); firefly.adobe.com cookies by themselves are not enough."; + +export function normalizeAdobeAspectRatio(sizeOrRatio: unknown, fallback = "1:1"): string { + if (typeof sizeOrRatio !== "string" || !sizeOrRatio.trim()) return fallback; + let raw = sizeOrRatio.trim().replace(/_/g, ":"); + if (raw.toLowerCase() === "auto") return fallback; + + if (/^\d+:\d+$/.test(raw)) return raw; + + // Short ratio forms like 16x9 / 9x16 + const short = raw.match(/^(\d+)x(\d+)$/i); + if (short) { + const a = Number(short[1]); + const b = Number(short[2]); + if (a > 0 && b > 0 && a < 100 && b < 100) return `${a}:${b}`; + } + + const lower = raw.toLowerCase(); + if (PIXEL_SIZE_TO_RATIO[lower]) return PIXEL_SIZE_TO_RATIO[lower]; + + // Generic WxH pixel sizes → closest common ratio + const pixel = lower.match(/^(\d+)x(\d+)$/); + if (pixel) { + const w = Number(pixel[1]); + const h = Number(pixel[2]); + if (w > 0 && h > 0) { + if (Math.abs(w - h) / Math.max(w, h) < 0.08) return "1:1"; + if (w > h * 1.5) return "16:9"; + if (h > w * 1.5) return "9:16"; + if (w > h) return "4:3"; + return "3:4"; + } + } + + return fallback; +} + +export function normalizeAdobeOutputResolution(quality: unknown, size: unknown): "1K" | "2K" | "4K" { + const q = String(quality ?? "").trim().toLowerCase(); + if (q === "4k" || q === "ultra" || q === "high") return "4K"; + if (q === "2k" || q === "hd" || q === "standard" || q === "medium") return "2K"; + if (q === "1k" || q === "low") return "1K"; + + const s = String(size ?? "").toLowerCase(); + if (s.includes("4k") || /4096|5504|3840/.test(s)) return "4K"; + if (s.includes("1k") || /1024x1024|768x1360|1360x768/.test(s)) return "1K"; + return "2K"; +} + +export function resolveAdobeImageModel(model: string): { + id: AdobeFireflyImageModelId; + spec: AdobeFireflyImageModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + // Accept long catalog ids like firefly-nano-banana-pro-2k-16x9 + if (raw.includes("nano-banana2") || raw.includes("nano-banana-2") || raw.includes("nano-banana-3")) { + return { id: "nano-banana-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"] }; + } + if (raw.includes("nano-banana-pro")) { + return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; + } + if (raw.includes("nano-banana")) { + return { id: "nano-banana", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"] }; + } + if (raw.includes("gpt-image-1.5") || raw.includes("gpt-image1.5")) { + return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; + } + // Prefer explicit "2" / "gpt-image-2" before generic gpt-image + if ( + raw === "gpt-image-2" || + raw.includes("gpt-image-2") || + raw.includes("gptimage2") || + raw === "gpt-image" || + raw.includes("gpt-image") + ) { + // Bare gpt-image and gpt-image-2 both map to upstream version "2" (GPT Image 2). + if (raw.includes("1.5")) { + return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; + } + const id = raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image"; + return { id: id as AdobeFireflyImageModelId, spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"] }; + } + if (raw.includes("flux-ultra") || raw.includes("fluxultra")) { + return { id: "flux-ultra", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-ultra"] }; + } + if (raw.includes("flux-pro") || raw.includes("fluxpro")) { + return { id: "flux-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-pro"] }; + } + if (raw.includes("flux")) { + return { id: "flux-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-2"] }; + } + if (raw.includes("seedream-5") || raw.includes("seedream_v5")) { + return { id: "seedream-5-lite", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"] }; + } + if (raw.includes("seedream")) { + return { id: "seedream-4", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-4"] }; + } + if (raw.includes("runway") && raw.includes("image")) { + return { id: "runway-gen4-image", spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"] }; + } + + if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { + const id = raw as AdobeFireflyImageModelId; + return { id, spec: ADOBE_FIREFLY_IMAGE_MODELS[id] }; + } + + // Default to Nano Banana Pro (most common Firefly image path). + return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; +} + +export function resolveAdobeVideoModel(model: string): { + id: AdobeFireflyVideoModelId; + spec: AdobeFireflyVideoModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + if (raw.includes("sora2-pro") || raw.includes("sora-2-pro") || raw.includes("sora2_pro")) { + return { id: "sora-2-pro", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2-pro"] }; + } + if (raw.includes("sora2") || raw.includes("sora-2") || raw.includes("sora")) { + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; + } + if (raw.includes("veo31-ref") || raw.includes("veo-3.1-ref") || raw.includes("veo31_ref")) { + return { id: "veo-3.1-ref", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"] }; + } + if (raw.includes("veo31-fast") || raw.includes("veo-3.1-fast") || raw.includes("veo31_fast")) { + return { id: "veo-3.1-fast", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"] }; + } + if (raw.includes("veo31") || raw.includes("veo-3.1") || raw.includes("veo")) { + return { id: "veo-3.1", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"] }; + } + if (raw.includes("kling")) { + return { id: "kling-3", spec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"] }; + } + + if (raw in ADOBE_FIREFLY_VIDEO_MODELS) { + const id = raw as AdobeFireflyVideoModelId; + return { id, spec: ADOBE_FIREFLY_VIDEO_MODELS[id] }; + } + + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; +} + +function gptDetailLevel(quality: unknown): number { + // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). + const q = String(quality ?? "medium").trim().toLowerCase(); + if (q === "high" || q === "4k" || q === "ultra") return 5; + if (q === "low" || q === "1k") return 1; + if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3; + return 3; +} + +export function buildAdobeImagePayload(opts: { + prompt: string; + aspectRatio: string; + outputResolution: "1K" | "2K" | "4K"; + modelSpec: AdobeFireflyImageModelSpec; + quality?: unknown; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; +}): Record { + const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1"; + const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)]; + const negative = String(opts.negativePrompt || "").trim(); + const genSettings: Record = {}; + if (negative) { + genSettings.avoidKeywords = negative + .replace(/;/g, ",") + .split(",") + .map((w) => w.trim()) + .filter(Boolean); + } + + if (opts.modelSpec.family === "gpt-image") { + // Live firefly.adobe.com body (adobe/image_generate.txt) — no top-level size / + // outputResolution; modelSpecificPayload.size is "auto". + const payload: Record = { + n: 1, + seeds, + output: { storeInputs: true }, + prompt: opts.prompt, + referenceBlobs: [], + modelSpecificPayload: { size: "auto" }, + modelId: opts.modelSpec.upstreamModelId, + modelVersion: opts.modelSpec.upstreamModelVersion, + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationSettings: { + detailLevel: gptDetailLevel(opts.quality), + ...genSettings, + }, + }; + if (opts.sourceImageIds?.length) { + payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ id, usage: "subject" })); + payload.modelSpecificPayload = {}; + } + return payload; + } + + // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape + const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"]; + const pixel = sizeMap[ratio] || sizeMap["1:1"]; + const payload: Record = { + modelId: opts.modelSpec.upstreamModelId, + modelVersion: opts.modelSpec.upstreamModelVersion, + n: 1, + prompt: opts.prompt, + size: pixel, + seeds, + groundSearch: false, + skipCai: false, + output: { storeInputs: true }, + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + modelSpecificPayload: { + parameters: { addWatermark: false }, + aspectRatio: ratio, + }, + referenceBlobs: [], + }; + if (Object.keys(genSettings).length) payload.generationSettings = genSettings; + + if (opts.sourceImageIds?.length) { + payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ id, usage: "general" })); + } + return payload; +} + +function videoSize(aspectRatio: string, resolution: string): { width: number; height: number } { + const res = String(resolution || "720p").toLowerCase(); + const short = res.includes("1080") ? 1080 : res.includes("480") ? 480 : 720; + const ratio = aspectRatio === "9:16" ? "9:16" : aspectRatio === "1:1" ? "1:1" : "16:9"; + if (ratio === "1:1") return { width: short, height: short }; + if (ratio === "9:16") return { width: Math.round((short * 9) / 16), height: short }; + return { width: Math.round((short * 16) / 9), height: short }; +} + +export function buildAdobeVideoPayload(opts: { + prompt: string; + aspectRatio: string; + duration: number; + modelSpec: AdobeFireflyVideoModelSpec; + resolution?: string; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; + generateAudio?: boolean; +}): Record { + const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); + const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; + const duration = Math.max(1, Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration))); + const resolution = opts.resolution || opts.modelSpec.defaultResolution; + const vidSize = videoSize(aspect, resolution); + const engine = opts.modelSpec.engine; + const sourceImageIds = opts.sourceImageIds || []; + const negative = String(opts.negativePrompt || ""); + + if (engine === "veo31-standard" || engine === "veo31-fast") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "veo", + modelVersion: + opts.modelSpec.modelVersion || + (engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"), + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generateAudio: opts.generateAudio !== false, + referenceBlobs: [] as Array>, + generationMetadata: { module: "text2video" }, + modelSpecificPayload: { + parameters: { + durationSeconds: duration, + aspectRatio: aspect, + addWaterMark: false, + }, + }, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + if (opts.modelSpec.referenceMode === "image") { + for (const imageId of sourceImageIds.slice(0, 3)) { + refs.push({ id: String(imageId), usage: "asset" }); + } + } else { + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "general", order: idx + 1 }); + }); + } + payload.generationMetadata = { module: "image2video" }; + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + if (engine === "kling3") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generationMetadata: { + module: sourceImageIds.length ? "image2video" : "text2video", + }, + duration, + generationSettings: { aspectRatio: aspect }, + referenceBlobs: [] as Array>, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "frame", order: idx + 1 }); + }); + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + // Sora 2 / Sora 2 Pro + const promptJson = JSON.stringify({ + prompt: opts.prompt, + duration, + ...(negative ? { negative_prompt: negative } : {}), + }); + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "sora", + modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2", + size: vidSize, + duration, + fps: 24, + prompt: promptJson, + generationMetadata: { module: sourceImageIds.length ? "image2video" : "text2video" }, + model: opts.modelSpec.upstreamModel, + generateLoop: false, + transparentBackground: false, + seed: String(seedVal), + locale: "en-US", + camera: { angle: "none", shotSize: "none", motion: null, promptStyle: null }, + negativePrompt: negative, + jobMode: "standard", + debugGenerationEndpoint: "", + referenceBlobs: [] as Array>, + referenceFrames: [] as Array | null>, + referenceVideo: null, + cameraMotionReferenceVideo: null, + characterReference: null, + editReferenceVideo: null, + output: { storeInputs: true }, + }; + if (sourceImageIds.length) { + const firstId = String(sourceImageIds[0]); + payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }]; + const frames: Array | null> = [{ localBlobRef: firstId }, null]; + if (sourceImageIds.length > 1) { + const lastId = String(sourceImageIds[1]); + (payload.referenceBlobs as Array>).push({ + id: lastId, + usage: "general", + promptReference: 2, + }); + frames[1] = { localBlobRef: lastId }; + } + payload.referenceFrames = frames; + } + return payload; +} + +function browserHeaders(): Record { + return { + "user-agent": DEFAULT_USER_AGENT, + origin: FIREFLY_ORIGIN, + referer: FIREFLY_REFERER, + "accept-language": "en-US,en;q=0.9", + "sec-ch-ua": DEFAULT_SEC_CH_UA, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }; +} + +/** Random 64-char hex fallback when token/prompt are missing for deterministic nonce. */ +export function generateAdobeNonce(): string { + const bytes = new Uint8Array(32); + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + } + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Deterministic x-nonce used by working open-source Firefly clients + * (adobe2api / GPT2Image-Pro / image2api): + * sha256(`${user_id}-${prompt.slice(0, 256)}`) + * + * Random nonces (browser-looking) still get colligo 408 on many accounts when + * the request is not from the SPA. Deterministic nonce is what unblocks generate. + */ +export function buildAdobeSubmitNonce(accessToken: string, prompt: string): string { + const userId = extractAdobeAccountIdFromToken(accessToken); + const promptPrefix = String(prompt || "").slice(0, 256); + if (!userId || !promptPrefix) return ""; + return createHash("sha256").update(`${userId}-${promptPrefix}`, "utf8").digest("hex"); +} + +/** + * Synthesize x-arp-session-id when no sherlockToken cookie is available. + * Shape matches adobe2api / GPT2Image-Pro: base64(JSON({sid, ftr})). + * Working clients ALWAYS send this header on generate-async. + */ +export function buildAdobeArpSessionId(): string { + const nowMs = Date.now(); + const rand = randomBytes(16).toString("hex"); + const sid = randomUUID(); + const pid = typeof process !== "undefined" && process.pid ? process.pid : 0; + // Magic suffix is part of the wire contract reverse-engineered by adobe2api. + const ftr = `${rand}_${nowMs}_${pid}_dUAL43-mnts-ants-d4_31ck__tt`; + const raw = JSON.stringify({ sid, ftr }); + return Buffer.from(raw, "utf-8").toString("base64"); +} + +/** + * Pull sherlockToken / x-arp-session-id from a Cookie header if present. + * Browser generate sends Cookie.sherlockToken as x-arp-session-id. + */ +export function extractAdobeArpSessionId(cookieOrBlob: string): string { + const raw = String(cookieOrBlob || ""); + const m = raw.match(/(?:^|[;\s])sherlockToken=([^;]+)/i); + if (m?.[1]) return decodeURIComponent(m[1].trim()); + const m2 = raw.match(/(?:^|[;\s])x-arp-session-id=([^;]+)/i); + if (m2?.[1]) return decodeURIComponent(m2[1].trim()); + return ""; +} + +export function buildAdobeSubmitHeaders( + accessToken: string, + extras?: { + arpSessionId?: string; + nonce?: string; + cookie?: string; + /** Required for deterministic x-nonce (sha256 user_id+prompt). */ + prompt?: string; + } +): Record { + // Live capture + working open-source clients (GPT2Image-Pro / adobe2api): + // Authorization + x-api-key + deterministic x-nonce + ALWAYS x-arp-session-id. + // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin / soft 408). + void extras?.cookie; + const deterministic = + extras?.nonce || + (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || + generateAdobeNonce(); + // Prefer pasted sherlockToken; otherwise mint a synthetic ARP session (required). + const arp = + (extras?.arpSessionId && String(extras.arpSessionId).trim()) || buildAdobeArpSessionId(); + const headers: Record = { + ...browserHeaders(), + Authorization: `Bearer ${accessToken}`, + // Must be clio-playground-web — same client_id that minted the IMS token. + "x-api-key": adobeFireflyApiKey(), + "content-type": "application/json", + accept: "*/*", + "cache-control": "no-cache", + pragma: "no-cache", + priority: "u=1, i", + "x-nonce": deterministic, + "x-arp-session-id": arp, + }; + return headers; +} + +/** Transient Adobe 3P overload / rate / edge errors worth retrying. */ +export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean { + if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { + return true; + } + const t = (bodyText || "").toLowerCase(); + return ( + t.includes("timeout_error") || + t.includes("system under load") || + t.includes("try again") || + t.includes("temporarily") || + t.includes("overloaded") + ); +} + +export function buildAdobePollHeaders(accessToken: string): Record { + // Live adobe/status_check.txt: Bearer + accept only (no x-api-key, no Cookie). + return { + Authorization: `Bearer ${accessToken}`, + accept: "*/*", + "cache-control": "no-cache", + pragma: "no-cache", + "user-agent": DEFAULT_USER_AGENT, + referer: FIREFLY_REFERER, + }; +} + +export function buildAdobeBalanceHeaders(accessToken: string): Record { + const accountId = extractAdobeAccountIdFromToken(accessToken); + const headers: Record = { + ...browserHeaders(), + Authorization: `Bearer ${accessToken}`, + accept: "application/json", + "content-type": "application/json", + "x-api-key": adobeFireflyBalanceApiKey(), + }; + if (accountId) headers["x-account-id"] = accountId; + return headers; +} + +export function buildAdobeDiscoveryHeaders(accessToken: string): Record { + return { + ...browserHeaders(), + Authorization: `Bearer ${accessToken}`, + "x-api-key": adobeFireflyApiKey(), + "content-type": "application/json", + // Missing Accept → HTTP 406 "Unsupported Accept Type or not allowed". + accept: "*/*", + }; +} + +/** User-facing message when Adobe colligo returns 408 "system under load". */ +export function formatAdobeSystemUnderLoadError(kind: "image" | "video", attempts: number): string { + return ( + `Adobe Firefly ${kind} generation is currently unavailable (HTTP 408 "system under load", ` + + `${attempts} attempt${attempts === 1 ? "" : "s"}). This is Adobe-side capacity/rate limiting ` + + `— not an invalid token (credits/models may still work). Wait 1–2 minutes and retry, or paste a ` + + `fresh IMS JWT from a browser request that just succeeded: firefly.adobe.com → F12 → Network → ` + + `firefly-3p generate-async → Authorization → copy token after "Bearer ".` + ); +} + +export function extractAdobeResultLink( + headers: Headers | Record, + body: unknown +): string { + const get = (name: string): string => { + if (typeof (headers as Headers).get === "function") { + return String((headers as Headers).get(name) || "").trim(); + } + const rec = headers as Record; + const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase()); + return String((key ? rec[key] : "") || "").trim(); + }; + + const override = get("x-override-status-link"); + if (override) return override; + + const data = body && typeof body === "object" ? (body as Record) : {}; + const links = data.links && typeof data.links === "object" ? (data.links as Record) : {}; + const result = links.result; + if (typeof result === "string" && result) return result; + if (result && typeof result === "object") { + const href = (result as Record).href; + if (typeof href === "string" && href) return href; + } + if (typeof data.statusUrl === "string" && data.statusUrl) return data.statusUrl; + if (typeof data.resultUrl === "string" && data.resultUrl) return data.resultUrl; + return ""; +} + +/** + * Rewrite Firefly EPO result links to the BKS poll endpoint used by the SPA. + * + * Live capture (adobe/status_check.txt): + * links.result = https://firefly-epo855232.adobe.io/jobs/result/{jobId} + * poll URL = https://bks-epo8552.adobe.io/v2/jobs/result/{jobId}?host=firefly-epo855232.adobe.io + * + * BKS host uses the first 4 digits of the EPO id when the id is longer (855232 → 8552). + */ +export function normalizeAdobePollUrl(rawUrl: string): string { + const url = String(rawUrl || "").trim(); + if (!url) return url; + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + if (!host.startsWith("firefly-epo")) return url; + + const path = parsed.pathname || ""; + const isJobPath = + path.includes("/jobs/result/") || + path.includes("/v2/status") || + path.includes("/status/"); + if (!isJobPath) return url; + + const jobId = path.split("/").filter(Boolean).pop() || ""; + if (!jobId || jobId === "status" || jobId === "result") return url; + + const epoId = host.slice("firefly-epo".length).split(".")[0] || ""; + // 855232 → 8552 (browser BKS host); short ids kept as-is. + const bksId = epoId.length > 4 ? epoId.slice(0, 4) : epoId; + return `https://bks-epo${bksId}.adobe.io/v2/jobs/result/${jobId}?host=${host}`; + } catch { + return url; + } +} + +export function extractAdobeMediaUrl( + latest: unknown, + kind: "image" | "video" +): string | null { + const body = latest && typeof latest === "object" ? (latest as Record) : {}; + const outputs = Array.isArray(body.outputs) ? body.outputs : []; + if (outputs.length > 0) { + const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; + const media = + kind === "image" + ? first.image && typeof first.image === "object" + ? (first.image as Record) + : null + : first.video && typeof first.video === "object" + ? (first.video as Record) + : null; + const url = media && typeof media.presignedUrl === "string" ? media.presignedUrl : null; + if (url) return url; + } + + // Fallback recursive search for a presigned URL. + const found = findPresignedUrl(latest, kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]); + return found; +} + +function findPresignedUrl(obj: unknown, exts: string[]): string | null { + if (!obj) return null; + if (typeof obj === "string") { + const s = obj.trim(); + if (/^https?:\/\//i.test(s) && (exts.some((e) => s.toLowerCase().includes(e)) || s.includes("presigned") || s.includes("X-Amz"))) { + return s; + } + return null; + } + if (Array.isArray(obj)) { + for (const item of obj) { + const found = findPresignedUrl(item, exts); + if (found) return found; + } + return null; + } + if (typeof obj === "object") { + const rec = obj as Record; + if (typeof rec.presignedUrl === "string" && rec.presignedUrl) return rec.presignedUrl; + for (const value of Object.values(rec)) { + const found = findPresignedUrl(value, exts); + if (found) return found; + } + } + return null; +} + +export function isAdobeJobInProgress(status: string): boolean { + const s = String(status || "").toUpperCase(); + return ( + !s || + s === "IN_PROGRESS" || + s === "PENDING" || + s === "RUNNING" || + s === "QUEUED" || + s === "PROCESSING" || + s === "SUBMITTED" + ); +} + +export function isAdobeJobFailed(status: string): boolean { + const s = String(status || "").toUpperCase(); + return s === "FAILED" || s === "CANCELLED" || s === "ERROR" || s === "CANCELED"; +} + +type ImsTokenResponse = { + access_token?: string; + account_type?: string; + guestId?: string; + token_type?: string; + error?: string; + error_description?: string; +}; + +async function imsCheckToken(opts: { + cookie: string; + clientId: string; + guestAllowed: boolean; + fetchImpl: typeof fetch; +}): Promise< + | { ok: true; token: string; data: ImsTokenResponse } + | { ok: false; status: number; error: string } +> { + const form = new URLSearchParams({ + client_id: opts.clientId, + scope: ADOBE_FIREFLY_IMS_SCOPE, + guest_allowed: opts.guestAllowed ? "true" : "false", + }); + + const resp = await opts.fetchImpl(ADOBE_FIREFLY_IMS_REFRESH_URL, { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Cookie: opts.cookie, + Origin: FIREFLY_ORIGIN, + Referer: FIREFLY_REFERER, + "User-Agent": DEFAULT_USER_AGENT, + }, + body: form.toString(), + }); + + const text = await resp.text().catch(() => ""); + let data: ImsTokenResponse | null = null; + try { + data = JSON.parse(text) as ImsTokenResponse; + } catch { + data = null; + } + + if (!resp.ok) { + return { + ok: false, + status: resp.status, + error: sanitizeErrorMessage( + data?.error_description || data?.error || text.slice(0, 200) || `HTTP ${resp.status}` + ), + }; + } + + const token = String(data?.access_token || "").trim(); + if (!token) { + return { + ok: false, + status: 401, + error: sanitizeErrorMessage( + data?.error_description || data?.error || "IMS response missing access_token" + ), + }; + } + return { ok: true, token, data: data || {} }; +} + +/** + * Exchange a browser Cookie header for an Adobe IMS **user** access_token. + * + * Live repro (user firefly.adobe.com Cookie export): + * - guest_allowed=true → account_type=guest (no AdobeID) → generate 401 / balance 403 + * - guest_allowed=false → "All session cookies are empty" (IMS cookies live on adobelogin.com) + * + * Reliable path: paste Authorization Bearer JWT from a live firefly-3p request. + */ +export async function exchangeAdobeCookieForAccessToken( + cookieHeader: string, + fetchImpl: typeof fetch = fetch +): Promise { + const cookie = String(cookieHeader || "").trim(); + if (!cookie) { + throw new AdobeFireflyError("Adobe Firefly cookie is empty", 401, "missing_cookie"); + } + + // HAR / mixed paste that already contains a user JWT + const embedded = extractAdobeCredentialToken(cookie); + if (embedded !== cookie && looksLikeAdobeJwt(embedded)) { + if (isAdobeGuestAccessToken(embedded)) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + return embedded; + } + + const clientIds = [adobeFireflyApiKey(), adobeFireflyExpressClientId()].filter( + (id, i, arr) => id && arr.indexOf(id) === i + ); + + let sawEmptySession = false; + let lastError = ""; + let lastStatus = 502; + let guestTokenSeen = false; + + for (const clientId of clientIds) { + // 1) Authenticated session only (needs IMS cookies from adobelogin.com) + const authed = await imsCheckToken({ + cookie, + clientId, + guestAllowed: false, + fetchImpl, + }); + if (authed.ok) { + if ( + isAdobeGuestAccessToken(authed.token) || + authed.data.account_type === "guest" || + authed.data.guestId + ) { + guestTokenSeen = true; + } else { + return authed.token; + } + } else { + lastStatus = authed.status; + lastError = authed.error; + if (/session cookies are empty/i.test(authed.error)) sawEmptySession = true; + } + + // 2) Guest path — never accept guest tokens for Firefly media/limits + const guest = await imsCheckToken({ + cookie, + clientId, + guestAllowed: true, + fetchImpl, + }); + if (guest.ok) { + if ( + guest.data.account_type === "guest" || + guest.data.guestId || + isAdobeGuestAccessToken(guest.token) + ) { + guestTokenSeen = true; + lastError = "IMS returned a guest token (no AdobeID session)"; + lastStatus = 401; + continue; + } + return guest.token; + } + lastStatus = guest.status; + lastError = guest.error; + if (/session cookies are empty/i.test(guest.error)) sawEmptySession = true; + } + + if (guestTokenSeen || sawEmptySession) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + + throw new AdobeFireflyError( + `Adobe IMS token exchange failed (${lastStatus}): ${lastError || "no access_token"}. ${GUEST_COOKIE_HELP}`, + lastStatus === 401 || lastStatus === 403 ? 401 : 502, + "ims_refresh_failed" + ); +} + +/** + * Resolve credentials into a usable **user** IMS access token (rejects guest tokens). + */ +export async function resolveAdobeAccessToken( + credentials: + | { + apiKey?: string; + accessToken?: string; + providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + } + | null + | undefined, + fetchImpl: typeof fetch = fetch +): Promise { + const psd = credentials?.providerSpecificData; + const candidates: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) candidates.push(v.trim()); + }; + push(credentials?.apiKey); + push(credentials?.accessToken); + push(psd?.access_token); + push(psd?.accessToken); + push(psd?.cookie); + + if (candidates.length === 0) { + throw new AdobeFireflyError( + "Adobe Firefly credentials missing. " + GUEST_COOKIE_HELP, + 401, + "missing_credentials" + ); + } + + for (const c of candidates) { + const extracted = extractAdobeCredentialToken(c); + if (looksLikeAdobeJwt(extracted) && isAdobeUserAccessToken(extracted)) { + return extracted; + } + } + + for (const c of candidates) { + const extracted = extractAdobeCredentialToken(c); + if (looksLikeAdobeJwt(extracted) && isAdobeGuestAccessToken(extracted)) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + } + + const cookieBlob = + candidates.find( + (c) => + c.includes(";") || + c.toLowerCase().includes("aux_sid") || + c.toLowerCase().includes("ff_session") + ) || candidates[0]; + + const token = await exchangeAdobeCookieForAccessToken(cookieBlob, fetchImpl); + if (isAdobeGuestAccessToken(token)) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + return token; +} + +// ── Credits balance (Limits) ──────────────────────────────────────────────── + +export interface AdobeFireflyCreditsBalance { + total: number; + used: number; + remaining: number; + availableUntil: string | null; + freeTotal: number; + freeUsed: number; + freeRemaining: number; + planTotal: number; + planUsed: number; + planRemaining: number; + raw?: unknown; +} + +function readQuotaBlock(block: unknown): { total: number; used: number; available: number } { + if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 }; + const q = + (block as Record).quota && + typeof (block as Record).quota === "object" + ? ((block as Record).quota as Record) + : (block as Record); + const total = Number(q.total ?? 0); + const used = Number(q.used ?? 0); + const available = Number(q.available ?? Math.max(0, total - used)); + return { + total: Number.isFinite(total) ? total : 0, + used: Number.isFinite(used) ? used : 0, + available: Number.isFinite(available) ? available : 0, + }; +} + +/** + * Parse GET /v1/credits/balance JSON (adobe/balance.txt Response). + * total.quota = aggregate; credits.firefly_* = free + plan buckets. + */ +export function parseAdobeCreditsBalance(body: unknown): AdobeFireflyCreditsBalance { + const root = body && typeof body === "object" ? (body as Record) : {}; + const totalBlock = readQuotaBlock(root.total); + const credits = + root.credits && typeof root.credits === "object" + ? (root.credits as Record) + : {}; + const free = readQuotaBlock(credits.firefly_free_credit); + const plan = readQuotaBlock(credits.firefly_plan_credit); + + // Prefer top-level total; fall back to free+plan sum when total missing. + let total = totalBlock.total; + let used = totalBlock.used; + let remaining = totalBlock.available; + if (total <= 0 && (free.total > 0 || plan.total > 0)) { + total = free.total + plan.total; + used = free.used + plan.used; + remaining = free.available + plan.available; + } + if (remaining <= 0 && total > 0) remaining = Math.max(0, total - used); + + const availableUntil = + root.total && + typeof root.total === "object" && + typeof (root.total as Record).availableUntil === "string" + ? String((root.total as Record).availableUntil) + : null; + + return { + total, + used, + remaining, + availableUntil, + freeTotal: free.total, + freeUsed: free.used, + freeRemaining: free.available, + planTotal: plan.total, + planUsed: plan.used, + planRemaining: plan.available, + raw: body, + }; +} + +export async function fetchAdobeCreditsBalance( + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise { + const resp = await fetchImpl(ADOBE_FIREFLY_CREDITS_BALANCE_URL, { + method: "GET", + headers: buildAdobeBalanceHeaders(accessToken), + }); + if (resp.status === 401 || resp.status === 403) { + throw new AdobeFireflyError("Adobe Firefly balance: token invalid or expired", 401, "auth"); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new AdobeFireflyError( + `Adobe Firefly balance failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`, + 502 + ); + } + const data = await resp.json().catch(() => ({})); + return parseAdobeCreditsBalance(data); +} + +// ── Models discovery ──────────────────────────────────────────────────────── + +export interface AdobeFireflyDiscoveredModel { + modelId: string; + modelVersion: string; + displayName: string; + modality: "image" | "video" | "audio" | "unknown"; + enabled: boolean; + healthStatus?: string; +} + +/** + * Parse POST /v2/models/discovery response into flat model/version rows. + */ +export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { + const root = body && typeof body === "object" ? (body as Record) : {}; + const models = Array.isArray(root.models) ? root.models : []; + const out: AdobeFireflyDiscoveredModel[] = []; + + for (const m of models) { + if (!m || typeof m !== "object") continue; + const rec = m as Record; + const modelId = String(rec.modelId || "").trim(); + if (!modelId) continue; + const versions = + rec.modelVersions && typeof rec.modelVersions === "object" + ? (rec.modelVersions as Record) + : {}; + for (const [ver, spec] of Object.entries(versions)) { + if (!spec || typeof spec !== "object") continue; + const s = spec as Record; + if (s.enabled === false) continue; + const mods = Array.isArray(s.outputModality) + ? s.outputModality.map((x) => String(x).toLowerCase()) + : []; + let modality: AdobeFireflyDiscoveredModel["modality"] = "unknown"; + if (mods.includes("image")) modality = "image"; + else if (mods.includes("video")) modality = "video"; + else if (mods.includes("audio")) modality = "audio"; + out.push({ + modelId, + modelVersion: ver, + displayName: String(s.modelDisplayName || s.modelCaiDisplayName || ver), + modality, + enabled: s.enabled !== false, + healthStatus: typeof s.healthStatus === "string" ? s.healthStatus : undefined, + }); + } + } + return out; +} + +export async function discoverAdobeFireflyModels( + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise { + const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, { + method: "POST", + headers: buildAdobeDiscoveryHeaders(accessToken), + body: JSON.stringify({ filters: { resolveSchema: true } }), + }); + if (resp.status === 401 || resp.status === 403) { + throw new AdobeFireflyError("Adobe Firefly model discovery: token invalid or expired", 401, "auth"); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new AdobeFireflyError( + `Adobe Firefly model discovery failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`, + 502 + ); + } + const data = await resp.json().catch(() => ({})); + return parseAdobeModelsDiscovery(data); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pollAdobeJob(opts: { + pollUrl: string; + accessToken: string; + kind: "image" | "video"; + timeoutMs: number; + pollIntervalMs?: number; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise<{ mediaUrl: string; latest: unknown }> { + const fetchImpl = opts.fetchImpl || fetch; + const deadline = Date.now() + opts.timeoutMs; + const interval = opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; + let attempt = 0; + let latest: unknown = {}; + + while (Date.now() < deadline) { + attempt += 1; + const pollResp = await fetchImpl(opts.pollUrl, { + method: "GET", + headers: buildAdobePollHeaders(opts.accessToken), + }); + + if (pollResp.status === 401 || pollResp.status === 403) { + const accessError = pollResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { + throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + } + throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth"); + } + + if (!pollResp.ok) { + const text = await pollResp.text().catch(() => ""); + if ( + pollResp.status === 408 || + pollResp.status === 429 || + pollResp.status === 451 || + pollResp.status >= 500 || + isAdobeTransientSubmitError(pollResp.status, text) + ) { + opts.log?.info?.("ADOBE-FIREFLY", `poll temporary ${pollResp.status}, attempt #${attempt}`); + await sleep(interval); + continue; + } + throw new AdobeFireflyError( + `Adobe Firefly poll failed (${pollResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`, + 502 + ); + } + + latest = await pollResp.json().catch(() => ({})); + const statusHeader = String(pollResp.headers.get("x-task-status") || "").toUpperCase(); + const statusVal = String( + (latest && typeof latest === "object" ? (latest as Record).status : "") || + statusHeader || + "" + ).toUpperCase(); + + const mediaUrl = extractAdobeMediaUrl(latest, opts.kind); + if (mediaUrl) { + return { mediaUrl, latest }; + } + + if (isAdobeJobFailed(statusVal)) { + throw new AdobeFireflyError( + `Adobe Firefly ${opts.kind} job failed: ${sanitizeErrorMessage(JSON.stringify(latest).slice(0, 300))}`, + 502, + "job_failed" + ); + } + + opts.log?.info?.("ADOBE-FIREFLY", `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`); + await sleep(interval); + } + + throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout"); +} + +// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load. +// Keep retries short: hammering Adobe with 8 long waits makes the Media page +// look broken while balance still works. SPA succeeds on a healthy queue/token. +const SUBMIT_MAX_ATTEMPTS = 4; +const SUBMIT_BASE_DELAY_MS = 1200; + +export async function adobeFireflyGenerateImage(opts: { + accessToken: string; + prompt: string; + model: string; + size?: unknown; + aspectRatio?: unknown; + quality?: unknown; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; + /** Optional Cookie blob — used only to lift sherlockToken → x-arp-session-id */ + sessionCookie?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise<{ url: string; b64_json?: string; latest: unknown }> { + const fetchImpl = opts.fetchImpl || fetch; + const { spec } = resolveAdobeImageModel(opts.model); + const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "1:1"); + const outputResolution = normalizeAdobeOutputResolution(opts.quality, opts.size); + const payload = buildAdobeImagePayload({ + prompt: opts.prompt, + aspectRatio, + outputResolution, + modelSpec: spec, + quality: opts.quality, + seed: opts.seed, + sourceImageIds: opts.sourceImageIds, + negativePrompt: opts.negativePrompt, + }); + + const sessionCookie = String(opts.sessionCookie || "").trim(); + const cookieHeader = extractAdobeCookieHeader(sessionCookie); + // Prefer real browser sherlockToken; buildAdobeSubmitHeaders mints synthetic ARP if empty. + const arpSessionId = + extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + let submitData: unknown = {}; + let submitHeaders: Headers | Record = new Headers(); + let lastSubmitError = ""; + let sawSystemUnderLoad = false; + + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + // Deterministic x-nonce from user_id+prompt (adobe2api/GPT2Image-Pro). Fresh ARP each attempt. + const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(opts.accessToken, { + arpSessionId: arpSessionId || undefined, + prompt: opts.prompt, + cookie: cookieHeader || undefined, + }), + body: JSON.stringify(payload), + }); + + if (submitResp.status === 401 || submitResp.status === 403) { + const accessError = submitResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { + throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + } + throw new AdobeFireflyError( + "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p), not page cookies alone.", + 401, + "auth" + ); + } + + if (!submitResp.ok) { + const text = await submitResp.text().catch(() => ""); + if (isAdobeTransientSubmitError(submitResp.status, text)) { + sawSystemUnderLoad = true; + } + lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; + if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + // Exponential backoff: 2s, 4s, 8s, 16s… capped at 45s (+ jitter) + const delay = + Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + + Math.floor(Math.random() * 750); + opts.log?.info?.( + "ADOBE-FIREFLY", + `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms` + ); + await sleep(delay); + continue; + } + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", attempt), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError, + submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + ); + } + + submitData = await submitResp.json().catch(() => ({})); + submitHeaders = submitResp.headers; + break; + } + + let pollUrl = extractAdobeResultLink(submitHeaders, submitData); + if (!pollUrl) { + if (sawSystemUnderLoad) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError || "Adobe Firefly image submit succeeded but no poll URL was returned", + 502 + ); + } + pollUrl = normalizeAdobePollUrl(pollUrl); + + const { mediaUrl, latest } = await pollAdobeJob({ + pollUrl, + accessToken: opts.accessToken, + kind: "image", + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS, + fetchImpl, + log: opts.log, + }); + + return { url: mediaUrl, latest }; +} + +export async function adobeFireflyGenerateVideo(opts: { + accessToken: string; + prompt: string; + model: string; + size?: unknown; + aspectRatio?: unknown; + duration?: unknown; + quality?: unknown; + resolution?: unknown; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; + generateAudio?: boolean; + sessionCookie?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise<{ url: string; b64_json?: string; format: string; latest: unknown }> { + const fetchImpl = opts.fetchImpl || fetch; + const { spec } = resolveAdobeVideoModel(opts.model); + const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "16:9"); + const duration = + typeof opts.duration === "number" + ? opts.duration + : typeof opts.duration === "string" && opts.duration.trim() + ? Number(opts.duration) + : spec.defaultDuration; + const resolution = + typeof opts.resolution === "string" && opts.resolution.trim() + ? opts.resolution + : typeof opts.quality === "string" && /p$/i.test(opts.quality) + ? opts.quality + : spec.defaultResolution; + + const payload = buildAdobeVideoPayload({ + prompt: opts.prompt, + aspectRatio, + duration: Number.isFinite(duration) ? Number(duration) : spec.defaultDuration, + modelSpec: spec, + resolution, + seed: opts.seed, + sourceImageIds: opts.sourceImageIds, + negativePrompt: opts.negativePrompt, + generateAudio: opts.generateAudio, + }); + + const sessionCookie = String(opts.sessionCookie || "").trim(); + const cookieHeader = extractAdobeCookieHeader(sessionCookie); + const arpSessionId = + extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + let submitData: unknown = {}; + let submitHeaders: Headers | Record = new Headers(); + let lastSubmitError = ""; + let sawSystemUnderLoad = false; + + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(opts.accessToken, { + arpSessionId: arpSessionId || undefined, + prompt: opts.prompt, + cookie: cookieHeader || undefined, + }), + body: JSON.stringify(payload), + }); + + if (submitResp.status === 401 || submitResp.status === 403) { + const accessError = submitResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { + throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + } + throw new AdobeFireflyError( + "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p), not page cookies alone.", + 401, + "auth" + ); + } + + if (!submitResp.ok) { + const text = await submitResp.text().catch(() => ""); + if (isAdobeTransientSubmitError(submitResp.status, text)) { + sawSystemUnderLoad = true; + } + lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; + if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + const delay = + Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + + Math.floor(Math.random() * 750); + opts.log?.info?.( + "ADOBE-FIREFLY", + `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms` + ); + await sleep(delay); + continue; + } + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", attempt), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError, + submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + ); + } + + submitData = await submitResp.json().catch(() => ({})); + submitHeaders = submitResp.headers; + break; + } + + let pollUrl = extractAdobeResultLink(submitHeaders, submitData); + if (!pollUrl) { + if (sawSystemUnderLoad) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError || "Adobe Firefly video submit succeeded but no poll URL was returned", + 502 + ); + } + pollUrl = normalizeAdobePollUrl(pollUrl); + + const { mediaUrl, latest } = await pollAdobeJob({ + pollUrl, + accessToken: opts.accessToken, + kind: "video", + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS, + fetchImpl, + log: opts.log, + }); + + return { url: mediaUrl, format: "mp4", latest }; +} diff --git a/open-sse/services/adobeFireflyModels.ts b/open-sse/services/adobeFireflyModels.ts new file mode 100644 index 0000000000..56290b6875 --- /dev/null +++ b/open-sse/services/adobeFireflyModels.ts @@ -0,0 +1,328 @@ +/** + * 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(); + + // 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 | 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}`, + })); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 7682784f04..14d061e75c 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -61,6 +61,7 @@ import { getClaudeUsage, getClaudePlanLabel } from "./usage/claude.ts"; import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; // Re-exported para os testes kiro-* (importam de services/usage). export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; +import { getAdobeFireflyUsage } from "./usage/adobeFirefly.ts"; // Quota / usage upstream URLs (overridable for testing or relays). const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/"; @@ -637,6 +638,10 @@ export async function getUsageForProvider( providerSpecificData, projectId ); + case "adobe-firefly": + case "firefly": + // Cookie or IMS JWT in apiKey/accessToken → GET firefly.adobe.io/v1/credits/balance + return await getAdobeFireflyUsage(apiKey, accessToken, providerSpecificData); case "hyperagent": case "ha": return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData); diff --git a/open-sse/services/usage/adobeFirefly.ts b/open-sse/services/usage/adobeFirefly.ts new file mode 100644 index 0000000000..4ecdec5007 --- /dev/null +++ b/open-sse/services/usage/adobeFirefly.ts @@ -0,0 +1,156 @@ +/** + * Adobe Firefly credits balance → UsageQuota for Limits page. + * + * Live capture (adobe/balance.txt): + * GET https://firefly.adobe.io/v1/credits/balance + * Authorization: Bearer + * x-api-key: SunbreakWebUI1 + * x-account-id: + * + * Response shape: + * { + * total: { quota: { total, used, available }, availableUntil }, + * credits: { + * firefly_free_credit: { quota: { total, used, available } }, + * firefly_plan_credit: { quota: { total, used, available } } + * } + * } + */ + +import { + fetchAdobeCreditsBalance, + parseAdobeCreditsBalance, + resolveAdobeAccessToken, + type AdobeFireflyCreditsBalance, +} from "../adobeFireflyClient.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +export { parseAdobeCreditsBalance }; + +function oneQuota( + used: number, + total: number, + remaining: number, + resetAt: string | null, + displayName: string +): UsageQuota { + const t = Math.max(0, total); + const u = Math.max(0, Math.min(t, used)); + const r = remaining > 0 ? remaining : Math.max(0, t - u); + const remainingPercentage = + t > 0 ? Math.round((r / t) * 1000) / 10 : r > 0 ? 100 : 0; + return { + used: u, + total: t, + remaining: r, + remainingPercentage, + resetAt, + unlimited: false, + displayName, + }; +} + +/** + * Build a **Record** of quotas (NOT an array). providerLimits only caches when + * `isRecord(usage.quotas)` is true — arrays are ignored and Limits stays empty. + */ +export function buildAdobeFireflyCreditsQuota( + balance: AdobeFireflyCreditsBalance +): UsageQuota { + const resetAt = parseResetTime(balance.availableUntil); + return oneQuota( + balance.used, + balance.total, + balance.remaining, + resetAt, + "Firefly credits" + ); +} + +export function buildAdobeFireflyQuotasRecord( + balance: AdobeFireflyCreditsBalance +): Record { + const resetAt = parseResetTime(balance.availableUntil); + const quotas: Record = {}; + + // Aggregate first (what Limits primarily shows) + if (balance.total > 0 || balance.remaining > 0) { + quotas["firefly_total"] = oneQuota( + balance.used, + balance.total, + balance.remaining, + resetAt, + "Firefly credits" + ); + } + if (balance.freeTotal > 0) { + quotas["firefly_free"] = oneQuota( + balance.freeUsed, + balance.freeTotal, + balance.freeRemaining, + resetAt, + "Free credits" + ); + } + if (balance.planTotal > 0) { + quotas["firefly_plan"] = oneQuota( + balance.planUsed, + balance.planTotal, + balance.planRemaining, + resetAt, + "Plan credits" + ); + } + + // Fallback if all zeros but we still got a parse + if (Object.keys(quotas).length === 0) { + quotas["firefly_total"] = oneQuota(0, 0, 0, resetAt, "Firefly credits"); + } + return quotas; +} + +export async function getAdobeFireflyUsage( + apiKey?: string, + accessToken?: string, + providerSpecificData?: Record | null, + fetchImpl: typeof fetch = fetch +): Promise< + | { quotas: Record; plan?: string } + | { message: string } +> { + try { + const token = await resolveAdobeAccessToken( + { + apiKey, + accessToken, + providerSpecificData: providerSpecificData as { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + } | null, + }, + fetchImpl + ); + const balance = await fetchAdobeCreditsBalance(token, fetchImpl); + if (balance.total <= 0 && balance.remaining <= 0 && balance.planTotal <= 0 && balance.freeTotal <= 0) { + return { + message: + "Adobe Firefly returned an empty credits balance. Paste a fresh IMS access_token JWT (Authorization: Bearer on firefly-3p generate/discovery) from a signed-in session — not firefly.adobe.com page cookies alone.", + }; + } + return { + quotas: buildAdobeFireflyQuotasRecord(balance), + plan: balance.planTotal > 0 ? "Firefly plan" : "Firefly free", + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Surface a short Limits-friendly message for guest/cookie failures + if (/guest|GUEST|Bearer|session cookies are empty|token invalid/i.test(msg)) { + return { + message: + "Adobe Firefly Limits need a signed-in IMS JWT. Open firefly.adobe.com → F12 → Network → firefly-3p → Authorization → copy token after Bearer (eyJ…). Page Cookie alone only mints a guest token.", + }; + } + return { message: msg || "Failed to fetch Adobe Firefly credits balance" }; + } +} diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 1cdeccbb1a..4f77a5591c 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -196,6 +196,17 @@ const EMBEDDED_DEFAULTS = { 89, 44, 91, 40, 51, 94, 49, 64, 32, 108, 54, 51, 86, 41, 80, 37, 111, 69, 6, 42, 95, 93, 45, 68, 87, 65, 77, 84, 105, 70, 51, 86, ], + // Adobe Firefly web (firefly.adobe.com) — public x-api-key + IMS client_id + // (`clio-playground-web`). Captured from live browser generate/discovery calls. + // Not a per-user secret; every Firefly SPA session sends the same value. + // (Express still uses `projectx_webapp` — see adobe_firefly_express_client_id.) + adobe_firefly_api_key: [12, 1, 7, 6, 95, 31, 25, 21, 28, 74, 2, 26, 23, 2, 13, 78, 90, 19, 83], + // Adobe Express fallback IMS client_id for cookie exchange when Firefly + // clio-playground-web refresh fails (older Express cookies). + adobe_firefly_express_client_id: [31, 31, 1, 3, 23, 12, 1, 12, 58, 90, 21, 23, 3, 28, 25], + // Firefly credits balance endpoint public x-api-key (`SunbreakWebUI1`) from + // GET firefly.adobe.io/v1/credits/balance browser traffic. + adobe_firefly_balance_api_key: [60, 24, 0, 11, 0, 10, 20, 31, 50, 72, 18, 32, 43, 93], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 2abea5bfba..5ca7f1af29 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -82,6 +82,9 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "qoder", "promptql", // PromptQL playground JWT → getCreditSummary USD credits "pql", + // Adobe Firefly: web-cookie / JWT stored as apikey → credits/balance + "adobe-firefly", + "firefly", // HyperAgent session cookie → billing/usage creditBlocks "hyperagent", "ha", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 81e9fd5502..648950a9d3 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -444,6 +444,9 @@ export const USAGE_SUPPORTED_PROVIDERS = [ // PromptQL playground credits (getCreditSummary → USD micros) "promptql", "pql", + // Adobe Firefly web (cookie/JWT as apikey) — GET firefly.adobe.io/v1/credits/balance + "adobe-firefly", + "firefly", "hyperagent", "ha", ]; diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index fe3def5b00..53aca4a611 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -423,6 +423,19 @@ export const WEB_COOKIE_PROVIDERS = { "Paste only the token_v2 cookie VALUE from app.notion.com (DevTools → Application → Cookies → token_v2). " + "Do not paste token_v2= or the full Cookie header. Workspace is auto-detected; space_id / notion_user_id are optional.", }, + "adobe-firefly": { + id: "adobe-firefly", + alias: "firefly", + name: "Adobe Firefly (Image/Video)", + icon: "auto_awesome", + color: "#EB1000", + textIcon: "FF", + website: "https://firefly.adobe.com", + authHint: + "RECOMMENDED: firefly.adobe.com signed-in → F12 → Network → click firefly-3p.ff.adobe.io (generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' (starts with eyJ…). Cookie-only from firefly.adobe.com mints a GUEST token → 401/403; only multi-domain IMS cookies (adobelogin.com) or that Bearer JWT work. Unofficial/experimental media + Limits.", + subscriptionRisk: true, + riskNoticeVariant: "webCookie", + }, hyperagent: { id: "hyperagent", alias: "ha", diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index bef2a791d7..648c51d039 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -302,6 +302,17 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: false, storageKeys: ["token", "jwt", "apiKey", "projectId", "project_id", "cookie"], }, + "adobe-firefly": { + // Prefer IMS access_token JWT (Bearer). Cookie from firefly.adobe.com alone + // only mints a guest IMS token. Kind stays "cookie" for multi-account UX; + // resolveAdobeAccessToken auto-detects JWT vs cookie and rejects guests. + kind: "cookie", + credentialName: "IMS access_token JWT (recommended) or multi-domain Cookie", + placeholder: + "Paste eyJ… JWT from Authorization: Bearer on firefly-3p generate request (not page Cookie alone)", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "token", "access_token", "accessToken"], + }, } satisfies Record; export function getWebSessionCredentialRequirement( diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts new file mode 100644 index 0000000000..68f973c0dd --- /dev/null +++ b/tests/unit/adobe-firefly.test.ts @@ -0,0 +1,711 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; +import { + ADOBE_FIREFLY_IMAGE_MODELS, + ADOBE_FIREFLY_VIDEO_MODELS, + adobeFireflyApiKey, + adobeFireflyBalanceApiKey, + buildAdobeImagePayload, + buildAdobePollHeaders, + buildAdobeSubmitHeaders, + buildAdobeVideoPayload, + extractAdobeAccountIdFromToken, + extractAdobeCredentialToken, + extractAdobeMediaUrl, + extractAdobeResultLink, + looksLikeAdobeJwt, + normalizeAdobeAspectRatio, + normalizeAdobeOutputResolution, + normalizeAdobePollUrl, + parseAdobeCreditsBalance, + parseAdobeModelsDiscovery, + resolveAdobeImageModel, + resolveAdobeVideoModel, + adobeFireflyGenerateImage, + adobeFireflyGenerateVideo, + exchangeAdobeCookieForAccessToken, + isAdobeGuestAccessToken, + isAdobeUserAccessToken, + isAdobeTransientSubmitError, + generateAdobeNonce, + extractAdobeArpSessionId, + resolveAdobeAccessToken, +} from "../../open-sse/services/adobeFireflyClient.ts"; +import { + ADOBE_FIREFLY_FALLBACK_MODELS, + getAdobeFireflyFallbackCatalog, + mapDiscoveredToCatalog, +} from "../../open-sse/services/adobeFireflyModels.ts"; +import { + buildAdobeFireflyCreditsQuota, + buildAdobeFireflyQuotasRecord, +} from "../../open-sse/services/usage/adobeFirefly.ts"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { handleAdobeFireflyImageGeneration } from "../../open-sse/handlers/imageGeneration/providers/adobeFirefly.ts"; +import { handleAdobeFireflyVideoGeneration } from "../../open-sse/handlers/videoGeneration/adobeFireflyHandler.ts"; +import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts"; +import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; +import { VIDEO_PROVIDERS } from "../../open-sse/config/videoRegistry.ts"; +import { getExecutor } from "../../open-sse/executors/index.ts"; + +// --- Registry -------------------------------------------------------------- + +test("adobe-firefly is registered in WEB_COOKIE_PROVIDERS with a webCookie risk notice", () => { + const entry = (WEB_COOKIE_PROVIDERS as Record)["adobe-firefly"]; + assert.ok(entry, "adobe-firefly must exist in WEB_COOKIE_PROVIDERS"); + assert.equal(entry.id, "adobe-firefly"); + assert.equal(entry.alias, "firefly"); + assert.equal(entry.subscriptionRisk, true); + assert.equal(entry.riskNoticeVariant, "webCookie"); + assert.match(entry.website, /firefly\.adobe\.com/); +}); + +test("adobe-firefly is registered in IMAGE_PROVIDERS with adobe-firefly-image format", () => { + const entry = (IMAGE_PROVIDERS as Record)["adobe-firefly"]; + assert.ok(entry); + assert.equal(entry.format, "adobe-firefly-image"); + assert.match(entry.baseUrl, /firefly-3p\.ff\.adobe\.io/); + assert.ok(Array.isArray(entry.models) && entry.models.length >= 4); +}); + +test("adobe-firefly is registered in VIDEO_PROVIDERS with adobe-firefly-video format", () => { + const entry = (VIDEO_PROVIDERS as Record)["adobe-firefly"]; + assert.ok(entry); + assert.equal(entry.format, "adobe-firefly-video"); + assert.match(entry.baseUrl, /3p-videos/); + assert.ok(Array.isArray(entry.models) && entry.models.length >= 5); +}); + +test("getExecutor(adobe-firefly) rejects chat completions", async () => { + const executor = getExecutor("adobe-firefly"); + assert.ok(executor); + const result = await executor.execute({ + model: "adobe-firefly/nano-banana-pro", + body: { model: "adobe-firefly/nano-banana-pro", messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "tok" }, + }); + assert.ok(result.response, "executor must return a Response wrapper"); + assert.equal(result.response.status, 400); + const bodyText = await result.response.text(); + assert.match(bodyText, /images\/generations|videos\/generations|media-generation/i); +}); + +// --- Public credential ----------------------------------------------------- + +test("adobe_firefly_api_key embedded default decodes to clio-playground-web", () => { + assert.equal(resolvePublicCred("adobe_firefly_api_key"), "clio-playground-web"); + assert.equal(adobeFireflyApiKey(), "clio-playground-web"); + assert.equal(adobeFireflyBalanceApiKey(), "SunbreakWebUI1"); +}); + +// --- Pure helpers ---------------------------------------------------------- + +test("looksLikeAdobeJwt detects JWT shape and rejects cookie blobs", () => { + const longJwt = `eyJhbGciOiJSUzI1NiJ9.${"a".repeat(40)}.${"b".repeat(40)}`; + assert.equal(looksLikeAdobeJwt(longJwt), true); + assert.equal(looksLikeAdobeJwt("aaa.bbb.ccc"), false); // too short + assert.equal(looksLikeAdobeJwt("s_ecid=foo; session=bar"), false); + assert.equal(looksLikeAdobeJwt("not-a-jwt"), false); +}); + +test("extractAdobeCredentialToken strips Bearer and access_token=", () => { + const longJwt = `eyJhbGciOiJSUzI1NiJ9.${"c".repeat(40)}.${"d".repeat(40)}`; + assert.equal(extractAdobeCredentialToken(`Bearer ${longJwt}`), longJwt); + assert.equal(extractAdobeCredentialToken(`access_token=${longJwt}; other=1`), longJwt); + assert.equal(extractAdobeCredentialToken(" rawcookie "), "rawcookie"); + // IMS sessionStorage shape (firefly.adobe.com) + const sessionJson = JSON.stringify({ + valid: true, + client_id: "clio-playground-web", + tokenValue: longJwt, + }); + assert.equal(extractAdobeCredentialToken(sessionJson), longJwt); +}); + +test("normalizeAdobeAspectRatio maps sizes and ratios", () => { + assert.equal(normalizeAdobeAspectRatio("16:9"), "16:9"); + assert.equal(normalizeAdobeAspectRatio("16x9"), "16:9"); + assert.equal(normalizeAdobeAspectRatio("1024x1024"), "1:1"); + assert.equal(normalizeAdobeAspectRatio("1792x1024"), "16:9"); + assert.equal(normalizeAdobeAspectRatio("1024x1792"), "9:16"); + assert.equal(normalizeAdobeAspectRatio("auto"), "1:1"); + assert.equal(normalizeAdobeAspectRatio(undefined), "1:1"); +}); + +test("normalizeAdobeOutputResolution maps quality tiers", () => { + assert.equal(normalizeAdobeOutputResolution("4k", null), "4K"); + assert.equal(normalizeAdobeOutputResolution("high", null), "4K"); + assert.equal(normalizeAdobeOutputResolution("2k", null), "2K"); + assert.equal(normalizeAdobeOutputResolution("low", null), "1K"); + assert.equal(normalizeAdobeOutputResolution(undefined, "4096x4096"), "4K"); + assert.equal(normalizeAdobeOutputResolution(undefined, undefined), "2K"); +}); + +test("resolveAdobeImageModel maps catalog and long model ids", () => { + assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "nano-banana-pro"); + assert.equal(resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, "nano-banana-2"); + assert.equal(resolveAdobeImageModel("firefly-nano-banana-pro-2k-16x9").id, "nano-banana-pro"); + assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image"); + assert.ok(ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].upstreamModelVersion); +}); + +test("resolveAdobeVideoModel maps sora/veo/kling families", () => { + assert.equal(resolveAdobeVideoModel("sora-2").id, "sora-2"); + assert.equal(resolveAdobeVideoModel("firefly-sora2-pro-8s-16x9").id, "sora-2-pro"); + assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast"); + assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-3"); + assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["sora-2"].defaultDuration > 0); +}); + +test("buildAdobeImagePayload produces nano and gpt-image shapes", () => { + const nano = buildAdobeImagePayload({ + prompt: "a cat", + aspectRatio: "16:9", + outputResolution: "2K", + modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }); + assert.equal(nano.modelId, "gemini-flash"); + assert.equal(nano.modelVersion, "nano-banana-2"); + assert.deepEqual(nano.size, { width: 2752, height: 1536 }); + assert.equal((nano.modelSpecificPayload as Record).aspectRatio, "16:9"); + + const gpt = buildAdobeImagePayload({ + prompt: "a dog", + aspectRatio: "1:1", + outputResolution: "1K", + modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], + quality: "high", + }); + assert.equal(gpt.modelId, "gpt-image"); + assert.equal((gpt.generationSettings as Record).detailLevel, 5); + // Live browser body uses size:"auto" and no top-level size/outputResolution + assert.equal((gpt.modelSpecificPayload as Record).size, "auto"); + assert.equal(gpt.size, undefined); + assert.equal(gpt.outputResolution, undefined); +}); + +test("buildAdobeVideoPayload produces sora and veo shapes", () => { + const sora = buildAdobeVideoPayload({ + prompt: "ocean waves", + aspectRatio: "16:9", + duration: 8, + modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"], + }); + assert.equal(sora.modelId, "sora"); + assert.equal(sora.duration, 8); + + const veo = buildAdobeVideoPayload({ + prompt: "city flyover", + aspectRatio: "9:16", + duration: 6, + modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"], + }); + assert.equal(veo.modelId, "veo"); + assert.equal(veo.modelVersion, "3.1-generate"); + assert.equal( + (veo.modelSpecificPayload as Record>).parameters + .durationSeconds, + 6 + ); + assert.equal(veo.generateAudio, true); +}); + +test("extractAdobeResultLink prefers x-override-status-link then links.result", () => { + const headers = new Headers({ "x-override-status-link": "https://poll.example/job/1" }); + assert.equal(extractAdobeResultLink(headers, {}), "https://poll.example/job/1"); + + const headers2 = new Headers(); + assert.equal( + extractAdobeResultLink(headers2, { links: { result: { href: "https://poll.example/job/2" } } }), + "https://poll.example/job/2" + ); +}); + +test("extractAdobeMediaUrl reads outputs[].image/video.presignedUrl", () => { + assert.equal( + extractAdobeMediaUrl( + { outputs: [{ image: { presignedUrl: "https://cdn.example/a.png" } }] }, + "image" + ), + "https://cdn.example/a.png" + ); + assert.equal( + extractAdobeMediaUrl( + { outputs: [{ video: { presignedUrl: "https://cdn.example/a.mp4" } }] }, + "video" + ), + "https://cdn.example/a.mp4" + ); +}); + +test("buildAdobeSubmitHeaders sets Bearer + clio-playground-web x-api-key", () => { + const headers = buildAdobeSubmitHeaders("tok-1", { arpSessionId: "arp-1" }); + assert.equal(headers.Authorization, "Bearer tok-1"); + assert.equal(headers["x-api-key"], "clio-playground-web"); + assert.equal(headers.origin, "https://firefly.adobe.com"); + assert.equal(headers["content-type"], "application/json"); + assert.equal(headers["x-arp-session-id"], "arp-1"); + // Always has x-nonce (random when no prompt/user_id) + assert.ok(headers["x-nonce"] && headers["x-nonce"].length === 64); + // Never attach firefly.adobe.com page Cookie to firefly-3p (soft 408 risk) + assert.equal(headers.cookie, undefined); + const poll = buildAdobePollHeaders("tok-1"); + assert.equal(poll.Authorization, "Bearer tok-1"); + assert.equal(poll.accept, "*/*"); + // status_check.txt: poll is Bearer-only (no x-api-key) + assert.equal(poll["x-api-key"], undefined); +}); + +test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { + const { + buildAdobeSubmitNonce, + buildAdobeArpSessionId, + buildAdobeSubmitHeaders, + extractAdobeAccountIdFromToken, + } = await import("../../open-sse/services/adobeFireflyClient.ts"); + // Minimal fake IMS JWT with AdobeID subject + const payload = Buffer.from( + JSON.stringify({ + user_id: "0EB681AF6A5FF6C10A495FF2@AdobeID", + type: "access_token", + client_id: "clio-playground-web", + }) + ) + .toString("base64url"); + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const token = `${header}.${payload}.${"x".repeat(40)}`; + // Pad token length for looksLikeAdobeJwt (>=80) + assert.ok(token.length >= 80 || true); + const prompt = "a red fox in snow"; + const nonce = buildAdobeSubmitNonce(token, prompt); + assert.equal(nonce.length, 64); + const { createHash } = await import("node:crypto"); + const expected = createHash("sha256") + .update(`0EB681AF6A5FF6C10A495FF2@AdobeID-${prompt}`, "utf8") + .digest("hex"); + assert.equal(nonce, expected); + // Same inputs → same nonce; different prompt → different nonce + assert.equal(buildAdobeSubmitNonce(token, prompt), nonce); + assert.notEqual(buildAdobeSubmitNonce(token, prompt + "!"), nonce); + assert.equal(extractAdobeAccountIdFromToken(token), "0EB681AF6A5FF6C10A495FF2@AdobeID"); + + const arp = buildAdobeArpSessionId(); + assert.ok(arp.length > 20); + const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8")); + assert.ok(decoded.sid); + assert.match(String(decoded.ftr), /dUAL43-mnts-ants-d4_31ck__tt$/); + + // Headers: deterministic nonce + always ARP (synthetic when none provided) + const h = buildAdobeSubmitHeaders(token, { prompt }); + assert.equal(h["x-nonce"], nonce); + assert.ok(h["x-arp-session-id"]); + assert.equal(h.cookie, undefined); +}); + +test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => { + const raw = + "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6"; + const out = normalizeAdobePollUrl(raw); + assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/); + assert.match(out, /host=firefly-epo855232\.adobe\.io/); +}); + +test("parseAdobeCreditsBalance maps total + free/plan buckets", () => { + const bal = parseAdobeCreditsBalance({ + total: { + quota: { total: 10010, used: 10, available: 10000 }, + availableUntil: "2026-07-28T22:48:31.576Z", + }, + credits: { + firefly_free_credit: { quota: { total: 10, used: 0, available: 10 } }, + firefly_plan_credit: { quota: { total: 10000, used: 10, available: 9990 } }, + }, + }); + assert.equal(bal.total, 10010); + assert.equal(bal.used, 10); + assert.equal(bal.remaining, 10000); + assert.equal(bal.freeTotal, 10); + assert.equal(bal.planTotal, 10000); + const quota = buildAdobeFireflyCreditsQuota(bal); + assert.equal(quota.total, 10010); + assert.equal(quota.remaining, 10000); + assert.equal(quota.displayName, "Firefly credits"); + // providerLimits requires quotas as a Record, not an array + const rec = buildAdobeFireflyQuotasRecord(bal); + assert.ok(rec.firefly_total); + assert.equal(rec.firefly_total.total, 10010); + assert.equal(rec.firefly_total.remaining, 10000); + assert.ok(rec.firefly_free); + assert.ok(rec.firefly_plan); +}); + +test("adobe-firefly is in USAGE_SUPPORTED_PROVIDERS for Limits", () => { + assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("adobe-firefly")); + assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("firefly")); +}); + +test("parseAdobeModelsDiscovery extracts image/video versions", () => { + const rows = parseAdobeModelsDiscovery({ + models: [ + { + modelId: "gemini-flash", + modelVersions: { + "nano-banana-2": { + enabled: true, + outputModality: ["image"], + modelDisplayName: "Gemini 3.0 (Nano Banana Pro)", + healthStatus: "HEALTHY", + }, + }, + }, + { + modelId: "sora", + modelVersions: { + "sora-2": { + enabled: true, + outputModality: ["video"], + modelDisplayName: "Sora 2", + }, + }, + }, + ], + }); + assert.equal(rows.length, 2); + assert.equal(rows[0].modality, "image"); + assert.equal(rows[1].modality, "video"); + const catalog = mapDiscoveredToCatalog(rows); + assert.ok(catalog.some((m) => m.id === "nano-banana-pro")); + assert.ok(catalog.some((m) => m.id === "sora-2")); +}); + +test("fallback catalog has image and video entries from get_models capture", () => { + assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.length >= 10); + assert.ok(getAdobeFireflyFallbackCatalog("image").length >= 4); + assert.ok(getAdobeFireflyFallbackCatalog("video").length >= 4); +}); + +test("extractAdobeAccountIdFromToken reads user_id claim", () => { + // {"user_id":"0EB@AdobeID"} base64url + const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString( + "base64url" + ); + const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`; + assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID"); +}); + +// --- Handlers (mocked fetch) ---------------------------------------------- + +function jsonResponse(status: number, body: unknown, headerMap: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => { + const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headerMap[key] : null; + }, + }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} + +test("handleAdobeFireflyImageGeneration returns 400 when prompt is missing", async () => { + const result = await handleAdobeFireflyImageGeneration({ + model: "nano-banana-pro", + provider: "adobe-firefly", + body: {}, + credentials: { apiKey: "aaa.bbb.ccc" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); + +function userImsJwt(userId = "0EB@AdobeID"): string { + return ( + `eyJhbGciOiJSUzI1NiJ9.` + + Buffer.from( + JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" }) + ).toString("base64url") + + `.` + + "sig".padEnd(40, "x") + ); +} + +test("handleAdobeFireflyImageGeneration submit+poll happy path (mocked)", async () => { + let calls = 0; + const fetchImpl = async (url: string, init?: RequestInit) => { + calls += 1; + const u = String(url); + if (u.includes("generate-async")) { + return jsonResponse( + 200, + { links: { result: "https://poll.example/job/img1" } }, + { "x-override-status-link": "https://poll.example/job/img1" } + ); + } + if (u.includes("poll.example")) { + return jsonResponse(200, { + status: "COMPLETED", + outputs: [{ image: { presignedUrl: "https://cdn.example/out.png" } }], + }); + } + throw new Error(`unexpected fetch ${u}`); + }; + + const result = await handleAdobeFireflyImageGeneration({ + model: "nano-banana-pro", + provider: "adobe-firefly", + body: { prompt: "sunset mountains", size: "16:9", quality: "2k" }, + credentials: { apiKey: userImsJwt() }, + fetchImpl: fetchImpl as typeof fetch, + }); + + assert.equal(result.success, true); + assert.ok(result.data?.data?.[0]?.url?.includes("cdn.example/out.png")); + assert.ok(calls >= 2); +}); + +test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => { + const fetchImpl = async (url: string) => { + const u = String(url); + if (u.includes("3p-videos")) { + return jsonResponse( + 200, + { links: { result: { href: "https://poll.example/job/vid1" } } }, + {} + ); + } + if (u.includes("poll.example")) { + return jsonResponse(200, { + status: "COMPLETED", + outputs: [{ video: { presignedUrl: "https://cdn.example/out.mp4" } }], + }); + } + throw new Error(`unexpected fetch ${u}`); + }; + + const result = await adobeFireflyGenerateVideo({ + accessToken: "tok", + prompt: "drone over forest", + model: "sora-2", + duration: 4, + aspectRatio: "16:9", + fetchImpl: fetchImpl as typeof fetch, + }); + assert.equal(result.format, "mp4"); + assert.match(result.url, /out\.mp4/); +}); + +test("handleAdobeFireflyVideoGeneration returns 400 without prompt", async () => { + const result = await handleAdobeFireflyVideoGeneration({ + model: "sora-2", + provider: "adobe-firefly", + body: {}, + credentials: { apiKey: "aaa.bbb.ccc" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); + +test("handleAdobeFireflyImageGeneration maps quota exhausted", async () => { + const fetchImpl = async () => + jsonResponse(403, { error: "nope" }, { "x-access-error": "taste_exhausted" }); + + const result = await handleAdobeFireflyImageGeneration({ + model: "nano-banana-pro", + provider: "adobe-firefly", + body: { prompt: "test" }, + credentials: { apiKey: userImsJwt() }, + fetchImpl: fetchImpl as typeof fetch, + }); + assert.equal(result.success, false); + assert.equal(result.status, 429); + assert.match(String(result.error), /quota/i); +}); + +test("guest JWT without AdobeID is detected", () => { + // Minimal JWT payload {} — no user_id → guest + const emptyPayload = Buffer.from("{}").toString("base64url"); + const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`; + // Pad to lookLikeAdobeJwt length if needed + const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40); + assert.equal(isAdobeGuestAccessToken(longGuest), true); + const userJwt = + `eyJhbGciOiJSUzI1NiJ9.` + + Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString( + "base64url" + ) + + `.` + + "y".repeat(40); + assert.equal(isAdobeGuestAccessToken(userJwt), false); + assert.equal(isAdobeUserAccessToken(userJwt), true); +}); + +test("cookie exchange rejects guest IMS tokens", async () => { + const fetchImpl = async (url: string, init?: RequestInit) => { + if (String(url).includes("ims/check")) { + const body = String(init?.body || ""); + if (body.includes("guest_allowed=false")) { + return jsonResponse(400, { + error: "invalid_credentials", + error_description: "All session cookies are empty", + }); + } + // guest_allowed=true → guest token (no user_id) + const guest = + `eyJhbGciOiJSUzI1NiJ9.` + + Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url") + + `.` + + "z".repeat(40); + return jsonResponse(200, { + access_token: guest, + account_type: "guest", + guestId: "1@GuestID", + }); + } + throw new Error(`unexpected ${url}`); + }; + + await assert.rejects( + () => + exchangeAdobeCookieForAccessToken( + "ff_session_guid=abc; aux_sid=xyz", + fetchImpl as typeof fetch + ), + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + assert.match(message, /GUEST|guest|Bearer/i); + return true; + } + ); +}); + +test("isAdobeTransientSubmitError detects 408 system under load", () => { + assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true); + assert.equal(isAdobeTransientSubmitError(429, "rate"), true); + assert.equal(isAdobeTransientSubmitError(400, "bad request"), false); + assert.ok(generateAdobeNonce().length === 64); + assert.equal( + extractAdobeArpSessionId("a=1; sherlockToken=eyJzaWQiOiJ4In0=; b=2"), + "eyJzaWQiOiJ4In0=" + ); +}); + +test("extractAdobeCookieHeader strips JWT from mixed paste", async () => { + const { extractAdobeCookieHeader, buildAdobeSubmitHeaders, extractAdobeArpSessionId } = + await import("../../open-sse/services/adobeFireflyClient.ts"); + const longJwt = `eyJhbGciOiJSUzI1NiJ9.${"e".repeat(40)}.${"f".repeat(40)}`; + const cookie = "ff_session_guid=abc; sherlockToken=tok123; aux_sid=xyz"; + const mixed = `${longJwt}\n${cookie}`; + assert.equal(extractAdobeCookieHeader(longJwt), ""); + assert.match(extractAdobeCookieHeader(mixed), /ff_session_guid=abc/); + assert.doesNotMatch(extractAdobeCookieHeader(mixed), /eyJhbGci/); + // Submit must not attach Cookie; arp id may still be lifted from the blob. + const arp = extractAdobeArpSessionId(mixed); + assert.equal(arp, "tok123"); + const headers = buildAdobeSubmitHeaders("access-tok", { + cookie: mixed, + arpSessionId: arp, + }); + assert.equal(headers.cookie, undefined); + assert.equal(headers["x-arp-session-id"], "tok123"); + assert.equal(headers.Authorization, "Bearer access-tok"); +}); + +test("resolveAdobeImageModel maps gpt-image-2 alias", async () => { + const { resolveAdobeImageModel } = await import("../../open-sse/services/adobeFireflyClient.ts"); + assert.equal(resolveAdobeImageModel("gpt-image-2").spec.upstreamModelVersion, "2"); + assert.equal(resolveAdobeImageModel("adobe-firefly/gpt-image").spec.upstreamModelVersion, "2"); + assert.equal(resolveAdobeImageModel("gpt-image-1.5").spec.upstreamModelVersion, "1.5"); +}); + +test("image submit retries on 408 then succeeds", async () => { + let submits = 0; + const userTok = userImsJwt(); + const fetchImpl = async (url: string) => { + const u = String(url); + if (u.includes("generate-async")) { + submits += 1; + if (submits < 3) { + return jsonResponse(408, { error_code: "timeout_error", message: "system under load" }); + } + return jsonResponse( + 200, + { links: { result: { href: "https://poll.example/job/r1" } } }, + {} + ); + } + if (u.includes("poll.example")) { + return jsonResponse(200, { + status: "COMPLETED", + outputs: [{ image: { presignedUrl: "https://cdn.example/retry.png" } }], + }); + } + throw new Error(`unexpected ${u}`); + }; + + const result = await adobeFireflyGenerateImage({ + accessToken: userTok, + prompt: "retry me", + model: "gpt-image", + fetchImpl: fetchImpl as typeof fetch, + }); + assert.equal(submits, 3); + assert.match(result.url, /retry\.png/); +}); + +test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => { + const userTok = + `eyJhbGciOiJSUzI1NiJ9.` + + Buffer.from( + JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" }) + ).toString("base64url") + + `.` + + "s".repeat(40); + const urls: string[] = []; + const fetchImpl = async (url: string, init?: RequestInit) => { + urls.push(String(url)); + if (String(url).includes("ims/check")) { + assert.equal(init?.method, "POST"); + // Authenticated exchange (guest_allowed=false) + return jsonResponse(200, { access_token: userTok, account_type: "type1" }); + } + if (String(url).includes("generate-async")) { + const auth = + (init?.headers as Record | undefined)?.Authorization || + (init?.headers as Headers)?.get?.("Authorization"); + // headers object from buildAdobeSubmitHeaders + const headerAuth = + typeof init?.headers === "object" && init.headers && !("get" in (init.headers as object)) + ? (init.headers as Record).Authorization + : auth; + assert.equal(headerAuth, `Bearer ${userTok}`); + return jsonResponse( + 200, + {}, + { "x-override-status-link": "https://poll.example/job/c1" } + ); + } + if (String(url).includes("poll.example")) { + return jsonResponse(200, { + outputs: [{ image: { presignedUrl: "https://cdn.example/cookie.png" } }], + }); + } + throw new Error(`unexpected ${url}`); + }; + + // Use the image handler which resolves credentials (cookie → IMS). + const result = await handleAdobeFireflyImageGeneration({ + model: "nano-banana-pro", + provider: "adobe-firefly", + body: { prompt: "cookie path" }, + credentials: { apiKey: "s_ecid=abc; sessionToken=xyz; other=1" }, + fetchImpl: fetchImpl as typeof fetch, + }); + assert.equal(result.success, true); + assert.ok(urls.some((u) => u.includes("ims/check"))); + assert.ok(urls.some((u) => u.includes("generate-async"))); +});