Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(providers): register a real Firefly auth probe under both the `firefly` alias and the `adobe-firefly` canonical id, and normalize the provider id before the generic web-cookie fallback, so a Firefly connection stops always reporting "Provider validation not supported" (#10522)
16 changes: 14 additions & 2 deletions src/lib/providers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isOpenAICompatibleProvider,
isSelfHostedChatProvider,
providerAllowsOptionalApiKey,
resolveProviderId,
WEB_COOKIE_PROVIDERS,
} from "@/shared/constants/providers";
import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal";
Expand Down Expand Up @@ -107,6 +108,7 @@ import {
validateBytezProvider,
} from "./validation/webCookie";
import { validateAiHordeProvider } from "./validation/aihorde";
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
import {
validateV0VercelProvider,
validateAuggieProvider,
Expand Down Expand Up @@ -184,6 +186,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
devin: validateDevinCloudAgentProvider,
auggie: validateAuggieProvider,
aihorde: validateAiHordeProvider,
// #10522: registered under both the canonical id and the short alias — Firefly
// connections are commonly stored as "firefly" (same prefix as firefly/<model>
// routing ids), not the canonical "adobe-firefly" WEB_COOKIE_PROVIDERS key.
"adobe-firefly": validateAdobeFireflyProvider,
firefly: validateAdobeFireflyProvider,
qoder: validateQoderProvider,
kiro: validateKiroProvider,
"command-code": validateCommandCodeProvider,
Expand Down Expand Up @@ -328,9 +335,14 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
// per-provider validator (grok-web, chatgpt-web, claude-web, …) are handled by
// SPECIALTY_VALIDATORS first and must not be shadowed by this generic probe (issue: the
// #4023 dispatch was placed too early and intercepted every web-cookie provider).
if (WEB_COOKIE_PROVIDERS[provider]) {
const canonicalProvider = resolveProviderId(provider);
if (WEB_COOKIE_PROVIDERS[canonicalProvider]) {
try {
return await validateWebCookieProvider({ provider, apiKey, providerSpecificData });
return await validateWebCookieProvider({
provider: canonicalProvider,
apiKey,
providerSpecificData,
});
} catch (error: any) {
return toValidationErrorResult(error);
}
Expand Down
38 changes: 38 additions & 0 deletions src/lib/providers/validation/adobeFirefly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Adobe Firefly key/cookie validation (#10522).
*
* validateProviderApiKey() previously had no SPECIALTY_VALIDATORS entry for
* "firefly"/"adobe-firefly" and the generic WEB_COOKIE_PROVIDERS fallback used a raw,
* unaliased lookup — so a Firefly connection always fell through to the "Provider
* validation not supported" branch regardless of whether the pasted cookie/JWT was
* valid. This reuses the already-working credits/balance probe
* (getAdobeFireflyUsage, used today by the Limits/quota page) as a real auth check:
* a successful balance fetch means the token is valid, a `{ message }` result means
* it was rejected (expired/guest/invalid), and thrown transport errors are mapped
* through the shared toValidationErrorResult() helper.
*/
import { getAdobeFireflyUsage } from "@omniroute/open-sse/services/usage/adobeFirefly.ts";
import { toValidationErrorResult } from "./transport";

export async function validateAdobeFireflyProvider({
apiKey,
providerSpecificData,
fetchImpl = fetch,
}: {
apiKey?: unknown;
providerSpecificData?: Record<string, unknown> | null;
fetchImpl?: typeof fetch;
}) {
try {
const key = typeof apiKey === "string" ? apiKey : undefined;
const result = await getAdobeFireflyUsage(key, undefined, providerSpecificData, fetchImpl);

if ("message" in result) {
return { valid: false, error: result.message };
}

return { valid: true, error: null };
} catch (error) {
return toValidationErrorResult(error);
}
}
90 changes: 90 additions & 0 deletions tests/unit/firefly-cookie-validation-10522.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { validateProviderApiKey } from "../../src/lib/providers/validation.ts";
import { validateAdobeFireflyProvider } from "../../src/lib/providers/validation/adobeFirefly.ts";
import { resolveProviderId } from "../../src/shared/constants/providers.ts";
import { ADOBE_FIREFLY_CREDITS_BALANCE_URL } from "../../open-sse/services/adobeFireflyClient.ts";

// A well-formed Adobe IMS *user* access token (3-segment JWT, non-guest payload, long
// enough to satisfy looksLikeAdobeJwt). Only used as a routing/shape fixture — never a
// real credential.
const userJwt =
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(
JSON.stringify({
user_id: "0EB@AdobeID",
type: "access_token",
client_id: "clio-playground-web",
})
).toString("base64url") +
`.` +
"x".repeat(60);

function jsonResponse(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}

test("#10522: resolveProviderId('firefly') stays stable (SPECIALTY_VALIDATORS dual-key registration depends on it)", () => {
assert.equal(resolveProviderId("firefly"), "adobe-firefly");
});

test("#10522: firefly alias dispatches to the Firefly validator, not the generic unsupported fallback", async () => {
const result = await validateProviderApiKey({
provider: "firefly",
apiKey: "not-a-real-token",
providerSpecificData: {},
});
assert.notEqual(result.unsupported, true);
});

test("#10522: adobe-firefly canonical id dispatches to the Firefly validator, not the generic unsupported fallback", async () => {
const result = await validateProviderApiKey({
provider: "adobe-firefly",
apiKey: "not-a-real-token",
providerSpecificData: {},
});
assert.notEqual(result.unsupported, true);
});

test("#10522: expired/invalid token reports valid:false with a real error message (not 'not supported')", async () => {
const fetchImpl = async (url: string | URL) => {
assert.equal(String(url), ADOBE_FIREFLY_CREDITS_BALANCE_URL);
return jsonResponse(401, { error: "invalid_token" });
};

const result = await validateAdobeFireflyProvider({
apiKey: userJwt,
providerSpecificData: {},
fetchImpl: fetchImpl as typeof fetch,
});

assert.equal(result.valid, false);
assert.notEqual((result as { unsupported?: boolean }).unsupported, true);
assert.ok(result.error && result.error.length > 0);
});

test("#10522: a genuine credits balance payload reports valid:true", async () => {
const fetchImpl = async (url: string | URL) => {
assert.equal(String(url), ADOBE_FIREFLY_CREDITS_BALANCE_URL);
return jsonResponse(200, {
total: { quota: { total: 100, used: 10, available: 90 } },
credits: {
firefly_free_credit: { quota: { total: 50, used: 5, available: 45 } },
firefly_plan_credit: { quota: { total: 50, used: 5, available: 45 } },
},
});
};

const result = await validateAdobeFireflyProvider({
apiKey: userJwt,
providerSpecificData: {},
fetchImpl: fetchImpl as typeof fetch,
});

assert.equal(result.valid, true);
assert.equal(result.error, null);
});
Loading