mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 18:22:48 +03:00
fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in
Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never
sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash.
Add optional managed Chrome warm (off-screen headed by default; Forter rejects
headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie
after a fresh SSO. Visible sign-in resets off-screen window placement and clears
prior Adobe session when adding another account.
This commit is contained in:
@@ -1,159 +1,160 @@
|
||||
// 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).
|
||||
//
|
||||
// Reference images (Media page / OpenAI edit aliases):
|
||||
// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] }
|
||||
// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }]
|
||||
// See browser network capture for live captures.
|
||||
|
||||
import { sanitizeErrorMessage } from "../../../utils/error.ts";
|
||||
import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.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;
|
||||
image_urls?: unknown;
|
||||
images?: unknown;
|
||||
[key: string]: 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 {
|
||||
// Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose,
|
||||
// cache, optional Playwright warm-up. Submit path rotates ARP on 408.
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
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;
|
||||
|
||||
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs =
|
||||
resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image")
|
||||
? 4
|
||||
: 2;
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxRefs,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
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,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
//
|
||||
// Reference images (Media page / OpenAI edit aliases):
|
||||
// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] }
|
||||
// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }]
|
||||
// See web_providers/adobe_atach_images.txt for live captures.
|
||||
|
||||
import { sanitizeErrorMessage } from "../../../utils/error.ts";
|
||||
import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.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;
|
||||
image_urls?: unknown;
|
||||
images?: unknown;
|
||||
[key: string]: 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 {
|
||||
// Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose,
|
||||
// cache, optional Playwright warm-up. Submit path rotates ARP on 408.
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
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;
|
||||
|
||||
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs =
|
||||
resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image")
|
||||
? 4
|
||||
: 2;
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxRefs,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
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,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
1179
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
1179
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,85 @@ export async function POST(
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
|
||||
|
||||
// Adobe Firefly uses a persistent off-screen Chrome profile (Forter/Arkose need a real
|
||||
// browser). Sign in once in a visible window; the profile then keeps the session fresh
|
||||
// with no token/cookie to paste. This is NOT the generic cookie-scrape login path.
|
||||
if (String(provider.provider || "") === "adobe-firefly") {
|
||||
try {
|
||||
const { loginAdobeFireflyViaChrome } = await import(
|
||||
"@omniroute/open-sse/services/adobeFireflyChromeRuntime.ts"
|
||||
);
|
||||
const cookieHint = typeof provider.api_key === "string" ? provider.api_key : undefined;
|
||||
// Default freshSession=true so "Add Account" can sign into a different Adobe identity
|
||||
// instead of silently reusing the previous SSO in the managed Chrome profile.
|
||||
const freshSession =
|
||||
typeof (body as { freshSession?: unknown }).freshSession === "boolean"
|
||||
? Boolean((body as { freshSession?: boolean }).freshSession)
|
||||
: true;
|
||||
const result = await loginAdobeFireflyViaChrome({
|
||||
cookie: cookieHint,
|
||||
waitForLoginMs: timeout,
|
||||
freshSession,
|
||||
});
|
||||
if (result.success) {
|
||||
// Persist JWT + Cookie (multi-line) so generate works immediately. sessionStorage JWT
|
||||
// dies when Chrome closes; the cookie jar + IMS SSO in the profile cover refresh/warm.
|
||||
const accessToken = String(result.accessToken || "").trim();
|
||||
const cookie = String(result.cookie || "").trim();
|
||||
const credential =
|
||||
accessToken && cookie
|
||||
? `${accessToken}\n${cookie}`
|
||||
: accessToken || cookie || JSON.stringify({
|
||||
mode: "browser-profile",
|
||||
account: result.account || "",
|
||||
signedInAt: Date.now(),
|
||||
});
|
||||
const marker = {
|
||||
mode: "browser-profile",
|
||||
account: result.account || "",
|
||||
signedInAt: Date.now(),
|
||||
arpSessionId: result.arpSessionId || "",
|
||||
};
|
||||
try {
|
||||
await updateProviderConnection(id, {
|
||||
api_key: credential,
|
||||
provider_specific_data: {
|
||||
...marker,
|
||||
cookie: credential,
|
||||
access_token: accessToken || undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal — return credentials to the host app either way */
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
account: result.account,
|
||||
accessToken: accessToken || undefined,
|
||||
cookie: cookie || undefined,
|
||||
arpSessionId: result.arpSessionId || undefined,
|
||||
credential,
|
||||
persisted: true,
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
"Sign-in did not complete. Open the browser window that appeared, log into your Adobe " +
|
||||
"account, and keep it open until this finishes.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Adobe Firefly sign-in error: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Adobe Firefly is special: the IMS JWT is only ever in the Authorization
|
||||
// header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so
|
||||
|
||||
@@ -918,6 +918,11 @@ test("resolveAdobeImageModel maps gpt-image-2 alias", async () => {
|
||||
});
|
||||
|
||||
test("image submit retries on 408 then succeeds", async () => {
|
||||
const { __resetAdobeFireflySessionCacheForTests } = await import(
|
||||
"../../open-sse/services/adobeFireflySession.ts"
|
||||
);
|
||||
__resetAdobeFireflySessionCacheForTests();
|
||||
|
||||
let submits = 0;
|
||||
const userTok = userImsJwt();
|
||||
const fetchImpl = async (url: string) => {
|
||||
@@ -952,6 +957,91 @@ test("image submit retries on 408 then succeeds", async () => {
|
||||
assert.match(result.url, /retry\.png/);
|
||||
});
|
||||
|
||||
test("sticky ARP: successful submit is reused by ensure on next call", async () => {
|
||||
const {
|
||||
__resetAdobeFireflySessionCacheForTests,
|
||||
markAdobeFireflyArpSuccess,
|
||||
ensureAdobeFireflySession,
|
||||
fingerprintAdobeCredential,
|
||||
} = await import("../../open-sse/services/adobeFireflySession.ts");
|
||||
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
|
||||
__resetAdobeFireflySessionCacheForTests();
|
||||
|
||||
const userTok = userImsJwt();
|
||||
const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`;
|
||||
const ark =
|
||||
"87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40";
|
||||
const cookie =
|
||||
`ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` +
|
||||
`forterToken=${encodeURIComponent(ftr)}`;
|
||||
const cred = `${userTok}\n${cookie}`;
|
||||
const fp = fingerprintAdobeCredential(cred);
|
||||
const stickyArp = Buffer.from(
|
||||
JSON.stringify({ sid: "sticky-sid", ark: "sticky-ark", ftr: "sticky-ftr" }),
|
||||
"utf8"
|
||||
).toString("base64");
|
||||
|
||||
markAdobeFireflyArpSuccess(fp, stickyArp);
|
||||
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials: { apiKey: cred },
|
||||
allowBrowserRefresh: false,
|
||||
fetchImpl: (async () => {
|
||||
throw new Error("no network expected");
|
||||
}) as typeof fetch,
|
||||
});
|
||||
assert.equal(session.arpSessionId, stickyArp, "ensure must stick to last successful ARP");
|
||||
assert.equal(session.fingerprint, fp);
|
||||
});
|
||||
|
||||
test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps ARP without browser", async () => {
|
||||
const {
|
||||
__resetAdobeFireflySessionCacheForTests,
|
||||
rotateAdobeFireflySessionOnError,
|
||||
markAdobeFireflyArpSuccess,
|
||||
buildAdobeArpSessionIdFromCookies,
|
||||
} = await import("../../open-sse/services/adobeFireflySession.ts");
|
||||
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
|
||||
__resetAdobeFireflySessionCacheForTests();
|
||||
|
||||
const ftr = `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`;
|
||||
const cookie =
|
||||
`ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`;
|
||||
const rebuilt = buildAdobeArpSessionIdFromCookies(cookie);
|
||||
assert.ok(rebuilt);
|
||||
|
||||
const base = {
|
||||
accessToken: userImsJwt(),
|
||||
cookie,
|
||||
arpSessionId: rebuilt!,
|
||||
tokenExpiresAt: Date.now() + 3600_000,
|
||||
updatedAt: Date.now(),
|
||||
fingerprint: "fp-rotate-test",
|
||||
source: "paste" as const,
|
||||
};
|
||||
markAdobeFireflyArpSuccess(base.fingerprint, rebuilt!);
|
||||
|
||||
const a1 = await rotateAdobeFireflySessionOnError(base, {
|
||||
attempt: 1,
|
||||
tryBrowser: false,
|
||||
});
|
||||
assert.equal(a1.arpSessionId, rebuilt, "attempt 1 reuses ARP (rate-limit quiet)");
|
||||
|
||||
const a2 = await rotateAdobeFireflySessionOnError(
|
||||
{ ...base, arpSessionId: rebuilt! },
|
||||
{ attempt: 2, tryBrowser: false }
|
||||
);
|
||||
assert.equal(a2.arpSessionId, rebuilt, "attempt 2 still reuses ARP (mid-batch quiet)");
|
||||
|
||||
const a3 = await rotateAdobeFireflySessionOnError(
|
||||
{ ...base, arpSessionId: rebuilt! },
|
||||
{ attempt: 3, tryBrowser: false }
|
||||
);
|
||||
// attempt 3 without browser: cookie rebuild (identical forter → same ARP)
|
||||
assert.ok(a3.arpSessionId, "attempt 3 still yields an ARP");
|
||||
assert.equal(a3.arpSessionId, rebuilt, "identical cookie rebuild keeps ARP (no synthetic thrash)");
|
||||
});
|
||||
|
||||
test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => {
|
||||
const { __resetAdobeFireflySessionCacheForTests } = await import(
|
||||
"../../open-sse/services/adobeFireflySession.ts"
|
||||
|
||||
Reference in New Issue
Block a user