mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 09:12:27 +03:00
Compare commits
5 Commits
feat/9544-
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b56692072 | ||
|
|
aae408f585 | ||
|
|
3e1c31c606 | ||
|
|
2e12ee89f7 | ||
|
|
723ce0b166 |
@@ -1 +0,0 @@
|
||||
- feat(providers): add Muse Code CLI provider preset (#9544)
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.",
|
||||
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
|
||||
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||
@@ -387,7 +388,7 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2361,
|
||||
"src/app/api/v1/models/catalog.ts": 1590,
|
||||
"src/app/api/v1/models/catalog.ts": 1597,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
"src/lib/db/core.ts": 1639,
|
||||
"src/lib/db/migrationRunner.ts": 1094,
|
||||
@@ -536,7 +537,7 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": "2148",
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": "1119",
|
||||
"src/app/api/providers/[id]/models/route.ts": "2361",
|
||||
"src/app/api/v1/models/catalog.ts": "1590",
|
||||
"src/app/api/v1/models/catalog.ts": "1597",
|
||||
"src/lib/tokenHealthCheck.ts": "1053",
|
||||
"src/lib/db/apiKeys.ts": "1529",
|
||||
"src/lib/db/core.ts": "1639",
|
||||
|
||||
@@ -12,6 +12,10 @@ import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts";
|
||||
import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts";
|
||||
import { GEMINI_IMAGEN_PROVIDER } from "./providers/registry/gemini/imageModels.ts";
|
||||
import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts";
|
||||
import {
|
||||
ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES,
|
||||
toRegistryImageModels,
|
||||
} from "../services/adobeFireflyModels.ts";
|
||||
|
||||
interface ImageModelEntry {
|
||||
id: string;
|
||||
@@ -22,6 +26,8 @@ interface ImageModelEntry {
|
||||
imageRequired?: boolean;
|
||||
description?: string;
|
||||
isMarket?: boolean;
|
||||
supportedSizes?: string[];
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ImageProviderConfig {
|
||||
@@ -35,6 +41,7 @@ interface ImageProviderConfig {
|
||||
authHeader: string;
|
||||
format: string;
|
||||
models: ImageModelEntry[];
|
||||
routingAliases?: readonly string[];
|
||||
supportedSizes: string[];
|
||||
}
|
||||
|
||||
@@ -46,6 +53,7 @@ interface ImageModelAliasEntry {
|
||||
inputModalities?: string[];
|
||||
imageRequired?: boolean;
|
||||
description?: string;
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ImageCatalogModelEntry {
|
||||
@@ -55,6 +63,7 @@ interface ImageCatalogModelEntry {
|
||||
supportedSizes: string[];
|
||||
inputModalities: string[];
|
||||
description?: string;
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const IMAGE_MODEL_ALIASES: Record<string, ImageModelAliasEntry> = {
|
||||
@@ -678,55 +687,9 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
||||
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"],
|
||||
},
|
||||
// Topaz Labs upscalers (inputMediaUseCase: ["upscaling"]).
|
||||
// Served by firefly-3p /v2/3p-images/upsample — see config/upscaleRegistry.ts.
|
||||
{
|
||||
id: "topaz-standard",
|
||||
name: "Firefly Topaz Upscale (Standard)",
|
||||
inputModalities: ["image"],
|
||||
imageRequired: true,
|
||||
},
|
||||
{
|
||||
id: "topaz-bloom",
|
||||
name: "Firefly Topaz Bloom (Creative Upscale)",
|
||||
inputModalities: ["image"],
|
||||
imageRequired: true,
|
||||
},
|
||||
],
|
||||
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"],
|
||||
models: toRegistryImageModels(),
|
||||
routingAliases: ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES,
|
||||
supportedSizes: [],
|
||||
},
|
||||
|
||||
// Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on
|
||||
@@ -887,7 +850,7 @@ export function parseImageModel(modelStr) {
|
||||
|
||||
// No provider prefix — try to find the model in every provider
|
||||
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
|
||||
if (config.models.some((m) => m.id === modelStr)) {
|
||||
if (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) {
|
||||
return { provider: providerId, model: modelStr };
|
||||
}
|
||||
}
|
||||
@@ -906,9 +869,10 @@ function imageProviderCatalogEntries(
|
||||
id: `${providerId}/${model.id}`,
|
||||
name: model.name,
|
||||
provider: providerId,
|
||||
supportedSizes: config.supportedSizes,
|
||||
supportedSizes: model.supportedSizes || config.supportedSizes,
|
||||
inputModalities: model.inputModalities || ["text"],
|
||||
description: model.description || undefined,
|
||||
mediaCapabilities: model.mediaCapabilities,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,6 @@ import { digitaloceanProvider } from "./registry/digitalocean/index.ts";
|
||||
import { hcnsecProvider } from "./registry/hcnsec/index.ts";
|
||||
import { promptqlProvider } from "./registry/promptql/index.ts";
|
||||
import { hyperagentProvider } from "./registry/hyperagent/index.ts";
|
||||
import { muse_codeProvider } from "./registry/muse-code/index.ts";
|
||||
|
||||
export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
aimlapi: aimlapiProvider,
|
||||
@@ -452,6 +451,5 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
hcnsec: hcnsecProvider,
|
||||
promptql: promptqlProvider,
|
||||
hyperagent: hyperagentProvider,
|
||||
"muse-code": muse_codeProvider,
|
||||
unorouter: unorouterProvider,
|
||||
};
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Muse Code CLI — Meta's agentic coding tool.
|
||||
*
|
||||
* Wire format: OpenAI Responses API (POST /responses).
|
||||
* Auth: Bearer token from META_API_KEY env var.
|
||||
* Reasoning efforts: xhigh/ultra -> high (handled generically).
|
||||
*
|
||||
* @see https://github.com/joymadhu49/muse-openrouter-shim
|
||||
*/
|
||||
export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "muse-code",
|
||||
alias: "mc",
|
||||
passthroughModels: true,
|
||||
defaultContextLength: 200000,
|
||||
models: [
|
||||
{
|
||||
id: "llama-4-maverick",
|
||||
name: "Llama 4 Maverick",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs", "logitBias"],
|
||||
},
|
||||
{
|
||||
id: "llama-4-scout",
|
||||
name: "Llama 4 Scout",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs", "logitBias"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-405b",
|
||||
name: "Llama 3.1 405B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-70b",
|
||||
name: "Llama 3.1 70B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-8b",
|
||||
name: "Llama 3.1 8B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-90b-vision",
|
||||
name: "Llama 3.2 90B Vision",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-11b-vision",
|
||||
name: "Llama 3.2 11B Vision",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -5,14 +5,17 @@
|
||||
* Supports local providers plus hosted task-based APIs such as Runway.
|
||||
*/
|
||||
|
||||
import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtils.ts";
|
||||
import { parseModelFromRegistry } from "./registryUtils.ts";
|
||||
import { RUNWAYML_SUPPORTED_VIDEO_MODELS } from "./runway.ts";
|
||||
import { SEGMIND_VIDEO_MODELS } from "./providers/registry/segmind/videoModels.ts";
|
||||
import { toRegistryVideoModels } from "../services/adobeFireflyModels.ts";
|
||||
|
||||
interface VideoModel {
|
||||
id: string;
|
||||
name: string;
|
||||
isMarket?: boolean;
|
||||
supportedSizes?: string[];
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface VideoProvider {
|
||||
@@ -326,8 +329,7 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
},
|
||||
|
||||
// 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).
|
||||
// Exact async video models and capabilities from the verified discovery snapshot.
|
||||
"adobe-firefly": {
|
||||
id: "adobe-firefly",
|
||||
alias: "firefly",
|
||||
@@ -335,18 +337,7 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
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" },
|
||||
],
|
||||
models: toRegistryVideoModels(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -368,5 +359,17 @@ export function parseVideoModel(modelStr: string | null) {
|
||||
* Get all video models as a flat list
|
||||
*/
|
||||
export function getAllVideoModels() {
|
||||
return getAllModelsFromRegistry(VIDEO_PROVIDERS);
|
||||
return Object.entries(VIDEO_PROVIDERS).flatMap(([providerId, config]) =>
|
||||
[providerId, config.alias]
|
||||
.filter((prefix): prefix is string => Boolean(prefix))
|
||||
.flatMap((prefix) =>
|
||||
config.models.map((model) => ({
|
||||
id: `${prefix}/${model.id}`,
|
||||
name: model.name,
|
||||
provider: providerId,
|
||||
supportedSizes: model.supportedSizes || [],
|
||||
mediaCapabilities: model.mediaCapabilities,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,11 +251,14 @@ export function normalizeDuckDuckGoModel(model: string | undefined): string {
|
||||
}
|
||||
|
||||
function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities {
|
||||
// Per duckchat/v1/models (2026-07-22): claude-haiku-4-5 and gpt-oss-120b take a "low"
|
||||
// reasoningEffort on the free tier; the others omit it (duck.ai applies its own default).
|
||||
// `reasoningEffort` is REQUIRED on every duckchat/v1/chat request. Omitting it
|
||||
// returns 400 ERR_BAD_REQUEST — A/B verified live against duck.ai with an
|
||||
// otherwise byte-identical payload (200 with the field, 400 without, repeated).
|
||||
// The live duck.ai bundle always sends one, so there is no "let the server
|
||||
// pick a default" path any more.
|
||||
if (model === "claude-haiku-4-5") return { reasoningEffort: "low" };
|
||||
if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" };
|
||||
return { reasoningEffort: null };
|
||||
return { reasoningEffort: "none" };
|
||||
}
|
||||
|
||||
function extractDuckDuckGoFeVersion(html: string): string | null {
|
||||
@@ -353,7 +356,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
private warmed = false;
|
||||
private seeded = false;
|
||||
private feVersion = DEFAULT_FE_VERSION;
|
||||
private pendingVqdHash1: string | null = null;
|
||||
private readonly cookieJar = new Map<string, string>();
|
||||
@@ -561,7 +563,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
await this.warmSession(mergedSignal);
|
||||
await this.seedChallengeChain(upstreamModel, mergedSignal);
|
||||
// NOTE: the throwaway "seed" chat POST that used to run here has been removed.
|
||||
// It existed to coax a usable challenge out of the upstream while the solver
|
||||
// was broken; now that the solver reproduces a real browser's probe vectors
|
||||
// exactly, the first real request succeeds on its own. Keeping it only doubled
|
||||
// the chat calls per user request against an IP-rate-limited endpoint, which
|
||||
// showed up as spurious 429 ERR_RATE_LIMIT.
|
||||
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
|
||||
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
|
||||
clearTimeout(timeout);
|
||||
@@ -770,41 +777,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
private async seedChallengeChain(model: string, signal: AbortSignal): Promise<void> {
|
||||
if (this.seeded || signal.aborted) return;
|
||||
this.seeded = true;
|
||||
const seedMessages = [{ role: "user", content: "hi" }];
|
||||
const previousPending = this.pendingVqdHash1;
|
||||
try {
|
||||
const vqdHeaders = await this.acquireAuthHeaders(signal);
|
||||
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
|
||||
this.pendingVqdHash1 = previousPending;
|
||||
return;
|
||||
}
|
||||
const response = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
"x-ddg-journey-id": randomUUID().replaceAll("-", ""),
|
||||
"x-fe-signals": makeDuckDuckGoFeSignals(),
|
||||
"x-fe-version": this.feVersion,
|
||||
...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}),
|
||||
...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}),
|
||||
}),
|
||||
body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)),
|
||||
signal,
|
||||
});
|
||||
this.rememberResponseCookies(response);
|
||||
if (response.ok) this.rememberChallengeHeader(response);
|
||||
else this.pendingVqdHash1 = previousPending;
|
||||
await response.body?.cancel().catch(() => {});
|
||||
} catch (error) {
|
||||
void error;
|
||||
this.pendingVqdHash1 = previousPending;
|
||||
}
|
||||
}
|
||||
|
||||
private async processResponse(
|
||||
response: Response,
|
||||
streaming: boolean,
|
||||
|
||||
@@ -5,12 +5,38 @@ import { createHash } from "node:crypto";
|
||||
import vm from "node:vm";
|
||||
import { parseFragment, serialize } from "parse5";
|
||||
|
||||
// WARNING: the contents of this template literal are NOT TypeScript — they are plain
|
||||
// script-mode JavaScript executed via `vm.runInContext`. `vm.runInContext` compiles in
|
||||
// script (non-module) mode, so an `export` keyword anywhere in here is a hard
|
||||
// SyntaxError that kills the whole solver. A refactor that mass-added `export` to the
|
||||
// five `function` declarations below silently broke every DuckDuckGo chat request
|
||||
// (solve threw -> unsolved challenge sent -> HTTP 418 ERR_CHALLENGE). Do not add
|
||||
// `export`/`import` to this string; `duckduckgo-challenge-split.test.ts` guards this.
|
||||
export const CHALLENGE_STUBS = String.raw`
|
||||
var __ua = __DDG_REAL_UA__;
|
||||
var __HTML_LOOKUP = __DDG_HTML_LOOKUP__;
|
||||
export function __makeHtmlElement(tag) {
|
||||
// Browser-fidelity shims for the DDG "am I a real browser" probes.
|
||||
// In a browser every built-in stringifies as native code; under a plain vm
|
||||
// context the user-land re-declarations below would otherwise leak their source.
|
||||
function __nativeFn(fn, name){
|
||||
Object.defineProperty(fn, 'name', { value: name, configurable: true });
|
||||
fn.toString = function(){ return 'function ' + name + '() { [native code] }'; };
|
||||
return fn;
|
||||
}
|
||||
__nativeFn(parseInt, 'parseInt');
|
||||
__nativeFn(parseFloat, 'parseFloat');
|
||||
__nativeFn(isNaN, 'isNaN');
|
||||
__nativeFn(encodeURIComponent, 'encodeURIComponent');
|
||||
__nativeFn(decodeURIComponent, 'decodeURIComponent');
|
||||
// NOTE: do NOT seal Math. Real Chromium reports Object.isSealed(Math) === false,
|
||||
// and at least one challenge variant probes exactly that; sealing it here made
|
||||
// the vector differ from the browser by one and failed the challenge.
|
||||
function __makeHtmlElement(tag) {
|
||||
var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' };
|
||||
var el = {
|
||||
// Instantiate against the real per-tag constructor so
|
||||
// document.createElement('div') instanceof HTMLDivElement holds.
|
||||
var el = Object.create(__ctorForTag(tag).prototype);
|
||||
Object.assign(el, {
|
||||
tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1,
|
||||
children: [], childNodes: [], classList: [], dataset: {},
|
||||
offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1,
|
||||
@@ -19,9 +45,9 @@ export function __makeHtmlElement(tag) {
|
||||
getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; },
|
||||
hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; },
|
||||
addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; },
|
||||
querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; },
|
||||
querySelectorAll: function(s){ if (s === '*') { return __makeNodeList(state._qsaCount); } return __makeNodeList(0); },
|
||||
cloneNode: function(){ return __makeHtmlElement(tag); }
|
||||
};
|
||||
});
|
||||
Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true });
|
||||
Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true });
|
||||
Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + '</' + tag + '>'; }, enumerable: true });
|
||||
@@ -30,7 +56,7 @@ export function __makeHtmlElement(tag) {
|
||||
Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true });
|
||||
return el;
|
||||
}
|
||||
export function __mkObj(name, base) {
|
||||
function __mkObj(name, base) {
|
||||
base = base || {};
|
||||
return new Proxy(base, {
|
||||
get: function(t, k) {
|
||||
@@ -54,18 +80,105 @@ export function __mkObj(name, base) {
|
||||
has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; }
|
||||
});
|
||||
}
|
||||
export function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; }
|
||||
export function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; }
|
||||
function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\s*display\s*:\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; }
|
||||
function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; }
|
||||
var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' });
|
||||
var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' });
|
||||
var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' });
|
||||
var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} });
|
||||
// document.body keeps a LIVE children collection: challenges append a node and
|
||||
// assert body.children.length grew by exactly 1, then remove it again.
|
||||
var __bodyKids = [];
|
||||
Object.defineProperty(__bodyKids, 'constructor', { value: HTMLCollection, enumerable: false, configurable: true });
|
||||
var __body = __mkObj('body', {
|
||||
appendChild: function(c){ __bodyKids.push(c); return c; },
|
||||
removeChild: function(c){ var i = __bodyKids.indexOf(c); if (i !== -1) __bodyKids.splice(i, 1); return c; },
|
||||
contains: function(c){ return __bodyKids.indexOf(c) !== -1; },
|
||||
querySelector: function(s){ return s === '#jsa' ? __iframeEl : null; },
|
||||
querySelectorAll: function(s){ return s === '#jsa' ? [__iframeEl] : __makeNodeList(0); },
|
||||
children: __bodyKids, childNodes: __bodyKids,
|
||||
tagName: 'BODY', nodeName: 'BODY', nodeType: 1
|
||||
});
|
||||
var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return __makeNodeList(__bodyKids.length + 3); }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __body, head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} });
|
||||
var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } });
|
||||
window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window;
|
||||
// Object.prototype.toString.call(window) must be "[object Window]".
|
||||
try { window[Symbol.toStringTag] = 'Window'; } catch (e) {}
|
||||
// In a browser a sloppy-mode function called with no receiver gets the global
|
||||
// object, and challenges assert (function(){return this;})() === window.
|
||||
// In a vm context that is the context's own global, so alias it to window.
|
||||
try {
|
||||
var __g = (function(){ return this; })();
|
||||
if (__g && __g !== window) {
|
||||
Object.defineProperty(__g, Symbol.toStringTag, { value: 'Window', configurable: true });
|
||||
// Copy by VALUE, not via accessors. Two reasons:
|
||||
// 1) the var top/self/navigator/... declarations further down are hoisted,
|
||||
// so those names already exist on the vm global and an "in" guard would
|
||||
// skip them, leaving window.navigator undefined;
|
||||
// 2) accessors closing over the window binding would recurse once it is
|
||||
// rebound to __g below.
|
||||
// The stub window is static, so a value copy is equivalent.
|
||||
var __winStub = window;
|
||||
for (var __k in __winStub) {
|
||||
try { __g[__k] = __winStub[__k]; } catch (e) {}
|
||||
}
|
||||
// hasOwnProperty is probed for the __DDG_* markers; keep the stub's version.
|
||||
try { __g.hasOwnProperty = function(k){ return __winStub.hasOwnProperty(k); }; } catch (e) {}
|
||||
window = __g;
|
||||
window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window;
|
||||
}
|
||||
} catch (e) {}
|
||||
var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history;
|
||||
var __R = null, __E = null;
|
||||
export function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; }
|
||||
var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response');
|
||||
// Real DOM constructor chain. Some DDG challenge variants assert
|
||||
// HTMLDivElement.prototype instanceof HTMLElement and
|
||||
// HTMLElement.prototype instanceof Element, so these cannot be flat
|
||||
// unrelated stubs — the prototype links have to be real.
|
||||
function __DomClass(name, parent){
|
||||
var c = function(){};
|
||||
if (parent) c.prototype = Object.create(parent.prototype);
|
||||
c.prototype.constructor = c;
|
||||
Object.defineProperty(c, 'name', { value: name, configurable: true });
|
||||
c.toString = function(){ return 'function ' + name + '() { [native code] }'; };
|
||||
return c;
|
||||
}
|
||||
var EventTarget = __DomClass('EventTarget', null);
|
||||
var Node = __DomClass('Node', EventTarget);
|
||||
var Element = __DomClass('Element', Node);
|
||||
var HTMLElement = __DomClass('HTMLElement', Element);
|
||||
var HTMLDivElement = __DomClass('HTMLDivElement', HTMLElement);
|
||||
var HTMLIFrameElement = __DomClass('HTMLIFrameElement', HTMLElement);
|
||||
var HTMLLIElement = __DomClass('HTMLLIElement', HTMLElement);
|
||||
var HTMLUnknownElement = __DomClass('HTMLUnknownElement', HTMLElement);
|
||||
var Document = __DomClass('Document', Node);
|
||||
var HTMLDocument = __DomClass('HTMLDocument', Document);
|
||||
var NodeList = __DomClass('NodeList', null);
|
||||
var HTMLCollection = __DomClass('HTMLCollection', null);
|
||||
// Map a tag name to the constructor a browser would use, so
|
||||
// document.createElement('div') instanceof HTMLDivElement holds.
|
||||
function __ctorForTag(tag){
|
||||
var t = String(tag||'div').toLowerCase();
|
||||
if (t === 'div') return HTMLDivElement;
|
||||
if (t === 'iframe') return HTMLIFrameElement;
|
||||
if (t === 'li') return HTMLLIElement;
|
||||
return HTMLElement;
|
||||
}
|
||||
// A NodeList-like: array-shaped but NOT a real Array, with .constructor.name
|
||||
// === 'NodeList' — challenges check both !Array.isArray(x) and the ctor name.
|
||||
function __makeNodeList(length){
|
||||
var nl = Object.create(NodeList.prototype);
|
||||
var n = length|0;
|
||||
for (var i = 0; i < n; i++) nl[i] = __makeHtmlElement('div');
|
||||
Object.defineProperty(nl, 'length', { value: n, enumerable: false, configurable: true });
|
||||
nl.item = function(i){ return this[i] || null; };
|
||||
nl.forEach = function(fn, thisArg){ for (var i = 0; i < n; i++) fn.call(thisArg, this[i], i, this); };
|
||||
nl[Symbol.iterator] = function(){ var i = 0, self = this; return { next: function(){ return i < n ? { value: self[i++], done: false } : { value: undefined, done: true }; } }; };
|
||||
return nl;
|
||||
}
|
||||
function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; }
|
||||
// NOTE: HTMLElement / HTMLDivElement / HTMLIFrameElement / Element / Node /
|
||||
// Document / HTMLDocument / NodeList are defined above via __DomClass with a
|
||||
// REAL prototype chain — do not redeclare them here or the instanceof probes break.
|
||||
var Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response');
|
||||
var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); };
|
||||
var getComputedStyle = __getComputedStyle;
|
||||
`;
|
||||
@@ -90,9 +203,16 @@ export function buildHtmlLookup(js: string): Record<string, { html: string; coun
|
||||
if (seen.has(html)) continue;
|
||||
seen.add(html);
|
||||
const fragment = parseFragment(html);
|
||||
// `count` backs `element.querySelectorAll('*').length` for an element whose
|
||||
// innerHTML is `html`. `querySelectorAll('*')` on a container returns its
|
||||
// DESCENDANTS, and `countHtmlElements` already excludes the `#document-fragment`
|
||||
// root, so the fragment's element count IS the descendant count. The former
|
||||
// `- 1` undercounted by one (verified against a real browser: for
|
||||
// `<li><div></li><li></div` Chromium reports 3, this returned 2), which
|
||||
// corrupted every probe that multiplies by that length.
|
||||
lookup[html] = {
|
||||
html: serialize(fragment),
|
||||
count: Math.max(0, countHtmlElements(fragment) - 1),
|
||||
count: countHtmlElements(fragment),
|
||||
};
|
||||
}
|
||||
return lookup;
|
||||
@@ -102,14 +222,33 @@ export function sha256Base64(value: string): string {
|
||||
return createHash("sha256").update(value, "utf8").digest("base64");
|
||||
}
|
||||
|
||||
// Shape of the object a DDG challenge program resolves to.
|
||||
type DuckDuckGoChallengeResult = {
|
||||
client_hashes?: unknown;
|
||||
meta?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Origin the solved challenge claims to come from. The duck.ai frontend stamps
|
||||
* `meta.origin` with its own origin and the upstream cross-checks it.
|
||||
*/
|
||||
export const DUCKDUCKGO_CHALLENGE_ORIGIN = "https://duck.ai";
|
||||
|
||||
/**
|
||||
* `meta.stack` mimics the frontend's captured Error stack. The upstream only
|
||||
* requires a plausible stack that points at the duck.ai bundle — verified by
|
||||
* ablation: a generic bundle path is accepted, omitting the field is not.
|
||||
*/
|
||||
function buildChallengeStack(origin: string, bundlePath: string): string {
|
||||
const url = `${origin}${bundlePath}`;
|
||||
return `Error\nat l (${url}:2:1695625)\nat async ${url}:2:1519117`;
|
||||
}
|
||||
|
||||
export async function solveDuckDuckGoChallenge(
|
||||
challenge: string,
|
||||
userAgent: string
|
||||
userAgent: string,
|
||||
options: { origin?: string; bundlePath?: string } = {}
|
||||
): Promise<string> {
|
||||
// SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext.
|
||||
// The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout
|
||||
@@ -121,14 +260,31 @@ export async function solveDuckDuckGoChallenge(
|
||||
);
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(stubs, context, { timeout: 5000 });
|
||||
const startedAt = Date.now();
|
||||
const result = (await vm.runInContext(js, context, {
|
||||
timeout: 5000,
|
||||
})) as DuckDuckGoChallengeResult;
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : [];
|
||||
if (clientHashes.length === 0)
|
||||
throw new Error("DuckDuckGo challenge returned empty client_hashes");
|
||||
clientHashes[0] = userAgent;
|
||||
result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash)));
|
||||
|
||||
// The real frontend augments the challenge's own `meta` with origin / stack /
|
||||
// duration before sending it back. Omitting them yields 418 ERR_CHALLENGE even
|
||||
// when every client_hash is correct (confirmed by capturing a real browser's
|
||||
// x-vqd-hash-1 header, which always carries all three).
|
||||
const origin = options.origin ?? DUCKDUCKGO_CHALLENGE_ORIGIN;
|
||||
const bundlePath = options.bundlePath ?? "/dist/duckai-dist/entry.duckai.js";
|
||||
const meta = (result.meta ?? {}) as Record<string, unknown>;
|
||||
result.meta = {
|
||||
...meta,
|
||||
origin,
|
||||
stack: buildChallengeStack(origin, bundlePath),
|
||||
duration: String(elapsedMs),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(result), "utf8").toString("base64");
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
adobeFireflyImageTimeoutMs,
|
||||
adobeFireflyMaxImageRefs,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
|
||||
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
|
||||
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
|
||||
|
||||
@@ -90,7 +90,8 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
|
||||
// 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 psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
@@ -98,15 +99,11 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
// Cap uploads by model family. gpt-image: 2 subject refs max (3–4+ stalls colligo → 504).
|
||||
// nano: 4 general refs for multi-panel composition.
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs = adobeFireflyMaxImageRefs(resolvedId);
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
const { spec } = resolveAdobeImageModel(model);
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxRefs,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
sessionCookie,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
@@ -121,13 +118,13 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
: undefined;
|
||||
const timeoutMs = adobeFireflyImageTimeoutMs({
|
||||
timeoutMs: explicitTimeout,
|
||||
refCount: sourceImageIds.length,
|
||||
refCount: references.length,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}/${maxRefs}` : "") +
|
||||
(references.length ? ` | refs: ${references.length}` : "") +
|
||||
` | pollTimeoutMs=${timeoutMs}`
|
||||
);
|
||||
|
||||
@@ -139,9 +136,8 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
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,
|
||||
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
references: references.length ? references : undefined,
|
||||
sessionCookie,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateVideo,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeVideoModel,
|
||||
} from "../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
@@ -55,7 +56,8 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
// Keep raw paste for Cookie + sherlockToken (x-arp-session-id).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })?.providerSpecificData;
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
@@ -63,13 +65,11 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
// Kling i2v / Veo ref / Sora frame: upload reference images first.
|
||||
const { id: videoModelId } = resolveAdobeVideoModel(String(model));
|
||||
const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3;
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
const { spec } = resolveAdobeVideoModel(String(model));
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxFrames,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
sessionCookie,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
@@ -79,7 +79,7 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
log?.info?.(
|
||||
"VIDEO",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "")
|
||||
(references.length ? ` | refs: ${references.length}` : "")
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateVideo({
|
||||
@@ -99,7 +99,7 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? body.negativePrompt
|
||||
: undefined,
|
||||
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
references: references.length ? references : undefined,
|
||||
sessionCookie,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
8
open-sse/services/adobeFireflyModelSnapshot.ts
Normal file
8
open-sse/services/adobeFireflyModelSnapshot.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -1,328 +1,590 @@
|
||||
/**
|
||||
* Adobe Firefly model catalog: live discovery + static fallback from browser capture.
|
||||
* Adobe Firefly model discovery and normalized media capabilities.
|
||||
*
|
||||
* 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.
|
||||
* The live discovery schema is authoritative. The generated snapshot is used only
|
||||
* when a request cannot perform authenticated discovery (for example /v1/models).
|
||||
*/
|
||||
|
||||
import {
|
||||
type AdobeFireflyDiscoveredModel,
|
||||
discoverAdobeFireflyModels,
|
||||
resolveAdobeAccessToken,
|
||||
} from "./adobeFireflyClient.ts";
|
||||
import { ADOBE_FIREFLY_DISCOVERY_SNAPSHOT } from "./adobeFireflyModelSnapshot.ts";
|
||||
|
||||
export type AdobeFireflyModality = "image" | "video" | "audio" | "unknown";
|
||||
|
||||
export interface AdobeFireflyDiscoveredModel {
|
||||
modelId: string;
|
||||
modelVersion: string;
|
||||
displayName: string;
|
||||
modality: AdobeFireflyModality;
|
||||
enabled: boolean;
|
||||
providerName?: string;
|
||||
releaseReadiness?: string;
|
||||
healthStatus?: string;
|
||||
inputMediaUseCases: string[];
|
||||
requestSchema?: Record<string, unknown>;
|
||||
backingModel?: string;
|
||||
}
|
||||
|
||||
export interface AdobeFireflyReferenceInputCapability {
|
||||
mediaType: string;
|
||||
usageType: string;
|
||||
minItems: number;
|
||||
maxItems: number | null;
|
||||
maxFileSizeBytes: number | null;
|
||||
}
|
||||
|
||||
export interface AdobeFireflyMediaCapabilities {
|
||||
inputMediaUseCases: string[];
|
||||
schemaProperties: string[];
|
||||
requiredProperties: string[];
|
||||
referenceInputs: AdobeFireflyReferenceInputCapability[];
|
||||
maxReferenceItems: number | null;
|
||||
supportedSizes: string[];
|
||||
supportedAspectRatios: string[];
|
||||
supportedResolutions: string[];
|
||||
supportedDurations: number[];
|
||||
durationMin: number | null;
|
||||
durationMax: number | null;
|
||||
durationDefault: number | null;
|
||||
outputCountMin: number | null;
|
||||
outputCountMax: number | null;
|
||||
promptMaxLength: number | null;
|
||||
releaseReadiness: string;
|
||||
healthStatus: string;
|
||||
}
|
||||
|
||||
export interface AdobeFireflyCatalogModel {
|
||||
/** OpenAI-style id without provider prefix, e.g. nano-banana-pro or flux-fluxPro */
|
||||
/** Stable API id without the provider prefix. */
|
||||
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[];
|
||||
providerName: string;
|
||||
backingModel: string;
|
||||
inputModalities: string[];
|
||||
capabilities: AdobeFireflyMediaCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
},
|
||||
];
|
||||
export interface AdobeFireflyImageModelSpec extends AdobeFireflyCatalogModel {
|
||||
modality: "image";
|
||||
/** Payload dialect observed for this model family. */
|
||||
family: "gemini" | "gpt-image" | "generic";
|
||||
}
|
||||
|
||||
/** Stable slug for upstream modelId + modelVersion (catalog id when not a friendly alias). */
|
||||
export interface AdobeFireflyVideoModelSpec extends AdobeFireflyCatalogModel {
|
||||
modality: "video";
|
||||
defaultDuration: number;
|
||||
defaultResolution: string;
|
||||
}
|
||||
|
||||
interface MergedObjectSchema {
|
||||
properties: Record<string, Record<string, unknown>>;
|
||||
required: string[];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => String(item)).filter((item) => item.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function finiteInteger(value: unknown): number | null {
|
||||
return Number.isInteger(value) ? (value as number) : null;
|
||||
}
|
||||
|
||||
/** Merge object properties/required keys contributed through JSON Schema allOf. */
|
||||
export function mergeAdobeObjectSchema(schema: unknown): MergedObjectSchema {
|
||||
const merged: MergedObjectSchema = { properties: {}, required: [] };
|
||||
const visit = (value: unknown) => {
|
||||
const node = asRecord(value);
|
||||
const properties = asRecord(node.properties);
|
||||
for (const [key, property] of Object.entries(properties)) {
|
||||
merged.properties[key] = asRecord(property);
|
||||
}
|
||||
merged.required.push(...asStringArray(node.required));
|
||||
if (Array.isArray(node.allOf)) node.allOf.forEach(visit);
|
||||
};
|
||||
visit(schema);
|
||||
merged.required = [...new Set(merged.required)];
|
||||
return merged;
|
||||
}
|
||||
|
||||
function schemaBranches(schema: unknown): Record<string, unknown>[] {
|
||||
const root = asRecord(schema);
|
||||
if (Object.keys(root).length === 0) return [];
|
||||
return [
|
||||
root,
|
||||
...(Array.isArray(root.anyOf) ? root.anyOf.map(asRecord) : []),
|
||||
...(Array.isArray(root.oneOf) ? root.oneOf.map(asRecord) : []),
|
||||
];
|
||||
}
|
||||
|
||||
function enumStrings(schema: unknown): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
schemaBranches(schema)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function integerBranch(schema: unknown): Record<string, unknown> {
|
||||
return schemaBranches(schema).find((branch) => branch.type === "integer") || {};
|
||||
}
|
||||
|
||||
/** Stable, collision-resistant public id for an exact upstream model/version pair. */
|
||||
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}`;
|
||||
const slug = (value: string, allowDot = false) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const family = slug(modelId);
|
||||
// Adobe still uses `kling_v3_omni*` internally, while discovery exposes these
|
||||
// products to users as Kling O3. Never leak the obsolete/internal "omni" name
|
||||
// into the public API catalog; the untouched upstream version stays in the spec.
|
||||
const publicVersion =
|
||||
family === "kling" ? modelVersion.replace(/^kling_v3_omni/i, "kling_o3") : modelVersion;
|
||||
const version = slug(publicVersion, true);
|
||||
if (!version || version === "default" || version === family) return family || "model";
|
||||
return `${family}-${version}`;
|
||||
}
|
||||
|
||||
/** Map discovery rows → catalog entries (image/video only). */
|
||||
export function mapDiscoveredToCatalog(
|
||||
rows: AdobeFireflyDiscoveredModel[]
|
||||
): AdobeFireflyCatalogModel[] {
|
||||
const out: AdobeFireflyCatalogModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
/** Parse POST /v2/models/discovery without discarding its resolved request schema. */
|
||||
export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] {
|
||||
const root = asRecord(body);
|
||||
const families = Array.isArray(root.models) ? root.models : [];
|
||||
const rows: AdobeFireflyDiscoveredModel[] = [];
|
||||
|
||||
// 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 familyValue of families) {
|
||||
const family = asRecord(familyValue);
|
||||
const modelId = String(family.modelId || "").trim();
|
||||
if (!modelId) continue;
|
||||
for (const [modelVersion, versionValue] of Object.entries(asRecord(family.modelVersions))) {
|
||||
const version = asRecord(versionValue);
|
||||
if (version.enabled === false) continue;
|
||||
const outputModalities = asStringArray(version.outputModality).map((item) =>
|
||||
item.toLowerCase()
|
||||
);
|
||||
const modality: AdobeFireflyModality = outputModalities.includes("image")
|
||||
? "image"
|
||||
: outputModalities.includes("video")
|
||||
? "video"
|
||||
: outputModalities.includes("audio")
|
||||
? "audio"
|
||||
: "unknown";
|
||||
rows.push({
|
||||
modelId,
|
||||
modelVersion,
|
||||
displayName: String(
|
||||
version.modelDisplayName || version.modelCaiDisplayName || modelVersion
|
||||
),
|
||||
modality,
|
||||
enabled: version.enabled !== false,
|
||||
providerName:
|
||||
typeof family.acModelFamilyProviderDisplayName === "string"
|
||||
? family.acModelFamilyProviderDisplayName
|
||||
: undefined,
|
||||
releaseReadiness:
|
||||
typeof version.releaseReadiness === "string" ? version.releaseReadiness : undefined,
|
||||
healthStatus: typeof version.healthStatus === "string" ? version.healthStatus : undefined,
|
||||
inputMediaUseCases: asStringArray(version.inputMediaUseCase),
|
||||
requestSchema: asRecord(version.requestSchema),
|
||||
backingModel:
|
||||
typeof version.bksGenerationModel === "string" ? version.bksGenerationModel : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function normalizeCapabilities(row: AdobeFireflyDiscoveredModel): AdobeFireflyMediaCapabilities {
|
||||
const schema = mergeAdobeObjectSchema(row.requestSchema);
|
||||
const referenceSchema = asRecord(schema.properties.referenceBlobs);
|
||||
const referenceInputs: AdobeFireflyReferenceInputCapability[] = [];
|
||||
const mediaCapabilities = Array.isArray(referenceSchema["x-capabilities"])
|
||||
? referenceSchema["x-capabilities"]
|
||||
: [];
|
||||
for (const mediaValue of mediaCapabilities) {
|
||||
const media = asRecord(mediaValue);
|
||||
const maxFileSizeBytes = finiteInteger(media.maxFileSizeBytes);
|
||||
const usageConstraints = Array.isArray(media.usageConstraints) ? media.usageConstraints : [];
|
||||
for (const usageValue of usageConstraints) {
|
||||
const usage = asRecord(usageValue);
|
||||
if (usage.deprecated === true) continue;
|
||||
const usageType = String(usage.usageType || "");
|
||||
const mediaType = String(media.mediaType || "");
|
||||
if (!usageType || !mediaType) continue;
|
||||
referenceInputs.push({
|
||||
mediaType,
|
||||
usageType,
|
||||
minItems: finiteInteger(usage.minItems) ?? 0,
|
||||
maxItems: finiteInteger(usage.maxItems),
|
||||
maxFileSizeBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const supportedSizes = [
|
||||
...new Set(
|
||||
schemaBranches(schema.properties.size)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.map(asRecord)
|
||||
.filter((size) => finiteInteger(size.width) !== null && finiteInteger(size.height) !== null)
|
||||
.map((size) => `${size.width}x${size.height}`)
|
||||
),
|
||||
];
|
||||
const supportedAspectRatios = [
|
||||
...new Set(
|
||||
schemaBranches(schema.properties.generationSettings).flatMap((branch) =>
|
||||
enumStrings(asRecord(asRecord(branch.properties).aspectRatio))
|
||||
)
|
||||
) {
|
||||
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
|
||||
}
|
||||
),
|
||||
];
|
||||
const duration = integerBranch(schema.properties.duration);
|
||||
const outputCount = integerBranch(schema.properties.n);
|
||||
const prompt =
|
||||
schemaBranches(schema.properties.prompt).find((branch) => branch.type === "string") || {};
|
||||
|
||||
return {
|
||||
models: getAdobeFireflyFallbackCatalog(opts.modality),
|
||||
source: "fallback",
|
||||
inputMediaUseCases: [...row.inputMediaUseCases],
|
||||
schemaProperties: Object.keys(schema.properties),
|
||||
requiredProperties: [...schema.required],
|
||||
referenceInputs,
|
||||
maxReferenceItems: finiteInteger(referenceSchema.maxItems),
|
||||
supportedSizes,
|
||||
supportedAspectRatios,
|
||||
supportedResolutions: enumStrings(schema.properties.resolution),
|
||||
supportedDurations: [
|
||||
...new Set(
|
||||
schemaBranches(schema.properties.duration)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter((value): value is number => Number.isInteger(value))
|
||||
),
|
||||
],
|
||||
durationMin: finiteInteger(duration.minimum),
|
||||
durationMax: finiteInteger(duration.maximum),
|
||||
durationDefault: finiteInteger(duration.default),
|
||||
outputCountMin: finiteInteger(outputCount.minimum),
|
||||
outputCountMax: finiteInteger(outputCount.maximum),
|
||||
promptMaxLength: finiteInteger(prompt.maxLength),
|
||||
releaseReadiness: row.releaseReadiness || "",
|
||||
healthStatus: row.healthStatus || "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 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"],
|
||||
}));
|
||||
function isCallableGenerationModel(row: AdobeFireflyDiscoveredModel): boolean {
|
||||
if (row.modality !== "image" && row.modality !== "video") return false;
|
||||
if (!mergeAdobeObjectSchema(row.requestSchema).properties.prompt) return false;
|
||||
const excluded = new Set(["upscaling", "sharpening", "denoising"]);
|
||||
return !row.inputMediaUseCases.some((value) => excluded.has(value.toLowerCase()));
|
||||
}
|
||||
|
||||
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}`,
|
||||
}));
|
||||
function deriveInputModalities(capabilities: AdobeFireflyMediaCapabilities): string[] {
|
||||
return ["text", ...new Set(capabilities.referenceInputs.map((reference) => reference.mediaType))];
|
||||
}
|
||||
|
||||
function semanticCatalogKey(model: AdobeFireflyCatalogModel): string {
|
||||
return JSON.stringify({
|
||||
backingModel: model.backingModel,
|
||||
name: model.name,
|
||||
modality: model.modality,
|
||||
capabilities: model.capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize and de-duplicate callable image/video rows from live discovery. */
|
||||
export function mapDiscoveredToCatalog(
|
||||
rows: AdobeFireflyDiscoveredModel[]
|
||||
): AdobeFireflyCatalogModel[] {
|
||||
const output: AdobeFireflyCatalogModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!isCallableGenerationModel(row)) continue;
|
||||
const capabilities = normalizeCapabilities(row);
|
||||
const model: AdobeFireflyCatalogModel = {
|
||||
id: slugifyAdobeModel(row.modelId, row.modelVersion),
|
||||
name: row.displayName,
|
||||
modality: row.modality as "image" | "video",
|
||||
upstreamModelId: row.modelId,
|
||||
upstreamModelVersion: row.modelVersion,
|
||||
providerName: row.providerName || "",
|
||||
backingModel: row.backingModel || "",
|
||||
inputModalities: deriveInputModalities(capabilities),
|
||||
capabilities,
|
||||
};
|
||||
const key = semanticCatalogKey(model);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
output.push(model);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function snapshotCatalog(): AdobeFireflyCatalogModel[] {
|
||||
return ADOBE_FIREFLY_DISCOVERY_SNAPSHOT.map((model) => {
|
||||
const capabilities: AdobeFireflyMediaCapabilities = {
|
||||
inputMediaUseCases: [...model.inputMediaUseCases],
|
||||
schemaProperties: [...model.schemaProperties],
|
||||
requiredProperties: [...model.requiredProperties],
|
||||
referenceInputs: model.referenceInputs.map((reference) => ({ ...reference })),
|
||||
maxReferenceItems: model.maxReferenceItems,
|
||||
supportedSizes: [...model.supportedSizes],
|
||||
supportedAspectRatios: [...model.supportedAspectRatios],
|
||||
supportedResolutions: [...model.supportedResolutions],
|
||||
supportedDurations: [...model.supportedDurations],
|
||||
durationMin: model.durationMin,
|
||||
durationMax: model.durationMax,
|
||||
durationDefault: model.durationDefault,
|
||||
outputCountMin: model.outputCountMin,
|
||||
outputCountMax: model.outputCountMax,
|
||||
promptMaxLength: model.promptMaxLength,
|
||||
releaseReadiness: model.releaseReadiness,
|
||||
healthStatus: model.healthStatus,
|
||||
};
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
modality: model.modality,
|
||||
upstreamModelId: model.upstreamModelId,
|
||||
upstreamModelVersion: model.upstreamModelVersion,
|
||||
providerName: model.providerName,
|
||||
backingModel: model.backingModel,
|
||||
inputModalities: deriveInputModalities(capabilities),
|
||||
capabilities,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const ADOBE_FIREFLY_FALLBACK_MODELS: AdobeFireflyCatalogModel[] = snapshotCatalog();
|
||||
|
||||
export function getAdobeFireflyFallbackCatalog(
|
||||
modality?: "image" | "video"
|
||||
): AdobeFireflyCatalogModel[] {
|
||||
return ADOBE_FIREFLY_FALLBACK_MODELS.filter((model) => !modality || model.modality === modality);
|
||||
}
|
||||
|
||||
function imageFamily(model: AdobeFireflyCatalogModel): AdobeFireflyImageModelSpec["family"] {
|
||||
if (model.upstreamModelId === "gemini-flash") return "gemini";
|
||||
if (model.upstreamModelId === "gpt-image" || model.upstreamModelId === "gpt-4o-image") {
|
||||
return "gpt-image";
|
||||
}
|
||||
return "generic";
|
||||
}
|
||||
|
||||
export const ADOBE_FIREFLY_IMAGE_MODELS: Record<string, AdobeFireflyImageModelSpec> =
|
||||
Object.fromEntries(
|
||||
getAdobeFireflyFallbackCatalog("image").map((model) => [
|
||||
model.id,
|
||||
{ ...model, modality: "image" as const, family: imageFamily(model) },
|
||||
])
|
||||
);
|
||||
|
||||
function defaultDuration(model: AdobeFireflyCatalogModel): number {
|
||||
const caps = model.capabilities;
|
||||
return caps.durationDefault ?? caps.supportedDurations[0] ?? caps.durationMin ?? 5;
|
||||
}
|
||||
|
||||
function defaultResolution(model: AdobeFireflyCatalogModel): string {
|
||||
if (model.capabilities.supportedSizes.some((value) => value.includes("1920x1080"))) {
|
||||
return "1080p";
|
||||
}
|
||||
return "720p";
|
||||
}
|
||||
|
||||
export const ADOBE_FIREFLY_VIDEO_MODELS: Record<string, AdobeFireflyVideoModelSpec> =
|
||||
Object.fromEntries(
|
||||
getAdobeFireflyFallbackCatalog("video").map((model) => [
|
||||
model.id,
|
||||
{
|
||||
...model,
|
||||
modality: "video" as const,
|
||||
defaultDuration: defaultDuration(model),
|
||||
defaultResolution: defaultResolution(model),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const LEGACY_MODEL_ALIASES: Record<string, string> = {
|
||||
"nano-banana": "gemini-flash-nano-banana",
|
||||
"nano-banana-pro": "gemini-flash-nano-banana-2",
|
||||
"nano-banana-2": "gemini-flash-nano-banana-3",
|
||||
"gpt-image": "gpt-image-2",
|
||||
"gpt-image-2": "gpt-image-2",
|
||||
"gpt-image-1.5": "gpt-image-1.5",
|
||||
"flux-2": "flux-2",
|
||||
"flux-pro": "flux-fluxpro",
|
||||
"flux-ultra": "flux-fluxultra",
|
||||
"seedream-4": "seedream-seedream-v4",
|
||||
"seedream-5-lite": "seedream-seedream-v5-lite",
|
||||
"runway-gen4-image": "runway-gen4-image",
|
||||
"veo-3.1": "veo-3.1-generate",
|
||||
"veo-3.1-fast": "veo-3.1-fast-generate",
|
||||
"luma-ray3": "luma-3.0-ray",
|
||||
"runway-gen4-turbo": "runway-gen4-turbo",
|
||||
// Backward compatibility only; the catalog advertises the exact discovered id.
|
||||
"kling-3": "kling-kling-v3-standard-i2v",
|
||||
};
|
||||
|
||||
// Preserve established API aliases when (and only when) they resolve to a model
|
||||
// that is present in the verified discovery snapshot. These keys are not listed.
|
||||
for (const [alias, target] of Object.entries(LEGACY_MODEL_ALIASES)) {
|
||||
const imageTarget = ADOBE_FIREFLY_IMAGE_MODELS[target];
|
||||
if (imageTarget) ADOBE_FIREFLY_IMAGE_MODELS[alias] = imageTarget;
|
||||
const videoTarget = ADOBE_FIREFLY_VIDEO_MODELS[target];
|
||||
if (videoTarget) ADOBE_FIREFLY_VIDEO_MODELS[alias] = videoTarget;
|
||||
}
|
||||
|
||||
/** Backward-compatible request ids. Kept out of every advertised model catalog. */
|
||||
export const ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES = Object.freeze(
|
||||
Object.entries(LEGACY_MODEL_ALIASES)
|
||||
.filter(([, target]) => Boolean(ADOBE_FIREFLY_IMAGE_MODELS[target]))
|
||||
.map(([alias]) => alias)
|
||||
);
|
||||
|
||||
function normalizeRequestedId(model: string): string {
|
||||
return String(model || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^adobe-firefly\//, "")
|
||||
.replace(/^firefly\//, "");
|
||||
}
|
||||
|
||||
function resolveCatalogId(model: string): string {
|
||||
const requested = normalizeRequestedId(model);
|
||||
return LEGACY_MODEL_ALIASES[requested] || requested;
|
||||
}
|
||||
|
||||
export function resolveAdobeImageModel(model: string): {
|
||||
id: string;
|
||||
spec: AdobeFireflyImageModelSpec;
|
||||
} {
|
||||
const id = resolveCatalogId(model);
|
||||
const spec = ADOBE_FIREFLY_IMAGE_MODELS[id];
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Unknown Adobe Firefly image model: ${normalizeRequestedId(model) || "(empty)"}`
|
||||
);
|
||||
}
|
||||
return { id, spec };
|
||||
}
|
||||
|
||||
export function resolveAdobeVideoModel(model: string): {
|
||||
id: string;
|
||||
spec: AdobeFireflyVideoModelSpec;
|
||||
} {
|
||||
const id = resolveCatalogId(model);
|
||||
const spec = ADOBE_FIREFLY_VIDEO_MODELS[id];
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Unknown Adobe Firefly video model: ${normalizeRequestedId(model) || "(empty)"}`
|
||||
);
|
||||
}
|
||||
return { id, spec };
|
||||
}
|
||||
|
||||
export function toRegistryImageModels(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
inputModalities: string[];
|
||||
imageRequired?: boolean;
|
||||
supportedSizes: string[];
|
||||
mediaCapabilities: Record<string, unknown>;
|
||||
}> {
|
||||
const generated = getAdobeFireflyFallbackCatalog("image").map((model) => ({
|
||||
id: model.id,
|
||||
name: `Firefly ${model.name}`,
|
||||
inputModalities: model.inputModalities,
|
||||
supportedSizes: model.capabilities.supportedSizes,
|
||||
mediaCapabilities: toAdobeMediaCapabilitiesApi(model),
|
||||
}));
|
||||
// Upscaling uses a distinct Firefly endpoint and is not returned by the image
|
||||
// generation discovery schema. Keep its two supported Topaz models visible in
|
||||
// the same provider catalog so image clients can select them deliberately.
|
||||
return [
|
||||
...generated,
|
||||
{
|
||||
id: "topaz-standard",
|
||||
name: "Firefly Topaz Upscale (Standard)",
|
||||
inputModalities: ["image"],
|
||||
imageRequired: true,
|
||||
supportedSizes: [],
|
||||
mediaCapabilities: { input_media_use_cases: ["upscaling"] },
|
||||
},
|
||||
{
|
||||
id: "topaz-bloom",
|
||||
name: "Firefly Topaz Bloom (Creative Upscale)",
|
||||
inputModalities: ["image"],
|
||||
imageRequired: true,
|
||||
supportedSizes: [],
|
||||
mediaCapabilities: { input_media_use_cases: ["upscaling"] },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function toRegistryVideoModels(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
supportedSizes: string[];
|
||||
mediaCapabilities: Record<string, unknown>;
|
||||
}> {
|
||||
return getAdobeFireflyFallbackCatalog("video").map((model) => ({
|
||||
id: model.id,
|
||||
name: `Firefly ${model.name}`,
|
||||
supportedSizes: model.capabilities.supportedSizes,
|
||||
mediaCapabilities: toAdobeMediaCapabilitiesApi(model),
|
||||
}));
|
||||
}
|
||||
|
||||
/** JSON-safe extension emitted by /v1/models. */
|
||||
export function toAdobeMediaCapabilitiesApi(
|
||||
model: AdobeFireflyCatalogModel
|
||||
): Record<string, unknown> {
|
||||
const caps = model.capabilities;
|
||||
return {
|
||||
upstream_model_id: model.upstreamModelId,
|
||||
upstream_model_version: model.upstreamModelVersion,
|
||||
provider_name: model.providerName,
|
||||
release_readiness: caps.releaseReadiness,
|
||||
health_status: caps.healthStatus,
|
||||
input_media_use_cases: caps.inputMediaUseCases,
|
||||
reference_inputs: caps.referenceInputs.map((reference) => ({
|
||||
media_type: reference.mediaType,
|
||||
usage_type: reference.usageType,
|
||||
min_items: reference.minItems,
|
||||
max_items: reference.maxItems,
|
||||
max_file_size_bytes: reference.maxFileSizeBytes,
|
||||
})),
|
||||
max_reference_items: caps.maxReferenceItems,
|
||||
supported_sizes: caps.supportedSizes,
|
||||
supported_aspect_ratios: caps.supportedAspectRatios,
|
||||
supported_resolutions: caps.supportedResolutions,
|
||||
supported_durations: caps.supportedDurations,
|
||||
duration_min: caps.durationMin,
|
||||
duration_max: caps.durationMax,
|
||||
duration_default: caps.durationDefault,
|
||||
output_count_min: caps.outputCountMin,
|
||||
output_count_max: caps.outputCountMax,
|
||||
prompt_max_length: caps.promptMaxLength,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdobeReferenceUploadLimit(
|
||||
model: AdobeFireflyCatalogModel,
|
||||
mediaType: string
|
||||
): number {
|
||||
if (model.capabilities.maxReferenceItems !== null) {
|
||||
return Math.max(1, Math.min(32, model.capabilities.maxReferenceItems));
|
||||
}
|
||||
const declaredTotal = model.capabilities.referenceInputs
|
||||
.filter((reference) => reference.mediaType === mediaType)
|
||||
.reduce((total, reference) => total + (reference.maxItems ?? 0), 0);
|
||||
return Math.max(1, Math.min(32, declaredTotal || 1));
|
||||
}
|
||||
|
||||
207
scripts/dev/generate-adobe-firefly-snapshot.mjs
Normal file
207
scripts/dev/generate-adobe-firefly-snapshot.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
"Usage: node scripts/dev/generate-adobe-firefly-snapshot.mjs <discovery.json> <output.ts>"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const [, , inputArg, outputArg] = process.argv;
|
||||
if (!inputArg || !outputArg) usage();
|
||||
|
||||
const inputPath = path.resolve(inputArg);
|
||||
const outputPath = path.resolve(outputArg);
|
||||
const inputBytes = fs.readFileSync(inputPath);
|
||||
const sourceHash = createHash("sha256").update(inputBytes).digest("hex");
|
||||
const root = JSON.parse(inputBytes.toString("utf8"));
|
||||
|
||||
function mergeObjectSchema(schema) {
|
||||
const merged = { properties: {}, required: [] };
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.properties && typeof node.properties === "object") {
|
||||
Object.assign(merged.properties, node.properties);
|
||||
}
|
||||
if (Array.isArray(node.required)) merged.required.push(...node.required);
|
||||
if (Array.isArray(node.allOf)) node.allOf.forEach(visit);
|
||||
};
|
||||
visit(schema);
|
||||
merged.required = [...new Set(merged.required)];
|
||||
return merged;
|
||||
}
|
||||
|
||||
function branches(schema) {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
return [schema, ...(schema.anyOf || []), ...(schema.oneOf || [])];
|
||||
}
|
||||
|
||||
function stringEnums(schema) {
|
||||
return [
|
||||
...new Set(
|
||||
branches(schema)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter((value) => typeof value === "string")
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function integerSchema(schema) {
|
||||
return branches(schema).find((branch) => branch.type === "integer") || {};
|
||||
}
|
||||
|
||||
function publicModelId(modelId, modelVersion) {
|
||||
const slug = (value, allowDot = false) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const family = slug(modelId);
|
||||
const publicVersion =
|
||||
family === "kling" ? String(modelVersion).replace(/^kling_v3_omni/i, "kling_o3") : modelVersion;
|
||||
const version = slug(publicVersion, true);
|
||||
if (!version || version === "default" || version === family) return family || "model";
|
||||
return `${family}-${version}`;
|
||||
}
|
||||
|
||||
function normalizeModel(family, modelVersion, version) {
|
||||
const schema = mergeObjectSchema(version.requestSchema);
|
||||
const properties = schema.properties;
|
||||
const referenceSchema = properties.referenceBlobs || {};
|
||||
const referenceInputs = [];
|
||||
for (const media of referenceSchema["x-capabilities"] || []) {
|
||||
for (const usage of media.usageConstraints || []) {
|
||||
if (usage.deprecated === true) continue;
|
||||
referenceInputs.push({
|
||||
mediaType: String(media.mediaType || ""),
|
||||
usageType: String(usage.usageType || ""),
|
||||
minItems: Number.isInteger(usage.minItems) ? usage.minItems : 0,
|
||||
maxItems: Number.isInteger(usage.maxItems) ? usage.maxItems : null,
|
||||
maxFileSizeBytes: Number.isInteger(media.maxFileSizeBytes) ? media.maxFileSizeBytes : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const supportedSizes = [
|
||||
...new Set(
|
||||
branches(properties.size)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter(
|
||||
(size) =>
|
||||
size &&
|
||||
Number.isInteger(size.width) &&
|
||||
size.width > 0 &&
|
||||
Number.isInteger(size.height) &&
|
||||
size.height > 0
|
||||
)
|
||||
.map((size) => `${size.width}x${size.height}`)
|
||||
),
|
||||
];
|
||||
const supportedAspectRatios = [
|
||||
...new Set(
|
||||
branches(properties.generationSettings).flatMap((branch) =>
|
||||
stringEnums(branch?.properties?.aspectRatio)
|
||||
)
|
||||
),
|
||||
];
|
||||
const duration = integerSchema(properties.duration);
|
||||
const supportedDurations = [
|
||||
...new Set(
|
||||
branches(properties.duration)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter(Number.isInteger)
|
||||
),
|
||||
];
|
||||
const prompt = branches(properties.prompt).find((branch) => branch.type === "string") || {};
|
||||
const outputCount = integerSchema(properties.n);
|
||||
|
||||
return {
|
||||
id: publicModelId(family.modelId, modelVersion),
|
||||
name: String(version.modelDisplayName || version.modelCaiDisplayName || modelVersion),
|
||||
modality: version.outputModality[0],
|
||||
upstreamModelId: family.modelId,
|
||||
upstreamModelVersion: modelVersion,
|
||||
providerName: String(family.acModelFamilyProviderDisplayName || ""),
|
||||
releaseReadiness: String(version.releaseReadiness || ""),
|
||||
healthStatus: String(version.healthStatus || ""),
|
||||
inputMediaUseCases: (version.inputMediaUseCase || []).map(String),
|
||||
schemaProperties: Object.keys(properties),
|
||||
requiredProperties: schema.required,
|
||||
referenceInputs,
|
||||
maxReferenceItems: Number.isInteger(referenceSchema.maxItems) ? referenceSchema.maxItems : null,
|
||||
supportedSizes,
|
||||
supportedAspectRatios,
|
||||
supportedResolutions: stringEnums(properties.resolution),
|
||||
supportedDurations,
|
||||
durationMin: Number.isInteger(duration.minimum) ? duration.minimum : null,
|
||||
durationMax: Number.isInteger(duration.maximum) ? duration.maximum : null,
|
||||
durationDefault: Number.isInteger(duration.default) ? duration.default : null,
|
||||
outputCountMin: Number.isInteger(outputCount.minimum) ? outputCount.minimum : null,
|
||||
outputCountMax: Number.isInteger(outputCount.maximum) ? outputCount.maximum : null,
|
||||
promptMaxLength: Number.isInteger(prompt.maxLength) ? prompt.maxLength : null,
|
||||
backingModel: String(version.bksGenerationModel || ""),
|
||||
};
|
||||
}
|
||||
|
||||
const rawModels = [];
|
||||
for (const family of Array.isArray(root.models) ? root.models : []) {
|
||||
for (const [modelVersion, version] of Object.entries(family.modelVersions || {})) {
|
||||
if (!version || version.enabled === false) continue;
|
||||
const modality = Array.isArray(version.outputModality)
|
||||
? version.outputModality.map((value) => String(value).toLowerCase())[0]
|
||||
: "";
|
||||
if (modality !== "image" && modality !== "video") continue;
|
||||
|
||||
const schema = mergeObjectSchema(version.requestSchema);
|
||||
if (!schema.properties.prompt) continue;
|
||||
const useCases = (version.inputMediaUseCase || []).map((value) => String(value).toLowerCase());
|
||||
if (useCases.some((value) => ["upscaling", "sharpening", "denoising"].includes(value))) {
|
||||
continue;
|
||||
}
|
||||
rawModels.push(normalizeModel(family, modelVersion, version));
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery currently repeats a few exact aliases (for example flux/fluxPro and
|
||||
// fluxPro/1.1). Keep the first canonical wire pair and suppress duplicate cards.
|
||||
const seen = new Set();
|
||||
const models = [];
|
||||
for (const model of rawModels) {
|
||||
const semanticKey = JSON.stringify({
|
||||
backingModel: model.backingModel,
|
||||
name: model.name,
|
||||
modality: model.modality,
|
||||
schemaProperties: model.schemaProperties,
|
||||
requiredProperties: model.requiredProperties,
|
||||
referenceInputs: model.referenceInputs,
|
||||
maxReferenceItems: model.maxReferenceItems,
|
||||
supportedSizes: model.supportedSizes,
|
||||
supportedAspectRatios: model.supportedAspectRatios,
|
||||
supportedResolutions: model.supportedResolutions,
|
||||
supportedDurations: model.supportedDurations,
|
||||
durationMin: model.durationMin,
|
||||
durationMax: model.durationMax,
|
||||
});
|
||||
if (seen.has(semanticKey)) continue;
|
||||
seen.add(semanticKey);
|
||||
models.push(model);
|
||||
}
|
||||
|
||||
const source = `/**
|
||||
* Generated from Adobe Firefly POST /v2/models/discovery with resolveSchema=true.
|
||||
* Source SHA-256: ${sourceHash}
|
||||
* Regenerate with scripts/dev/generate-adobe-firefly-snapshot.mjs; do not edit by hand.
|
||||
* The generated literal stays compact to satisfy the repository's line-count gate.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export const ADOBE_FIREFLY_DISCOVERY_SNAPSHOT = ${JSON.stringify(models)} as const;
|
||||
`;
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, source, "utf8");
|
||||
console.log(`Wrote ${models.length} models to ${outputPath}`);
|
||||
73
src/app/api/providers/[id]/models/adobeFireflyDiscovery.ts
Normal file
73
src/app/api/providers/[id]/models/adobeFireflyDiscovery.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
discoverAdobeFireflyModels,
|
||||
resolveAdobeAccessToken,
|
||||
} from "@omniroute/open-sse/services/adobeFireflyClient.ts";
|
||||
import {
|
||||
getAdobeFireflyFallbackCatalog,
|
||||
mapDiscoveredToCatalog,
|
||||
toAdobeMediaCapabilitiesApi,
|
||||
type AdobeFireflyCatalogModel,
|
||||
} from "@omniroute/open-sse/services/adobeFireflyModels.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
type AdobeProviderData = { cookie?: unknown; access_token?: unknown; accessToken?: unknown };
|
||||
|
||||
interface AdobeProviderModelsResult {
|
||||
models: Array<Record<string, unknown>>;
|
||||
source: "api" | "local_catalog";
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
function toModelResponse(model: AdobeFireflyCatalogModel): Record<string, unknown> {
|
||||
const endpoint = model.modality === "image" ? "images" : "videos";
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
owned_by: "adobe-firefly",
|
||||
apiFormat: endpoint,
|
||||
supportedEndpoints: [endpoint],
|
||||
type: model.modality,
|
||||
input_modalities: model.inputModalities,
|
||||
output_modalities: [model.modality],
|
||||
supported_sizes: model.capabilities.supportedSizes,
|
||||
media_capabilities: toAdobeMediaCapabilitiesApi(model),
|
||||
};
|
||||
}
|
||||
|
||||
function fallback(warning: string): AdobeProviderModelsResult {
|
||||
return {
|
||||
models: getAdobeFireflyFallbackCatalog().map(toModelResponse),
|
||||
source: "local_catalog",
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdobeModels(
|
||||
apiKey: string | undefined,
|
||||
accessToken: string | undefined,
|
||||
providerData: unknown,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<AdobeProviderModelsResult> {
|
||||
const providerSpecificData =
|
||||
providerData && typeof providerData === "object" ? (providerData as AdobeProviderData) : {};
|
||||
try {
|
||||
const token = await resolveAdobeAccessToken(
|
||||
{
|
||||
apiKey,
|
||||
accessToken,
|
||||
providerSpecificData,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
const models = mapDiscoveredToCatalog(await discoverAdobeFireflyModels(token, fetchImpl));
|
||||
return models.length > 0
|
||||
? { models: models.map(toModelResponse), source: "api" }
|
||||
: fallback("Adobe Firefly discovery returned no callable image or video models");
|
||||
} catch (error) {
|
||||
return fallback(
|
||||
`Adobe Firefly discovery unavailable: ${sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,8 @@ import {
|
||||
isAutoFetchModelsEnabled,
|
||||
persistDiscoveredModels,
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
import {
|
||||
buildProviderModelsUrl,
|
||||
getDiscoveryClientVersionOptions,
|
||||
} from "./discoveryClientVersion";
|
||||
import { buildProviderModelsUrl, getDiscoveryClientVersionOptions } from "./discoveryClientVersion";
|
||||
import { getAdobeModels } from "./adobeFireflyDiscovery";
|
||||
import {
|
||||
parseGeminiModelsList,
|
||||
type GeminiDiscoveryModel,
|
||||
@@ -422,10 +420,7 @@ export async function GET(
|
||||
// #6267 — a models-endpoint redirect (307/308) is not a fixable-config
|
||||
// error. safeOutboundFetch throws REDIRECT_BLOCKED which
|
||||
// getSafeOutboundFetchErrorStatus maps to 503, but unlike the other 503
|
||||
// cases (URL_GUARD_BLOCKED / INVALID_URL, which are genuinely
|
||||
// unrecoverable and stay hard errors) a blocked redirect should degrade to
|
||||
// the local/cached catalog OmniRoute ships instead of surfacing a raw 503.
|
||||
// General fix — covers any config-driven provider that 307s (e.g. qwen-web).
|
||||
// Redirect blocks degrade to the local/cached catalog; invalid URLs remain hard errors.
|
||||
if (error instanceof SafeOutboundFetchError && error.code === "REDIRECT_BLOCKED") {
|
||||
return buildDiscoveryFallbackResponse(warnings);
|
||||
}
|
||||
@@ -434,6 +429,11 @@ export async function GET(
|
||||
return buildDiscoveryFallbackResponse(warnings);
|
||||
};
|
||||
|
||||
if (provider === "adobe-firefly") {
|
||||
const discovery = await getAdobeModels(apiKey, accessToken, connection.providerSpecificData);
|
||||
return buildResponse({ provider, connectionId, ...discovery });
|
||||
}
|
||||
|
||||
const maybeReturnCachedDiscovery = () => {
|
||||
if (!refresh && cachedDiscoveryModels.length > 0) {
|
||||
return buildCachedDiscoveryResponse();
|
||||
|
||||
@@ -1113,6 +1113,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
input_modalities: imgModel.inputModalities || ["text"],
|
||||
output_modalities: ["image"],
|
||||
...(imgModel.description ? { description: imgModel.description } : {}),
|
||||
...(imgModel.mediaCapabilities ? { media_capabilities: imgModel.mediaCapabilities } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1178,6 +1179,12 @@ async function buildUnifiedModelsResponseCore(
|
||||
created: timestamp,
|
||||
owned_by: videoModel.provider,
|
||||
type: "video",
|
||||
supported_sizes: videoModel.supportedSizes,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["video"],
|
||||
...(videoModel.mediaCapabilities
|
||||
? { media_capabilities: videoModel.mediaCapabilities }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Muse Code CLI proprietary model catalog endpoint.
|
||||
*
|
||||
* Muse CLI calls GET /muse-code/models (or --base-url/muse-code/models)
|
||||
* to discover available models. Returns the proprietary Muse format:
|
||||
*
|
||||
* { object: "list", data: [{ id, object, created, owned_by, metadata }] }
|
||||
*
|
||||
* Each model's metadata includes: name, family, reasoning, tool_call,
|
||||
* modalities, limit, cost.
|
||||
*/
|
||||
|
||||
import { muse_codeProvider } from "@omniroute/open-sse/config/providers/registry/muse-code/index.ts";
|
||||
|
||||
const MUSECODE_TIMESTAMP = Math.floor(Date.now() / 1000);
|
||||
|
||||
interface MuseCodeModel {
|
||||
id: string;
|
||||
object: "model";
|
||||
created: number;
|
||||
owned_by: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
family: string;
|
||||
reasoning: boolean;
|
||||
tool_call: boolean;
|
||||
modalities: string[];
|
||||
limit: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
function buildModelCatalog(): MuseCodeModel[] {
|
||||
const data: MuseCodeModel[] = [];
|
||||
|
||||
for (const model of muse_codeProvider.models) {
|
||||
let family = "llama";
|
||||
if (model.id.includes("llama-4")) family = "llama-4";
|
||||
else if (model.id.includes("llama-3.3")) family = "llama-3.3";
|
||||
else if (model.id.includes("llama-3.2")) family = "llama-3.2";
|
||||
else if (model.id.includes("llama-3.1")) family = "llama-3.1";
|
||||
|
||||
const modalities: string[] = ["text"];
|
||||
if (model.supportsVision) modalities.push("image");
|
||||
|
||||
data.push({
|
||||
id: model.id,
|
||||
object: "model",
|
||||
created: MUSECODE_TIMESTAMP,
|
||||
owned_by: "meta",
|
||||
metadata: {
|
||||
name: model.name,
|
||||
family,
|
||||
reasoning: !!model.supportsReasoning,
|
||||
tool_call: !!model.toolCalling,
|
||||
modalities,
|
||||
limit: model.contextLength ?? 200_000,
|
||||
cost: model.id.includes("maverick") || model.id.includes("405b") ? 3 : 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Cache the catalog for the lifetime of the process — model list is static.
|
||||
const CATALOG = buildModelCatalog();
|
||||
const CATALOG_PAYLOAD = JSON.stringify({ object: "list", data: CATALOG }, null, 2);
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return new Response(CATALOG_PAYLOAD, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,13 +51,6 @@ const GEMINI_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
|
||||
"User-Agent": "GeminiCLI/0.1.0 (linux; x64)",
|
||||
}),
|
||||
});
|
||||
const MUSE_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
|
||||
id: "muse-cli",
|
||||
label: "Muse Code CLI",
|
||||
headers: Object.freeze({
|
||||
"User-Agent": "MuseCodeCLI/0.1.0 (linux; x64)",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Ordered so `CLIENT_IDENTITY_PROFILE_OPTIONS` renders "Default" first. */
|
||||
export const CLIENT_IDENTITY_PROFILES: Readonly<Record<string, ClientIdentityProfile>> =
|
||||
@@ -66,7 +59,6 @@ export const CLIENT_IDENTITY_PROFILES: Readonly<Record<string, ClientIdentityPro
|
||||
"claude-cli": CLAUDE_CLI_PROFILE,
|
||||
"codex-cli": CODEX_CLI_PROFILE,
|
||||
"gemini-cli": GEMINI_CLI_PROFILE,
|
||||
"muse-cli": MUSE_CLI_PROFILE,
|
||||
});
|
||||
|
||||
export const CLIENT_IDENTITY_PROFILE_IDS: readonly string[] = Object.keys(CLIENT_IDENTITY_PROFILES);
|
||||
|
||||
@@ -275,19 +275,4 @@ export const APIKEY_PROVIDERS_FRONTIER = {
|
||||
"Writer Palmyra is OpenAI-compatible at https://api.writer.com/v1. palmyra-x5 offers a 1M-token context window.",
|
||||
hasFree: false,
|
||||
},
|
||||
"muse-code": {
|
||||
id: "muse-code",
|
||||
alias: "mc",
|
||||
name: "Muse Code (Meta)",
|
||||
icon: "auto_awesome",
|
||||
color: "#0866FF",
|
||||
textIcon: "MC",
|
||||
website: "https://github.com/meta-llama/llama-stack",
|
||||
authHint:
|
||||
"Use your META_API_KEY env var as a Bearer token. Muse Code CLI uses the OpenAI Responses API wire format (POST /responses).",
|
||||
apiHint:
|
||||
"Muse Code is OpenAI-compatible. OmniRoute routes chat traffic through the Responses API and exposes the proprietary model catalog at /v1/muse-code/models.",
|
||||
passthroughModels: true,
|
||||
hasFree: false,
|
||||
},
|
||||
};
|
||||
|
||||
106
tests/fixtures/duckduckgo/challenge-variants.json
vendored
Normal file
106
tests/fixtures/duckduckgo/challenge-variants.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -3405,26 +3405,6 @@
|
||||
"stream": "https://api.morphllm.com/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"muse-code": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
},
|
||||
"url": {}
|
||||
},
|
||||
"muse-spark-web": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
90
tests/unit/adobe-firefly-references.test.ts
Normal file
90
tests/unit/adobe-firefly-references.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
ADOBE_FIREFLY_VIDEO_MODELS,
|
||||
extractAdobeSourceImageReferences,
|
||||
normalizeAdobeReferenceBlobs,
|
||||
} from "../../open-sse/services/adobeFireflyClient.ts";
|
||||
import { getAdobeModels } from "../../src/app/api/providers/[id]/models/adobeFireflyDiscovery.ts";
|
||||
|
||||
function userImsJwt(): string {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
user_id: "test@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url");
|
||||
return `eyJhbGciOiJSUzI1NiJ9.${payload}.${"sig".padEnd(40, "x")}`;
|
||||
}
|
||||
|
||||
test("reference validation enforces discovered roles, counts, and frame order", () => {
|
||||
const kling = ADOBE_FIREFLY_VIDEO_MODELS["kling-3"];
|
||||
assert.deepEqual(
|
||||
normalizeAdobeReferenceBlobs(kling, [
|
||||
{ id: "frame-a", mediaType: "image", usage: "frame" },
|
||||
{ id: "frame-b", mediaType: "image", usage: "frame" },
|
||||
]),
|
||||
[
|
||||
{ id: "frame-a", usage: "frame", order: 1 },
|
||||
{ id: "frame-b", usage: "frame", order: 2 },
|
||||
]
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAdobeReferenceBlobs(kling, [{ id: "bad", mediaType: "image", usage: "mask" }]),
|
||||
/does not support image references with usage 'mask'/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdobeReferenceBlobs(kling, [
|
||||
{ id: "frame-a", usage: "frame" },
|
||||
{ id: "frame-b", usage: "frame" },
|
||||
{ id: "frame-c", usage: "frame" },
|
||||
]),
|
||||
/at most 2 frame image reference/
|
||||
);
|
||||
});
|
||||
|
||||
test("structured references skip malformed entries and preserve explicit roles", () => {
|
||||
assert.deepEqual(
|
||||
extractAdobeSourceImageReferences({
|
||||
adobe_reference_inputs: [
|
||||
null,
|
||||
{ media_type: "video", source: "ignored" },
|
||||
{ media_type: "image", source: "data:image/png;base64,AAAA", usage: "frame", order: 2 },
|
||||
],
|
||||
}),
|
||||
[{ source: "data:image/png;base64,AAAA", usage: "frame", order: 2 }]
|
||||
);
|
||||
});
|
||||
|
||||
test("provider discovery adapter returns live capabilities and verified fallback", async () => {
|
||||
const live = await getAdobeModels(undefined, userImsJwt(), {}, async () =>
|
||||
Response.json({
|
||||
models: [
|
||||
{
|
||||
modelId: "firefly-image",
|
||||
acModelFamilyProviderDisplayName: "Adobe",
|
||||
modelVersions: {
|
||||
image5: {
|
||||
enabled: true,
|
||||
outputModality: ["image"],
|
||||
modelDisplayName: "Firefly Image 5",
|
||||
requestSchema: { type: "object", properties: { prompt: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
assert.equal(live.source, "api");
|
||||
assert.equal(live.models[0].id, "firefly-image-image5");
|
||||
assert.ok(live.models[0].media_capabilities);
|
||||
|
||||
const fallback = await getAdobeModels(undefined, userImsJwt(), {}, async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
assert.equal(fallback.source, "local_catalog");
|
||||
assert.equal(fallback.models.length, 52);
|
||||
assert.match(fallback.warning || "", /discovery unavailable/);
|
||||
});
|
||||
@@ -78,6 +78,11 @@ test("adobe-firefly is registered in IMAGE_PROVIDERS with adobe-firefly-image fo
|
||||
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);
|
||||
assert.equal(
|
||||
entry.models.some((model: { id: string }) => model.id === "nano-banana-pro"),
|
||||
false,
|
||||
"routing-only compatibility aliases must not be advertised as discovered models"
|
||||
);
|
||||
});
|
||||
|
||||
test("adobe-firefly is registered in VIDEO_PROVIDERS with adobe-firefly-video format", () => {
|
||||
@@ -154,20 +159,25 @@ test("normalizeAdobeOutputResolution maps quality tiers", () => {
|
||||
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");
|
||||
test("resolveAdobeImageModel maps valid aliases to exact discovery ids", () => {
|
||||
assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "gemini-flash-nano-banana-2");
|
||||
assert.equal(
|
||||
resolveAdobeImageModel("adobe-firefly/nano-banana-2").id,
|
||||
"gemini-flash-nano-banana-3"
|
||||
);
|
||||
assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image-2");
|
||||
assert.throws(
|
||||
() => resolveAdobeImageModel("invented-image-model"),
|
||||
/Unknown Adobe Firefly image model/
|
||||
);
|
||||
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("resolveAdobeVideoModel maps only discovered video models", () => {
|
||||
assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast-generate");
|
||||
assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-kling-v3-standard-i2v");
|
||||
assert.throws(() => resolveAdobeVideoModel("sora-2"), /Unknown Adobe Firefly video model/);
|
||||
assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"].defaultDuration > 0);
|
||||
});
|
||||
|
||||
test("buildAdobeImagePayload produces nano and gpt-image shapes", () => {
|
||||
@@ -265,41 +275,12 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"],
|
||||
});
|
||||
assert.deepEqual(gpt.referenceBlobs, [
|
||||
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" },
|
||||
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "source" },
|
||||
]);
|
||||
assert.equal((gpt.generationMetadata as Record<string, unknown>).module, "image2image");
|
||||
|
||||
// gpt-image: only first 2 subject refs survive (extra screenshots hang colligo).
|
||||
const gptMany = buildAdobeImagePayload({
|
||||
prompt: "edit me",
|
||||
aspectRatio: "1:1",
|
||||
outputResolution: "1K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-2"],
|
||||
sourceImageIds: ["id-1", "id-2", "id-3", "id-4", "id-5"],
|
||||
});
|
||||
assert.deepEqual(gptMany.referenceBlobs, [
|
||||
{ id: "id-1", usage: "subject" },
|
||||
{ id: "id-2", usage: "subject" },
|
||||
]);
|
||||
|
||||
// nano keeps up to 4 general refs for multi-panel composition.
|
||||
const nanoMany = buildAdobeImagePayload({
|
||||
prompt: "compose",
|
||||
aspectRatio: "16:9",
|
||||
outputResolution: "2K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"],
|
||||
sourceImageIds: ["a", "b", "c", "d", "e"],
|
||||
});
|
||||
assert.equal((nanoMany.referenceBlobs as unknown[]).length, 4);
|
||||
assert.equal((nanoMany.referenceBlobs as Array<{ usage: string }>)[0].usage, "general");
|
||||
});
|
||||
|
||||
test("adobeFireflyMaxImageRefs + adaptive image timeout", () => {
|
||||
assert.equal(adobeFireflyMaxImageRefs("gpt-image-2"), 2);
|
||||
assert.equal(adobeFireflyMaxImageRefs("adobe-firefly/gpt-image"), 2);
|
||||
assert.equal(adobeFireflyMaxImageRefs("nano-banana-2"), 4);
|
||||
assert.equal(adobeFireflyMaxImageRefs("flux-2"), 2);
|
||||
|
||||
test("adobeFireflyImageTimeoutMs scales boundedly with reference count", () => {
|
||||
assert.equal(adobeFireflyImageTimeoutMs({ refCount: 0 }), DEFAULT_IMAGE_TIMEOUT_MS);
|
||||
assert.equal(
|
||||
adobeFireflyImageTimeoutMs({ refCount: 2 }),
|
||||
@@ -381,16 +362,7 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
|
||||
assert.equal(ADOBE_FIREFLY_IMAGE_UPLOAD_URL.includes("storage/image"), true);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
test("buildAdobeVideoPayload follows discovered fields and reference roles", () => {
|
||||
const veo = buildAdobeVideoPayload({
|
||||
prompt: "city flyover",
|
||||
aspectRatio: "9:16",
|
||||
@@ -399,12 +371,30 @@ test("buildAdobeVideoPayload produces sora and veo shapes", () => {
|
||||
});
|
||||
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.duration, 6);
|
||||
assert.equal(veo.generateAudio, true);
|
||||
|
||||
const kling = buildAdobeVideoPayload({
|
||||
prompt: "ocean waves",
|
||||
aspectRatio: "16:9",
|
||||
duration: 5,
|
||||
modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"],
|
||||
sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"],
|
||||
});
|
||||
assert.equal(kling.modelVersion, "kling_v3_standard_i2v");
|
||||
assert.deepEqual(kling.referenceBlobs, [
|
||||
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "frame", order: 1 },
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildAdobeVideoPayload({
|
||||
prompt: "bad duration",
|
||||
aspectRatio: "16:9",
|
||||
duration: 5,
|
||||
modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"],
|
||||
}),
|
||||
/supports duration/
|
||||
);
|
||||
});
|
||||
|
||||
test("extractAdobeResultLink prefers x-override-status-link then links.result", () => {
|
||||
@@ -539,7 +529,7 @@ test("adobe-firefly is in USAGE_SUPPORTED_PROVIDERS for Limits", () => {
|
||||
assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("firefly"));
|
||||
});
|
||||
|
||||
test("parseAdobeModelsDiscovery extracts image/video versions", () => {
|
||||
test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
|
||||
const rows = parseAdobeModelsDiscovery({
|
||||
models: [
|
||||
{
|
||||
@@ -550,16 +540,44 @@ test("parseAdobeModelsDiscovery extracts image/video versions", () => {
|
||||
outputModality: ["image"],
|
||||
modelDisplayName: "Gemini 3.0 (Nano Banana Pro)",
|
||||
healthStatus: "HEALTHY",
|
||||
inputMediaUseCase: ["editing"],
|
||||
bksGenerationModel: "firefly_3p:external:gemini_flash_2",
|
||||
requestSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prompt: { type: "string" },
|
||||
referenceBlobs: {
|
||||
maxItems: 14,
|
||||
"x-capabilities": [
|
||||
{
|
||||
mediaType: "image",
|
||||
usageConstraints: [{ usageType: "general", minItems: 0, maxItems: 14 }],
|
||||
maxFileSizeBytes: 104857600,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "sora",
|
||||
modelId: "veo",
|
||||
modelVersions: {
|
||||
"sora-2": {
|
||||
"3.1-generate": {
|
||||
enabled: true,
|
||||
outputModality: ["video"],
|
||||
modelDisplayName: "Sora 2",
|
||||
modelDisplayName: "Veo 3.1",
|
||||
requestSchema: {
|
||||
allOf: [
|
||||
{
|
||||
properties: {
|
||||
prompt: { type: "string" },
|
||||
duration: { anyOf: [{ type: "integer", enum: [4, 6, 8] }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -569,14 +587,35 @@ test("parseAdobeModelsDiscovery extracts image/video versions", () => {
|
||||
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"));
|
||||
assert.ok(catalog.some((m) => m.id === "gemini-flash-nano-banana-2"));
|
||||
assert.ok(catalog.some((m) => m.id === "veo-3.1-generate"));
|
||||
assert.equal(catalog[0].capabilities.referenceInputs[0].maxItems, 14);
|
||||
assert.deepEqual(catalog[1].capabilities.supportedDurations, [4, 6, 8]);
|
||||
});
|
||||
|
||||
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("fallback catalog is the verified discovery snapshot without invented Sora", () => {
|
||||
assert.equal(ADOBE_FIREFLY_FALLBACK_MODELS.length, 52);
|
||||
assert.equal(getAdobeFireflyFallbackCatalog("image").length, 17);
|
||||
assert.equal(getAdobeFireflyFallbackCatalog("video").length, 35);
|
||||
assert.equal(
|
||||
ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id.includes("sora")),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
ADOBE_FIREFLY_FALLBACK_MODELS.some(
|
||||
(model) => model.id.includes("kling") && model.id.includes("omni")
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id === "kling-kling-o3"));
|
||||
assert.equal(
|
||||
ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].capabilities.referenceInputs[0].maxItems,
|
||||
14
|
||||
);
|
||||
assert.equal(
|
||||
ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"].capabilities.referenceInputs[0].maxItems,
|
||||
16
|
||||
);
|
||||
});
|
||||
|
||||
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
|
||||
@@ -716,7 +755,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => {
|
||||
const result = await adobeFireflyGenerateVideo({
|
||||
accessToken: "tok",
|
||||
prompt: "drone over forest",
|
||||
model: "sora-2",
|
||||
model: "veo-3.1",
|
||||
duration: 4,
|
||||
aspectRatio: "16:9",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
@@ -727,7 +766,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => {
|
||||
|
||||
test("handleAdobeFireflyVideoGeneration returns 400 without prompt", async () => {
|
||||
const result = await handleAdobeFireflyVideoGeneration({
|
||||
model: "sora-2",
|
||||
model: "veo-3.1",
|
||||
provider: "adobe-firefly",
|
||||
body: {},
|
||||
credentials: { apiKey: "aaa.bbb.ccc" },
|
||||
|
||||
258
tests/unit/duckduckgo-challenge-solver-regression.test.ts
Normal file
258
tests/unit/duckduckgo-challenge-solver-regression.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import vm from "node:vm";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
CHALLENGE_STUBS,
|
||||
buildHtmlLookup,
|
||||
countHtmlElements,
|
||||
sha256Base64,
|
||||
solveDuckDuckGoChallenge,
|
||||
DUCKDUCKGO_CHALLENGE_ORIGIN,
|
||||
} from "../../open-sse/executors/duckduckgo-web/challenge.ts";
|
||||
|
||||
/**
|
||||
* Regression suite for the DuckDuckGo AI Chat anti-abuse challenge solver.
|
||||
*
|
||||
* Background: every duckduckgo-web chat request was failing with HTTP 418
|
||||
* ERR_CHALLENGE while duck.ai worked normally in a browser from the same IP.
|
||||
* Root-causing it turned up several independent defects, each of which is
|
||||
* pinned below. The fixtures in `tests/fixtures/duckduckgo/challenge-variants.json`
|
||||
* are REAL challenge programs captured from duckduckgo.com, together with the
|
||||
* probe vectors a real (headful) Chromium produced for those exact programs.
|
||||
* Matching Chromium bit-for-bit is the actual correctness criterion, so these
|
||||
* tests assert against recorded browser behaviour rather than our own output.
|
||||
*/
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(HERE, "../fixtures/duckduckgo/challenge-variants.json");
|
||||
|
||||
type Variant = {
|
||||
challengeBase64: string;
|
||||
browserProbes: string[];
|
||||
browserReduceVectors: Array<{ seed: number; booleans: number[] }>;
|
||||
};
|
||||
const VARIANTS = JSON.parse(readFileSync(FIXTURES, "utf8")) as Record<string, Variant>;
|
||||
const UA =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
|
||||
|
||||
function makeContext(challengeJs: string): vm.Context {
|
||||
const stubs = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", JSON.stringify(UA)).replace(
|
||||
"__DDG_HTML_LOOKUP__",
|
||||
JSON.stringify(buildHtmlLookup(challengeJs))
|
||||
);
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(stubs, context, { timeout: 5000 });
|
||||
return context;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 1 — module syntax inside the sandbox source.
|
||||
// `vm.runInContext` compiles in SCRIPT mode. A refactor mass-added `export` to
|
||||
// the `function` declarations inside CHALLENGE_STUBS (they look like ordinary
|
||||
// top-level TS functions), making every solve throw SyntaxError. The executor
|
||||
// swallows solve failures and posts the raw unsolved challenge, so the upstream
|
||||
// answered 418 for every request.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("CHALLENGE_STUBS uses no module syntax and compiles in script mode", () => {
|
||||
assert.doesNotMatch(
|
||||
CHALLENGE_STUBS,
|
||||
/(^|[\s;{}])(export|import)[\s{*]/,
|
||||
"vm.runInContext compiles in script mode — export/import is a hard SyntaxError"
|
||||
);
|
||||
const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"ua"').replace(
|
||||
"__DDG_HTML_LOOKUP__",
|
||||
"{}"
|
||||
);
|
||||
assert.doesNotThrow(() => new vm.Script(source));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 2 — regex escaping inside a String.raw template.
|
||||
// CHALLENGE_STUBS is a String.raw literal, so `\\s` reaches the sandbox as a
|
||||
// literal backslash-backslash-s and the display regex never matched. One
|
||||
// challenge variant asserts getComputedStyle(el).getPropertyValue('display')
|
||||
// is non-empty, so this silently flipped a probe to false.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("computed-style display probe resolves a real value", () => {
|
||||
const context = makeContext("");
|
||||
const display = vm.runInContext(
|
||||
`(function(){
|
||||
var d = document.createElement('div');
|
||||
d.style.cssText = 'display:inline-block;padding:8px;position:absolute;visibility:hidden;';
|
||||
return getComputedStyle(d).getPropertyValue('display');
|
||||
})()`,
|
||||
context
|
||||
);
|
||||
assert.equal(display, "inline-block");
|
||||
});
|
||||
|
||||
test("CHALLENGE_STUBS contains no double-escaped regex metacharacters", () => {
|
||||
// String.raw means `\\s` in the source IS `\\s` in the sandbox — always a bug.
|
||||
assert.doesNotMatch(CHALLENGE_STUBS, /\\\\[sdwbSDWB]/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 3 — buildHtmlLookup descendant count was off by one.
|
||||
// `count` backs `el.querySelectorAll('*').length` for an element whose
|
||||
// innerHTML is the given markup. querySelectorAll('*') returns DESCENDANTS, and
|
||||
// countHtmlElements already skips the #document-fragment root, so subtracting 1
|
||||
// undercounted. A variant multiplies innerHTML.length by that count, so the
|
||||
// error propagated straight into the hash.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("buildHtmlLookup reports the browser's descendant count", () => {
|
||||
// Chromium: for innerHTML = '<li><div></li><li></div', querySelectorAll('*')
|
||||
// has length 3 and innerHTML serializes to 29 characters.
|
||||
const html = "<li><div></li><li></div";
|
||||
const entry = buildHtmlLookup(`x = "${html}"`)[html];
|
||||
assert.equal(entry.html, "<li><div></div></li><li></li>");
|
||||
assert.equal(entry.html.length, 29);
|
||||
assert.equal(entry.count, 3);
|
||||
assert.equal(countHtmlElements({ nodeName: undefined, childNodes: [] }), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 4 — the browser-fidelity probes.
|
||||
// Newer challenge variants interrogate JS/DOM invariants that a naive stub
|
||||
// object does not satisfy (prototype chains, NodeList identity, live
|
||||
// HTMLCollection, native-code toString, sloppy-mode `this`). Nine of thirteen
|
||||
// failed. Each is pinned individually so a future stub regression names itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FIDELITY_PROBES: Array<[string, string, boolean]> = [
|
||||
[
|
||||
"built-ins stringify as native code",
|
||||
`window.parseInt.toString().includes("[native code]")`,
|
||||
true,
|
||||
],
|
||||
[
|
||||
"Array subclass survives map",
|
||||
`(function(){ class S extends Array {}; return new S(1,2,3).map(function(x){return x*2;}) instanceof S; })()`,
|
||||
true,
|
||||
],
|
||||
[
|
||||
"window brands as [object Window]",
|
||||
`Object.prototype.toString.call(window) === "[object Window]"`,
|
||||
true,
|
||||
],
|
||||
["Error instances are real", `new Error() instanceof Error`, true],
|
||||
[
|
||||
"captureStackTrace is absent or a function",
|
||||
`Error.captureStackTrace === undefined || typeof Error.captureStackTrace === "function"`,
|
||||
true,
|
||||
],
|
||||
// Chromium reports false here; sealing Math made our vector differ by one.
|
||||
["Math is NOT sealed (matches Chromium)", `Object.isSealed(Math)`, false],
|
||||
["sloppy-mode this is window", `(function(){ return this; })() === window`, true],
|
||||
[
|
||||
"document.body.children is live",
|
||||
`(function(){
|
||||
var c = document.body.children, n = c.length, d = document.createElement('div');
|
||||
document.body.appendChild(d);
|
||||
var grew = c.length === n + 1;
|
||||
document.body.removeChild(d);
|
||||
return grew && c.length === n;
|
||||
})()`,
|
||||
true,
|
||||
],
|
||||
["querySelectorAll is not an Array", `!Array.isArray(document.querySelectorAll("*"))`, true],
|
||||
[
|
||||
"querySelectorAll is a NodeList",
|
||||
`document.querySelectorAll("*").constructor.name === "NodeList"`,
|
||||
true,
|
||||
],
|
||||
[
|
||||
"createElement('div') is an HTMLDivElement",
|
||||
`document.createElement("div") instanceof HTMLDivElement`,
|
||||
true,
|
||||
],
|
||||
[
|
||||
"HTMLDivElement derives from HTMLElement",
|
||||
`HTMLDivElement.prototype instanceof HTMLElement`,
|
||||
true,
|
||||
],
|
||||
["HTMLElement derives from Element", `HTMLElement.prototype instanceof Element`, true],
|
||||
["navigator.webdriver is falsy", `navigator.webdriver === true`, false],
|
||||
["navigator survives the global aliasing", `navigator.userAgent === ${JSON.stringify(UA)}`, true],
|
||||
["window.document is the document", `window.document === document`, true],
|
||||
];
|
||||
|
||||
for (const [name, expression, expected] of FIDELITY_PROBES) {
|
||||
test(`browser-fidelity probe: ${name}`, () => {
|
||||
const context = makeContext("");
|
||||
assert.equal(vm.runInContext(expression, context), expected);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The real acceptance criterion: for every captured challenge variant our
|
||||
// sandbox must produce exactly the probe values a real Chromium produced.
|
||||
// ---------------------------------------------------------------------------
|
||||
for (const [file, variant] of Object.entries(VARIANTS)) {
|
||||
test(`challenge variant ${file} matches real-browser probe values`, async () => {
|
||||
const js = Buffer.from(variant.challengeBase64, "base64").toString("utf8");
|
||||
const context = makeContext(js);
|
||||
const result = (await vm.runInContext(js, context, { timeout: 5000 })) as {
|
||||
client_hashes: unknown[];
|
||||
};
|
||||
// `result` crosses the vm realm boundary, so its arrays carry the sandbox's
|
||||
// Array.prototype. Copy into this realm or deepStrictEqual fails on the
|
||||
// prototype even when every element matches.
|
||||
const ours = Array.from(result.client_hashes).slice(1).map(String);
|
||||
assert.deepEqual(
|
||||
ours,
|
||||
variant.browserProbes,
|
||||
`probe values must match Chromium exactly for ${file}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 5 — the solved payload dropped meta.origin / meta.stack / meta.duration.
|
||||
// The duck.ai frontend always sends all three; captured browser requests
|
||||
// confirm it. Without them the upstream returns 418 even when every
|
||||
// client_hash is correct.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("solveDuckDuckGoChallenge stamps meta.origin/stack/duration", async () => {
|
||||
const [variant] = Object.values(VARIANTS);
|
||||
const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA);
|
||||
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
|
||||
|
||||
assert.equal(decoded.meta.origin, DUCKDUCKGO_CHALLENGE_ORIGIN);
|
||||
assert.match(decoded.meta.stack, /^Error\n\s*at l \(https:\/\/duck\.ai\/.*\.js:\d+:\d+\)/);
|
||||
assert.match(String(decoded.meta.duration), /^\d+$/);
|
||||
// The challenge's own meta must survive alongside the added fields.
|
||||
assert.equal(decoded.meta.v, "4");
|
||||
assert.ok(decoded.meta.challenge_id);
|
||||
});
|
||||
|
||||
test("solveDuckDuckGoChallenge honours an explicit origin/bundle", async () => {
|
||||
const [variant] = Object.values(VARIANTS);
|
||||
const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA, {
|
||||
origin: "https://duckduckgo.com",
|
||||
bundlePath: "/dist/x.js",
|
||||
});
|
||||
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
|
||||
assert.equal(decoded.meta.origin, "https://duckduckgo.com");
|
||||
assert.ok(decoded.meta.stack.includes("https://duckduckgo.com/dist/x.js"));
|
||||
});
|
||||
|
||||
test("solveDuckDuckGoChallenge hashes client_hashes with the real UA in slot 0", async () => {
|
||||
const [file, variant] = Object.entries(VARIANTS)[0];
|
||||
const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA);
|
||||
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
|
||||
|
||||
const expected = [sha256Base64(UA), ...variant.browserProbes.map((p) => sha256Base64(p))];
|
||||
assert.deepEqual(decoded.client_hashes, expected, `client_hashes mismatch for ${file}`);
|
||||
// server_hashes are echoed back untouched.
|
||||
assert.ok(Array.isArray(decoded.server_hashes));
|
||||
});
|
||||
|
||||
test("solveDuckDuckGoChallenge rejects a challenge with no client_hashes", async () => {
|
||||
const bad = Buffer.from(`(async function(){ return { client_hashes: [] }; })()`, "utf8").toString(
|
||||
"base64"
|
||||
);
|
||||
await assert.rejects(() => solveDuckDuckGoChallenge(bad, UA), /empty client_hashes/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Split-guard for the duckduckgo-web challenge-solver extraction.
|
||||
// The anti-abuse challenge solver + FE signals live in duckduckgo-web/challenge.ts
|
||||
@@ -35,3 +36,81 @@ test("makeDuckDuckGoFeSignals returns a base64 string", async () => {
|
||||
assert.equal(typeof out, "string");
|
||||
assert.ok(out.length > 0);
|
||||
});
|
||||
|
||||
// Regression guard: CHALLENGE_STUBS is browser-emulation source executed by
|
||||
// `vm.runInContext`, which compiles in SCRIPT mode — module syntax is a hard
|
||||
// SyntaxError there. A refactor once mass-added `export` to the `function`
|
||||
// declarations inside this template literal (they look like ordinary top-level
|
||||
// TS functions), which made every solve throw. The executor swallows solve
|
||||
// failures and sends the raw unsolved challenge, so DuckDuckGo answered every
|
||||
// chat request with HTTP 418 ERR_CHALLENGE while the site worked fine in a
|
||||
// browser from the same IP. The three tests below fail on that class of bug.
|
||||
test("CHALLENGE_STUBS contains no module syntax (vm runs it in script mode)", async () => {
|
||||
const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
|
||||
assert.doesNotMatch(
|
||||
CHALLENGE_STUBS,
|
||||
/(^|[\s;{}])(export|import)[\s{*]/,
|
||||
"CHALLENGE_STUBS must not use export/import — vm.runInContext compiles in script mode"
|
||||
);
|
||||
});
|
||||
|
||||
test("CHALLENGE_STUBS compiles as a script", async () => {
|
||||
const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
|
||||
// Placeholders are substituted before execution; do the same here so the
|
||||
// source is syntactically complete.
|
||||
const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"test-ua"').replace(
|
||||
"__DDG_HTML_LOOKUP__",
|
||||
"{}"
|
||||
);
|
||||
assert.doesNotThrow(() => new vm.Script(source), "CHALLENGE_STUBS must parse in script mode");
|
||||
});
|
||||
|
||||
test("CHALLENGE_STUBS evaluates and defines the browser stubs the challenge probes", async () => {
|
||||
const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
|
||||
const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"test-ua"').replace(
|
||||
"__DDG_HTML_LOOKUP__",
|
||||
"{}"
|
||||
);
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(source, context, { timeout: 5000 });
|
||||
|
||||
// A real DDG challenge reads these; if the stubs silently failed to evaluate
|
||||
// they would all be undefined and the solver would produce garbage.
|
||||
assert.equal(
|
||||
vm.runInContext("navigator.userAgent", context),
|
||||
"test-ua",
|
||||
"navigator.userAgent must carry the injected UA"
|
||||
);
|
||||
assert.equal(vm.runInContext("typeof document.querySelector", context), "function");
|
||||
assert.equal(vm.runInContext("document.getElementById('jsa').tagName", context), "IFRAME");
|
||||
assert.equal(vm.runInContext("typeof getComputedStyle", context), "function");
|
||||
assert.equal(vm.runInContext("window.top === window", context), true);
|
||||
});
|
||||
|
||||
// End-to-end guard on the solver itself, using a stand-in challenge that mimics
|
||||
// the real one's contract: an async IIFE returning { server_hashes, client_hashes,
|
||||
// signals, meta }. This exercises the full stubs -> vm -> hash -> base64 path
|
||||
// without hitting the network.
|
||||
test("solveDuckDuckGoChallenge solves a representative challenge payload", async () => {
|
||||
const { solveDuckDuckGoChallenge, sha256Base64 } =
|
||||
await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
|
||||
const fakeChallenge = `(async function(){
|
||||
return {
|
||||
server_hashes: ["s1", "s2"],
|
||||
client_hashes: [navigator.userAgent, document.getElementById('jsa').tagName],
|
||||
signals: {},
|
||||
meta: { v: "4", challenge_id: "test" }
|
||||
};
|
||||
})()`;
|
||||
const ua = "Mozilla/5.0 (X11; Linux x86_64) TestAgent/1.0";
|
||||
const solved = await solveDuckDuckGoChallenge(
|
||||
Buffer.from(fakeChallenge, "utf8").toString("base64"),
|
||||
ua
|
||||
);
|
||||
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
|
||||
|
||||
assert.deepEqual(decoded.server_hashes, ["s1", "s2"], "server_hashes pass through untouched");
|
||||
// Slot 0 is overwritten with the real UA before hashing, then every slot is sha256+base64.
|
||||
assert.deepEqual(decoded.client_hashes, [sha256Base64(ua), sha256Base64("IFRAME")]);
|
||||
assert.equal(decoded.meta.challenge_id, "test");
|
||||
});
|
||||
|
||||
134
tests/unit/duckduckgo-reasoning-effort-required.test.ts
Normal file
134
tests/unit/duckduckgo-reasoning-effort-required.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { DuckDuckGoWebExecutor } from "../../open-sse/executors/duckduckgo-web.ts";
|
||||
|
||||
/**
|
||||
* Regression: duckchat/v1/chat now REQUIRES a `reasoningEffort` field.
|
||||
*
|
||||
* The executor previously omitted it for most models on the assumption that the
|
||||
* upstream would apply its own default. It does not: an otherwise byte-identical
|
||||
* payload returns 200 with the field and 400 ERR_BAD_REQUEST without it
|
||||
* (A/B verified live against duck.ai, repeated). The live duck.ai bundle always
|
||||
* sends one, so every outgoing payload must carry it.
|
||||
*
|
||||
* These tests capture the executor's real outgoing request body by stubbing
|
||||
* fetch, so they assert on the wire format rather than on internal helpers.
|
||||
*/
|
||||
|
||||
type Captured = { url: string; body: Record<string, unknown> };
|
||||
|
||||
async function captureChatPayload(model: string): Promise<Captured> {
|
||||
const realFetch = globalThis.fetch;
|
||||
const captured: Captured[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
|
||||
const url = typeof input === "string" ? input : String((input as { url?: string })?.url ?? "");
|
||||
|
||||
if (url.includes("/duckchat/v1/status")) {
|
||||
// Hand back a trivially solvable challenge so the executor proceeds to the
|
||||
// chat POST without touching the network.
|
||||
const challenge = Buffer.from(
|
||||
`(async function(){ return { server_hashes: [], client_hashes: ["ua"], signals: {}, meta: {} }; })()`,
|
||||
"utf8"
|
||||
).toString("base64");
|
||||
return new Response("{}", { status: 200, headers: { "x-vqd-hash-1": challenge } });
|
||||
}
|
||||
|
||||
if (url.includes("/duckchat/v1/chat")) {
|
||||
captured.push({ url, body: JSON.parse(String(init.body)) });
|
||||
return new Response(`data: {"action":"success","message":"OK"}\n\ndata: [DONE]\n\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
// Warm-up fetches (homepage, country.json, auth/token).
|
||||
return new Response("", { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const executor = new DuckDuckGoWebExecutor();
|
||||
await executor.execute({
|
||||
model,
|
||||
body: { messages: [{ role: "user", content: "Say OK" }] },
|
||||
stream: false,
|
||||
} as never);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
|
||||
assert.ok(captured.length > 0, "executor never issued a chat request");
|
||||
return captured[captured.length - 1];
|
||||
}
|
||||
|
||||
test("chat payload always carries reasoningEffort", async () => {
|
||||
const { body } = await captureChatPayload("duckduckgo-web/gpt-4o-mini");
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(body, "reasoningEffort"),
|
||||
"omitting reasoningEffort yields 400 ERR_BAD_REQUEST upstream"
|
||||
);
|
||||
assert.equal(typeof body.reasoningEffort, "string");
|
||||
assert.notEqual(body.reasoningEffort, "");
|
||||
});
|
||||
|
||||
test("default models send reasoningEffort 'none'", async () => {
|
||||
const { body } = await captureChatPayload("duckduckgo-web/gpt-5.4-mini");
|
||||
assert.equal(body.model, "gpt-5.4-mini");
|
||||
assert.equal(body.reasoningEffort, "none");
|
||||
});
|
||||
|
||||
test("reasoning models keep their 'low' effort", async () => {
|
||||
const haiku = await captureChatPayload("duckduckgo-web/claude-haiku-4-5");
|
||||
assert.equal(haiku.body.model, "claude-haiku-4-5");
|
||||
assert.equal(haiku.body.reasoningEffort, "low");
|
||||
|
||||
const oss = await captureChatPayload("duckduckgo-web/gpt-oss-120b");
|
||||
assert.equal(oss.body.model, "tinfoil/gpt-oss-120b");
|
||||
assert.equal(oss.body.reasoningEffort, "low");
|
||||
});
|
||||
|
||||
test("retired model ids are still aliased to live wire ids", async () => {
|
||||
const { body } = await captureChatPayload("duckduckgo-web/gpt-4o-mini");
|
||||
assert.equal(body.model, "gpt-5.4-mini");
|
||||
});
|
||||
|
||||
test("executor issues exactly one chat request per call", async () => {
|
||||
// A throwaway "seed" chat POST used to run before the real one, doubling the
|
||||
// request volume against an IP-rate-limited endpoint and causing spurious 429s.
|
||||
const realFetch = globalThis.fetch;
|
||||
let chatCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
|
||||
const url = typeof input === "string" ? input : String((input as { url?: string })?.url ?? "");
|
||||
if (url.includes("/duckchat/v1/status")) {
|
||||
const challenge = Buffer.from(
|
||||
`(async function(){ return { server_hashes: [], client_hashes: ["ua"], signals: {}, meta: {} }; })()`,
|
||||
"utf8"
|
||||
).toString("base64");
|
||||
return new Response("{}", { status: 200, headers: { "x-vqd-hash-1": challenge } });
|
||||
}
|
||||
if (url.includes("/duckchat/v1/chat")) {
|
||||
chatCalls++;
|
||||
void init;
|
||||
return new Response(`data: {"action":"success","message":"OK"}\n\ndata: [DONE]\n\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response("", { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const executor = new DuckDuckGoWebExecutor();
|
||||
await executor.execute({
|
||||
model: "duckduckgo-web/gpt-5.4-mini",
|
||||
body: { messages: [{ role: "user", content: "Say OK" }] },
|
||||
stream: false,
|
||||
} as never);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
|
||||
assert.equal(chatCalls, 1, "expected exactly one POST /duckchat/v1/chat per user request");
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Tests for Muse Code CLI model catalog endpoint.
|
||||
*
|
||||
* Verifies GET /v1/muse-code/models returns the proprietary Muse format.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { muse_codeProvider } from "../../open-sse/config/providers/registry/muse-code/index.ts";
|
||||
|
||||
// ── Model catalog shape ─────────────────────────────────────────────────────
|
||||
|
||||
test("muse-code provider has at least one model", () => {
|
||||
assert.ok(muse_codeProvider.models.length >= 1);
|
||||
});
|
||||
|
||||
test("muse-code models have unique ids", () => {
|
||||
const ids = muse_codeProvider.models.map((m) => m.id);
|
||||
const unique = new Set(ids);
|
||||
assert.equal(unique.size, ids.length, "model IDs must be unique");
|
||||
});
|
||||
|
||||
test("muse-code models include llama-4-maverick", () => {
|
||||
const ids = muse_codeProvider.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("llama-4-maverick"), "must include llama-4-maverick");
|
||||
});
|
||||
|
||||
test("muse-code models include llama-4-scout", () => {
|
||||
const ids = muse_codeProvider.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("llama-4-scout"), "must include llama-4-scout");
|
||||
});
|
||||
|
||||
test("muse-code models include llama-3.3-70b", () => {
|
||||
const ids = muse_codeProvider.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("llama-3.3-70b"), "must include llama-3.3-70b");
|
||||
});
|
||||
|
||||
test("llama-4 models have supportsXHighEffort", () => {
|
||||
const maverick = muse_codeProvider.models.find((m) => m.id === "llama-4-maverick");
|
||||
assert.ok(maverick, "llama-4-maverick must exist");
|
||||
assert.equal(maverick.supportsXHighEffort, true);
|
||||
|
||||
const scout = muse_codeProvider.models.find((m) => m.id === "llama-4-scout");
|
||||
assert.ok(scout, "llama-4-scout must exist");
|
||||
assert.equal(scout.supportsXHighEffort, true);
|
||||
});
|
||||
|
||||
test("llama-3.3-70b does not support reasoning", () => {
|
||||
const model = muse_codeProvider.models.find((m) => m.id === "llama-3.3-70b");
|
||||
assert.ok(model, "llama-3.3-70b must exist");
|
||||
assert.equal(model.supportsReasoning, false);
|
||||
});
|
||||
|
||||
test("non-reasoning models do not declare supportsXHighEffort", () => {
|
||||
for (const model of muse_codeProvider.models) {
|
||||
if (!model.supportsReasoning) {
|
||||
assert.equal(
|
||||
model.supportsXHighEffort,
|
||||
undefined,
|
||||
`${model.id} is not a reasoning model but has supportsXHighEffort`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Vision models ───────────────────────────────────────────────────────────
|
||||
|
||||
test("vision models have supportsVision: true", () => {
|
||||
const expectedVision = [
|
||||
"llama-4-maverick",
|
||||
"llama-4-scout",
|
||||
"llama-3.2-90b-vision",
|
||||
"llama-3.2-11b-vision",
|
||||
];
|
||||
for (const model of muse_codeProvider.models) {
|
||||
if (expectedVision.includes(model.id)) {
|
||||
assert.equal(model.supportsVision, true, `${model.id} should have supportsVision`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Tests for Muse Code CLI provider registry entry.
|
||||
*
|
||||
* Verifies the provider entry loads correctly with expected config.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { muse_codeProvider } from "../../open-sse/config/providers/registry/muse-code/index.ts";
|
||||
import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
|
||||
|
||||
// ── Registry entry structure ────────────────────────────────────────────────
|
||||
|
||||
test("muse-code provider entry has id", () => {
|
||||
assert.equal(muse_codeProvider.id, "muse-code");
|
||||
});
|
||||
|
||||
test("muse-code provider entry has alias", () => {
|
||||
assert.equal(muse_codeProvider.alias, "mc");
|
||||
});
|
||||
|
||||
test("muse-code provider uses openai format", () => {
|
||||
assert.equal(muse_codeProvider.format, "openai");
|
||||
});
|
||||
|
||||
test("muse-code provider uses apikey auth", () => {
|
||||
assert.equal(muse_codeProvider.authType, "apikey");
|
||||
assert.equal(muse_codeProvider.authHeader, "bearer");
|
||||
});
|
||||
|
||||
test("muse-code provider has passthroughModels enabled", () => {
|
||||
assert.equal(muse_codeProvider.passthroughModels, true);
|
||||
});
|
||||
|
||||
// ── Model entries ───────────────────────────────────────────────────────────
|
||||
|
||||
test("muse-code provider has curated models", () => {
|
||||
assert.ok(muse_codeProvider.models.length > 0);
|
||||
});
|
||||
|
||||
test("all muse-code models have contextLength", () => {
|
||||
for (const model of muse_codeProvider.models) {
|
||||
assert.ok(
|
||||
typeof model.contextLength === "number" && model.contextLength > 0,
|
||||
`${model.id} must have positive contextLength`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("all muse-code models have toolCalling: true", () => {
|
||||
for (const model of muse_codeProvider.models) {
|
||||
assert.equal(model.toolCalling, true, `${model.id} must have toolCalling enabled`);
|
||||
}
|
||||
});
|
||||
|
||||
test("all muse-code models have targetFormat: openai-responses", () => {
|
||||
for (const model of muse_codeProvider.models) {
|
||||
assert.equal(
|
||||
model.targetFormat,
|
||||
"openai-responses",
|
||||
`${model.id} must use openai-responses target format`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reasoning models have supportsXHighEffort", () => {
|
||||
for (const model of muse_codeProvider.models) {
|
||||
if (model.supportsReasoning) {
|
||||
assert.equal(
|
||||
model.supportsXHighEffort,
|
||||
true,
|
||||
`${model.id} is a reasoning model but missing supportsXHighEffort`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Registry discovery ──────────────────────────────────────────────────────
|
||||
|
||||
test("muse-code is discoverable via getRegistryEntry", () => {
|
||||
const entry = getRegistryEntry("muse-code");
|
||||
assert.ok(entry, "getRegistryEntry must return muse-code entry");
|
||||
assert.equal(entry.id, "muse-code");
|
||||
});
|
||||
|
||||
test("muse-code is discoverable via alias", () => {
|
||||
const entry = getRegistryEntry("mc");
|
||||
assert.ok(entry, "getRegistryEntry must find muse-code by alias mc");
|
||||
assert.equal(entry.id, "muse-code");
|
||||
});
|
||||
Reference in New Issue
Block a user