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
33 changed files with 2339 additions and 2197 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

@@ -1,2 +0,0 @@
- Add a default-off connection setting for Codex, OpenAI, and OpenAI-compatible Responses API providers that preserves client-supplied `reasoning.encrypted_content` items for replay, including per-target combo routing.
- Omit opaque encrypted reasoning values from persisted call logs while retaining compact diagnostic markers.

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

@@ -32,7 +32,6 @@ import {
} from "../config/codexIdentity.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
@@ -223,6 +222,90 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): void {
}
}
/**
* Strip server-generated item IDs from the input array.
*
* The Codex /codex/responses endpoint does not persist response items even when
* store=true is sent. When proxy clients (e.g. OpenClaw) include response items
* from previous turns in the input array, those items carry server-assigned IDs
* (prefixed with "rs_", "fc_", "resp_", "msg_"). The Codex backend tries to
* validate these IDs against its persistence store and returns 404 when the items
* are not found (because store was effectively false).
*
* This function:
* 1. Removes bare string references ("rs_abc123") from the input array
* 2. Removes object items with type "item_reference" (explicit stored-item refs)
* 3. Strips the "id" field from any object in input whose id matches a
* server-generated prefix (rs_, fc_, resp_, msg_) — so the content is
* preserved but the backend won't try to look it up
*/
export function stripStoredItemReferences(body: Record<string, unknown>): void {
if (Array.isArray(body.input) && body.input.length === 0) {
body.input = [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "continue" }],
},
];
}
if (!Array.isArray(body.input)) return;
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
let strippedCount = 0;
body.input = body.input.filter((item) => {
// Bare string references: "rs_abc123", "resp_abc123"
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) {
strippedCount++;
return false;
}
// Object references: { type: "item_reference", id: "rs_..." }
if (
item &&
typeof item === "object" &&
!Array.isArray(item) &&
(item as Record<string, unknown>).type === "item_reference"
) {
strippedCount++;
return false;
}
// Reasoning blobs (encrypted_content) are unusable with store=false since
// previous_response_id is deleted — strip them to avoid wasting context
// tokens (O(n^2) growth across agentic turns).
if (
item &&
typeof item === "object" &&
!Array.isArray(item) &&
(item as Record<string, unknown>).type === "reasoning"
) {
strippedCount++;
return false;
}
// Object items with server-generated IDs: strip the id field but keep the item.
// e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id
// e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id
if (item && typeof item === "object" && !Array.isArray(item)) {
const record = item as Record<string, unknown>;
if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) {
delete record.id;
strippedCount++;
}
}
return true;
});
if (strippedCount > 0) {
console.debug(
`[Codex] stripStoredItemReferences: sanitized ${strippedCount} server-generated ID(s) from input`
);
}
}
function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): void {
if (!Array.isArray(body.input)) return;
@@ -1213,7 +1296,7 @@ export class CodexExecutor extends BaseExecutor {
}
// Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input.
// This MUST run before convertSystemToDeveloperRole.
// This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences.
if (!body.input && Array.isArray(body.messages)) {
body.input = body.messages.map((msg: ResponsesMessageInput) => ({
type: "message",
@@ -1336,6 +1419,11 @@ export class CodexExecutor extends BaseExecutor {
preserveCustomTools: nativeCodexPassthrough,
});
// Strip stored response item references (rs_, resp_, msg_ IDs) from input.
// The /codex/responses endpoint does not persist responses even with store=true,
// so any references to previous response items would cause 404 errors.
stripStoredItemReferences(body);
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
// a `messages` or `prompt` array which the strict Codex Responses schema rejects.
delete body.messages;
@@ -1427,11 +1515,6 @@ export class CodexExecutor extends BaseExecutor {
delete body.session_id;
delete body.conversation_id;
applyResponsesInputPolicy(
body,
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
);
if (nativeCodexPassthrough) {
return body;
}

View File

@@ -21,7 +21,6 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe
import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts";
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
import {
getHeaderValueCaseInsensitive,
isNoMemoryRequested,
@@ -208,6 +207,7 @@ import { stageTrace } from "./chatCore/stageTrace.ts";
import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts";
import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts";
import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts";
import {
getCallLogPipelineCaptureStreamChunks,
getCallLogPipelineMaxSizeBytes,
@@ -367,7 +367,9 @@ import {
isTpmExhausted,
isRpmExhausted,
} from "../services/geminiRateLimitTracker.ts";
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
@@ -387,8 +389,10 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
* @param {boolean} options.isCombo - Whether this request is from a combo
* @param {string} options.connectionId - Connection ID for settings lookup
*/
// extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so
// existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here.
export async function handleChatCore({
body,
modelInfo,
@@ -424,6 +428,7 @@ export async function handleChatCore({
/* fail open */
}
}
// Per-request model-routing metadata (first extracted slice of the request-setup phase).
const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup(
modelInfo,
@@ -437,6 +442,7 @@ export async function handleChatCore({
// (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id
// is a log-correlation token, not a security secret.
const traceId = globalThis.crypto.randomUUID().slice(0, 6);
// Emit request.started event for real-time dashboard
setImmediate(() => {
emit("request.started", {
@@ -1065,13 +1071,6 @@ export async function handleChatCore({
return cacheHit;
}
if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") {
applyResponsesInputPolicy(
body as Record<string, unknown>,
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
);
}
body = sanitizeChatRequestBody(body, sourceFormat, targetFormat);
// Per-request opt-out: clients that manage their own context send
// `x-omniroute-no-memory: true` to skip memory+skills injection (a null owner
@@ -5026,6 +5025,7 @@ export async function handleChatCore({
}),
};
}
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
if (!expiresAt) return false;
const expiresAtMs = new Date(expiresAt).getTime();

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,55 +0,0 @@
type JsonRecord = Record<string, unknown>;
const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/;
/**
* Applies the persistence-independent policy for replayed Responses input items.
* Stored references can only be resolved by the upstream that created them, so
* they are always removed. Self-contained encrypted reasoning is retained only
* when the selected connection explicitly opts in.
*/
export function applyResponsesInputPolicy(
body: Record<string, unknown>,
preserveEncryptedReasoning = false
): void {
if (Array.isArray(body.input) && body.input.length === 0) {
body.input = [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "continue" }],
},
];
}
if (!Array.isArray(body.input)) return;
body.input = body.input.filter((item) => {
if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) {
return false;
}
const record =
item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null;
if (!record) return true;
if (record.type === "item_reference") {
return false;
}
if (
record.type === "reasoning" &&
(!preserveEncryptedReasoning ||
typeof record.encrypted_content !== "string" ||
record.encrypted_content.trim().length === 0)
) {
return false;
}
if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) {
delete record.id;
}
return true;
});
}

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

@@ -1,4 +1,5 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
@@ -57,6 +58,7 @@ import AgentrouterConsoleFields from "./AgentrouterConsoleFields";
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
import ProviderRegionField, { getProviderRegionConfig } from "./AlibabaProviderRegionField";
export interface EditConnectionModalConnection {
id?: string;
name?: string;
@@ -71,6 +73,7 @@ export interface EditConnectionModalConnection {
healthCheckInterval?: number;
projectId?: string | null;
}
export interface EditConnectionModalProps {
isOpen: boolean;
connection: EditConnectionModalConnection | null;
@@ -81,7 +84,9 @@ export interface EditConnectionModalProps {
onResyncModels?: (connectionId: string) => void | Promise<void>;
onClose: () => void;
}
const stringField = (value: unknown) => (typeof value === "string" ? value : "");
export default function EditConnectionModal({
isOpen,
connection,
@@ -122,7 +127,6 @@ export default function EditConnectionModal({
codexReasoningEffort: "medium",
codexServiceTier: "default" as CodexServiceTier,
codexOpenaiStoreEnabled: false,
preserveEncryptedReasoning: false,
consoleApiKey: "",
newApiUserId: "",
newApiAggregatorBalance: false,
@@ -165,6 +169,7 @@ export default function EditConnectionModal({
>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const showEmail = useEmailPrivacyStore((state) => state.emailsVisible);
// #6147 — built-in providers can opt in to an advanced base-URL override.
// OAuth connections are excluded: their save path does not persist
// providerSpecificData.baseUrl.
@@ -188,13 +193,6 @@ export default function EditConnectionModal({
const openRouterPreset = useOpenRouterPresetControl(provider, t);
const setOpenRouterPreset = openRouterPreset.setValue;
const isCodex = provider === "codex";
const isResponsesConnection =
isCodex ||
provider === "openai" ||
(isOpenAICompatibleProvider(provider) &&
(provider.startsWith("openai-compatible-responses-") ||
connectionProviderSpecificData?.apiType === "responses" ||
formData.targetFormat === "openai-responses"));
const isClaude = provider === "claude";
const isAntigravityFamily = provider === "antigravity" || provider === "agy";
const localProviderMetadata = getLocalProviderMetadata(provider);
@@ -241,6 +239,7 @@ export default function EditConnectionModal({
})),
[t]
);
useEffect(() => {
if (isOpen && connection) {
const effectiveProvider = connection.provider || providerId;
@@ -319,8 +318,6 @@ export default function EditConnectionModal({
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
preserveEncryptedReasoning:
connection.providerSpecificData?.preserveEncryptedReasoning === true,
consoleApiKey: existingConsoleApiKey,
newApiUserId: existingNewApiUserId,
newApiAggregatorBalance: connection.providerSpecificData?.newApiAggregatorBalance === true,
@@ -381,6 +378,7 @@ export default function EditConnectionModal({
defaultRegion,
setOpenRouterPreset,
]);
const handleTest = async () => {
if (!provider) return;
setTesting(true);
@@ -409,6 +407,7 @@ export default function EditConnectionModal({
setTesting(false);
}
};
const handleValidate = async () => {
if (
!provider ||
@@ -441,6 +440,7 @@ export default function EditConnectionModal({
setValidating(false);
}
};
const handleAddParsedExtraKeys = (raw: string) => {
const { added, duplicates } = parseExtraApiKeys(raw, extraApiKeys);
if (added.length > 0) {
@@ -451,6 +451,7 @@ export default function EditConnectionModal({
notify.warning(t("bulkPasteDuplicatesIgnored", { count: duplicates }));
}
};
const handleSubmit = async () => {
setSaving(true);
setSaveError(null);
@@ -466,12 +467,14 @@ export default function EditConnectionModal({
}
parsedMaxConcurrent = numericMaxConcurrent;
}
const updates: any = {
name: formData.name,
priority: formData.priority,
maxConcurrent: parsedMaxConcurrent,
healthCheckInterval: formData.healthCheckInterval,
};
const overrides: Record<string, number> = {};
if (formData.rpm.trim()) overrides.rpm = Number(formData.rpm);
if (formData.tpm.trim()) overrides.tpm = Number(formData.tpm);
@@ -480,13 +483,16 @@ export default function EditConnectionModal({
if (formData.rateLimitMaxConcurrent.trim())
overrides.maxConcurrent = Number(formData.rateLimitMaxConcurrent);
updates.rateLimitOverrides = Object.keys(overrides).length > 0 ? overrides : null;
if (isAntigravityFamily) {
updates.projectId = trimmedCloudCodeProjectId || null;
}
if (isGooglePse && !formData.cx.trim()) {
setSaveError(t("searchEngineIdRequired"));
return;
}
let validatedBaseUrl = null;
if (usesBaseUrl) {
// #6147 — an opt-in override left blank clears it (no default to fall
@@ -502,6 +508,7 @@ export default function EditConnectionModal({
validatedBaseUrl = checked.value;
}
}
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
@@ -604,10 +611,6 @@ export default function EditConnectionModal({
updates.providerSpecificData.targetFormat = formData.targetFormat || null;
}
}
if (isResponsesConnection && updates.providerSpecificData) {
updates.providerSpecificData.preserveEncryptedReasoning =
formData.preserveEncryptedReasoning === true;
}
const freeOnlyChanged =
showFreeModelsToggle &&
formData.importFreeModelsOnly !==
@@ -631,24 +634,15 @@ export default function EditConnectionModal({
setSaving(false);
}
};
if (!connection) return null;
const isOAuth = connection.authType === "oauth";
const testErrorMeta =
!testResult?.valid && testResult?.diagnosis?.type
? ERROR_TYPE_LABELS[testResult.diagnosis.type] || null
: null;
const preserveEncryptedReasoningToggle = isResponsesConnection ? (
<Toggle
checked={formData.preserveEncryptedReasoning}
onChange={(checked) => setFormData({ ...formData, preserveEncryptedReasoning: checked })}
label={providerText(t, "preserveEncryptedReasoningLabel", "Preserve encrypted reasoning")}
description={providerText(
t,
"preserveEncryptedReasoningDescription",
"Forward encrypted Responses reasoning items supplied by the client."
)}
/>
) : null;
return (
<Modal isOpen={isOpen} title={t("editConnection")} onClose={onClose}>
<div className="flex flex-col gap-4">
@@ -742,7 +736,6 @@ export default function EditConnectionModal({
description={t("importFreeModelsOnlyHint")}
/>
)}
{preserveEncryptedReasoningToggle}
<Toggle
checked={formData.disableCooling}
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
@@ -1032,6 +1025,7 @@ export default function EditConnectionModal({
/>
</>
)}
{/* #6147 — opt-in "Advanced → override base URL" for eligible built-ins */}
{!usesBaseUrl && isBaseUrlOverrideEligible && (
<button
@@ -1042,6 +1036,7 @@ export default function EditConnectionModal({
{providerText(t, "overrideBaseUrlAdvanced", "Advanced: override base URL")}
</button>
)}
{usesBaseUrl && (
<Input
label={t("baseUrlLabel")}
@@ -1060,6 +1055,7 @@ export default function EditConnectionModal({
}
/>
)}
{showProtocolSelector && (
<Select
label={providerText(t, "apiProtocolLabel", "API protocol")}
@@ -1079,11 +1075,13 @@ export default function EditConnectionModal({
)}
/>
)}
<ProviderRegionField
provider={provider}
value={formData.region}
onChange={(region) => setFormData({ ...formData, region })}
/>
{isCloudflare && (
<Input
label={t("accountIdLabel")}
@@ -1093,6 +1091,7 @@ export default function EditConnectionModal({
hint={t("accountIdHint")}
/>
)}
{isGlm && (
<div className="flex flex-col gap-3">
<div>
@@ -1116,6 +1115,7 @@ export default function EditConnectionModal({
/>
</div>
)}
{!isOAuth && connection?.apiKey && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-main">{t("apiKeyHealthLabel")}</label>

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

@@ -20,29 +20,6 @@ const SENSITIVE_KEYS = new Set([
type JsonRecord = Record<string, unknown>;
const ENCRYPTED_REASONING_KEY = "encrypted_content";
function encryptedReasoningOmissionMarker(length?: number): string {
return length === undefined
? "[omitted: encrypted reasoning]"
: `[omitted: encrypted reasoning, ${length} chars]`;
}
// Matches a JSON string field in captured SSE text. Alternatives inside the value are disjoint,
// keeping the scan linear even for large encrypted blobs.
const SERIALIZED_ENCRYPTED_REASONING_RE = /(\"encrypted_content\"\s*:\s*\")((?:\\.|[^\"\\])*)\"/g;
const STREAM_CHUNK_TIMESTAMP_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\] /;
export function omitEncryptedReasoningFromLogChunks(chunks: string[]): string[] {
const combined = chunks.map((chunk) => chunk.replace(STREAM_CHUNK_TIMESTAMP_RE, "")).join("");
let found = false;
const omitted = combined.replace(SERIALIZED_ENCRYPTED_REASONING_RE, (_match, prefix: string) => {
found = true;
return `${prefix}${encryptedReasoningOmissionMarker()}\"`;
});
return found ? [omitted] : chunks;
}
/**
* True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other
* typed arrays). `Array.isArray()` returns false for these, so callers that
@@ -79,28 +56,6 @@ export function normalizePayloadForLog(payload: unknown): unknown {
}
}
/**
* Remove opaque encrypted reasoning from log copies. The value is replayable by clients but
* provides no useful diagnostics, so retaining its size is sufficient for observability.
*/
export function omitEncryptedReasoningForLog(payload: unknown): unknown {
if (!payload || typeof payload !== "object") return payload;
if (isOpaqueBinary(payload)) return describeOpaqueBinary(payload);
if (Array.isArray(payload)) return payload.map(omitEncryptedReasoningForLog);
const omitted: JsonRecord = {};
for (const [key, value] of Object.entries(payload)) {
if (key === ENCRYPTED_REASONING_KEY && typeof value === "string" && value.length > 0) {
omitted[key] = encryptedReasoningOmissionMarker(value.length);
} else if (typeof value === "object" && value !== null) {
omitted[key] = omitEncryptedReasoningForLog(value);
} else {
omitted[key] = value;
}
}
return omitted;
}
export function redactPayload(payload: unknown): unknown {
if (!payload || typeof payload !== "object") return payload;
if (isOpaqueBinary(payload)) return describeOpaqueBinary(payload);
@@ -145,8 +100,7 @@ export function sanitizePayloadPII(payload: unknown): unknown {
export function protectPayloadForLog(payload: unknown): unknown {
if (payload === null || payload === undefined) return null;
const normalized = normalizePayloadForLog(payload);
const reasoningOmitted = omitEncryptedReasoningForLog(normalized);
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
const piiSanitized = sanitizePayloadPII(normalized);
return redactPayload(piiSanitized);
}

View File

@@ -193,13 +193,6 @@ export function normalizeProviderSpecificData(
delete normalized.openaiStoreEnabled;
}
if (
"preserveEncryptedReasoning" in normalized &&
typeof normalized.preserveEncryptedReasoning !== "boolean"
) {
delete normalized.preserveEncryptedReasoning;
}
if ("blockExtraUsage" in normalized && typeof normalized.blockExtraUsage !== "boolean") {
delete normalized.blockExtraUsage;
}

View File

@@ -1,6 +1,6 @@
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { sanitizePII } from "../../piiSanitizer";
import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads";
import { protectPayloadForLog } from "../../logPayloads";
import type { CallLogDetailState } from "../callLogArtifacts";
// #7879: re-export the canonical helper so existing consumers of this module
// keep importing `toNumber` from here unchanged.
@@ -79,12 +79,9 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo
if (key === "streamChunks" && value && typeof value === "object") {
const chunks = value as Record<string, unknown>;
const compacted = Object.fromEntries(
Object.entries(chunks)
.filter(([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0)
.map(([stage, chunkValue]) => [
stage,
omitEncryptedReasoningFromLogChunks(chunkValue as string[]),
])
Object.entries(chunks).filter(
([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0
)
);
if (Object.keys(compacted).length > 0) {
protectedPayloads.streamChunks = protectPayloadForLog(

View File

@@ -154,15 +154,6 @@ export function validateProviderSpecificData(
});
}
const preserveEncryptedReasoning = data.preserveEncryptedReasoning;
if (preserveEncryptedReasoning !== undefined && typeof preserveEncryptedReasoning !== "boolean") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.preserveEncryptedReasoning must be a boolean",
path: ["preserveEncryptedReasoning"],
});
}
const blockExtraUsage = data.blockExtraUsage;
if (blockExtraUsage !== undefined && typeof blockExtraUsage !== "boolean") {
ctx.addIssue({

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

@@ -4,8 +4,10 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatcore-translation-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
@@ -47,12 +49,14 @@ const { resetPayloadRulesConfigForTests, setPayloadRulesConfig } =
await import("../../open-sse/services/payloadRules.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { register, getRequestTranslator } = await import("../../open-sse/translator/registry.ts");
const originalFetch = globalThis.fetch;
const originalResponsesToOpenAI = getRequestTranslator(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI);
const originalSetTimeout = globalThis.setTimeout;
const originalBackgroundConfig = getBackgroundDegradationConfig();
const originalCallLogPipelineCaptureStreamChunks =
process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS;
function noopLog() {
return {
debug() {},
@@ -61,6 +65,7 @@ function noopLog() {
error() {},
};
}
function restorePipelineCaptureEnv() {
if (originalCallLogPipelineCaptureStreamChunks === undefined) {
delete process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS;
@@ -69,6 +74,7 @@ function restorePipelineCaptureEnv() {
originalCallLogPipelineCaptureStreamChunks;
}
}
function toPlainHeaders(headers) {
if (!headers) return {};
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
@@ -76,6 +82,7 @@ function toPlainHeaders(headers) {
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
);
}
function buildOpenAIResponse(stream, text = "ok") {
if (stream) {
return new Response(
@@ -90,6 +97,7 @@ function buildOpenAIResponse(stream, text = "ok") {
}
);
}
return new Response(
JSON.stringify({
id: "chatcmpl-json",
@@ -114,6 +122,7 @@ function buildOpenAIResponse(stream, text = "ok") {
}
);
}
function buildClaudeResponse(stream, text = "ok") {
if (stream) {
return new Response(
@@ -161,6 +170,7 @@ function buildClaudeResponse(stream, text = "ok") {
}
);
}
return new Response(
JSON.stringify({
id: "msg_json",
@@ -379,6 +389,7 @@ test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("chatCore times out upstream execution before provider response headers", async () => {
// This test asserts pendingDetail.providerRequest — only attached when the
// call-log pipeline capture is enabled. Declare the dependency explicitly
@@ -445,6 +456,7 @@ test("chatCore times out upstream execution before provider response headers", a
globalThis.fetch = originalFetch;
}
});
test("chatCore can disable pipeline stream chunk capture through environment", async () => {
process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS = "false";
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
@@ -467,6 +479,7 @@ test("chatCore can disable pipeline stream chunk capture through environment", a
assert.ok(detail.pipelinePayloads, "expected pipeline payloads when capture is enabled");
assert.equal((detail.pipelinePayloads as any).streamChunks, undefined);
});
test("chatCore keeps Responses-native Codex payloads in native passthrough mode", async () => {
const { call, result } = await invokeChatCore({
provider: "codex",
@@ -494,6 +507,7 @@ test("chatCore keeps Responses-native Codex payloads in native passthrough mode"
assert.deepEqual(call.body.metadata, { source: "codex-client" });
assert.equal("messages" in call.body, false);
});
test("chatCore honors providerSpecificData.apiType for legacy openai-compatible providers", async () => {
const { call, result } = await invokeChatCore({
provider: "openai-compatible-sp-openai",
@@ -523,78 +537,7 @@ test("chatCore honors providerSpecificData.apiType for legacy openai-compatible
assert.equal("messages" in call.body, false);
assert.equal(payload.choices[0].message.content, "ok");
});
test("chatCore applies Responses input policy to openai-compatible targets", async () => {
const reasoningItems = [
{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
{ type: "reasoning", encrypted_content: "" },
{ type: "reasoning", summary: [{ text: "not self-contained" }] },
{ type: "item_reference", id: "rs_reference" },
{ id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" },
];
for (const preserveEncryptedReasoning of [false, true]) {
const { call, result } = await invokeChatCore({
provider: "openai-compatible-sp-openai",
model: "gpt-5.4",
endpoint: "/v1/responses",
credentials: {
apiKey: "sk-test",
providerSpecificData: {
apiType: "responses",
baseUrl: "https://proxy.example.com/v1",
prefix: "sp-openai",
preserveEncryptedReasoning,
},
},
body: { model: "gpt-5.4", stream: false, input: reasoningItems },
responseFormat: "openai-responses",
});
assert.equal(result.success, true);
const input = call.body.input as Array<Record<string, unknown>>;
assert.deepEqual(
input.filter((item) => item.type === "reasoning"),
preserveEncryptedReasoning ? [{ type: "reasoning", encrypted_content: "encrypted-blob" }] : []
);
assert.equal(
input.some((item) => item.type === "item_reference"),
false
);
assert.equal(input.find((item) => item.type === "function_call")?.id, undefined);
}
});
test("chatCore preserves opted-in encrypted reasoning for Codex", async () => {
const { call, result } = await invokeChatCore({
provider: "codex",
model: "gpt-5.1-codex",
endpoint: "/v1/responses",
credentials: {
accessToken: "codex-token",
providerSpecificData: { preserveEncryptedReasoning: true },
},
body: {
model: "gpt-5.1-codex",
stream: false,
input: [
{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
{ type: "reasoning", encrypted_content: "" },
{ type: "item_reference", id: "rs_reference" },
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
],
},
responseFormat: "openai-responses",
});
assert.equal(result.success, true);
assert.deepEqual(
call.body.input.filter((item) => item.type === "reasoning"),
[{ type: "reasoning", encrypted_content: "encrypted-blob" }]
);
assert.equal(
call.body.input.some((item) => item.type === "item_reference"),
false
);
});
test("chatCore helper exports detect responses passthrough paths and token expiry windows", () => {
assert.equal(
shouldUseNativeCodexPassthrough({
@@ -622,6 +565,7 @@ test("chatCore helper exports detect responses passthrough paths and token expir
);
assert.equal(isTokenExpiringSoon(null), false);
});
test("chatCore helper detects Claude Code semantic passthrough only for direct Claude-Code routes", () => {
assert.equal(
isClaudeCodeSemanticPassthroughRequest({
@@ -661,6 +605,7 @@ test("chatCore helper detects Claude Code semantic passthrough only for direct C
false
);
});
test("chatCore applies payload rules after translating Responses input into Chat payloads", async () => {
setPayloadRulesConfig({
default: [
@@ -706,6 +651,7 @@ test("chatCore applies payload rules after translating Responses input into Chat
assert.equal(call.body.messages[0].metadata.routeTag, "feature-110");
assert.equal(call.body.messages[0].role, "user");
});
test("chatCore builds Claude Code-compatible upstream requests for CC providers", async () => {
const { call, result } = await invokeChatCore({
provider: "anthropic-compatible-cc-test",
@@ -815,6 +761,7 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa
// user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
assert.equal(call.body.messages[2].content[0].type, "tool_result");
});
test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
@@ -868,6 +815,7 @@ test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", asyn
);
assert.equal(call.body.messages[3].content[0].cache_control, undefined);
});
test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough", async () => {
const { call, result } = await invokeChatCore({
provider: "claude",
@@ -995,6 +943,7 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
// user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
assert.equal(call.body.messages[2].content[0].type, "tool_result");
});
test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
@@ -1041,6 +990,7 @@ test("chatCore preserves cache_control automatically for Claude Code single-mode
// base.ts executor explicitly strips cache_control from tools for Claude Code clients
assert.equal(call.body.tools[0].cache_control, undefined);
});
test("chatCore supplements a missing message cache breakpoint for native Claude Code requests", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
@@ -1086,6 +1036,7 @@ test("chatCore supplements a missing message cache breakpoint for native Claude
assert.deepEqual(call.body.messages[2].content[0].cache_control, { type: "ephemeral" });
assert.equal(call.body.tools[0].cache_control, undefined);
});
test("chatCore auto cache policy becomes false for nondeterministic combos", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
@@ -1119,6 +1070,7 @@ test("chatCore auto cache policy becomes false for nondeterministic combos", asy
true
);
});
test("chatCore always-preserve mode keeps cache_control even without Claude Code user-agent", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" });
invalidateCacheControlSettingsCache();
@@ -1140,6 +1092,7 @@ test("chatCore always-preserve mode keeps cache_control even without Claude Code
assert.equal(hasCacheControl(call.body), true);
assert.deepEqual(call.body.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
});
test("chatCore disables raw Claude passthrough when cache preservation is off and normalizes through OpenAI", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "never" });
invalidateCacheControlSettingsCache();
@@ -1175,6 +1128,7 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an
// Tools disable flag is applied
assert.equal("_disableToolPrefix" in call.body, false);
});
test("chatCore default translation converts Claude requests to OpenAI and strips cache markers for non-Claude providers", async () => {
const { call } = await invokeChatCore({
provider: "openai",
@@ -1200,6 +1154,7 @@ test("chatCore default translation converts Claude requests to OpenAI and strips
assert.equal(call.body.messages[0].role, "system");
assert.equal(JSON.stringify(call.body).includes("cache_control"), false);
});
test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text blocks, and cleans helper flags", async () => {
const { call } = await invokeChatCore({
provider: "claude",
@@ -1242,6 +1197,7 @@ test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text bl
["hello"]
);
});
test("chatCore restores prefixed Claude passthrough tool names in upstream responses", async () => {
const { result } = await invokeChatCore({
provider: "claude",
@@ -1293,6 +1249,7 @@ test("chatCore restores prefixed Claude passthrough tool names in upstream respo
assert.equal(result.success, true);
assert.equal(payload.content[0].name, "Bash");
});
test("chatCore strips unsupported reasoning params and caps provider token fields", async () => {
const { call } = await invokeChatCore({
provider: "openai",
@@ -1314,6 +1271,7 @@ test("chatCore strips unsupported reasoning params and caps provider token field
assert.equal(call.body.max_tokens, undefined);
assert.equal(call.body.max_completion_tokens, 16384);
});
test("chatCore preserves reasoning_effort for assistant-prefill OpenAI-compatible requests", async () => {
const { call, result } = await invokeChatCore({
provider: "openai-compatible-aio",
@@ -1335,6 +1293,7 @@ test("chatCore preserves reasoning_effort for assistant-prefill OpenAI-compatibl
assert.equal(call.body.model, "glm-5.1");
assert.equal(call.body.reasoning_effort, "xhigh");
});
test("chatCore logs chat completions endpoint as OpenAI protocol", async () => {
const { call, result } = await invokeChatCore({
provider: "openrouter",
@@ -1361,6 +1320,7 @@ test("chatCore logs chat completions endpoint as OpenAI protocol", async () => {
assert.equal(logEntry.path, "/v1/chat/completions");
assert.equal(logEntry.sourceFormat, FORMATS.OPENAI);
});
test("chatCore surfaces translation errors with explicit status codes", async () => {
register(
FORMATS.OPENAI_RESPONSES,
@@ -1387,6 +1347,7 @@ test("chatCore surfaces translation errors with explicit status codes", async ()
assert.equal(result.status, 409);
assert.equal(result.error, "responses translator rejected the payload");
});
test("chatCore surfaces typed translation errors with the declared error type", async () => {
register(
FORMATS.OPENAI_RESPONSES,
@@ -1417,6 +1378,7 @@ test("chatCore surfaces typed translation errors with the declared error type",
assert.equal(payload.error.type, "unsupported_feature");
assert.equal(payload.error.code, "unsupported_feature");
});
test("chatCore returns 500 when translation throws a generic error", async () => {
register(
FORMATS.OPENAI_RESPONSES,
@@ -1441,6 +1403,7 @@ test("chatCore returns 500 when translation throws a generic error", async () =>
assert.equal(result.status, 500);
assert.equal(result.error, "unexpected translator crash");
});
test("chatCore refreshes GitHub credentials after 401 and retries with the refreshed Copilot token", async () => {
let refreshedCredentials = null;
const { calls, result } = await invokeChatCore({
@@ -1506,6 +1469,7 @@ test("chatCore refreshes GitHub credentials after 401 and retries with the refre
assert.equal(refreshedCredentials?.providerSpecificData?.copilotToken, "copilot-refreshed-token");
assert.equal(payload.choices[0].message.content, "retry succeeded after refresh");
});
test("chatCore uses the native executor when no upstream proxy mode is enabled", async () => {
const { call } = await invokeChatCore({
provider: "openai",
@@ -1519,6 +1483,7 @@ test("chatCore uses the native executor when no upstream proxy mode is enabled",
assert.match(call.url, /^https:\/\/api\.openai\.com\/v1\/chat\/completions$/);
});
test("chatCore routes providers through CLIProxyAPI in passthrough mode", async () => {
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "qoder",
@@ -1540,6 +1505,7 @@ test("chatCore routes providers through CLIProxyAPI in passthrough mode", async
assert.match(call.url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
assert.equal(call.headers.Authorization ?? call.headers.authorization, "Bearer qoder-token");
});
test("chatCore fallback proxy mode retries through CLIProxyAPI after retryable native failures", async () => {
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "github",
@@ -1580,6 +1546,7 @@ test("chatCore fallback proxy mode retries through CLIProxyAPI after retryable n
assert.match(calls[0].url, /^https:\/\/api\.githubcopilot\.com\/chat\/completions$/);
assert.match(calls[1].url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
});
test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after a retryable native status", async () => {
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "github",
@@ -1620,6 +1587,7 @@ test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after a retryable
assert.equal(result.status, 502);
assert.equal(result.error, "[502]: cliproxy retry failed");
});
test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after native executor throws", async () => {
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "github",
@@ -1657,6 +1625,7 @@ test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after native exec
assert.equal(result.status, 502);
assert.equal(result.error, "[502]: cliproxy transport exploded");
});
test("chatCore serves a cached idempotent response without hitting the provider twice", async () => {
const sharedHeaders = { "idempotency-key": "unit-idempotent-key" };
@@ -1692,6 +1661,7 @@ test("chatCore serves a cached idempotent response without hitting the provider
const payload = (await second.result.response.json()) as any;
assert.equal(payload.choices[0].message.content, "ok");
});
test("chatCore returns a semantic cache HIT for repeated deterministic requests", async () => {
let upstreamHits = 0;
const sharedBody = {
@@ -1744,6 +1714,7 @@ test("chatCore returns a semantic cache HIT for repeated deterministic requests"
assert.equal(semanticLog.path, "/v1/chat/completions");
assert.equal(semanticLog.status, 200);
});
test("chatCore skips semantic cache when disabled in settings", async () => {
await settingsDb.updateSettings({ semanticCacheEnabled: false });
@@ -1786,6 +1757,7 @@ test("chatCore skips semantic cache when disabled in settings", async () => {
const payload = (await second.result.response.json()) as any;
assert.equal(payload.choices[0].message.content, "fresh-2");
});
test("chatCore attaches OmniRoute response metadata headers to non-stream responses", async () => {
const { result } = await invokeChatCore({
provider: "claude",
@@ -1807,6 +1779,7 @@ test("chatCore attaches OmniRoute response metadata headers to non-stream respon
assert.ok(Number(result.response.headers.get("X-OmniRoute-Latency-Ms")) >= 0);
assert.match(String(result.response.headers.get("X-OmniRoute-Response-Cost")), /^\d+\.\d{10}$/);
});
test("chatCore does not expose provider request credentials in non-stream response headers", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -1825,6 +1798,7 @@ test("chatCore does not expose provider request credentials in non-stream respon
assert.equal(result.response.headers.get("Content-Type"), "application/json");
assert.equal(result.response.headers.get("X-OmniRoute-Cache"), "MISS");
});
test("chatCore normalizes tool finish reasons and estimates usage when upstream omits it", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -1876,6 +1850,7 @@ test("chatCore normalizes tool finish reasons and estimates usage when upstream
assert.ok(payload.usage.total_tokens > 0);
assert.ok(payload.usage.prompt_tokens > 0);
});
test("chatCore bypasses Claude CLI warmup probes before touching the provider", async () => {
const { calls, result } = await invokeChatCore({
model: "gpt-5",
@@ -1892,6 +1867,7 @@ test("chatCore bypasses Claude CLI warmup probes before touching the provider",
assert.equal(calls.length, 0);
assert.match(payload.choices[0].message.content, /CLI Command Execution/);
});
test("chatCore redirects background utility tasks to a cheaper mapped model", async () => {
setBackgroundDegradationConfig({
enabled: true,
@@ -1918,6 +1894,7 @@ test("chatCore redirects background utility tasks to a cheaper mapped model", as
assert.equal(result.success, true);
assert.equal(call.body.model, "gpt-5-mini");
});
test("chatCore preserves Codex dual-window scope cooldowns on 429 responses", async () => {
const connection = await providersDb.createProviderConnection({
provider: "codex",
@@ -1971,6 +1948,7 @@ test("chatCore preserves Codex dual-window scope cooldowns on 429 responses", as
);
assert.equal((updated as any).providerSpecificData.codexExhaustedWindow, "5h");
});
test("chatCore 429 lets account fallback apply the configured resilience cooldown", async () => {
await settingsDb.updateSettings({
resilienceSettings: {
@@ -2031,6 +2009,7 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow
assert.equal((afterFallback as any).testStatus, "unavailable");
assert.ok(cooldownRemaining > 0 && cooldownRemaining <= 2_000);
});
test("chatCore falls back to the next family model when the requested model is unavailable", async () => {
const { calls, result } = await invokeChatCore({
provider: "openai",
@@ -2057,6 +2036,7 @@ test("chatCore falls back to the next family model when the requested model is u
assert.equal(calls[1].body.model, "gpt-5.1-mini");
assert.equal(payload.choices[0].message.content, "family fallback ok");
});
test("chatCore falls back to a larger-context sibling when the request overflows context", async () => {
saveModelsDevCapabilities({
unknown: {
@@ -2091,6 +2071,7 @@ test("chatCore falls back to a larger-context sibling when the request overflows
assert.equal(calls[1].body.model, "gpt-4o");
assert.equal(payload.choices[0].message.content, "larger context fallback");
});
test("chatCore parses upstream SSE payloads for non-streaming requests", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -2109,6 +2090,7 @@ test("chatCore parses upstream SSE payloads for non-streaming requests", async (
assert.equal(result.success, true);
assert.equal(payload.choices[0].message.content, "sse json");
});
test("chatCore rejects malformed non-streaming SSE payloads", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -2130,6 +2112,7 @@ test("chatCore rejects malformed non-streaming SSE payloads", async () => {
assert.equal(result.status, 502);
assert.match(result.error, /Invalid SSE response/);
});
test("chatCore rejects malformed non-streaming JSON payloads", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -2151,6 +2134,7 @@ test("chatCore rejects malformed non-streaming JSON payloads", async () => {
assert.equal(result.status, 502);
assert.equal(result.error, "Invalid JSON response from provider");
});
test("chatCore falls back after an empty-content success response", async () => {
const { calls, result } = await invokeChatCore({
provider: "openai",
@@ -2191,6 +2175,7 @@ test("chatCore falls back after an empty-content success response", async () =>
assert.equal(calls[1].body.model, "gpt-5.1-mini");
assert.equal(payload.choices[0].message.content, "empty-content fallback ok");
});
test("chatCore returns a gateway error when the empty-content fallback responds with invalid JSON", async () => {
const { result, calls } = await invokeChatCore({
provider: "openai",
@@ -2235,6 +2220,7 @@ test("chatCore returns a gateway error when the empty-content fallback responds
assert.equal(calls.length, 2);
assert.equal(calls[1].body.model, "gpt-5.1-mini");
});
test("chatCore records Claude prompt cache and cache usage metadata in call logs", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" });
invalidateCacheControlSettingsCache();
@@ -2312,6 +2298,7 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs
cacheCreationTokens: 2,
});
});
test("chatCore propagates budget errors without an executor-level emergency hop", async () => {
// The emergency budget fallback is orchestrated by the routing layer
// (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency
@@ -2351,6 +2338,7 @@ test("chatCore propagates budget errors without an executor-level emergency hop"
"emergency fallback model must not be called at executor level"
);
});
test("chatCore injects progress events into streaming responses when requested", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -2372,6 +2360,7 @@ test("chatCore injects progress events into streaming responses when requested",
assert.equal(result.response.headers.get("X-OmniRoute-Progress"), "enabled");
assert.match(streamText, /event: progress/);
});
test("chatCore emits final SSE metadata comments before [DONE] on streaming responses", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -2399,6 +2388,7 @@ test("chatCore emits final SSE metadata comments before [DONE] on streaming resp
streamText.indexOf(": x-omniroute-response-cost=") < streamText.indexOf("data: [DONE]")
);
});
test("buildStreamingResponseHeaders drops upstream compression and framing headers", () => {
const headers = new Headers(
buildStreamingResponseHeaders(
@@ -2427,6 +2417,7 @@ test("buildStreamingResponseHeaders drops upstream compression and framing heade
assert.equal(headers.get("X-Upstream-Trace"), "trace-1");
assert.equal(headers.get("X-OmniRoute-Cache"), "MISS");
});
test("chatCore strips upstream compression and length headers from streaming responses", async () => {
const upstreamPayload = `data: ${JSON.stringify({
id: "chatcmpl-stream-headers",
@@ -2461,6 +2452,7 @@ test("chatCore strips upstream compression and length headers from streaming res
assert.equal(result.response.headers.get("X-OmniRoute-Cache"), "MISS");
await result.response.text();
});
test("chatCore maps upstream aborts to request-aborted errors", async () => {
const { result } = await invokeChatCore({
provider: "openai",
@@ -2481,6 +2473,7 @@ test("chatCore maps upstream aborts to request-aborted errors", async () => {
assert.equal(result.status, 499);
assert.equal(result.error, "Request aborted");
});
test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async () => {
// abort(reason) rejects the upstream fetch with the raw reason — often a
// bare string with no `name`/`status`. It must map to 499 like a named
@@ -2543,6 +2536,7 @@ test("chatCore does not log a synthetic clientResponse body for a client abort",
"an aborted request never delivered anything to the client — clientResponse must stay unset"
);
});
test("chatCore returns streaming responses without waiting for upstream completion", async () => {
const encoder = new TextEncoder();
let closeUpstream: (() => void) | null = null;
@@ -2611,6 +2605,7 @@ test("chatCore returns streaming responses without waiting for upstream completi
assert.equal(result.success, true);
assert.match(streamText, /streamed-without-buffering/);
});
test("chatCore releases account semaphore slots when upstream execution throws", async () => {
const connectionId = "sem-exception";
const semaphoreKey = buildAccountSemaphoreKey({
@@ -2643,6 +2638,7 @@ test("chatCore releases account semaphore slots when upstream execution throws",
assert.equal(result.status, 502);
assert.equal(getAccountSemaphoreStats()[semaphoreKey], undefined);
});
test("chatCore locks per-model quota failures without dropping quota helper references", async () => {
const model = "gemini-1.5-pro";
const connection = await providersDb.createProviderConnection({
@@ -2688,6 +2684,7 @@ test("chatCore locks per-model quota failures without dropping quota helper refe
});
// ── Streaming semantic cache tests ──────────────────────────────────────────
test("chatCore caches streaming response and serves cache HIT on repeat", async () => {
let upstreamHits = 0;
const sharedBody = {
@@ -2742,6 +2739,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async
assert.match(sse, /^data:/m, "cache HIT should be SSE-framed");
assert.match(sse, /streamed-once/, "SSE cache HIT should carry the cached content");
});
test("chatCore does not cache streaming response when temperature > 0", async () => {
let upstreamHits = 0;
const sharedBody = {
@@ -2782,6 +2780,7 @@ test("chatCore does not cache streaming response when temperature > 0", async ()
assert.equal(upstreamHits, 2, "both requests should hit upstream");
assert.equal(second.calls.length, 1, "second request should reach upstream");
});
test("chatCore skips streaming cache when X-OmniRoute-No-Cache header is set", async () => {
let upstreamHits = 0;
const sharedBody = {
@@ -2828,6 +2827,7 @@ test("chatCore skips streaming cache when X-OmniRoute-No-Cache header is set", a
await second.result.response.text();
assert.equal(upstreamHits, 2, "both requests should hit upstream with no-cache");
});
test("chatCore returns cache HIT as SSE when the client requests streaming", async () => {
const sharedBody = {
model: "gpt-4o-mini",

View File

@@ -42,36 +42,6 @@ test("provider schemas reject non-boolean openaiStoreEnabled values", () => {
assert.equal(updated.success, false);
});
test("provider schemas accept boolean preserveEncryptedReasoning in providerSpecificData", () => {
const created = createProviderSchema.safeParse({
provider: "codex",
apiKey: "token",
name: "Codex",
providerSpecificData: { preserveEncryptedReasoning: true },
});
const updated = updateProviderConnectionSchema.safeParse({
providerSpecificData: { preserveEncryptedReasoning: false },
});
assert.equal(created.success, true);
assert.equal(updated.success, true);
});
test("provider schemas reject non-boolean preserveEncryptedReasoning values", () => {
const created = createProviderSchema.safeParse({
provider: "codex",
apiKey: "token",
name: "Codex",
providerSpecificData: { preserveEncryptedReasoning: "yes" },
});
const updated = updateProviderConnectionSchema.safeParse({
providerSpecificData: { preserveEncryptedReasoning: 1 },
});
assert.equal(created.success, false);
assert.equal(updated.success, false);
});
test("provider schemas accept boolean CC-compatible request defaults", () => {
const created = createProviderSchema.safeParse({
provider: "anthropic-compatible-cc-demo",

View File

@@ -15,21 +15,6 @@ test("Codex request defaults accept max but leave ultra to the Codex client", ()
assert.equal(normalizeCodexReasoningEffort("ultra"), undefined);
});
test("normalizeProviderSpecificData keeps only boolean preserveEncryptedReasoning", () => {
assert.equal(
normalizeProviderSpecificData("codex", { preserveEncryptedReasoning: true })
?.preserveEncryptedReasoning,
true
);
assert.equal(
normalizeProviderSpecificData("codex", {
preserveEncryptedReasoning: "yes",
tag: "primary",
})?.preserveEncryptedReasoning,
undefined
);
});
test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => {
assert.equal(
buildOpenAIStoreSessionId("ext:client session/abc"),

View File

@@ -1,4 +1,3 @@
import { protectPipelinePayloads } from "../../src/lib/usage/callLogs/format.ts";
import test from "node:test";
import assert from "node:assert/strict";
@@ -35,46 +34,6 @@ test("normalizes JSON strings before log protection and redacts sensitive keys",
});
});
test("omits encrypted reasoning values from structured log payloads", () => {
const encryptedContent = "encrypted".repeat(128);
const payload = {
output: [
{
type: "reasoning",
encrypted_content: encryptedContent,
reasoning_content: "visible diagnostic reasoning",
},
],
};
const protectedPayload = protectPayloadForLog(payload) as typeof payload;
assert.equal(
protectedPayload.output[0].encrypted_content,
`[omitted: encrypted reasoning, ${encryptedContent.length} chars]`
);
assert.equal(protectedPayload.output[0].reasoning_content, "visible diagnostic reasoning");
assert.equal(payload.output[0].encrypted_content, encryptedContent);
});
test("omits encrypted reasoning split across captured SSE chunks", () => {
const encryptedContent = "opaque-replay-state".repeat(128);
const protectedPipeline = protectPipelinePayloads({
streamChunks: {
provider: [
'[12:00:00.000] data: {"type":"response.completed","response":{"output":[{"type":"reasoning","encrypted_',
`[12:00:00.001] content":"${encryptedContent}","summary":[]}]}}\n\n`,
],
},
});
const storedChunks = protectedPipeline?.streamChunks?.provider ?? [];
assert.equal(storedChunks.length, 1);
assert.equal(storedChunks[0].includes(encryptedContent), false);
assert.equal(storedChunks[0].includes("[omitted: encrypted reasoning]"), true);
assert.equal(storedChunks[0].includes('"summary":[]'), true);
});
test("wraps raw text payloads in JSON-safe objects", () => {
const normalized = normalizePayloadForLog("event: ping\ndata: plain-text\n\n");

View File

@@ -1,14 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyResponsesInputPolicy } from "../../open-sse/services/responsesInputPolicy.ts";
import { stripStoredItemReferences } from "../../open-sse/executors/codex.ts";
import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts";
// Port of decolua/9router#1599 — strip unusable reasoning blobs from agentic
// context to prevent O(n^2) token growth across turns. Encrypted reasoning is
// self-contained and may be replayed only through an explicit connection opt-in.
// Port of decolua/9router#1599 — strip reasoning blobs from agentic context to
// prevent O(n^2) token growth across turns.
//
// (1) codex.ts stripStoredItemReferences: object items of type "reasoning"
// (encrypted_content) are unusable with store=false (previous_response_id is
// deleted) and must be dropped from the Responses `input` array.
// (2) openaiHelper.ts filterToOpenAIFormat: assistant+tool_calls messages must
// have `reasoning_content` stripped instead of being returned as-is.
test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
test("stripStoredItemReferences drops object items with type=reasoning", () => {
const body: Record<string, unknown> = {
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
@@ -24,7 +29,7 @@ test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
],
};
applyResponsesInputPolicy(body);
stripStoredItemReferences(body);
const input = body.input as Array<Record<string, unknown>>;
// Both reasoning items must be gone.
@@ -40,66 +45,6 @@ test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
assert.equal(input[1].id, undefined, "fc_ server id stripped, item kept");
});
test("selected connection policy preserves encrypted reasoning input", () => {
const body: Record<string, unknown> = {
input: [
{
id: "rs_encrypted123",
type: "reasoning",
encrypted_content: "encrypted-blob",
summary: [{ type: "summary_text", text: "safe summary" }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
],
};
applyResponsesInputPolicy(body, true);
assert.deepEqual(body.input, [
{
type: "reasoning",
encrypted_content: "encrypted-blob",
summary: [{ type: "summary_text", text: "safe summary" }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
]);
});
test("preserving encrypted reasoning still removes stored references", () => {
const body: Record<string, unknown> = {
input: [
{ id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" },
"rs_stored123",
{ type: "item_reference", id: "resp_stored123" },
{ type: "function_call", id: "fc_stored123", call_id: "call_1" },
],
};
applyResponsesInputPolicy(body, true);
assert.deepEqual(body.input, [
{ type: "reasoning", encrypted_content: "encrypted-blob" },
{ type: "function_call", call_id: "call_1" },
]);
});
test("applyResponsesInputPolicy still drops summary-only reasoning when enabled", () => {
const body: Record<string, unknown> = {
input: [
{ id: "rs_summary123", type: "reasoning", summary: [{ text: "thinking..." }] },
{ type: "reasoning", encrypted_content: "" },
{ type: "reasoning", encrypted_content: 42 },
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
],
};
applyResponsesInputPolicy(body, true);
assert.deepEqual(body.input, [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
]);
});
test("filterToOpenAIFormat strips reasoning_content from assistant+tool_calls messages", () => {
const body = {
messages: [

View File

@@ -169,112 +169,6 @@ describe("EditConnectionModal — import only free models", () => {
});
});
describe("EditConnectionModal — encrypted Responses reasoning", () => {
const PRESERVE_TOGGLE = 'button[role="switch"][aria-label="Preserve encrypted reasoning"]';
it("loads and saves the opt-in for an OpenAI-compatible Responses connection", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const el = render({
providerId: "openai-compatible-responses-12345678-1234-1234-1234-123456789abc",
connection: {
id: "conn-responses",
provider: "openai-compatible-responses-12345678-1234-1234-1234-123456789abc",
authType: "apikey",
providerSpecificData: { preserveEncryptedReasoning: true },
},
onSave,
});
const toggle = el.querySelector<HTMLButtonElement>(PRESERVE_TOGGLE)!;
expect(toggle.getAttribute("aria-checked")).toBe("true");
act(() => toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const saveBtn = Array.from(el.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "save"
)!;
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await waitFor(() => onSave.mock.calls.length > 0);
expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(false);
});
it("defaults off and persists an opt-in for first-party OpenAI", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const el = render({
providerId: "openai",
connection: {
id: "conn-openai",
provider: "openai",
authType: "apikey",
providerSpecificData: {},
},
onSave,
});
const toggle = el.querySelector<HTMLButtonElement>(PRESERVE_TOGGLE)!;
expect(toggle.getAttribute("aria-checked")).toBe("false");
act(() => toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const saveBtn = Array.from(el.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "save"
)!;
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await waitFor(() => onSave.mock.calls.length > 0);
expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(true);
});
it("is absent for a chat-only compatible connection", () => {
const el = render({
providerId: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
connection: {
id: "conn-chat",
provider: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
authType: "apikey",
providerSpecificData: {},
},
});
expect(el.querySelector(PRESERVE_TOGGLE)).toBeNull();
});
it("appears when a compatible connection selects the Responses target format", () => {
const el = render({
providerId: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
connection: {
id: "conn-selected-responses",
provider: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
authType: "apikey",
providerSpecificData: { targetFormat: "openai-responses" },
},
});
expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("false");
});
it("keeps Codex controls and persists the opt-in on its OAuth save path", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const el = render({
providerId: "codex",
connection: {
id: "conn-codex",
provider: "codex",
authType: "oauth",
providerSpecificData: { preserveEncryptedReasoning: true },
},
onSave,
});
expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("true");
expect(el.textContent).toContain("defaultThinkingStrengthLabel");
expect(
el.querySelector('button[role="switch"][aria-label="openaiResponsesStoreLabel"]')
).toBeTruthy();
const cooldownToggle = el.querySelector<HTMLButtonElement>(
'button[role="switch"][aria-label="disableCoolingLabel"]'
)!;
const reasoningToggle = el.querySelector<HTMLButtonElement>(PRESERVE_TOGGLE)!;
expect(reasoningToggle.parentElement?.nextElementSibling).toBe(cooldownToggle.parentElement);
const saveBtn = Array.from(el.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "save"
)!;
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await waitFor(() => onSave.mock.calls.length > 0);
expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(true);
});
});
describe("EditConnectionModal — quota scraping fields", () => {
it("saves OpenCode Go workspace and replacement auth cookie", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);