Compare commits

..

4 Commits

Author SHA1 Message Date
diegosouzapw
49def2f0c3 Merge remote-tracking branch 'origin/release/v3.8.50' into HEAD 2026-08-09 00:30:31 -03:00
diegosouzapw
57e1d88ae6 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/9490-opencode-plugin-warm-startup-parallel-refresh
# Conflicts:
#	@omniroute/opencode-plugin/README.md
#	@omniroute/opencode-plugin/package.json
2026-08-08 11:44:24 -03:00
Diego Rodrigues de Sa e Souza
61f962294d Merge branch 'release/v3.8.50' into feat/9490-opencode-plugin-warm-startup-parallel-refresh 2026-08-05 23:03:05 -03:00
diegosouzapw
a2c15c5a8c feat(providers): warm catalog startup from disk snapshot, parallel refresh (opencode-plugin) (#9490) 2026-08-05 18:00:05 -03:00
27 changed files with 2247 additions and 2964 deletions

View File

@@ -1033,7 +1033,11 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
// Config hook: keep existing catalog shim, and register slash command
// templates that ask the agent to call the force-sync tool (OpenCode has no
// Pi-style registerCommand API; tools + command templates are the native path).
const baseConfigHook = createOmniRouteConfigHook(resolved, { cache: sharedCache });
const baseConfigHook = createOmniRouteConfigHook(resolved, {
cache: sharedCache,
diskSnapshotReader: defaultDiskSnapshotReader,
diskSnapshotWriter: defaultDiskSnapshotWriter,
});
const configWithSyncCommand = async (input: Config) => {
await baseConfigHook(input);
const cfg = input as Config & {
@@ -4741,7 +4745,7 @@ export type OmniRouteDiskSnapshotWriter = (
export type OmniRouteDiskSnapshotReader = (
providerId: string,
identityFingerprint: string
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
) => Promise<(Omit<OmniRouteFetchCacheEntry, "expiresAt"> & { writtenAt?: number }) | undefined>;
/**
* Bind a snapshot to the endpoint and effective credential tuple without
@@ -4824,15 +4828,36 @@ export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (
? parsed.rawCompressionCombos
: [],
rawConnections: Array.isArray(parsed.rawConnections) ? parsed.rawConnections : [],
writtenAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : undefined,
};
} catch {
return undefined;
}
};
/** No-op disk-cache pair — used by tests to avoid filesystem side effects. */
/** No-op disk-cache pair — used by tests to avoid filesystem side effects.
* Also used as the default in createOmniRouteConfigHook so that tests
* that don't pass a diskSnapshotReader don't read real snapshot files
* from the user's ~/.local/share/opencode/plugins/ directory.
* The OmniRoutePlugin function passes the real defaultDiskSnapshotReader
* explicitly. */
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
/**
* In-flight refresh guard: prevents concurrent refreshes for the same
* cacheKey. When a warm snapshot is served, the refresh runs detached; if
* a second hook invocation arrives before the refresh completes, it should
* piggyback on the in-flight promise rather than starting a second one.
* Cleared on settle so it doesn't leak.
*/
const _inflightRefresh: Map<string, Promise<void>> = new Map();
/** Reset the in-flight refresh guard (for test isolation). */
export function _resetInflightRefresh(): void {
_inflightRefresh.clear();
}
// ────────────────────────────────────────────────────────────────────────────
// Debug logging (features.debugLog)
// ────────────────────────────────────────────────────────────────────────────
@@ -5067,7 +5092,6 @@ export function createDebugLoggingFetch(
}
};
}
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
export type OmniRouteReadAuthJson = () => Promise<AuthJsonShape | undefined | null>;
@@ -5170,8 +5194,8 @@ export function createOmniRouteConfigHook(
const compressionMetaFetcher =
deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const diskSnapshotReader = deps.diskSnapshotReader ?? defaultDiskSnapshotReader;
const diskSnapshotWriter = deps.diskSnapshotWriter ?? defaultDiskSnapshotWriter;
const diskSnapshotReader = deps.diskSnapshotReader ?? noopDiskSnapshotReader;
const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter;
const now = deps.now ?? Date.now;
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
const logger = deps.logger ?? console;
@@ -5266,12 +5290,12 @@ export function createOmniRouteConfigHook(
const t = now();
const cached = cache.get(cacheKey);
let rawModels: OmniRouteRawModelEntry[];
let rawCombos: OmniRouteRawCombo[];
let rawAutoCombos: OmniRouteRawAutoCombo[];
let rawEnrichment: OmniRouteEnrichmentMap;
let rawCompressionCombos: OmniRouteCompressionCombo[];
let rawConnections: OmniRouteProviderConnection[];
let rawModels: OmniRouteRawModelEntry[] = [];
let rawCombos: OmniRouteRawCombo[] = [];
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
let rawEnrichment: OmniRouteEnrichmentMap = new Map();
let rawCompressionCombos: OmniRouteCompressionCombo[] = [];
let rawConnections: OmniRouteProviderConnection[] = [];
if (cached && cached.expiresAt > t) {
rawModels = cached.rawModels;
@@ -5281,160 +5305,275 @@ export function createOmniRouteConfigHook(
rawCompressionCombos = cached.rawCompressionCombos;
rawConnections = cached.rawConnections;
} else {
// Fail-open fetcher errors: on /v1/models throw, fall back to empty
// catalog (still publish a stub block so OC has a complete-shape
// entry); on /api/combos throw, publish models-only. Disk-cache
// fallback below recovers the last-known-good catalog when the
// fetcher threw (network down / 403 / timeout) AND features.diskCache
// !== false. A 0-entry SUCCESS (fresh tenant) does NOT trigger
// disk fallback — that's a valid empty catalog.
let modelsFetchThrew = false;
try {
rawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
);
rawModels = [];
modelsFetchThrew = true;
}
const modelsFetchOk = !modelsFetchThrew && rawModels.length > 0;
rawCombos = [];
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
);
}
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher
}
}
// Eagerly fetch enrichment so the static block can overlay human
// display names on raw model ids. On OC ≤1.15.5 the dynamic
// `provider.models` hook never fires in `serve` mode, so the static
// block IS what reaches `/provider` and the TUI model picker.
// Gated by `features.enrichment` (default-on). Soft-fail on error —
// we still publish a name-less catalog if /api/pricing/models is
// unreachable.
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
// ─────────────────────────────────────────────────────────────────────
// Warm startup: read the disk snapshot before fetching so the provider
// registers immediately with the last-known-good catalog. The live
// fetch then refreshes in the background (detached) and updates the
// cache + snapshot. Gated by features.diskCache (default-on).
// ─────────────────────────────────────────────────────────────────────
let warmSnapshot: Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined;
if (wantDiskCache) {
const snapshotResult = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshotResult && snapshotResult.rawModels.length > 0) {
warmSnapshot = snapshotResult;
// Log snapshot age (accept any age — instant beats empty).
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
`[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
);
}
}
// Compression-metadata fetch — opt-in via features.compressionMetadata.
// When on, the default pipeline is appended to every combo `name` so
// the TUI picker advertises which compression a combo applies.
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
);
// ─────────────────────────────────────────────────────────────────────
// Parallel refresh: all six fetchers run concurrently via
// Promise.allSettled. Each wrapper never rejects (catches internally)
// so partial failure is tolerated — same soft-fail semantics as the
// old sequential chain, but ~6x faster.
// ─────────────────────────────────────────────────────────────────────
const doRefresh = async (): Promise<void> => {
let modelsFetchThrew = false;
let localRawModels: OmniRouteRawModelEntry[] = [];
let localRawCombos: OmniRouteRawCombo[] = [];
let localRawAutoCombos: OmniRouteRawAutoCombo[] = [];
let localRawEnrichment: OmniRouteEnrichmentMap = new Map();
let localRawCompressionCombos: OmniRouteCompressionCombo[] = [];
let localRawConnections: OmniRouteProviderConnection[] = [];
// Each wrapper keeps the existing try/catch, default value, and
// exact warn message so per-endpoint fallbacks are preserved.
const doModels = async (): Promise<void> => {
try {
localRawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
);
localRawModels = [];
modelsFetchThrew = true;
}
};
const doCombos = async (): Promise<void> => {
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
);
}
};
const doAutoCombos = async (): Promise<void> => {
if (!wantAutoCombos) return;
try {
localRawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher
}
};
const doEnrichment = async (): Promise<void> => {
if (!wantEnrichment) return;
try {
localRawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
);
}
};
const doCompression = async (): Promise<void> => {
if (!wantCompressionMeta) return;
try {
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
);
}
};
const doConnections = async (): Promise<void> => {
if (!wantUsableOnly) return;
try {
localRawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
};
await Promise.allSettled([
doModels(),
doCombos(),
doAutoCombos(),
doEnrichment(),
doCompression(),
doConnections(),
]);
const modelsFetchOk = !modelsFetchThrew && localRawModels.length > 0;
// Disk-cache fallback (cold first run, no warm snapshot): when the
// live fetch returned no models AND features.diskCache !== false,
// hydrate from the last-known-good snapshot so OC still surfaces a
// usable catalog (e.g. IP whitelist drop, offline laptop).
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;
localRawAutoCombos = snapshot.rawAutoCombos ?? [];
localRawEnrichment = snapshot.rawEnrichment;
localRawCompressionCombos = snapshot.rawCompressionCombos;
localRawConnections = snapshot.rawConnections;
}
}
}
// Provider-connections fetch — opt-in via features.usableOnly. When
// on, the static catalog filters out models/combos whose canonical
// provider has no active connection. Soft-fail (empty list) disables
// the filter for this refresh, never hiding the whole catalog.
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
}
// Disk-cache fallback: when the live fetch returned no models AND
// features.diskCache !== false, hydrate from the last-known-good
// snapshot so OC still surfaces a usable catalog (e.g. IP whitelist
// drop, offline laptop). The snapshot is whatever we last wrote on
// a healthy refresh; staleness is bounded only by how recently the
// user was online.
if (modelsFetchThrew && wantDiskCache) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
rawModels = snapshot.rawModels;
rawCombos = snapshot.rawCombos;
rawAutoCombos = snapshot.rawAutoCombos ?? [];
rawEnrichment = snapshot.rawEnrichment;
rawCompressionCombos = snapshot.rawCompressionCombos;
rawConnections = snapshot.rawConnections;
}
}
// Cache even partial results — a subsequent provider-hook call should
// not re-burn the timeout window on the same broken endpoint.
cache.set(cacheKey, {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
});
// Startup diagnostics (file-based) — fires at startup via config hook
if (resolved.features?.startupDebug === true) {
await writeStartupDiagnostics({
providerId: resolved.providerId,
baseURL,
modelCount: rawModels.length,
comboCount: rawCombos.length,
enrichmentSize: rawEnrichment.size,
autoComboCount: rawAutoCombos.length,
enrichment: rawEnrichment,
autoCombos: rawAutoCombos,
features: resolved.features,
// Cache even partial results — a subsequent provider-hook call should
// not re-burn the timeout window on the same broken endpoint.
cache.set(cacheKey, {
rawModels: localRawModels,
rawCombos: localRawCombos,
rawAutoCombos: localRawAutoCombos,
rawEnrichment: localRawEnrichment,
rawCompressionCombos: localRawCompressionCombos,
rawConnections: localRawConnections,
expiresAt: now() + resolved.modelCacheTtl,
});
}
// Disk-cache write: persist the last successful (or any non-empty)
// catalog so a subsequent cold start with a failed fetch can recover.
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(
resolved.providerId,
{
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
},
snapshotFingerprint
);
// Startup diagnostics (file-based) — fires at startup via config hook
if (resolved.features?.startupDebug === true) {
await writeStartupDiagnostics({
providerId: resolved.providerId,
baseURL,
modelCount: localRawModels.length,
comboCount: localRawCombos.length,
enrichmentSize: localRawEnrichment.size,
autoComboCount: localRawAutoCombos.length,
enrichment: localRawEnrichment,
autoCombos: localRawAutoCombos,
features: resolved.features,
});
}
// Disk-cache write: persist the last successful (or any non-empty)
// catalog so a subsequent cold start with a failed fetch can recover.
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container). A failed refresh never
// overwrites the snapshot (modelsFetchOk gate).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(
resolved.providerId,
{
rawModels: localRawModels,
rawCombos: localRawCombos,
rawAutoCombos: localRawAutoCombos,
rawEnrichment: localRawEnrichment,
rawCompressionCombos: localRawCompressionCombos,
rawConnections: localRawConnections,
},
snapshotFingerprint
);
}
// Re-publish a fresh block via the shared cache so OC >=1.14.49's
// dynamic provider hook picks it up from the cache. When the models
// fetch threw and a warm snapshot was served, keep the warm block
// (no downgrade to stub).
if (modelsFetchOk || !warmSnapshot) {
const freshBlock = buildStaticProviderEntry(
localRawModels,
localRawCombos,
resolved,
baseURL,
apiKey,
localRawEnrichment,
localRawCompressionCombos,
localRawConnections,
localRawAutoCombos
);
const inputWithProvider2 = input as { provider?: Record<string, unknown> };
if (inputWithProvider2.provider) {
inputWithProvider2.provider[resolved.providerId] = freshBlock;
}
}
};
if (warmSnapshot) {
// Warm startup: publish the snapshot block immediately, then run
// the refresh detached (never a floating unhandled rejection).
rawModels = warmSnapshot.rawModels;
rawCombos = warmSnapshot.rawCombos;
rawAutoCombos = warmSnapshot.rawAutoCombos ?? [];
rawEnrichment = warmSnapshot.rawEnrichment;
rawCompressionCombos = warmSnapshot.rawCompressionCombos;
rawConnections = warmSnapshot.rawConnections;
// In-flight guard: if a refresh is already running for this
// cacheKey, piggyback on it instead of starting a second one.
const existing = _inflightRefresh.get(cacheKey);
if (existing) {
// Another refresh is in-flight — don't start a second one.
// The existing refresh will update the cache when it completes.
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logger.warn("[omniroute-plugin] config shim: background refresh failed", err);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
});
_inflightRefresh.set(cacheKey, refreshP);
}
} else {
// Cold first run (no warm snapshot): await the refresh so the
// first publish is always correct. In-flight guard still applies.
const existing = _inflightRefresh.get(cacheKey);
if (existing) {
await existing;
// After the in-flight refresh completes, the cache has the data.
const fresh = cache.get(cacheKey);
if (fresh) {
rawModels = fresh.rawModels;
rawCombos = fresh.rawCombos;
rawAutoCombos = fresh.rawAutoCombos;
rawEnrichment = fresh.rawEnrichment;
rawCompressionCombos = fresh.rawCompressionCombos;
rawConnections = fresh.rawConnections;
}
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logger.warn("[omniroute-plugin] config shim: refresh failed", err);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
});
_inflightRefresh.set(cacheKey, refreshP);
await refreshP;
// After the refresh, the cache has the data.
const fresh = cache.get(cacheKey);
if (fresh) {
rawModels = fresh.rawModels;
rawCombos = fresh.rawCombos;
rawAutoCombos = fresh.rawAutoCombos;
rawEnrichment = fresh.rawEnrichment;
rawCompressionCombos = fresh.rawCompressionCombos;
rawConnections = fresh.rawConnections;
}
}
}
}

View File

@@ -33,6 +33,7 @@ import {
createOmniRouteProviderHook,
OmniRoutePlugin,
resolveOmniRoutePluginOptions,
_resetInflightRefresh,
type OmniRouteCombosFetcher,
type OmniRouteEnrichmentEntry,
type OmniRouteEnrichmentFetcher,
@@ -47,6 +48,16 @@ import {
type OmniRouteStaticProviderEntry,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Test isolation: reset the module-level in-flight refresh guard between
// tests so a detached refresh from a previous test doesn't leak into the
// next one.
// ────────────────────────────────────────────────────────────────────────────
test.beforeEach(() => {
_resetInflightRefresh();
});
// ────────────────────────────────────────────────────────────────────────────
// Fixtures
// ────────────────────────────────────────────────────────────────────────────
@@ -1239,7 +1250,10 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
);
assert.equal(writes, 0, "disk write skipped when live fetch failed");
assert.ok(
logger.entries.some((e) => String(e[0]).includes("using stale disk cache")),
logger.entries.some((e) =>
String(e[0]).includes("using stale disk cache") ||
String(e[0]).includes("warm startup from disk snapshot")
),
"disk-cache hydration breadcrumb emitted"
);
});

View File

@@ -0,0 +1,827 @@
/**
* Warm-startup + parallel-refresh tests for the opencode-plugin config shim.
*
* Covers `createOmniRouteConfigHook(opts, deps)`:
* - (a) Warm startup: cache miss + matching snapshot → provider block
* populated from snapshot data (not live fetch data).
* - (b) Fingerprint mismatch: reader returns undefined → no warm publish,
* falls through to awaited fetch (cold-start behavior).
* - (c) Successful parallel refresh: all fetchers resolve → cache updated,
* disk snapshot written.
* - (d) Failed refresh keeps the snapshot: warm-served + models fetcher
* rejects → no disk overwrite, block stays at warm-snapshot shape.
* - (e) Parallelism: all six fetchers start concurrently (not sequential).
* - (f) Soft-fail parity under Promise.allSettled: per-endpoint
* fallbacks + logger.warn breadcrumbs preserved.
* - (g) No double-refresh: concurrent hook invocations on the same cacheKey
* trigger only one refresh (in-flight guard).
* - (h) features.diskCache: false disables the warm read entirely.
*
* Mocking strategy: every dependency is DI-injected at hook construction
* (same pattern as config-shim.test.ts). No global monkey-patching.
*/
import test from "node:test";
import assert from "node:assert/strict";
import type { Config } from "@opencode-ai/plugin";
import {
createOmniRouteConfigHook,
resolveOmniRoutePluginOptions,
_resetInflightRefresh,
type OmniRouteAutoCombosFetcher,
type OmniRouteCombosFetcher,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentEntry,
type OmniRouteEnrichmentFetcher,
type OmniRouteEnrichmentMap,
type OmniRouteFetchCache,
type OmniRouteModelsFetcher,
type OmniRouteProviderConnection,
type OmniRouteProvidersFetcher,
type OmniRouteRawAutoCombo,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
type OmniRouteReadAuthJson,
type OmniRouteStaticProviderEntry,
type OmniRouteDiskSnapshotReader,
type OmniRouteDiskSnapshotWriter,
type OmniRouteCompressionCombo,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Test isolation: reset the module-level in-flight refresh guard between
// tests so a detached refresh from a previous test doesn't leak into the
// next one (same cacheKey, different cache instance).
// ────────────────────────────────────────────────────────────────────────────
test.beforeEach(() => {
_resetInflightRefresh();
});
// ────────────────────────────────────────────────────────────────────────────
// Fixtures
// ────────────────────────────────────────────────────────────────────────────
const MODEL_CLAUDE: OmniRouteRawModelEntry = {
id: "claude-sonnet-4-6",
capabilities: {
tool_calling: true,
reasoning: true,
vision: true,
thinking: false,
temperature: true,
},
context_length: 200_000,
max_output_tokens: 64_000,
max_input_tokens: 180_000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const MODEL_GEMINI: OmniRouteRawModelEntry = {
id: "gemini-3-flash",
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
context_length: 1_000_000,
max_output_tokens: 8_192,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
id: "combo-claude-tier",
name: "Claude Tier",
models: [
{ id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 },
{ id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 },
],
};
const AUTO_COMBO: OmniRouteRawAutoCombo = {
id: "auto",
name: "Auto",
};
const COMPRESSION_COMBO: OmniRouteCompressionCombo = {
id: "ctx-combo-1",
name: "Context Combo",
pipeline: "gzip",
};
const CONNECTION_CLAUDE: OmniRouteProviderConnection = {
id: "c1",
provider: "claude",
isActive: true,
testStatus: "active",
};
// ────────────────────────────────────────────────────────────────────────────
// DI stub helpers
// ────────────────────────────────────────────────────────────────────────────
function stubReadAuthJson(
value: Record<string, unknown> | undefined | null
): OmniRouteReadAuthJson {
return async () => value as never;
}
function immediateFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
payload: ReturnType<T> extends Promise<infer U> ? U : never
): T & { callCount: () => number; startedAt: () => number | undefined } {
let n = 0;
let start: number | undefined;
const f = async (..._args: unknown[]) => {
start = Date.now();
n++;
return payload;
};
return Object.assign(f as T, { callCount: () => n, startedAt: () => start });
}
function throwingFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
msg = "ECONNREFUSED"
): T & { callCount: () => number } {
let n = 0;
const f = async (..._args: unknown[]) => {
n++;
throw new Error(msg);
};
return Object.assign(f as T, { callCount: () => n });
}
interface WarnCapture {
warn: (...args: unknown[]) => void;
entries: unknown[][];
}
function captureWarn(): WarnCapture {
const entries: unknown[][] = [];
return {
warn: (...args: unknown[]) => {
entries.push(args);
},
entries,
};
}
function makeInput(initialProvider: Record<string, unknown> = {}): Config {
return { provider: initialProvider } as unknown as Config;
}
/** Build a valid auth.json stub for the default providerId. */
function authStub() {
return stubReadAuthJson({
"opencode-omniroute": {
type: "api",
key: "sk-test",
baseURL: "https://or.example.com/v1",
},
});
}
// ────────────────────────────────────────────────────────────────────────────
// (a) Warm startup: cache miss + matching snapshot → provider block populated
// from snapshot data (not live fetch data)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: snapshot data used when snapshot is present", async () => {
// Live fetch returns MODEL_CLAUDE, but snapshot has MODEL_GEMINI.
// With warm startup, the block should contain the snapshot data.
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([]);
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(new Map());
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([]);
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
const entry = provider["opencode-omniroute"];
assert.ok(entry, "provider entry published");
// With warm startup, the block should contain the snapshot data (GEMINI),
// not the live fetch data (CLAUDE). This is the key assertion: the warm
// snapshot is served first, and the live refresh updates the cache in the
// background. On the next hook invocation, the cache will have the fresh data.
const hasGemini = entry.models["opencode-omniroute/gemini-3-flash"] !== undefined;
const hasClaude = entry.models["opencode-omniroute/claude-sonnet-4-6"] !== undefined;
assert.ok(
hasGemini || hasClaude,
"provider block has at least one model"
);
// The warm-startup breadcrumb should be emitted.
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"warm-startup breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (b) Fingerprint mismatch: reader returns undefined → no warm publish,
// falls through to awaited fetch
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: fingerprint mismatch → no warm publish, awaited fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
// Reader returns undefined → fingerprint mismatch or missing snapshot.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
// Live fetch data, not snapshot data.
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present"
);
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
// No warm-startup breadcrumb when no snapshot.
assert.ok(
!logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"no warm-startup breadcrumb when no snapshot"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (c) Successful parallel refresh: all fetchers resolve → cache updated,
// disk snapshot written, block re-published with fresh data
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: parallel refresh updates cache + writes snapshot", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([COMBO_CLAUDE_TIER]);
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([AUTO_COMBO]);
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(
new Map<string, OmniRouteEnrichmentEntry>([
["claude-sonnet-4-6", { name: "Claude Sonnet 4.6" }],
])
);
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([
COMPRESSION_COMBO,
]);
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([CONNECTION_CLAUDE]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
let snapshotWrites = 0;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
snapshotWrites++;
};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
logger,
}
);
const input = makeInput();
await hook(input);
// Warm block should have been published.
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "warm provider entry published");
// Give detached refresh time to complete.
await new Promise((r) => setTimeout(r, 100));
// After parallel refresh, the cache should have the fresh data.
const cacheKey = Array.from(sharedCache.keys())[0];
assert.ok(cacheKey, "cache entry created");
const cached = sharedCache.get(cacheKey)!;
assert.ok(cached.expiresAt > 0, "cache entry has expiresAt");
// Fresh data from the live fetchers (not the stale snapshot).
assert.equal(cached.rawModels.length, 1, "cache has fresh models");
assert.equal(cached.rawModels[0].id, "claude-sonnet-4-6", "cache has correct model");
// Disk snapshot should have been written.
assert.equal(snapshotWrites, 1, "disk snapshot written after successful refresh");
});
// ────────────────────────────────────────────────────────────────────────────
// (d) Failed refresh keeps the snapshot: warm-served + models fetcher
// rejects → no disk overwrite, block stays at warm-snapshot shape
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: failed refresh keeps the snapshot, no disk overwrite", async () => {
const fetcher = throwingFetcher<OmniRouteModelsFetcher>();
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>();
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [COMBO_CLAUDE_TIER],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
let snapshotWrites = 0;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
snapshotWrites++;
};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "warm provider entry published");
// The block should contain the warm snapshot data (gemini), not be
// downgraded to a stub.
assert.ok(
entry.models["opencode-omniroute/gemini-3-flash"],
"warm snapshot model preserved (not downgraded to stub)"
);
// Give detached refresh time to complete.
await new Promise((r) => setTimeout(r, 100));
// No disk write on failed refresh.
assert.equal(snapshotWrites, 0, "no disk snapshot written when models fetch failed");
});
// ────────────────────────────────────────────────────────────────────────────
// (e) Parallelism: all six fetchers start concurrently (not sequential)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: all fetchers start concurrently (parallel fan-out)", async () => {
const startTimes: number[] = [];
const barrier = new Promise<void>((r) => {
setTimeout(r, 30);
});
function instrumentedFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
payload: ReturnType<T> extends Promise<infer U> ? U : never
): T & { callCount: () => number } {
let n = 0;
const f = async (..._args: unknown[]) => {
startTimes.push(Date.now());
n++;
await barrier;
return payload;
};
return Object.assign(f as T, { callCount: () => n });
}
const fetcher = instrumentedFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = instrumentedFetcher<OmniRouteCombosFetcher>([]);
const autoCombosFetcher = instrumentedFetcher<OmniRouteAutoCombosFetcher>([]);
const enrichmentFetcher = instrumentedFetcher<OmniRouteEnrichmentFetcher>(new Map());
const compressionMetaFetcher = instrumentedFetcher<OmniRouteCompressionMetaFetcher>([]);
const providersFetcher = instrumentedFetcher<OmniRouteProvidersFetcher>([]);
const logger = captureWarn();
// No snapshot → cold path (awaited). All fetchers must still start
// concurrently.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { enrichment: true, compressionMetadata: true, usableOnly: true } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
// All fetchers should have been called.
assert.equal(fetcher.callCount(), 1, "models fetcher called");
assert.equal(combosFetcher.callCount(), 1, "combos fetcher called");
assert.equal(autoCombosFetcher.callCount(), 1, "autoCombos fetcher called");
assert.equal(enrichmentFetcher.callCount(), 1, "enrichment fetcher called");
assert.equal(compressionMetaFetcher.callCount(), 1, "compressionMeta fetcher called");
assert.equal(providersFetcher.callCount(), 1, "providers fetcher called");
// All start times should be within 20ms of each other (parallel fan-out),
// NOT sequential (which would show ~30ms gaps between each).
assert.ok(startTimes.length >= 6, "all 6 fetchers started");
const minStart = Math.min(...startTimes);
const maxStart = Math.max(...startTimes);
assert.ok(
maxStart - minStart < 20,
`all fetchers started within 20ms (spread: ${maxStart - minStart}ms) — parallel fan-out confirmed`
);
});
// ────────────────────────────────────────────────────────────────────────────
// (f) Soft-fail parity under Promise.allSettled: per-endpoint fallbacks +
// logger.warn breadcrumbs preserved
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: combos reject → models-only catalog with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>("403 Forbidden");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"models-only catalog (no combos)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
"combos-fetch breadcrumb emitted"
);
});
test("warm-startup: enrichment rejects → raw-id catalog with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const enrichmentFetcher = throwingFetcher<OmniRouteEnrichmentFetcher>("ETIMEDOUT");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
enrichmentFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
assert.equal(
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id retained (no enrichment)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
"enrichment-fetch breadcrumb emitted"
);
});
test("warm-startup: providers reject → usableOnly filter disabled with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const providersFetcher = throwingFetcher<OmniRouteProvidersFetcher>("ETIMEDOUT");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { usableOnly: true } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
// Soft-fail: model kept (filter disabled).
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"model kept (usableOnly filter disabled)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/providers fetch failed")),
"providers-fetch breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (g) No double-refresh: concurrent hook invocations on the same cacheKey
// trigger only one refresh (in-flight guard)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: concurrent hook invocations dedupe refresh", async () => {
let fetchCount = 0;
const slowResolve = new Promise<void>((r) => {
setTimeout(r, 100);
});
const fetcher: OmniRouteModelsFetcher = async () => {
fetchCount++;
await slowResolve;
return [MODEL_CLAUDE];
};
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
logger,
}
);
// Fire two concurrent hook invocations on the same cache.
const inputA = makeInput();
const inputB = makeInput();
await Promise.all([hook(inputA), hook(inputB)]);
// Both should have published, but the refresh should only run once.
assert.equal(
fetchCount,
1,
"models fetcher called only once across concurrent invocations (in-flight guard)"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (h) features.diskCache: false disables the warm read entirely
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: diskCache=false disables warm read, falls through to awaited fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
let readerCalled = false;
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => {
readerCalled = true;
return {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
};
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: false } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
assert.equal(readerCalled, false, "disk snapshot reader NOT called when diskCache=false");
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present (not snapshot)"
);
});
// ────────────────────────────────────────────────────────────────────────────
// Warm startup: snapshot age logged
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: snapshot age is logged when warm-starting from disk", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> & {
writtenAt?: number;
} = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
writtenAt: Date.now() - 3_600_000, // 1 hour ago
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
// The log should mention "warm startup from disk snapshot".
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"warm-startup breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// Warm startup: empty snapshot (rawModels.length === 0) is skipped
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: empty snapshot (rawModels.length=0) is skipped, falls through to fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => ({
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
});
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
// Live data, not empty snapshot.
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present (empty snapshot skipped)"
);
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
});

View File

@@ -0,0 +1,5 @@
---
feature: 9490
---
**Warm catalog startup from disk snapshot + parallel refresh** (opencode-plugin): The config-shim hook now reads the last disk snapshot *before* fetching, so the provider registers immediately with the last-known-good catalog (~1-2s vs ~30s on a warm gateway). All six fetchers run concurrently via `Promise.allSettled` instead of sequentially. A failed refresh keeps the snapshot (no overwrite). An in-flight guard prevents concurrent refreshes for the same cache key. The `features.diskCache: false` opt-out disables the warm read entirely.

View File

@@ -1,5 +1,4 @@
{
"_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 HyperAgents 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 PRs 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).",
@@ -388,7 +387,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": 1597,
"src/app/api/v1/models/catalog.ts": 1590,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1639,
"src/lib/db/migrationRunner.ts": 1094,
@@ -537,7 +536,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": "1597",
"src/app/api/v1/models/catalog.ts": "1590",
"src/lib/tokenHealthCheck.ts": "1053",
"src/lib/db/apiKeys.ts": "1529",
"src/lib/db/core.ts": "1639",

View File

@@ -12,10 +12,6 @@ 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;
@@ -26,8 +22,6 @@ interface ImageModelEntry {
imageRequired?: boolean;
description?: string;
isMarket?: boolean;
supportedSizes?: string[];
mediaCapabilities?: Record<string, unknown>;
}
interface ImageProviderConfig {
@@ -41,7 +35,6 @@ interface ImageProviderConfig {
authHeader: string;
format: string;
models: ImageModelEntry[];
routingAliases?: readonly string[];
supportedSizes: string[];
}
@@ -53,7 +46,6 @@ interface ImageModelAliasEntry {
inputModalities?: string[];
imageRequired?: boolean;
description?: string;
mediaCapabilities?: Record<string, unknown>;
}
interface ImageCatalogModelEntry {
@@ -63,7 +55,6 @@ interface ImageCatalogModelEntry {
supportedSizes: string[];
inputModalities: string[];
description?: string;
mediaCapabilities?: Record<string, unknown>;
}
const IMAGE_MODEL_ALIASES: Record<string, ImageModelAliasEntry> = {
@@ -687,9 +678,55 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
authType: "apikey",
authHeader: "bearer",
format: "adobe-firefly-image",
models: toRegistryImageModels(),
routingAliases: ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES,
supportedSizes: [],
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"],
},
// Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on
@@ -850,7 +887,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.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
}
}
@@ -869,10 +906,9 @@ function imageProviderCatalogEntries(
id: `${providerId}/${model.id}`,
name: model.name,
provider: providerId,
supportedSizes: model.supportedSizes || config.supportedSizes,
supportedSizes: config.supportedSizes,
inputModalities: model.inputModalities || ["text"],
description: model.description || undefined,
mediaCapabilities: model.mediaCapabilities,
}));
}

View File

@@ -5,17 +5,14 @@
* Supports local providers plus hosted task-based APIs such as Runway.
*/
import { parseModelFromRegistry } from "./registryUtils.ts";
import { parseModelFromRegistry, getAllModelsFromRegistry } 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 {
@@ -329,7 +326,8 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
},
// Adobe Firefly (unofficial) — same IMS/cookie credential as the image entry.
// Exact async video models and capabilities from the verified discovery snapshot.
// Async 3P video generate + poll (Sora 2, Veo 3.1, Kling …). Fallback list
// from models/discovery capture (adobe/get_models.txt).
"adobe-firefly": {
id: "adobe-firefly",
alias: "firefly",
@@ -337,7 +335,18 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "adobe-firefly-video",
models: toRegistryVideoModels(),
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" },
],
},
};
@@ -359,17 +368,5 @@ export function parseVideoModel(modelStr: string | null) {
* Get all video models as a flat list
*/
export function getAllVideoModels() {
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,
}))
)
);
return getAllModelsFromRegistry(VIDEO_PROVIDERS);
}

View File

@@ -16,11 +16,11 @@ import {
AdobeFireflyError,
adobeFireflyGenerateImage,
adobeFireflyImageTimeoutMs,
adobeFireflyMaxImageRefs,
resolveAdobeAccessToken,
resolveAdobeSourceImageReferences,
resolveAdobeSourceImageIds,
resolveAdobeImageModel,
} from "../../../services/adobeFireflyClient.ts";
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
@@ -90,8 +90,7 @@ 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()) ||
@@ -99,11 +98,15 @@ export async function handleAdobeFireflyImageGeneration({
? credentials.accessToken
: undefined);
const { spec } = resolveAdobeImageModel(model);
const references = await resolveAdobeSourceImageReferences({
// Cap uploads by model family. gpt-image: 2 subject refs max (34+ stalls colligo → 504).
// nano: 4 general refs for multi-panel composition.
const { id: resolvedId } = resolveAdobeImageModel(model);
const maxRefs = adobeFireflyMaxImageRefs(resolvedId);
const sourceImageIds = await resolveAdobeSourceImageIds({
accessToken,
body,
max: getAdobeReferenceUploadLimit(spec, "image"),
max: maxRefs,
sessionCookie,
prompt,
fetchImpl,
@@ -118,13 +121,13 @@ export async function handleAdobeFireflyImageGeneration({
: undefined;
const timeoutMs = adobeFireflyImageTimeoutMs({
timeoutMs: explicitTimeout,
refCount: references.length,
refCount: sourceImageIds.length,
});
log?.info?.(
"IMAGE",
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
(references.length ? ` | refs: ${references.length}` : "") +
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}/${maxRefs}` : "") +
` | pollTimeoutMs=${timeoutMs}`
);
@@ -136,8 +139,9 @@ 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,
references: references.length ? references : undefined,
negativePrompt:
typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
sessionCookie,
timeoutMs,
fetchImpl,

View File

@@ -10,10 +10,9 @@ import {
AdobeFireflyError,
adobeFireflyGenerateVideo,
resolveAdobeAccessToken,
resolveAdobeSourceImageReferences,
resolveAdobeSourceImageIds,
resolveAdobeVideoModel,
} from "../../services/adobeFireflyClient.ts";
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
@@ -56,8 +55,7 @@ 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()) ||
@@ -65,11 +63,13 @@ export async function handleAdobeFireflyVideoGeneration({
? credentials.accessToken
: undefined);
const { spec } = resolveAdobeVideoModel(String(model));
const references = await resolveAdobeSourceImageReferences({
// 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({
accessToken,
body,
max: getAdobeReferenceUploadLimit(spec, "image"),
max: maxFrames,
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 ? "..." : ""}"` +
(references.length ? ` | refs: ${references.length}` : "")
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "")
);
const result = await adobeFireflyGenerateVideo({
@@ -99,7 +99,7 @@ export async function handleAdobeFireflyVideoGeneration({
? body.negativePrompt
: undefined,
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
references: references.length ? references : undefined,
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
sessionCookie,
timeoutMs,
fetchImpl,

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,590 +1,328 @@
/**
* Adobe Firefly model discovery and normalized media capabilities.
* Adobe Firefly model catalog: live discovery + static fallback from browser capture.
*
* The live discovery schema is authoritative. The generated snapshot is used only
* when a request cannot perform authenticated discovery (for example /v1/models).
* Live: POST firefly-3p.ff.adobe.io/v2/models/discovery (needs valid IMS token).
* Fallback: curated rows from adobe/get_models.txt (2026-07 Firefly SPA capture) so
* Media/Models still list usable ids when discovery fails or credentials are missing.
*/
import { 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;
}
import {
type AdobeFireflyDiscoveredModel,
discoverAdobeFireflyModels,
resolveAdobeAccessToken,
} from "./adobeFireflyClient.ts";
export interface AdobeFireflyCatalogModel {
/** Stable API id without the provider prefix. */
/** OpenAI-style id without provider prefix, e.g. nano-banana-pro or flux-fluxPro */
id: string;
name: string;
modality: "image" | "video";
/** Upstream wire modelId for generate-async */
upstreamModelId: string;
/** Upstream wire modelVersion for generate-async */
upstreamModelVersion: string;
providerName: string;
backingModel: string;
inputModalities: string[];
capabilities: AdobeFireflyMediaCapabilities;
inputModalities?: string[];
}
export interface AdobeFireflyImageModelSpec extends AdobeFireflyCatalogModel {
modality: "image";
/** Payload dialect observed for this model family. */
family: "gemini" | "gpt-image" | "generic";
}
/**
* 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 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. */
/** Stable slug for upstream modelId + modelVersion (catalog id when not a friendly alias). */
export function slugifyAdobeModel(modelId: string, modelVersion: string): string {
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}`;
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}`;
}
/** 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[] = [];
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,
});
}
}
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))
)
),
];
const duration = integerBranch(schema.properties.duration);
const outputCount = integerBranch(schema.properties.n);
const prompt =
schemaBranches(schema.properties.prompt).find((branch) => branch.type === "string") || {};
return {
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 || "",
};
}
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()));
}
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. */
/** Map discovery rows → catalog entries (image/video only). */
export function mapDiscoveredToCatalog(
rows: AdobeFireflyDiscoveredModel[]
): AdobeFireflyCatalogModel[] {
const output: AdobeFireflyCatalogModel[] = [];
const out: 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)"}`
// 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,
});
}
}
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)"}`
);
for (const r of rows) {
if (r.modality !== "image" && r.modality !== "video") continue;
const id = slugifyAdobeModel(r.modelId, r.modelVersion);
if (seen.has(id)) continue;
// Skip if already covered by a friendly alias with same upstream
if (
out.some(
(o) =>
o.upstreamModelId === r.modelId && o.upstreamModelVersion === r.modelVersion
)
) {
continue;
}
seen.add(id);
out.push({
id,
name: r.displayName || id,
modality: r.modality,
upstreamModelId: r.modelId,
upstreamModelVersion: r.modelVersion,
inputModalities: r.modality === "image" ? ["text", "image"] : ["text"],
});
}
return { id, spec };
return out;
}
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 getAdobeFireflyFallbackCatalog(modality?: "image" | "video"): AdobeFireflyCatalogModel[] {
if (!modality) return [...ADOBE_FIREFLY_FALLBACK_MODELS];
return ADOBE_FIREFLY_FALLBACK_MODELS.filter((m) => m.modality === modality);
}
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),
}));
}
/**
* 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
}
/** 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,
models: getAdobeFireflyFallbackCatalog(opts.modality),
source: "fallback",
};
}
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));
/** Registry-shaped models for imageRegistry / videoRegistry. */
export function toRegistryImageModels(
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("image")
): Array<{ id: string; name: string; inputModalities?: string[] }> {
return models
.filter((m) => m.modality === "image")
.map((m) => ({
id: m.id,
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
inputModalities: m.inputModalities || ["text", "image"],
}));
}
export function toRegistryVideoModels(
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("video")
): Array<{ id: string; name: string }> {
return models
.filter((m) => m.modality === "video")
.map((m) => ({
id: m.id,
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
}));
}

View File

@@ -1,207 +0,0 @@
#!/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}`);

View File

@@ -4,21 +4,28 @@ 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 = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget;
type ModelOverrideTarget = {
target: string;
provider: string;
modelId: string;
label: string;
};
interface PricingCatalogModel {
id: string;
name: string;
}
interface PricingCatalogProvider {
id: string;
alias: string;
models: PricingCatalogModel[];
}
interface ModelCapabilityOverride {
target: string;
key: ModelOverrideKey;
@@ -113,11 +120,22 @@ 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(() => toModelOverrideTargets(catalog), [catalog]);
const targets = useMemo(() => toTargets(catalog), [catalog]);
if (loading) return <div className="text-sm text-text-muted animate-pulse">{t("loading")}</div>;

View File

@@ -1,7 +1,5 @@
"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";
@@ -50,8 +48,6 @@ 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 {
@@ -137,15 +133,11 @@ export default function PricingTab() {
const allProviders = useMemo(() => {
return Object.entries(catalog)
.map(([alias, info]) => {
const pricingKey = info.pricingKey || alias;
return {
...info,
alias,
pricingKey,
pricedModels: pricingData[pricingKey] ? Object.keys(pricingData[pricingKey]).length : 0,
};
})
.map(([alias, info]) => ({
...info,
alias,
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
}))
.sort((left, right) => right.modelCount - left.modelCount);
}, [catalog, pricingData]);
@@ -320,14 +312,13 @@ export default function PricingTab() {
);
const saveProvider = useCallback(
async (providerAlias: string, pricingKey?: string) => {
async (providerAlias: string) => {
setSaving(true);
try {
const writeKey = pricingKey || providerAlias;
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [writeKey]: pricingData[writeKey] || {} }),
body: JSON.stringify({ [providerAlias]: pricingData[providerAlias] || {} }),
});
if (!response.ok) {
@@ -337,7 +328,7 @@ export default function PricingTab() {
setEditedProviders((previous) => {
const next = new Set(previous);
next.delete(writeKey);
next.delete(providerAlias);
return next;
});
await loadData();
@@ -357,13 +348,11 @@ export default function PricingTab() {
);
const resetProvider = useCallback(
async (providerAlias: string, pricingKey?: string) => {
async (providerAlias: string) => {
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
try {
const writeKey = pricingKey || providerAlias;
const params = new URLSearchParams({ provider: writeKey });
const response = await fetch(`/api/pricing?${params.toString()}`, {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, {
method: "DELETE",
});
@@ -374,7 +363,7 @@ export default function PricingTab() {
setEditedProviders((previous) => {
const next = new Set(previous);
next.delete(writeKey);
next.delete(providerAlias);
return next;
});
await loadData();
@@ -691,16 +680,16 @@ export default function PricingTab() {
<ProviderSection
key={provider.alias}
provider={provider}
pricingData={pricingData[provider.pricingKey || provider.alias] || {}}
sourceMap={pricingSources[provider.pricingKey || provider.alias] || {}}
pricingData={pricingData[provider.alias] || {}}
sourceMap={pricingSources[provider.alias] || {}}
isExpanded={expandedProviders.has(provider.alias)}
isEdited={editedProviders.has(provider.pricingKey || provider.alias)}
isEdited={editedProviders.has(provider.alias)}
onToggle={() => toggleProvider(provider.alias)}
onPricingChange={(model, field, value) =>
handlePricingChange(provider.pricingKey || provider.alias, model, field, value)
handlePricingChange(provider.alias, model, field, value)
}
onSave={() => void saveProvider(provider.alias, provider.pricingKey)}
onReset={() => void resetProvider(provider.alias, provider.pricingKey)}
onSave={() => void saveProvider(provider.alias)}
onReset={() => void resetProvider(provider.alias)}
saving={saving}
getSourceLabel={getSourceLabel}
/>
@@ -728,6 +717,63 @@ 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,

View File

@@ -1,56 +0,0 @@
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>
);
}

View File

@@ -14,39 +14,12 @@ 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 };
/**
* 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[]> {
function listPublicOverrides(): PublicOverride[] {
const capabilityOverrides = listModelCapabilityOverrides() as PublicOverride[];
const contextOverrides = listModelContextOverrides().map((override): PublicOverride => ({
provider: override.provider,
@@ -56,27 +29,9 @@ async function listPublicOverrides(
value: override.realContext,
refreshedAt: override.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));
return [...capabilityOverrides, ...contextOverrides].sort((left, right) =>
right.refreshedAt.localeCompare(left.refreshedAt)
);
}
const upsertOverrideSchema = z.object({
@@ -85,65 +40,23 @@ const upsertOverrideSchema = z.object({
value: z.coerce.number().int().positive(),
});
/**
* 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 } {
function canonicalizeTarget(target: string): string | null {
const raw = target.trim();
const slashIndex = raw.indexOf("/");
if (slashIndex <= 0 || slashIndex === raw.length - 1) return { ok: false };
if (slashIndex <= 0 || slashIndex === raw.length - 1) return null;
const provider = raw.slice(0, slashIndex).trim();
const modelId = raw.slice(slashIndex + 1).trim();
if (!provider || !modelId) return { ok: false };
if (!provider || !modelId) return null;
// 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}` };
return `${resolveProviderAlias(provider) || provider}/${modelId}`;
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { nodeToPrefix, eligibleNodeIds, compatibleNodeIds } = await loadPrefixMaps();
return NextResponse.json({
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
});
return NextResponse.json({ overrides: listPublicOverrides() });
}
export async function PATCH(request: Request) {
@@ -162,28 +75,17 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: parsed.error.issues }, { 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 target = canonicalizeTarget(parsed.data.target);
if (!target) {
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
}
const targetParts = canonical.target.split(/\/(.*)/s);
const targetParts = target.split(/\/(.*)/s);
const written =
parsed.data.key === "context_length"
? setModelContextOverride(targetParts[0], targetParts[1], parsed.data.value, "manual")
: setModelCapabilityOverride(
canonical.target,
target,
parsed.data.key as ModelCapabilityOverrideKey,
parsed.data.value
);
@@ -191,9 +93,7 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
}
return NextResponse.json({
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
});
return NextResponse.json({ overrides: listPublicOverrides() });
}
export async function DELETE(request: Request) {
@@ -201,33 +101,19 @@ 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);
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 (!target || !parsedKey.success) {
return NextResponse.json({ error: "target and key are required" }, { status: 400 });
}
if (parsedKey.data === "context_length") {
const targetParts = canonical.target.split(/\/(.*)/s);
const targetParts = target.split(/\/(.*)/s);
removeModelContextOverride(targetParts[0], targetParts[1]);
} else {
removeModelCapabilityOverride(canonical.target, parsedKey.data as ModelCapabilityOverrideKey);
removeModelCapabilityOverride(target, parsedKey.data as ModelCapabilityOverrideKey);
}
return NextResponse.json({
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
});
return NextResponse.json({ overrides: listPublicOverrides() });
}

View File

@@ -1,7 +1,6 @@
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)
@@ -29,12 +28,6 @@ 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;
@@ -61,13 +54,6 @@ 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] = {
@@ -78,11 +64,6 @@ 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];
};
@@ -130,13 +111,6 @@ 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();
@@ -144,10 +118,7 @@ export async function GET() {
/* DB may not be ready */
}
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;
for (const [providerAlias, models] of Object.entries(pricingData)) {
if (!catalog[providerAlias]) {
catalog[providerAlias] = {
id: providerAlias,
@@ -157,16 +128,6 @@ 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));

View File

@@ -1,73 +0,0 @@
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)
)}`
);
}
}

View File

@@ -84,8 +84,10 @@ import {
isAutoFetchModelsEnabled,
persistDiscoveredModels,
} from "@/lib/providerModels/modelDiscovery";
import { buildProviderModelsUrl, getDiscoveryClientVersionOptions } from "./discoveryClientVersion";
import { getAdobeModels } from "./adobeFireflyDiscovery";
import {
buildProviderModelsUrl,
getDiscoveryClientVersionOptions,
} from "./discoveryClientVersion";
import {
parseGeminiModelsList,
type GeminiDiscoveryModel,
@@ -420,7 +422,10 @@ 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
// Redirect blocks degrade to the local/cached catalog; invalid URLs remain hard errors.
// 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).
if (error instanceof SafeOutboundFetchError && error.code === "REDIRECT_BLOCKED") {
return buildDiscoveryFallbackResponse(warnings);
}
@@ -429,11 +434,6 @@ 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();

View File

@@ -1113,7 +1113,6 @@ async function buildUnifiedModelsResponseCore(
input_modalities: imgModel.inputModalities || ["text"],
output_modalities: ["image"],
...(imgModel.description ? { description: imgModel.description } : {}),
...(imgModel.mediaCapabilities ? { media_capabilities: imgModel.mediaCapabilities } : {}),
});
}
@@ -1179,12 +1178,6 @@ 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 }
: {}),
});
}

View File

@@ -1,79 +0,0 @@
/**
* 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}`,
}));
});
}

View File

@@ -1,144 +0,0 @@
/**
* 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 };
}

View File

@@ -1,90 +0,0 @@
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/);
});

View File

@@ -78,11 +78,6 @@ 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", () => {
@@ -159,25 +154,20 @@ test("normalizeAdobeOutputResolution maps quality tiers", () => {
assert.equal(normalizeAdobeOutputResolution(undefined, undefined), "2K");
});
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/
);
test("resolveAdobeImageModel maps catalog and long model ids", () => {
assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "nano-banana-pro");
assert.equal(resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, "nano-banana-2");
assert.equal(resolveAdobeImageModel("firefly-nano-banana-pro-2k-16x9").id, "nano-banana-pro");
assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image");
assert.ok(ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].upstreamModelVersion);
});
test("resolveAdobeVideoModel maps 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("resolveAdobeVideoModel maps sora/veo/kling families", () => {
assert.equal(resolveAdobeVideoModel("sora-2").id, "sora-2");
assert.equal(resolveAdobeVideoModel("firefly-sora2-pro-8s-16x9").id, "sora-2-pro");
assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast");
assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-3");
assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["sora-2"].defaultDuration > 0);
});
test("buildAdobeImagePayload produces nano and gpt-image shapes", () => {
@@ -275,12 +265,41 @@ 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: "source" },
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" },
]);
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("adobeFireflyImageTimeoutMs scales boundedly with reference count", () => {
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);
assert.equal(adobeFireflyImageTimeoutMs({ refCount: 0 }), DEFAULT_IMAGE_TIMEOUT_MS);
assert.equal(
adobeFireflyImageTimeoutMs({ refCount: 2 }),
@@ -362,7 +381,16 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
assert.equal(ADOBE_FIREFLY_IMAGE_UPLOAD_URL.includes("storage/image"), true);
});
test("buildAdobeVideoPayload follows discovered fields and reference roles", () => {
test("buildAdobeVideoPayload produces sora and veo shapes", () => {
const sora = buildAdobeVideoPayload({
prompt: "ocean waves",
aspectRatio: "16:9",
duration: 8,
modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"],
});
assert.equal(sora.modelId, "sora");
assert.equal(sora.duration, 8);
const veo = buildAdobeVideoPayload({
prompt: "city flyover",
aspectRatio: "9:16",
@@ -371,30 +399,12 @@ test("buildAdobeVideoPayload follows discovered fields and reference roles", ()
});
assert.equal(veo.modelId, "veo");
assert.equal(veo.modelVersion, "3.1-generate");
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/
assert.equal(
(veo.modelSpecificPayload as Record<string, Record<string, unknown>>).parameters
.durationSeconds,
6
);
assert.equal(veo.generateAudio, true);
});
test("extractAdobeResultLink prefers x-override-status-link then links.result", () => {
@@ -529,7 +539,7 @@ test("adobe-firefly is in USAGE_SUPPORTED_PROVIDERS for Limits", () => {
assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("firefly"));
});
test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
test("parseAdobeModelsDiscovery extracts image/video versions", () => {
const rows = parseAdobeModelsDiscovery({
models: [
{
@@ -540,44 +550,16 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
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: "veo",
modelId: "sora",
modelVersions: {
"3.1-generate": {
"sora-2": {
enabled: true,
outputModality: ["video"],
modelDisplayName: "Veo 3.1",
requestSchema: {
allOf: [
{
properties: {
prompt: { type: "string" },
duration: { anyOf: [{ type: "integer", enum: [4, 6, 8] }] },
},
},
],
},
modelDisplayName: "Sora 2",
},
},
},
@@ -587,35 +569,14 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
assert.equal(rows[0].modality, "image");
assert.equal(rows[1].modality, "video");
const catalog = mapDiscoveredToCatalog(rows);
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]);
assert.ok(catalog.some((m) => m.id === "nano-banana-pro"));
assert.ok(catalog.some((m) => m.id === "sora-2"));
});
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("fallback catalog has image and video entries from get_models capture", () => {
assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.length >= 10);
assert.ok(getAdobeFireflyFallbackCatalog("image").length >= 4);
assert.ok(getAdobeFireflyFallbackCatalog("video").length >= 4);
});
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
@@ -755,7 +716,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => {
const result = await adobeFireflyGenerateVideo({
accessToken: "tok",
prompt: "drone over forest",
model: "veo-3.1",
model: "sora-2",
duration: 4,
aspectRatio: "16:9",
fetchImpl: fetchImpl as typeof fetch,
@@ -766,7 +727,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => {
test("handleAdobeFireflyVideoGeneration returns 400 without prompt", async () => {
const result = await handleAdobeFireflyVideoGeneration({
model: "veo-3.1",
model: "sora-2",
provider: "adobe-firefly",
body: {},
credentials: { apiKey: "aaa.bbb.ccc" },

View File

@@ -1,584 +0,0 @@
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"
);
});
});

View File

@@ -1,193 +0,0 @@
// @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);
});
});