mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Compare commits
6 Commits
feat/9544-
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4b9b4dba9 | ||
|
|
ffea901c45 | ||
|
|
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,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -4,28 +4,21 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import { matchesSearch } from "@/shared/utils/turkishText";
|
||||
import {
|
||||
toModelOverrideTargets,
|
||||
type PricingCatalogProvider,
|
||||
} from "@/lib/modelCapabilityOverrideTargets";
|
||||
|
||||
type ModelOverrideKey = "context_length" | "max_input_tokens" | "max_output_tokens";
|
||||
type StatusTone = "success" | "error" | "info";
|
||||
|
||||
type ModelOverrideTarget = {
|
||||
target: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
label: string;
|
||||
};
|
||||
type ModelOverrideTarget = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget;
|
||||
|
||||
interface PricingCatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PricingCatalogProvider {
|
||||
id: string;
|
||||
alias: string;
|
||||
models: PricingCatalogModel[];
|
||||
}
|
||||
|
||||
interface ModelCapabilityOverride {
|
||||
target: string;
|
||||
key: ModelOverrideKey;
|
||||
@@ -120,22 +113,11 @@ function useModelCapabilityOverridesData() {
|
||||
return { catalog, overrides, loading, statusMessage, saveOverride, removeOverride };
|
||||
}
|
||||
|
||||
function toTargets(catalog: Record<string, PricingCatalogProvider>): ModelOverrideTarget[] {
|
||||
return Object.values(catalog).flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
target: `${provider.id}/${model.id}`,
|
||||
provider: provider.id,
|
||||
modelId: model.id,
|
||||
label: `${provider.id}/${model.id}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export default function ModelCapabilityOverridesTab() {
|
||||
const t = useTranslations("settings");
|
||||
const { catalog, overrides, loading, statusMessage, saveOverride, removeOverride } =
|
||||
useModelCapabilityOverridesData();
|
||||
const targets = useMemo(() => toTargets(catalog), [catalog]);
|
||||
const targets = useMemo(() => toModelOverrideTargets(catalog), [catalog]);
|
||||
|
||||
if (loading) return <div className="text-sm text-text-muted animate-pulse">{t("loading")}</div>;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FilterSelect, HeroStat, SyncMini } from "./PricingTabHelpers";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
@@ -48,6 +50,8 @@ interface PricingCatalogProvider {
|
||||
format: string;
|
||||
modelCount: number;
|
||||
models: PricingCatalogModel[];
|
||||
/** Original pricing namespace (e.g. public prefix) when it differs from `alias`. */
|
||||
pricingKey?: string;
|
||||
}
|
||||
|
||||
function getSourceTone(source: PricingSource): string {
|
||||
@@ -133,11 +137,15 @@ export default function PricingTab() {
|
||||
|
||||
const allProviders = useMemo(() => {
|
||||
return Object.entries(catalog)
|
||||
.map(([alias, info]) => ({
|
||||
...info,
|
||||
alias,
|
||||
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
|
||||
}))
|
||||
.map(([alias, info]) => {
|
||||
const pricingKey = info.pricingKey || alias;
|
||||
return {
|
||||
...info,
|
||||
alias,
|
||||
pricingKey,
|
||||
pricedModels: pricingData[pricingKey] ? Object.keys(pricingData[pricingKey]).length : 0,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.modelCount - left.modelCount);
|
||||
}, [catalog, pricingData]);
|
||||
|
||||
@@ -312,13 +320,14 @@ export default function PricingTab() {
|
||||
);
|
||||
|
||||
const saveProvider = useCallback(
|
||||
async (providerAlias: string) => {
|
||||
async (providerAlias: string, pricingKey?: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const writeKey = pricingKey || providerAlias;
|
||||
const response = await fetch("/api/pricing", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [providerAlias]: pricingData[providerAlias] || {} }),
|
||||
body: JSON.stringify({ [writeKey]: pricingData[writeKey] || {} }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -328,7 +337,7 @@ export default function PricingTab() {
|
||||
|
||||
setEditedProviders((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(providerAlias);
|
||||
next.delete(writeKey);
|
||||
return next;
|
||||
});
|
||||
await loadData();
|
||||
@@ -348,11 +357,13 @@ export default function PricingTab() {
|
||||
);
|
||||
|
||||
const resetProvider = useCallback(
|
||||
async (providerAlias: string) => {
|
||||
async (providerAlias: string, pricingKey?: string) => {
|
||||
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pricing?provider=${providerAlias}`, {
|
||||
const writeKey = pricingKey || providerAlias;
|
||||
const params = new URLSearchParams({ provider: writeKey });
|
||||
const response = await fetch(`/api/pricing?${params.toString()}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -363,7 +374,7 @@ export default function PricingTab() {
|
||||
|
||||
setEditedProviders((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(providerAlias);
|
||||
next.delete(writeKey);
|
||||
return next;
|
||||
});
|
||||
await loadData();
|
||||
@@ -680,16 +691,16 @@ export default function PricingTab() {
|
||||
<ProviderSection
|
||||
key={provider.alias}
|
||||
provider={provider}
|
||||
pricingData={pricingData[provider.alias] || {}}
|
||||
sourceMap={pricingSources[provider.alias] || {}}
|
||||
pricingData={pricingData[provider.pricingKey || provider.alias] || {}}
|
||||
sourceMap={pricingSources[provider.pricingKey || provider.alias] || {}}
|
||||
isExpanded={expandedProviders.has(provider.alias)}
|
||||
isEdited={editedProviders.has(provider.alias)}
|
||||
isEdited={editedProviders.has(provider.pricingKey || provider.alias)}
|
||||
onToggle={() => toggleProvider(provider.alias)}
|
||||
onPricingChange={(model, field, value) =>
|
||||
handlePricingChange(provider.alias, model, field, value)
|
||||
handlePricingChange(provider.pricingKey || provider.alias, model, field, value)
|
||||
}
|
||||
onSave={() => void saveProvider(provider.alias)}
|
||||
onReset={() => void resetProvider(provider.alias)}
|
||||
onSave={() => void saveProvider(provider.alias, provider.pricingKey)}
|
||||
onReset={() => void resetProvider(provider.alias, provider.pricingKey)}
|
||||
saving={saving}
|
||||
getSourceLabel={getSourceLabel}
|
||||
/>
|
||||
@@ -717,63 +728,6 @@ export default function PricingTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold tabular-nums leading-tight ${accent || "text-text-main"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncMini({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/30 bg-bg-base/40 px-2 py-1.5">
|
||||
<p className="text-[9px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-text-main mt-0.5 truncate" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="font-semibold uppercase tracking-wide">{label}:</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer focus:outline-none focus:border-primary"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSection({
|
||||
provider,
|
||||
pricingData,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold tabular-nums leading-tight ${accent || "text-text-main"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncMini({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/30 bg-bg-base/40 px-2 py-1.5">
|
||||
<p className="text-[9px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-text-main mt-0.5 truncate" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="font-semibold uppercase tracking-wide">{label}:</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer focus:outline-none focus:border-primary"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,39 @@ import {
|
||||
removeModelContextOverride,
|
||||
setModelContextOverride,
|
||||
} from "@/lib/db/modelContextOverrides";
|
||||
import { getProviderPrefixIndex, type ProviderPrefixEntry } from "@/lib/providerNodePrefixes";
|
||||
|
||||
const overrideKeySchema = z.enum(["context_length", "max_input_tokens", "max_output_tokens"]);
|
||||
type PublicOverrideKey = z.infer<typeof overrideKeySchema>;
|
||||
type PublicOverride = Omit<ModelCapabilityOverride, "key"> & { key: PublicOverrideKey };
|
||||
|
||||
function listPublicOverrides(): PublicOverride[] {
|
||||
/**
|
||||
* One-time per-request snapshot of the provider-node prefix index. Loaded once
|
||||
* per handler (never N times per row) straight from the DB — no module-global
|
||||
* mutable caches, no route-to-route imports.
|
||||
*/
|
||||
async function loadPrefixMaps(): Promise<{
|
||||
entries: Map<string, ProviderPrefixEntry>;
|
||||
nodeToPrefix: Map<string, string>;
|
||||
prefixToNode: Map<string, string>;
|
||||
eligibleNodeIds: Set<string>;
|
||||
compatibleNodeIds: Set<string>;
|
||||
}> {
|
||||
const index = await getProviderPrefixIndex();
|
||||
return {
|
||||
entries: index.entries,
|
||||
nodeToPrefix: index.nodeToPrefix,
|
||||
prefixToNode: index.prefixToNode,
|
||||
eligibleNodeIds: index.eligibleNodeIds,
|
||||
compatibleNodeIds: index.compatibleNodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function listPublicOverrides(
|
||||
nodeToPrefix: Map<string, string>,
|
||||
eligibleNodeIds: Set<string>,
|
||||
compatibleNodeIds: Set<string>
|
||||
): Promise<PublicOverride[]> {
|
||||
const capabilityOverrides = listModelCapabilityOverrides() as PublicOverride[];
|
||||
const contextOverrides = listModelContextOverrides().map((override): PublicOverride => ({
|
||||
provider: override.provider,
|
||||
@@ -29,9 +56,27 @@ function listPublicOverrides(): PublicOverride[] {
|
||||
value: override.realContext,
|
||||
refreshedAt: override.refreshedAt,
|
||||
}));
|
||||
return [...capabilityOverrides, ...contextOverrides].sort((left, right) =>
|
||||
right.refreshedAt.localeCompare(left.refreshedAt)
|
||||
);
|
||||
const merged = [...capabilityOverrides, ...contextOverrides];
|
||||
return merged
|
||||
.filter((override) => {
|
||||
// A compatible node that is NOT the unique non-reserved prefix winner is
|
||||
// ineligible for Model Overrides: never surface it under a generated node
|
||||
// UUID. Eligible winners are those in `eligibleNodeIds` (routable via
|
||||
// their public prefix); built-in providers (not in `compatibleNodeIds`)
|
||||
// are always eligible.
|
||||
return !compatibleNodeIds.has(override.provider) || eligibleNodeIds.has(override.provider);
|
||||
})
|
||||
.map((override) => {
|
||||
const displayProvider = nodeToPrefix.get(override.provider) || override.provider;
|
||||
return {
|
||||
...override,
|
||||
// Both `provider` and `target` are exposed under the public prefix so no
|
||||
// generated node UUID ever leaks into the JSON for a prefixed node.
|
||||
provider: displayProvider,
|
||||
target: `${displayProvider}/${override.modelId}`,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.refreshedAt.localeCompare(left.refreshedAt));
|
||||
}
|
||||
|
||||
const upsertOverrideSchema = z.object({
|
||||
@@ -40,23 +85,65 @@ const upsertOverrideSchema = z.object({
|
||||
value: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
function canonicalizeTarget(target: string): string | null {
|
||||
/**
|
||||
* Canonicalize a public `<prefix>/<model>` target to `<internalNodeId>/<model>`
|
||||
* so the override is stored where runtime lookup reads it. Mirrors runtime
|
||||
* prefix routing exactly:
|
||||
*
|
||||
* - `unique` configured prefix → canonicalize to the single runtime-routable
|
||||
* winner node (first openai-compatible then anthropic-compatible, by id).
|
||||
* - `reserved` configured prefix (collides with a built-in registry id/alias,
|
||||
* e.g. a node with `prefix="cx"`) → route via `resolveProviderAlias` to the
|
||||
* built-in canonical provider (runtime routes `cx/` to codex), never 400.
|
||||
* - `ambiguous` (no runtime winner selectable) → fail closed (400).
|
||||
* - A bare built-in alias/id that is NOT a configured node prefix (e.g.
|
||||
* `openai` typed directly) resolves via `resolveProviderAlias` (unchanged).
|
||||
*
|
||||
* Returns the canonical `provider/model` on success, or `{ ok: false }`.
|
||||
*/
|
||||
function canonicalizeTarget(
|
||||
target: string,
|
||||
entries: Map<string, ProviderPrefixEntry>,
|
||||
prefixToNode: Map<string, string>,
|
||||
compatibleNodeIds: Set<string>,
|
||||
eligibleNodeIds: Set<string>
|
||||
): { ok: true; target: string } | { ok: false } {
|
||||
const raw = target.trim();
|
||||
const slashIndex = raw.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === raw.length - 1) return null;
|
||||
if (slashIndex <= 0 || slashIndex === raw.length - 1) return { ok: false };
|
||||
|
||||
const provider = raw.slice(0, slashIndex).trim();
|
||||
const modelId = raw.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelId) return null;
|
||||
if (!provider || !modelId) return { ok: false };
|
||||
|
||||
return `${resolveProviderAlias(provider) || provider}/${modelId}`;
|
||||
// Raw internal compatible-node IDs are never a public Model Overrides target.
|
||||
// Only the public prefix of an eligible runtime winner may select a node.
|
||||
// Reject ineligible raw IDs as well as eligible raw IDs so stale or direct API
|
||||
// callers cannot create UUID-keyed overrides that the UI cannot manage.
|
||||
if (compatibleNodeIds.has(provider)) return { ok: false };
|
||||
|
||||
const configured = entries.get(provider);
|
||||
// A reserved configured prefix is routable to the built-in canonical provider
|
||||
// (runtime never routes it to the compatible node). `resolveProviderAlias`
|
||||
// maps e.g. `cx` → `codex`. Only an ambiguous prefix has no routable target.
|
||||
if (configured && configured.status === "ambiguous") {
|
||||
return { ok: false };
|
||||
}
|
||||
const resolvedNodeId = prefixToNode.get(provider);
|
||||
if (resolvedNodeId && !eligibleNodeIds.has(resolvedNodeId)) return { ok: false };
|
||||
const canonicalProvider = resolvedNodeId || resolveProviderAlias(provider) || provider;
|
||||
|
||||
return { ok: true, target: `${canonicalProvider}/${modelId}` };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
return NextResponse.json({ overrides: listPublicOverrides() });
|
||||
const { nodeToPrefix, eligibleNodeIds, compatibleNodeIds } = await loadPrefixMaps();
|
||||
return NextResponse.json({
|
||||
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
@@ -75,17 +162,28 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const target = canonicalizeTarget(parsed.data.target);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
|
||||
const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
|
||||
await loadPrefixMaps();
|
||||
const canonical = canonicalizeTarget(
|
||||
parsed.data.target,
|
||||
entries,
|
||||
prefixToNode,
|
||||
compatibleNodeIds,
|
||||
eligibleNodeIds
|
||||
);
|
||||
if (!canonical.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or ambiguous model capability override target" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const targetParts = target.split(/\/(.*)/s);
|
||||
const targetParts = canonical.target.split(/\/(.*)/s);
|
||||
const written =
|
||||
parsed.data.key === "context_length"
|
||||
? setModelContextOverride(targetParts[0], targetParts[1], parsed.data.value, "manual")
|
||||
: setModelCapabilityOverride(
|
||||
target,
|
||||
canonical.target,
|
||||
parsed.data.key as ModelCapabilityOverrideKey,
|
||||
parsed.data.value
|
||||
);
|
||||
@@ -93,7 +191,9 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ overrides: listPublicOverrides() });
|
||||
return NextResponse.json({
|
||||
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
@@ -101,19 +201,33 @@ export async function DELETE(request: Request) {
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const target = canonicalizeTarget(searchParams.get("target") || "");
|
||||
const key = searchParams.get("key") || "";
|
||||
const parsedKey = overrideKeySchema.safeParse(key);
|
||||
|
||||
if (!target || !parsedKey.success) {
|
||||
return NextResponse.json({ error: "target and key are required" }, { status: 400 });
|
||||
const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
|
||||
await loadPrefixMaps();
|
||||
const canonical = canonicalizeTarget(
|
||||
searchParams.get("target") || "",
|
||||
entries,
|
||||
prefixToNode,
|
||||
compatibleNodeIds,
|
||||
eligibleNodeIds
|
||||
);
|
||||
|
||||
if (!canonical.ok || !parsedKey.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "target and key are required; target must be a valid model override target" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedKey.data === "context_length") {
|
||||
const targetParts = target.split(/\/(.*)/s);
|
||||
const targetParts = canonical.target.split(/\/(.*)/s);
|
||||
removeModelContextOverride(targetParts[0], targetParts[1]);
|
||||
} else {
|
||||
removeModelCapabilityOverride(target, parsedKey.data as ModelCapabilityOverrideKey);
|
||||
removeModelCapabilityOverride(canonical.target, parsedKey.data as ModelCapabilityOverrideKey);
|
||||
}
|
||||
return NextResponse.json({ overrides: listPublicOverrides() });
|
||||
return NextResponse.json({
|
||||
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb";
|
||||
import { getProviderPrefixIndex } from "@/lib/providerNodePrefixes";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -28,6 +29,12 @@ export async function GET() {
|
||||
try {
|
||||
const catalog: Record<string, any> = {};
|
||||
|
||||
// Pre-load compatible-provider node public prefixes once (shared across the
|
||||
// whole catalog build — never N lookups per model). Only uniquely-routable
|
||||
// prefixes are exposed as public targets (reserved/ambiguous are not).
|
||||
const { nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
|
||||
await getProviderPrefixIndex();
|
||||
|
||||
// ── 1. Registry models (hardcoded) ──────────────────────────────
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
const alias = entry.alias || entry.id;
|
||||
@@ -54,6 +61,13 @@ export async function GET() {
|
||||
return providerId;
|
||||
};
|
||||
|
||||
// A compatible provider node should surface under its configured public
|
||||
// prefix, never its generated `openai-compatible-chat-<uuid>` node id
|
||||
// (#9557). The internal `id` (node id) is preserved for PricingTab and
|
||||
// runtime capability lookup. Only a uniquely-routable non-reserved winner
|
||||
// is Model-Overrides eligible (marked explicitly); a compatible node that
|
||||
// is reserved/losing/no-prefix is marked ineligible and skipped by the
|
||||
// Model-Overrides helper.
|
||||
const ensureCatalogProvider = (providerId: string, alias: string) => {
|
||||
if (!catalog[alias]) {
|
||||
catalog[alias] = {
|
||||
@@ -64,6 +78,11 @@ export async function GET() {
|
||||
format: "openai",
|
||||
models: [],
|
||||
};
|
||||
const prefix = nodeToPrefix.get(providerId);
|
||||
if (prefix) catalog[alias].displayPrefix = prefix;
|
||||
if (compatibleNodeIds.has(providerId)) {
|
||||
catalog[alias].modelOverrideEligible = eligibleNodeIds.has(providerId);
|
||||
}
|
||||
}
|
||||
return catalog[alias];
|
||||
};
|
||||
@@ -111,6 +130,13 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// ── 4. Pricing-only models (DB) ─────────────────────────────────
|
||||
// Pricing may be keyed by the node's public prefix (what the operator typed)
|
||||
// or by the internal node id. When keyed by a uniquely-routable public
|
||||
// prefix, reconcile it to that node so the model list merges into the
|
||||
// canonical compatible-provider entry instead of duplicating it, and
|
||||
// preserve the original pricing namespace as `pricingKey` so PricingTab can
|
||||
// read/save/reset against it. Reserved / ambiguous prefixes have no single
|
||||
// routable node and stay as-is.
|
||||
let pricingData: Record<string, any> = {};
|
||||
try {
|
||||
pricingData = await getPricing();
|
||||
@@ -118,7 +144,10 @@ export async function GET() {
|
||||
/* DB may not be ready */
|
||||
}
|
||||
|
||||
for (const [providerAlias, models] of Object.entries(pricingData)) {
|
||||
for (const [rawProviderAlias, models] of Object.entries(pricingData)) {
|
||||
// `rawProviderAlias` is the original pricing namespace the operator used.
|
||||
const pricingKey = rawProviderAlias;
|
||||
const providerAlias = prefixToNode.get(rawProviderAlias) || rawProviderAlias;
|
||||
if (!catalog[providerAlias]) {
|
||||
catalog[providerAlias] = {
|
||||
id: providerAlias,
|
||||
@@ -128,6 +157,16 @@ export async function GET() {
|
||||
format: "openai",
|
||||
models: [],
|
||||
};
|
||||
const prefix = nodeToPrefix.get(providerAlias);
|
||||
if (prefix) catalog[providerAlias].displayPrefix = prefix;
|
||||
if (compatibleNodeIds.has(providerAlias)) {
|
||||
catalog[providerAlias].modelOverrideEligible = eligibleNodeIds.has(providerAlias);
|
||||
}
|
||||
}
|
||||
// When the entry is keyed internally by the node id but priced under a
|
||||
// public prefix, remember the original pricing namespace for PricingTab.
|
||||
if (pricingKey !== providerAlias && !catalog[providerAlias].pricingKey) {
|
||||
catalog[providerAlias].pricingKey = pricingKey;
|
||||
}
|
||||
|
||||
const existingIds = new Set(catalog[providerAlias].models.map((m) => m.id));
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
79
src/lib/modelCapabilityOverrideTargets.ts
Normal file
79
src/lib/modelCapabilityOverrideTargets.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Pure catalog → Model Override target conversion.
|
||||
*
|
||||
* The operator-facing Model Overrides surface must present a compatible
|
||||
* provider node under its configured public `prefix` (e.g. `vibeproxy/gpt-4o`)
|
||||
* — never the generated `openai-compatible-chat-<uuid>` node id (#9557).
|
||||
*
|
||||
* The pricing catalog keeps the internal `id` (the DB node id, which PricingTab
|
||||
* uses to key pricing data) and, when the node has a configured prefix, also
|
||||
* carries `displayPrefix`. This helper prefers `displayPrefix` for the public
|
||||
* label/target while leaving the raw id untouched for storage/runtime lookup.
|
||||
*
|
||||
* Model-Overrides eligibility seam: a compatible provider node is eligible only
|
||||
* when it is the unique, non-reserved runtime-routable winner of its configured
|
||||
* prefix. The catalog marks such winners with `modelOverrideEligible === true`
|
||||
* (and a `displayPrefix`); reserved/losing/no-public-prefix compatible nodes are
|
||||
* marked `modelOverrideEligible === false` and are SKIPPED — never surfaced
|
||||
* under a generated node UUID. Built-in / no-compatible catalog entries carry no
|
||||
* flag and remain targetable.
|
||||
*/
|
||||
|
||||
export interface PricingCatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PricingCatalogProvider {
|
||||
id: string;
|
||||
alias: string;
|
||||
displayPrefix?: string;
|
||||
/** Explicit Model-Overrides eligibility; undefined ⇒ eligible (built-in/no-compatible). */
|
||||
modelOverrideEligible?: boolean;
|
||||
models: PricingCatalogModel[];
|
||||
}
|
||||
|
||||
export interface ModelOverrideTarget {
|
||||
target: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a catalog provider is targetable in Model Overrides. Only compatible
|
||||
* nodes marked ineligible (reserved/losing/no-public-prefix) are skipped; all
|
||||
* built-in and no-compatible entries are eligible.
|
||||
*/
|
||||
export function isModelOverrideEligible(provider: PricingCatalogProvider): boolean {
|
||||
return provider.modelOverrideEligible !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public display prefix for a compatible provider node, falling back to its
|
||||
* internal id when no operator-configured prefix is set.
|
||||
*/
|
||||
export function modelOverrideProviderPrefix(provider: PricingCatalogProvider): string {
|
||||
return provider.displayPrefix?.trim() || provider.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the /api/pricing/models catalog into Model Override targets. Each
|
||||
* target uses the node's public prefix (when configured) so the selector,
|
||||
* search, selected model, and the target sent to the override API never expose
|
||||
* a generated node UUID. Ineligible compatible nodes are skipped entirely.
|
||||
*/
|
||||
export function toModelOverrideTargets(
|
||||
catalog: Record<string, PricingCatalogProvider>
|
||||
): ModelOverrideTarget[] {
|
||||
return Object.values(catalog).flatMap((provider) => {
|
||||
if (!isModelOverrideEligible(provider)) return [];
|
||||
const prefix = modelOverrideProviderPrefix(provider);
|
||||
return provider.models.map((model) => ({
|
||||
target: `${prefix}/${model.id}`,
|
||||
provider: prefix,
|
||||
modelId: model.id,
|
||||
label: `${prefix}/${model.id}`,
|
||||
}));
|
||||
});
|
||||
}
|
||||
144
src/lib/providerNodePrefixes.ts
Normal file
144
src/lib/providerNodePrefixes.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Shared provider-node public-prefix index (#9557).
|
||||
*
|
||||
* A compatible provider node (openai/anthropic-compatible) can carry an
|
||||
* operator-configured public `prefix` (e.g. `vibeproxy`) that the Model
|
||||
* Overrides surface must expose instead of the generated
|
||||
* `openai-compatible-chat-<uuid>` node id.
|
||||
*
|
||||
* This module is the single narrow home for that index so both the pricing
|
||||
* catalog route and the override route resolve node → prefix / prefix → node
|
||||
* consistently. It does one `getProviderNodes()` DB read per call and derives
|
||||
* every map from it — no module-global mutable caches, no route-to-route
|
||||
* imports.
|
||||
*
|
||||
* Classification of each configured prefix (mirrors runtime semantics):
|
||||
* - `reserved`: the prefix collides with a built-in registry id/alias
|
||||
* (e.g. `cx` → codex). Such a node must NOT be advertised as a compatible
|
||||
* public target and the prefix must never be canonicalized to that node —
|
||||
* runtime routes reserved prefixes to the built-in provider, so the
|
||||
* override route must too.
|
||||
* - `unique`: a single runtime-routable node owns the prefix. When two or
|
||||
* more nodes share a prefix, the runtime winner is deterministic (first
|
||||
* openai-compatible node by id order, else first anthropic-compatible
|
||||
* node — see `getModelInfo`), and the prefix index selects that same
|
||||
* winner. Only the winner is targetable/displayed under the prefix;
|
||||
* losing nodes are ineligible and never fall back to a node UUID target.
|
||||
* - `ambiguous`: multiple nodes share the prefix but no runtime winner is
|
||||
* selectable (no compatible node matches) — practically unreachable since
|
||||
* only compatible nodes carry prefixes, kept for safety.
|
||||
*
|
||||
* Model-Overrides eligibility: a compatible node is eligible only when it is
|
||||
* the unique, non-reserved winner of its configured prefix (i.e. it is in
|
||||
* `eligibleNodeIds`). Reserved/losing/no-public-prefix compatible nodes are
|
||||
* ineligible and must be skipped — never surfaced under a generated node UUID.
|
||||
* Built-in/no-compatible catalog entries are always eligible.
|
||||
*/
|
||||
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getProviderNodes } from "@/lib/db/providers/nodes";
|
||||
|
||||
export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved";
|
||||
|
||||
export interface ProviderPrefixEntry {
|
||||
prefix: string;
|
||||
status: ProviderPrefixStatus;
|
||||
/** Present only when `status === "unique"`. */
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export interface ProviderPrefixIndex {
|
||||
/** prefix → classification entry (every configured prefix). */
|
||||
entries: Map<string, ProviderPrefixEntry>;
|
||||
/** nodeId → public prefix, only for uniquely-routable non-reserved winners. */
|
||||
nodeToPrefix: Map<string, string>;
|
||||
/** public prefix → nodeId, only for uniquely-routable non-reserved winners. */
|
||||
prefixToNode: Map<string, string>;
|
||||
/** Every compatible provider node id present in the node table. */
|
||||
compatibleNodeIds: Set<string>;
|
||||
/** Compatible node ids eligible for Model Overrides (unique non-reserved winners). */
|
||||
eligibleNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in reserved prefixes — registry ids + aliases, the same semantics the
|
||||
* runtime `getReservedProviderPrefixes()` uses so user-defined compatible-node
|
||||
* prefixes can never shadow a built-in provider.
|
||||
*/
|
||||
export function buildReservedPrefixes(): Set<string> {
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
return reserved;
|
||||
}
|
||||
|
||||
export interface CompatibleNodeLike {
|
||||
id?: string;
|
||||
type?: string;
|
||||
prefix?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure prefix→node winner selection replicating the runtime `getModelInfo`
|
||||
* rule exactly: the first openai-compatible node (by DB/id order) whose
|
||||
* `prefix` matches wins; otherwise the first anthropic-compatible node.
|
||||
* `nodes` must already be in the runtime's id-ascending order (as
|
||||
* `getProviderNodes` returns).
|
||||
*/
|
||||
export function selectCompatibleNodeForPrefix(
|
||||
nodes: CompatibleNodeLike[],
|
||||
prefix: string
|
||||
): CompatibleNodeLike | null {
|
||||
const openaiMatch = nodes.find((n) => n.type === "openai-compatible" && n.prefix === prefix);
|
||||
if (openaiMatch) return openaiMatch;
|
||||
return nodes.find((n) => n.type === "anthropic-compatible" && n.prefix === prefix) ?? null;
|
||||
}
|
||||
|
||||
export async function getProviderPrefixIndex(): Promise<ProviderPrefixIndex> {
|
||||
const reserved = buildReservedPrefixes();
|
||||
const nodes = (await getProviderNodes()) as CompatibleNodeLike[];
|
||||
const compatible = nodes.filter(
|
||||
(n) => n.type === "openai-compatible" || n.type === "anthropic-compatible"
|
||||
);
|
||||
|
||||
const compatibleNodeIds = new Set<string>();
|
||||
for (const node of compatible) {
|
||||
if (node.id) compatibleNodeIds.add(node.id);
|
||||
}
|
||||
|
||||
const byPrefix = new Map<string, CompatibleNodeLike[]>();
|
||||
for (const node of compatible) {
|
||||
const prefix = node.prefix?.trim();
|
||||
if (!node.id || !prefix) continue;
|
||||
const list = byPrefix.get(prefix) ?? [];
|
||||
list.push(node);
|
||||
byPrefix.set(prefix, list);
|
||||
}
|
||||
|
||||
const entries = new Map<string, ProviderPrefixEntry>();
|
||||
const nodeToPrefix = new Map<string, string>();
|
||||
const prefixToNode = new Map<string, string>();
|
||||
const eligibleNodeIds = new Set<string>();
|
||||
|
||||
for (const [prefix, prefixNodes] of byPrefix) {
|
||||
if (reserved.has(prefix)) {
|
||||
// Built-in registry id/alias — never a compatible public target.
|
||||
entries.set(prefix, { prefix, status: "reserved" });
|
||||
continue;
|
||||
}
|
||||
const winner = selectCompatibleNodeForPrefix(prefixNodes, prefix);
|
||||
if (!winner?.id) {
|
||||
entries.set(prefix, { prefix, status: "ambiguous" });
|
||||
continue;
|
||||
}
|
||||
// The runtime-routable winner alone owns the prefix.
|
||||
entries.set(prefix, { prefix, status: "unique", nodeId: winner.id });
|
||||
nodeToPrefix.set(winner.id, prefix);
|
||||
prefixToNode.set(prefix, winner.id);
|
||||
eligibleNodeIds.add(winner.id);
|
||||
}
|
||||
|
||||
return { entries, nodeToPrefix, prefixToNode, compatibleNodeIds, eligibleNodeIds };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
|
||||
584
tests/unit/model-overrides-provider-prefix-9557.test.ts
Normal file
584
tests/unit/model-overrides-provider-prefix-9557.test.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { describe, it, beforeEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-model-overrides-prefix-"));
|
||||
process.env.DATA_DIR = moduleDataDir;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const nodes = await import("../../src/lib/db/providers/nodes.ts");
|
||||
const models = await import("../../src/lib/db/models.ts");
|
||||
const overrides = await import("../../src/lib/db/modelCapabilityOverrides.ts");
|
||||
const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts");
|
||||
const pricingRoute = await import("../../src/app/api/pricing/models/route.ts");
|
||||
const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts");
|
||||
const targets = await import("../../src/lib/modelCapabilityOverrideTargets.ts");
|
||||
const caps = await import("../../src/lib/modelCapabilities.ts");
|
||||
const prefixIndex = await import("../../src/lib/providerNodePrefixes.ts");
|
||||
// Runtime prefix→node resolution (same path the request pipeline uses).
|
||||
const sseModel = await import("../../src/sse/services/model.ts");
|
||||
|
||||
beforeEach(() => {
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(moduleDataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(moduleDataDir, { recursive: true });
|
||||
coreDb.getDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(moduleDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441";
|
||||
const NODE_PREFIX = "vibeproxy";
|
||||
const NODE_TYPE = "openai-compatible";
|
||||
|
||||
async function seedNodeWithSyncedModel(modelId = "gpt-4o", opts: { prefix?: string | null } = {}) {
|
||||
await nodes.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: NODE_TYPE,
|
||||
prefix: opts.prefix === undefined ? NODE_PREFIX : opts.prefix,
|
||||
name: "VibeProxy",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(NODE_ID, NODE_ID, [
|
||||
{ id: modelId, name: modelId },
|
||||
]);
|
||||
}
|
||||
|
||||
describe("issue #9557: model overrides expose configured provider prefix, not node UUID", () => {
|
||||
it("pricing/models returns public displayPrefix while retaining internal node id", async () => {
|
||||
await seedNodeWithSyncedModel();
|
||||
const response = await pricingRoute.GET();
|
||||
assert.equal(response.status, 200);
|
||||
const catalog = (await response.json()) as Record<
|
||||
string,
|
||||
{ id: string; alias: string; displayPrefix?: string; models: Array<{ id: string }> }
|
||||
>;
|
||||
|
||||
const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID);
|
||||
assert.ok(entry, "compatible node must appear in the pricing catalog");
|
||||
assert.equal(entry.id, NODE_ID, "internal node id must be preserved for PricingTab");
|
||||
assert.equal(entry.displayPrefix, NODE_PREFIX, "public display prefix must be exposed");
|
||||
assert.ok(
|
||||
entry.models.some((model) => model.id === "gpt-4o"),
|
||||
"synced model must be listed under the compatible node"
|
||||
);
|
||||
});
|
||||
|
||||
it("toModelOverrideTargets labels/selects with the public prefix, never the node UUID", () => {
|
||||
const catalog = {
|
||||
[NODE_ID]: {
|
||||
id: NODE_ID,
|
||||
alias: NODE_ID,
|
||||
displayPrefix: NODE_PREFIX,
|
||||
models: [{ id: "gpt-4o", name: "gpt-4o" }],
|
||||
},
|
||||
openai: {
|
||||
id: "openai",
|
||||
alias: "openai",
|
||||
models: [{ id: "gpt-4o", name: "gpt-4o" }],
|
||||
},
|
||||
// A losing/reserved compatible node explicitly marked ineligible must be
|
||||
// skipped entirely — never surfaced under a node UUID.
|
||||
"openai-compatible-chat-loser": {
|
||||
id: "openai-compatible-chat-loser",
|
||||
alias: "openai-compatible-chat-loser",
|
||||
displayPrefix: NODE_PREFIX,
|
||||
modelOverrideEligible: false,
|
||||
models: [{ id: "lost-model", name: "lost-model" }],
|
||||
},
|
||||
};
|
||||
const result = targets.toModelOverrideTargets(catalog);
|
||||
const [compatible, builtin] = result;
|
||||
|
||||
assert.equal(result.length, 2, "ineligible compatible node is skipped");
|
||||
assert.equal(compatible.target, `${NODE_PREFIX}/gpt-4o`);
|
||||
assert.equal(compatible.provider, NODE_PREFIX);
|
||||
assert.equal(compatible.label, `${NODE_PREFIX}/gpt-4o`);
|
||||
assert.ok(!compatible.target.includes(NODE_ID), "public target must not leak the node UUID");
|
||||
assert.ok(!result.some((t) => t.target.includes("lost-model")), "lost node not a target");
|
||||
assert.equal(builtin.target, "openai/gpt-4o");
|
||||
assert.equal(
|
||||
targets.isModelOverrideEligible(catalog[NODE_ID]),
|
||||
true,
|
||||
"unflagged compatible winner is eligible"
|
||||
);
|
||||
assert.equal(
|
||||
targets.isModelOverrideEligible(catalog["openai-compatible-chat-loser"]),
|
||||
false,
|
||||
"explicitly ineligible node is excluded"
|
||||
);
|
||||
assert.equal(targets.isModelOverrideEligible(catalog.openai), true, "built-in is eligible");
|
||||
});
|
||||
|
||||
it("PATCH canonicalizes a configured prefix to the internal node id and runtime lookup applies it", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 123456,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
|
||||
// Persisted under the internal node id (where runtime lookup reads it).
|
||||
const stored = overrides.listModelCapabilityOverrides();
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].provider, NODE_ID);
|
||||
assert.equal(stored[0].modelId, "gpt-4o");
|
||||
|
||||
// Runtime capability lookup resolves provider/model and applies the override.
|
||||
const resolved = caps.getResolvedModelCapabilities({
|
||||
provider: NODE_ID,
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.equal(resolved.maxOutputTokens, 123456);
|
||||
const explicit = caps.getExplicitModelOutputCap({ provider: NODE_ID, model: "gpt-4o" });
|
||||
assert.equal(explicit, 123456);
|
||||
});
|
||||
|
||||
it("runtime getModelInfo resolves prefix/model back to the node id (routing seam)", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
|
||||
const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`);
|
||||
assert.ok(info, "getModelInfo must resolve");
|
||||
assert.equal(info.provider, NODE_ID, "prefix must route to the internal node id at runtime");
|
||||
});
|
||||
|
||||
it("index winner matches runtime getModelInfo for a duplicated prefix", async () => {
|
||||
// Two nodes share the prefix. Runtime resolves the FIRST openai-compatible
|
||||
// node by id order. The prefix index must select that same winner so the
|
||||
// UI and PATCH agree with the routing seam.
|
||||
const nodeAId = NODE_ID;
|
||||
const nodeBId = "openai-compatible-chat-22222222-2222-4333-8444-555555555555";
|
||||
await nodes.createProviderNode({
|
||||
id: nodeBId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy B",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/b/v1",
|
||||
});
|
||||
await nodes.createProviderNode({
|
||||
id: nodeAId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy A",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/a/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [
|
||||
{ id: "gpt-4o", name: "gpt-4o" },
|
||||
]);
|
||||
|
||||
const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`);
|
||||
assert.equal(info.provider, nodeAId, "runtime resolves the first node by id");
|
||||
|
||||
const index = await prefixIndex.getProviderPrefixIndex();
|
||||
assert.equal(
|
||||
index.prefixToNode.get(NODE_PREFIX),
|
||||
info.provider,
|
||||
"index prefix→node must match the runtime winner"
|
||||
);
|
||||
assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, info.provider);
|
||||
assert.ok(index.eligibleNodeIds.has(info.provider));
|
||||
assert.ok(!index.eligibleNodeIds.has(nodeBId));
|
||||
|
||||
// And PATCH persists to the same winner runtime resolves to.
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 77777,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
assert.equal(overrides.listModelCapabilityOverrides()[0].provider, info.provider);
|
||||
});
|
||||
|
||||
it("GET surfaces stored overrides under the public prefix and old raw rows still list/apply", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
|
||||
// New-style row saved via the API (canonicalized to node id).
|
||||
await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 99999,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Old raw-UUID-keyed row inserted directly (legacy pre-#9557 data).
|
||||
assert.equal(
|
||||
overrides.setModelCapabilityOverride(`${NODE_ID}/gpt-4o`, "max_output_tokens", 55555),
|
||||
true
|
||||
);
|
||||
|
||||
const get = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
assert.equal(get.status, 200);
|
||||
const payload = (await get.json()) as {
|
||||
overrides: Array<{ target: string; key: string; value: number }>;
|
||||
};
|
||||
const targetsFound = payload.overrides.map((entry) => entry.target).sort();
|
||||
assert.deepEqual(targetsFound, [`${NODE_PREFIX}/gpt-4o`, `${NODE_PREFIX}/gpt-4o`]);
|
||||
assert.ok(
|
||||
payload.overrides.every((entry) => !entry.target.includes(NODE_ID)),
|
||||
"public override list must not leak the node UUID"
|
||||
);
|
||||
|
||||
// Old raw row still applies at runtime.
|
||||
assert.equal(
|
||||
caps.getResolvedModelCapabilities({ provider: NODE_ID, model: "gpt-4o" }).maxOutputTokens,
|
||||
55555
|
||||
);
|
||||
});
|
||||
|
||||
it("GET/PATCH/DELETE JSON responses never leak the node UUID for a prefixed node", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 333,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const get = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
assert.ok(!(await get.text()).includes(NODE_ID), "GET body must not contain node UUID");
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 444,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.ok(!(await patch.text()).includes(NODE_ID), "PATCH body must not contain node UUID");
|
||||
|
||||
const del = await overrideRoute.DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/model-capability-overrides?target=${NODE_PREFIX}/gpt-4o&key=max_output_tokens`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(del.status, 200);
|
||||
const delBody = await del.text();
|
||||
assert.ok(!delBody.includes(NODE_ID), "DELETE body must not contain node UUID");
|
||||
// DELETE still returns the updated override list for the UI.
|
||||
const delPayload = JSON.parse(delBody) as { overrides: unknown[] };
|
||||
assert.ok(Array.isArray(delPayload.overrides), "DELETE returns { overrides }");
|
||||
assert.equal(delPayload.overrides.length, 1, "remaining override still present");
|
||||
});
|
||||
|
||||
it("duplicate prefix selects the same runtime winner; losing node never falls back to UUID", async () => {
|
||||
// Two nodes share the same prefix → the runtime winner is the FIRST
|
||||
// openai-compatible node by id order. The index and PATCH must choose that
|
||||
// same winner; the losing node must NOT be targetable/displayed under the
|
||||
// prefix and must NOT fall back to a node-UUID target.
|
||||
const nodeAId = NODE_ID;
|
||||
const nodeBId = "openai-compatible-chat-11111111-2222-4333-8444-555555555555";
|
||||
await nodes.createProviderNode({
|
||||
id: nodeBId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy B",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/b/v1",
|
||||
});
|
||||
await nodes.createProviderNode({
|
||||
id: nodeAId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy A",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/a/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [
|
||||
{ id: "gpt-4o", name: "gpt-4o" },
|
||||
]);
|
||||
|
||||
const index = await prefixIndex.getProviderPrefixIndex();
|
||||
// Runtime resolves the first openai-compatible node by id order → nodeAId.
|
||||
assert.equal(index.entries.get(NODE_PREFIX)?.status, "unique");
|
||||
assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, nodeAId, "winner is first by id");
|
||||
assert.equal(index.prefixToNode.get(NODE_PREFIX), nodeAId);
|
||||
assert.equal(index.nodeToPrefix.get(nodeAId), NODE_PREFIX);
|
||||
// Losing node is ineligible — no node-UUID target, no prefix→node mapping.
|
||||
assert.ok(!index.eligibleNodeIds.has(nodeBId), "losing node is ineligible");
|
||||
assert.ok(!index.prefixToNode.has(nodeBId), "losing node id must not be a prefix target");
|
||||
assert.ok(!index.nodeToPrefix.has(nodeBId), "losing node must not map to the shared prefix");
|
||||
|
||||
// Catalog advertises only the winner under the public prefix.
|
||||
const catalogRes = await pricingRoute.GET();
|
||||
const catalog = (await catalogRes.json()) as Record<
|
||||
string,
|
||||
{ id: string; displayPrefix?: string }
|
||||
>;
|
||||
const winner = Object.values(catalog).find((p) => p.id === nodeAId);
|
||||
assert.equal(winner?.displayPrefix, NODE_PREFIX, "winner advertised under prefix");
|
||||
assert.ok(
|
||||
!Object.values(catalog).some((p) => p.id === nodeBId && p.displayPrefix === NODE_PREFIX),
|
||||
"losing node must not be advertised under the shared prefix"
|
||||
);
|
||||
|
||||
// PATCH via the prefix canonicalizes to the winner (first by id) and the
|
||||
// losing node is not targetable.
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 123456,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
const stored = overrides.listModelCapabilityOverrides();
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].provider, nodeAId, "PATCH must persist to the runtime winner");
|
||||
|
||||
// Raw compatible-node UUIDs are not public Model Overrides targets. The
|
||||
// losing node remains routable internally, but stale/direct clients must
|
||||
// use the configured public prefix rather than create an unmanageable row.
|
||||
const losePatch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${nodeBId}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 7,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(losePatch.status, 400);
|
||||
const loseDelete = await overrideRoute.DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${nodeBId}/gpt-4o`)}&key=max_input_tokens`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(loseDelete.status, 400);
|
||||
assert.equal(overrides.listModelCapabilityOverrides().length, 1);
|
||||
});
|
||||
|
||||
it("reserved prefix (cx → codex) is not advertised and not canonicalized to any node", async () => {
|
||||
const RESERVED = "cx";
|
||||
const reservedNodeId = "openai-compatible-chat-99999999-2222-4333-8444-555555555555";
|
||||
await nodes.createProviderNode({
|
||||
id: reservedNodeId,
|
||||
type: NODE_TYPE,
|
||||
prefix: RESERVED,
|
||||
name: "Reserved Hijack",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/cx/v1",
|
||||
});
|
||||
|
||||
const index = await prefixIndex.getProviderPrefixIndex();
|
||||
assert.equal(index.entries.get(RESERVED)?.status, "reserved");
|
||||
assert.ok(
|
||||
!index.nodeToPrefix.has(reservedNodeId),
|
||||
"reserved-prefix node must not be exposed as a compatible target"
|
||||
);
|
||||
assert.ok(!index.prefixToNode.has(RESERVED), "reserved prefix must not map to any node");
|
||||
|
||||
// Catalog: reserved prefix must not surface as a displayPrefix on any entry.
|
||||
const catalogRes = await pricingRoute.GET();
|
||||
const catalog = (await catalogRes.json()) as Record<
|
||||
string,
|
||||
{ displayPrefix?: string; id?: string }
|
||||
>;
|
||||
assert.ok(
|
||||
!Object.values(catalog).some((provider) => provider.displayPrefix === RESERVED),
|
||||
"reserved prefix must not be advertised as a compatible public target"
|
||||
);
|
||||
|
||||
// PATCH using `cx/<model>` must route via resolveProviderAlias to the
|
||||
// built-in codex provider (runtime routes `cx/` to codex) — never 400, and
|
||||
// never persisted under the reserved-hijack node.
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${RESERVED}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 1,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200, "PATCH must route reserved prefix via resolveProviderAlias");
|
||||
|
||||
const stored = overrides.listModelCapabilityOverrides();
|
||||
assert.equal(
|
||||
stored.length,
|
||||
1,
|
||||
"reserved prefix override is persisted to the built-in provider"
|
||||
);
|
||||
assert.notEqual(stored[0].provider, reservedNodeId, "must never persist to the hijack node");
|
||||
assert.equal(stored[0].provider, "codex", "reserved prefix must resolve to built-in codex");
|
||||
|
||||
const rawPatch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${reservedNodeId}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 2,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(rawPatch.status, 400, "reserved node UUID must not be writable directly");
|
||||
});
|
||||
|
||||
it("pricing-only compatible-provider model entry carries displayPrefix + pricingKey", async () => {
|
||||
// Node exists but has NO synced/custom models; pricing is keyed by the
|
||||
// public prefix and must be reconciled onto the unique node without
|
||||
// duplicating entries, while preserving the original pricing namespace.
|
||||
await nodes.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
const { updatePricing } = await import("../../src/lib/db/settings/pricing.ts");
|
||||
// Seed a user pricing row keyed by the node's public prefix.
|
||||
await updatePricing({
|
||||
[NODE_PREFIX]: { "priced-model": { input_cost_per_million: 1, output_cost_per_million: 2 } },
|
||||
});
|
||||
|
||||
const catalogRes = await pricingRoute.GET();
|
||||
const catalog = (await catalogRes.json()) as Record<
|
||||
string,
|
||||
{ id: string; displayPrefix?: string; pricingKey?: string; models: Array<{ id: string }> }
|
||||
>;
|
||||
const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID);
|
||||
assert.ok(entry, "pricing-only model must reconcile onto the compatible node");
|
||||
assert.equal(entry.displayPrefix, NODE_PREFIX);
|
||||
assert.equal(
|
||||
entry.pricingKey,
|
||||
NODE_PREFIX,
|
||||
"pricingKey must preserve the original pricing namespace"
|
||||
);
|
||||
assert.deepEqual(
|
||||
entry.models.map((model) => model.id),
|
||||
["priced-model"],
|
||||
"pricing-only entry must be created without relying on synced/custom models"
|
||||
);
|
||||
// No duplicate entry keyed by the public prefix alone.
|
||||
assert.ok(
|
||||
!Object.values(catalog).some(
|
||||
(provider) => provider.id === NODE_PREFIX && provider.displayPrefix !== NODE_PREFIX
|
||||
),
|
||||
"pricing must not create a duplicate node-keyed entry"
|
||||
);
|
||||
});
|
||||
|
||||
it("no-prefix fallback and built-in providers keep raw internal id / unchanged behavior", async () => {
|
||||
// Node with no prefix → target falls back to internal node id.
|
||||
const noPrefixNodeId = "openai-compatible-chat-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
await nodes.createProviderNode({
|
||||
id: noPrefixNodeId,
|
||||
type: NODE_TYPE,
|
||||
prefix: null,
|
||||
name: "NoPrefix",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/np/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(noPrefixNodeId, noPrefixNodeId, [
|
||||
{ id: "np-model", name: "np-model" },
|
||||
]);
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${noPrefixNodeId}/np-model`,
|
||||
key: "max_output_tokens",
|
||||
value: 777,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 400, "no-prefix compatible node UUID must not be writable");
|
||||
assert.equal(overrides.listModelCapabilityOverrides().length, 0);
|
||||
|
||||
const rawDelete = await overrideRoute.DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${noPrefixNodeId}/np-model`)}&key=max_output_tokens`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(rawDelete.status, 400);
|
||||
|
||||
// Model-Overrides eligibility seam: a compatible node with NO public prefix
|
||||
// is skipped from the public override list — it is never surfaced under a
|
||||
// generated node UUID. Built-in / no-compatible entries remain targetable.
|
||||
const get = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
const payload = (await get.json()) as { overrides: Array<{ target: string }> };
|
||||
assert.ok(
|
||||
!payload.overrides.some((entry) => entry.target === `${noPrefixNodeId}/np-model`),
|
||||
"no-public-prefix compatible node must be skipped from the override list"
|
||||
);
|
||||
|
||||
// Built-in provider (openai) unchanged.
|
||||
const openaiPatch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: "openai/gpt-4o",
|
||||
key: "max_input_tokens",
|
||||
value: 888,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(openaiPatch.status, 200);
|
||||
const openaiGet = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
const openaiPayload = (await openaiGet.json()) as { overrides: Array<{ target: string }> };
|
||||
assert.ok(
|
||||
openaiPayload.overrides.some((entry) => entry.target === "openai/gpt-4o"),
|
||||
"built-in provider target must remain openai/gpt-4o"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
193
tests/unit/ui/model-capability-overrides-tab-9557.test.tsx
Normal file
193
tests/unit/ui/model-capability-overrides-tab-9557.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Issue #9557 UI regression: the Model Overrides dashboard tab must render and
|
||||
// PATCH/DELETE against the operator-configured public provider prefix
|
||||
// (e.g. `vibeproxy/gpt-4o`), never the generated `openai-compatible-chat-<uuid>`
|
||||
// node id. We mount the REAL ModelCapabilityOverridesTab and drive it through
|
||||
// its fetch-backed data hook with a stubbed `global.fetch`.
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ModelCapabilityOverridesTab from "@/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab";
|
||||
|
||||
const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441";
|
||||
const NODE_PREFIX = "vibeproxy";
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<ModelCapabilityOverridesTab />);
|
||||
});
|
||||
roots.push({ root, el });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init: { ok: boolean } = { ok: true }): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.ok ? 200 : 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelCapabilityOverridesTab (issue #9557)", () => {
|
||||
it("renders the public prefix, never the node UUID, and PATCH/DELETE use prefix/model", async () => {
|
||||
const catalog = {
|
||||
[NODE_ID]: {
|
||||
id: NODE_ID,
|
||||
alias: NODE_ID,
|
||||
displayPrefix: NODE_PREFIX,
|
||||
name: "VibeProxy",
|
||||
authType: "unknown",
|
||||
format: "openai",
|
||||
models: [{ id: "gpt-4o", name: "gpt-4o" }],
|
||||
},
|
||||
};
|
||||
const overrides: Array<{ target: string; provider: string; key: string; value: number }> = [
|
||||
{
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
provider: NODE_PREFIX,
|
||||
key: "max_output_tokens",
|
||||
value: 123456,
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementation((input: any) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/pricing/models")) {
|
||||
return Promise.resolve(jsonResponse(catalog));
|
||||
}
|
||||
if (url.includes("/api/model-capability-overrides")) {
|
||||
return Promise.resolve(jsonResponse({ overrides }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
// Flush the async load.
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
// The target label (public prefix) must be visible.
|
||||
const bodyText = document.body.textContent ?? "";
|
||||
expect(bodyText).toContain(`${NODE_PREFIX}/gpt-4o`);
|
||||
expect(bodyText).not.toContain(NODE_ID);
|
||||
|
||||
// The stored override value is rendered.
|
||||
expect(bodyText).toContain("123456");
|
||||
|
||||
// Click the Add button to PATCH a new override on the currently-selected
|
||||
// (prefixed) target, then assert the request body used prefix/model.
|
||||
const addButton = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent ?? "").includes("Add key value")
|
||||
);
|
||||
expect(addButton).toBeTruthy();
|
||||
// Value field + Add → PATCH with a new max_input_tokens value.
|
||||
const valueInput = document.querySelector('input[type="number"]') as HTMLInputElement;
|
||||
expect(valueInput).toBeTruthy();
|
||||
// Select a different key for the new override.
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
act(() => {
|
||||
select.value = "max_input_tokens";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
// React controlled inputs ignore direct `.value` writes — use the native
|
||||
// descriptor so the onChange handler fires with the new value.
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(valueInput, "99999");
|
||||
valueInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
addButton!.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const patchCall = fetchMock.mock.calls.find(([input, init]) => {
|
||||
const u = String(input);
|
||||
const method = (init as RequestInit | undefined)?.method;
|
||||
return u.includes("/api/model-capability-overrides") && method === "PATCH";
|
||||
});
|
||||
expect(patchCall).toBeTruthy();
|
||||
const patchBody = JSON.parse(String(patchCall![1].body)) as { target: string };
|
||||
expect(patchBody.target).toBe(`${NODE_PREFIX}/gpt-4o`);
|
||||
expect(patchBody.target).not.toContain(NODE_ID);
|
||||
|
||||
// DELETE path: the row's Remove button must send a DELETE with prefix/model.
|
||||
// Re-mock GET to return the saved override so the row renders.
|
||||
const withNewOverride = [
|
||||
...overrides,
|
||||
{
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
provider: NODE_PREFIX,
|
||||
key: "max_input_tokens",
|
||||
value: 99999,
|
||||
},
|
||||
];
|
||||
fetchMock.mockImplementation((input: any, init: RequestInit | undefined) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (url.includes("/api/pricing/models")) return Promise.resolve(jsonResponse(catalog));
|
||||
if (url.includes("/api/model-capability-overrides")) {
|
||||
if (method === "PATCH")
|
||||
return Promise.resolve(jsonResponse({ overrides: withNewOverride }));
|
||||
if (method === "DELETE")
|
||||
return Promise.resolve(jsonResponse({ overrides: [withNewOverride[0]] }));
|
||||
return Promise.resolve(jsonResponse({ overrides: withNewOverride }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
|
||||
});
|
||||
|
||||
// Re-render fresh to pick up the new override list.
|
||||
for (const { root, el } of roots.splice(0)) act(() => root.unmount());
|
||||
render();
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
const removeButtons = Array.from(document.querySelectorAll("button")).filter(
|
||||
(b) => (b.textContent ?? "").trim() === "Remove"
|
||||
);
|
||||
expect(removeButtons.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
removeButtons[0].click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const deleteCall = fetchMock.mock.calls.find(([input, init]) => {
|
||||
const method = (init as RequestInit | undefined)?.method;
|
||||
return method === "DELETE";
|
||||
});
|
||||
expect(deleteCall).toBeTruthy();
|
||||
const deleteUrl = String(deleteCall![0]);
|
||||
expect(deleteUrl).toContain(`target=${encodeURIComponent(`${NODE_PREFIX}/gpt-4o`)}`);
|
||||
expect(deleteUrl).not.toContain(NODE_ID);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user