feat(media): Adobe Firefly image + video generation provider (#8006)

* feat(media): Adobe Firefly image + video generation provider

Add unofficial adobe-firefly media provider with full OpenAI-compatible
image and video generation: Nano Banana / GPT Image families, Sora 2,
Veo 3.1 (standard/fast/reference), and Kling 3.0.

Supports browser session cookies (auto IMS token exchange) or direct
IMS access tokens, async submit-and-poll against Firefly 3P endpoints,
aspect-ratio and resolution controls, and multi-account web-session UX.
Chat completions are intentionally rejected (media-only surface).

Includes unit coverage for registry wiring, payload builders, auth
resolution, and mocked generate happy-paths.

* fix(adobe-firefly): clio auth, discovery fallback, credits balance

Root-cause 401 invalid token against live firefly.adobe.com captures:
generate/discovery use x-api-key + IMS client_id clio-playground-web
(not projectx_webapp). Align headers/origin, dual cookie to IMS exchange
(clio first, Express fallback), BKS poll rewrite for /jobs/result.

Models: parse POST /v2/models/discovery + static fallback catalog from
adobe/get_models.txt; expand image/video registries.

Limits: GET firefly.adobe.io/v1/credits/balance (SunbreakWebUI1) with
total/remaining + free/plan detail quotas. Clarify cookie vs JWT UX.

Unit tests: 27/27 pass.

* fix(adobe-firefly): reject guest tokens from page-only cookies

Live repro with firefly.adobe.com Cookie export: IMS check with
guest_allowed=true returns account_type=guest (no AdobeID). That token
fails generate (401 invalid token) and credits/balance (403
ErrMismatchOauthToken). guest_allowed=false needs adobelogin.com IMS
session cookies which are not present in a page-only Cookie paste.

- Detect/reject guest JWTs; clear error tells user to paste Bearer JWT
- Prefer user JWT from HAR/mixed paste; improve credential extraction
- Update web-cookie + credential UX to recommend Authorization Bearer

Unit tests 29/29.

* fix(adobe-firefly): production auth, Limits, and 408 load handling

Live validation against firefly.adobe.com + packaged VibeProxy:

Auth / credentials
- Prefer IMS user JWT (Bearer from firefly-3p); reject guest tokens from
  page-only cookies with an actionable error
- Extract JWT from Bearer, access_token=, IMS sessionStorage tokenValue,
  and mixed HAR pastes; prefer non-guest tokens
- Strip JWT from Cookie header (undici Headers.append crash on mixed paste)
- Keep sherlockToken → x-arp-session-id + sanitized Cookie for generate

Limits
- credits/balance → Record quotas (firefly_total / free / plan) so
  providerLimits caches them (arrays were ignored)
- Allowlist adobe-firefly + firefly in USAGE_SUPPORTED + APIKEY limits
- Live: 10000 plan credits parsed end-to-end after refresh

Generate
- Browser-shaped gpt-image body (size auto, no extra top-level size)
- Exponential 408 "system under load" retries (8 attempts) with clear
  client message that 408 is Adobe capacity, not invalid token
- Live: generate returns proper 408 under load; balance/models stay 200

Tests: adobe-firefly unit suite 33/33 pass.

* fix(adobe-firefly): match live capture headers; add gpt-image-2

- Do not send firefly.adobe.com Cookie to firefly-3p (wrong-origin; soft 408)
- Lift sherlockToken only into x-arp-session-id
- Poll headers match status_check.txt (Bearer + accept, no x-api-key)
- Catalog gpt-image-2 alias → upstream modelVersion "2" (GPT Image 2)
- Shorter 408 retry budget so clients fail fast with clear message
- Unit suite 34/34

* fix(adobe-firefly): always send x-arp-session-id on generate (fixes 408)

Root cause of Bearer JWT → HTTP 408 colligo "system under load":
submit only set x-arp-session-id when sherlockToken was present in a
cookie paste. JWT-only credentials never sent the header, and Adobe
soft-blocks those requests with instant 408 (x-colligo-timeout:0.0).

A/B against a real user IMS token:
- det nonce + synthetic ARP → 200
- random nonce + synthetic ARP → 200
- det nonce without ARP → 408

Match adobe2api / GPT2Image-Pro:
- buildAdobeSubmitNonce = sha256(user_id + prompt[:256])
- buildAdobeArpSessionId = base64({sid, ftr}) synthetic session
- buildAdobeSubmitHeaders always sets both headers

Live adobeFireflyGenerateImage end-to-end: submit + poll → S3 presigned URL.

* fix(adobe-firefly): drop literal cred fallbacks + type-clean tests

Addresses pre-merge review feedback on #8006:
- Removes the `|| "literal"` fallback after resolvePublicCred() in
  adobeFireflyApiKey()/adobeFireflyExpressClientId()/adobeFireflyBalanceApiKey()
  (open-sse/services/adobeFireflyClient.ts). resolvePublicCred() already
  always returns the decoded embedded default, so the literal fallback
  was dead code that reproduced the exact env-or-literal anti-pattern
  docs/security/PUBLIC_CREDS.md documents as BAD (Hard Rule #11).
- Replaces the 11 `@typescript-eslint/no-explicit-any` casts in
  tests/unit/adobe-firefly.test.ts with concrete types
  (Record<string, unknown>, Headers, Error-narrowing on the
  assert.rejects predicate), matching the pattern already used
  elsewhere in this suite. `no-explicit-any` is a hard ESLint error
  under tests/ in this repo.
- Freezes file-size baseline entries for the new
  open-sse/services/adobeFireflyClient.ts (1958 LOC, new-file cap 800,
  mirrors the qoderCli.ts precedent for a legitimately large new
  provider client), open-sse/config/imageRegistry.ts (800->821, new
  adobe-firefly registry entry) and the +3 LOC growth in
  src/lib/usage/providerLimits.ts (1000->1003).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
This commit is contained in:
NOXX - Commiter
2026-07-22 14:12:07 +03:00
committed by GitHub
parent 1b010f6c40
commit e86e5bcc51
19 changed files with 3577 additions and 0 deletions

View File

@@ -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,

View File

@@ -639,6 +639,34 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
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"],
},
};
/**

View File

@@ -264,6 +264,30 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
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" },
],
},
};
/**

View File

@@ -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;

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,
});
}
}

View File

@@ -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,

View File

@@ -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<string, unknown>;
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 };
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>();
// Prefer friendly aliases when upstream matches known fallback rows.
for (const fb of ADOBE_FIREFLY_FALLBACK_MODELS) {
const hit = rows.find(
(r) =>
r.modelId === fb.upstreamModelId &&
r.modelVersion === fb.upstreamModelVersion &&
(r.modality === fb.modality || r.modality === "unknown")
);
if (hit && !seen.has(fb.id)) {
seen.add(fb.id);
out.push({
...fb,
name: hit.displayName || fb.name,
});
}
}
for (const r of rows) {
if (r.modality !== "image" && r.modality !== "video") continue;
const id = slugifyAdobeModel(r.modelId, r.modelVersion);
if (seen.has(id)) continue;
// Skip if already covered by a friendly alias with same upstream
if (
out.some(
(o) =>
o.upstreamModelId === r.modelId && o.upstreamModelVersion === r.modelVersion
)
) {
continue;
}
seen.add(id);
out.push({
id,
name: r.displayName || id,
modality: r.modality,
upstreamModelId: r.modelId,
upstreamModelVersion: r.modelVersion,
inputModalities: r.modality === "image" ? ["text", "image"] : ["text"],
});
}
return out;
}
export function getAdobeFireflyFallbackCatalog(modality?: "image" | "video"): AdobeFireflyCatalogModel[] {
if (!modality) return [...ADOBE_FIREFLY_FALLBACK_MODELS];
return ADOBE_FIREFLY_FALLBACK_MODELS.filter((m) => m.modality === modality);
}
/**
* Live discovery when credentials resolve; otherwise static fallback from get_models capture.
*/
export async function resolveAdobeFireflyCatalog(opts: {
credentials?: {
apiKey?: string;
accessToken?: string;
providerSpecificData?: Record<string, unknown> | null;
} | null;
modality?: "image" | "video";
fetchImpl?: typeof fetch;
}): Promise<{ models: AdobeFireflyCatalogModel[]; source: "api" | "fallback" }> {
const fetchImpl = opts.fetchImpl || fetch;
try {
if (opts.credentials) {
const token = await resolveAdobeAccessToken(opts.credentials, fetchImpl);
const discovered = await discoverAdobeFireflyModels(token, fetchImpl);
let catalog = mapDiscoveredToCatalog(discovered);
if (opts.modality) catalog = catalog.filter((m) => m.modality === opts.modality);
if (catalog.length > 0) return { models: catalog, source: "api" };
}
} catch {
// fall through to static catalog
}
return {
models: getAdobeFireflyFallbackCatalog(opts.modality),
source: "fallback",
};
}
/** Registry-shaped models for imageRegistry / videoRegistry. */
export function toRegistryImageModels(
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("image")
): Array<{ id: string; name: string; inputModalities?: string[] }> {
return models
.filter((m) => m.modality === "image")
.map((m) => ({
id: m.id,
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
inputModalities: m.inputModalities || ["text", "image"],
}));
}
export function toRegistryVideoModels(
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("video")
): Array<{ id: string; name: string }> {
return models
.filter((m) => m.modality === "video")
.map((m) => ({
id: m.id,
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
}));
}

View File

@@ -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);

View File

@@ -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 <IMS access_token>
* x-api-key: SunbreakWebUI1
* x-account-id: <user_id from JWT>
*
* 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<string, UsageQuota> {
const resetAt = parseResetTime(balance.availableUntil);
const quotas: Record<string, UsageQuota> = {};
// 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<string, unknown> | null,
fetchImpl: typeof fetch = fetch
): Promise<
| { quotas: Record<string, UsageQuota>; 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" };
}
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",
];

View File

@@ -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",

View File

@@ -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<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;
export function getWebSessionCredentialRequirement(

View File

@@ -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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>).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<string, unknown>).detailLevel, 5);
// Live browser body uses size:"auto" and no top-level size/outputResolution
assert.equal((gpt.modelSpecificPayload as Record<string, unknown>).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<string, Record<string, unknown>>).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<string, string> = {}) {
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<string, string> | 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<string, string>).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")));
});