mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
Compare commits
35 Commits
fix/9981-i
...
feat/free-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c079bbbe05 | ||
|
|
675c030ce3 | ||
|
|
9d251c0557 | ||
|
|
2856f06da1 | ||
|
|
56322d498a | ||
|
|
deefd15db1 | ||
|
|
ebcd16785a | ||
|
|
a18d4575a9 | ||
|
|
4304ee7369 | ||
|
|
6055e6026c | ||
|
|
ae29ec743b | ||
|
|
d90ebdcd72 | ||
|
|
08d18d4e68 | ||
|
|
1e2acdecc0 | ||
|
|
5fd2325be7 | ||
|
|
3acb74fe0a | ||
|
|
0a1127be35 | ||
|
|
44069c5f54 | ||
|
|
41208fa398 | ||
|
|
3797bbcd82 | ||
|
|
bb5b68d7bd | ||
|
|
0481246bf4 | ||
|
|
5e02cf7623 | ||
|
|
4d0182daa0 | ||
|
|
5deb40a33a | ||
|
|
1bf38e85d5 | ||
|
|
d3037d1fdc | ||
|
|
227795cd08 | ||
|
|
995618d27a | ||
|
|
696ad182cd | ||
|
|
344e4398c8 | ||
|
|
8fc4023f94 | ||
|
|
aafdc4d4c6 | ||
|
|
fed0858f89 | ||
|
|
2b6977229b |
23
.env.example
23
.env.example
@@ -1173,6 +1173,12 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# Or enable for all providers at once:
|
||||
# CLI_COMPAT_ALL=1
|
||||
|
||||
# Allow the Antigravity request translator to skip its strict CLI request-signature
|
||||
# validation when the upstream refuses real signatures (debug/antiquated-CLI mode).
|
||||
# Default: real signatures enforced (unset) — signature bypass disabled.
|
||||
# Used by: open-sse/translator/request/openai-to-gemini.ts
|
||||
# ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0
|
||||
|
||||
# ── Kimi Coding CLI identity overrides ──
|
||||
# Used by: src/lib/oauth/providers/kimi-coding.ts — sent in OAuth + API headers.
|
||||
# Leave unset to use the captured defaults baked into the OmniRoute build.
|
||||
@@ -1848,6 +1854,17 @@ APP_LOG_TO_FILE=true
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_MIDSTREAM_ENABLED=true
|
||||
|
||||
# Active-stream throughput watchdog (#9709). Detects streams that keep sending
|
||||
# heartbeats/chunks but produce too little useful assistant text. Separate from
|
||||
# STREAM_IDLE_TIMEOUT_MS (silence) and the hard upstream attempt deadline. OFF by
|
||||
# default. Tool-call/reasoning phases suspend judgement; post-commit streams are
|
||||
# never blindly replayed.
|
||||
# STREAM_THROUGHPUT_WATCHDOG_ENABLED=true
|
||||
# STREAM_THROUGHPUT_WATCHDOG_WARMUP_MS=30000
|
||||
# STREAM_THROUGHPUT_WATCHDOG_WINDOW_MS=30000
|
||||
# STREAM_THROUGHPUT_WATCHDOG_MIN_BYTES_PER_SECOND=4
|
||||
# STREAM_THROUGHPUT_WATCHDOG_MIN_USEFUL_BYTES=1
|
||||
|
||||
# Stagger interval (ms) between provider token healthchecks at startup.
|
||||
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
|
||||
# HEALTHCHECK_STAGGER_MS=3000
|
||||
@@ -2003,6 +2020,12 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 0.33.2
|
||||
# COMMAND_CODE_VERSION=0.33.2
|
||||
|
||||
# Base URL for the Command Code usage/quota upstream, used by smartphone
|
||||
# quota-fetcher telemetry.
|
||||
# Used by: open-sse/services/usage/command-code.ts
|
||||
# Default: https://api.commandcode.ai
|
||||
# COMMANDCODE_API_URL=https://api.commandcode.ai
|
||||
|
||||
# ── MITM debug proxy (development only) ──
|
||||
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
|
||||
# MITM_LOCAL_PORT=443
|
||||
|
||||
22
.github/workflows/quality.yml
vendored
22
.github/workflows/quality.yml
vendored
@@ -194,6 +194,25 @@ jobs:
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
- name: Forgotten sibling tests (advisory)
|
||||
env:
|
||||
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/check/check-forgotten-sibling-tests.mjs \
|
||||
--summary-file forgotten-sibling-tests.md \
|
||||
--json-file forgotten-sibling-tests.json
|
||||
cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload forgotten sibling report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: forgotten-sibling-tests
|
||||
path: |
|
||||
forgotten-sibling-tests.md
|
||||
forgotten-sibling-tests.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
|
||||
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
|
||||
# step. Each gate runs in a loop with ::group::; failures are collected and
|
||||
@@ -279,7 +298,8 @@ jobs:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
# The advisory sibling-test step generates the same map earlier in this job.
|
||||
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
||||
# be measured against fast-unit's full-suite verdict across releases BEFORE
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
827
@omniroute/opencode-plugin/tests/warm-startup.test.ts
Normal file
827
@omniroute/opencode-plugin/tests/warm-startup.test.ts
Normal 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)");
|
||||
});
|
||||
1
changelog.d/features/6671-deepai-multimodal-provider.md
Normal file
1
changelog.d/features/6671-deepai-multimodal-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** add DeepAI as paid API-key image provider ([#6671](https://github.com/diegosouzapw/OmniRoute/issues/6671))
|
||||
@@ -0,0 +1,7 @@
|
||||
feat(images): execute full combo strategy + fallback in /v1/images/generations (#9239)
|
||||
|
||||
Add open-sse/services/imageCombo.ts that expands combo targets, filters
|
||||
to images-capable, executes priority strategy with handleImageGeneration
|
||||
per target, and returns first success or last failure. Route patches
|
||||
detect combo names before model resolution and divert to the new
|
||||
execution path.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
- Add an advisory forgotten-sibling-tests report to pull-request quality checks. The report traces changed modules through their static consumers to candidate sibling tests, while keeping barrel and dynamic-import cases non-blocking and requiring reviewed, referenced exceptions.
|
||||
1
changelog.d/features/9544-muse-code-cli-provider.md
Normal file
1
changelog.d/features/9544-muse-code-cli-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add Muse Code CLI provider preset (#9544)
|
||||
2
changelog.d/features/9620-cache-read-write-logs.md
Normal file
2
changelog.d/features/9620-cache-read-write-logs.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- Show cache-read and cache-write token counts in request log rows and details when providers
|
||||
report them.
|
||||
@@ -0,0 +1 @@
|
||||
- feat(memory): support custom OpenAI-compatible endpoints for Memory embeddings (#9622)
|
||||
1
changelog.d/features/9709-stream-throughput-watchdog.md
Normal file
1
changelog.d/features/9709-stream-throughput-watchdog.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(resilience): add an opt-in watchdog for persistently slow upstream streams (#9709)
|
||||
4
changelog.d/features/9752-one-click-free-providers.md
Normal file
4
changelog.d/features/9752-one-click-free-providers.md
Normal file
@@ -0,0 +1,4 @@
|
||||
- **Onboarding:** add an explicit, reviewable one-click setup for eligible no-auth LLM providers,
|
||||
with per-provider caution links, selectable confirmation, idempotent creation, and safe partial
|
||||
retries. Existing provider connections are never changed and setup completion never enables
|
||||
providers silently. ([#9752](https://github.com/diegosouzapw/OmniRoute/issues/9752))
|
||||
1
changelog.d/features/9807-audio-modality-bridge.md
Normal file
1
changelog.d/features/9807-audio-modality-bridge.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(modality bridge):** Transcribe chat audio for text-only models through the existing speech-to-text providers, with configurable limits, caching, runtime stats, and a dashboard self-test ([#9807](https://github.com/diegosouzapw/OmniRoute/pull/9807))
|
||||
1
changelog.d/fixes/9532-unit-ceiling-measurement.md
Normal file
1
changelog.d/fixes/9532-unit-ceiling-measurement.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(ci):** tighten unit suite ceiling from 100min to 80min as a conservative step (#9532)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(ratelimit):** added queue-wait timeout tests and updateFromResponseBody sequencing tests for the existing RATE_LIMIT_QUEUE_TIMEOUT feature in withRateLimit (#9533)
|
||||
1
changelog.d/fixes/9776-radar-entitlement-refresh.md
Normal file
1
changelog.d/fixes/9776-radar-entitlement-refresh.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(radar):** refresh signed catalog/referral caches when supporter entitlement changes, preserve the one-time live-to-community downgrade, and test real provider connection IDs from the setup tour
|
||||
1
changelog.d/fixes/9914-search-exa-contents.md
Normal file
1
changelog.d/fixes/9914-search-exa-contents.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(search): nest Exa contents options (text/highlights) for /search API (#9914)
|
||||
1
changelog.d/fixes/9927-encryption-log-identity.md
Normal file
1
changelog.d/fixes/9927-encryption-log-identity.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(encryption): name failing credential + recovery path in decrypt errors, dedupe per connection (#9927)
|
||||
1
changelog.d/fixes/9934-migration-fresh-setup.md
Normal file
1
changelog.d/fixes/9934-migration-fresh-setup.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(migrations): don't abort on fresh install with only the 001 seed (#9934)
|
||||
1
changelog.d/fixes/9971-empty-choices-vps.md
Normal file
1
changelog.d/fixes/9971-empty-choices-vps.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(chat): don't misclassify content-less thinking/redacted Claude bodies as empty_choices (#9971)
|
||||
1
changelog.d/fixes/9985-basereds-docs-size.md
Normal file
1
changelog.d/fixes/9985-basereds-docs-size.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(quality): green release/v3.8.50 base-reds — sync 4 env vars into .env.example/ENVIRONMENT.md and freeze the new proxied-TLS proxyFetch helper in the file-size baseline (#9985)
|
||||
@@ -98,6 +98,7 @@
|
||||
"omniglyph",
|
||||
"open",
|
||||
"opencode-ai",
|
||||
"onnxruntime-node",
|
||||
"ora",
|
||||
"parse5",
|
||||
"pino",
|
||||
|
||||
@@ -420,8 +420,10 @@
|
||||
"open-sse/executors/default.ts": 1355,
|
||||
"open-sse/executors/kiro.ts": 1390,
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1374,
|
||||
"open-sse/utils/sseHeartbeat.ts": 194
|
||||
"open-sse/utils/sseHeartbeat.ts": 194,
|
||||
"open-sse/utils/proxyFetch.ts": 1207
|
||||
},
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) \u2014 owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore \u2014 the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
"_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) \u2014 owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* \u2261 codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.",
|
||||
"_rebaseline_2026_07_28_8842_antigravity_projectid_refresh": "PR #8842 (fix/antigravity-projectid-refresh) own growth: open-sse/executors/antigravity.ts 1493->1528 (+35 = projectId discovery in refreshCredentials: import ensureAntigravityProjectAssigned + trim projectId + call ensureAntigravityProjectAssigned with 8s timeout + persistDiscoveredAntigravityProjectId + log success/failure). Irreducible wiring at the existing credential-refresh chokepoint. Covered by tests/unit/executor-antigravity.test.ts (4 new test cases).",
|
||||
|
||||
4
config/quality/forgotten-sibling-allowlist.json
Normal file
4
config/quality/forgotten-sibling-allowlist.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"entries": []
|
||||
}
|
||||
@@ -149,10 +149,11 @@
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"codeqlAlerts": {
|
||||
"value": 1,
|
||||
"value": 2,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_08_06_base_grew": "Base branch file-size drift: translator-openai-to-gemini.test.ts grew 1619->1622 (test assertions for Gemini translator compatibility). CodeQL alert (js/insufficient-password-hash in raycast.ts) is pre-existing base-red; incremented baseline to match."
|
||||
"_rebaseline_2026_08_06_base_grew": "Base branch file-size drift: translator-openai-to-gemini.test.ts grew 1619->1622 (test assertions for Gemini translator compatibility). CodeQL alert (js/insufficient-password-hash in raycast.ts) is pre-existing base-red; incremented baseline to match.",
|
||||
"_rebaseline_2026_08_10_9940_fingerprint": "CodeQL base-red (green-prs sweep, issue #9985): 2nd js/insufficient-password-hash alert at src/shared/middleware/chatBodyAdmission.ts:265,269 introduced by #9940 (per-connection virtual admission lanes). Both are API-key/bearer FINGERPRINTS (createHash('sha256') truncated to 16-hex admission-lane key), not password VERIFICATION — false-positive class for this rule. Reproduces on release/v3.8.50 tip. Owner-authorized rebaseline 1->2; revisit at v3.9.0."
|
||||
},
|
||||
"secretFindings": {
|
||||
"_note": "Zeroed 2026-07-13 (WS6/D3): the 3 frozen generic-api-key FPs are allowlisted with justification in .gitleaks.toml — any NEW finding regresses the ratchet.",
|
||||
|
||||
@@ -29,11 +29,28 @@ changes:
|
||||
| `Build (advisory)` | Non-draft code PRs and Mergify queue branches; Node 24, `npm-ci-retry`, `check:node-runtime`, `npm run build` with `OMNIROUTE_USE_TURBOPACK=1`; no artifact upload because no downstream quality job consumes it | **Advisory** (`continue-on-error: true`; remove after one week of stable release-PR runs) |
|
||||
| `Docs Gates (fast-path)` | Docs/code PRs; API docs refs and docs-all | Yes |
|
||||
| `Fast Quality Gates` | Code PRs; static checks, typecheck, dashboard typecheck, impacted unit tests | Yes |
|
||||
| `Forgotten sibling tests` | Code PRs; changed modules traced to static consumers and candidate sibling tests; barrel and dynamic-import paths are reported as advisory diagnostics, with referenced allowlist exceptions | **Advisory** |
|
||||
| `Vitest (fast-path)` | Code PRs; fast vitest suite | Yes |
|
||||
| `Unit Tests fast-path` | Code PRs; 4-shard unit suite | Yes |
|
||||
| `No new ESLint warnings` | Code PRs; suppressions-aware lint guard | Yes for own-origin, advisory for forks |
|
||||
| `Merge integrity (changelog + generated skills)` | Non-draft PRs; changelog and generated skill sync | Yes for own-origin, advisory for forks |
|
||||
|
||||
#### Forgotten sibling tests report
|
||||
|
||||
`npm run check:forgotten-sibling-tests` reuses the import resolver behind the test-impact map.
|
||||
For every changed production module, it reports deterministic
|
||||
`changed module/symbol -> static consumer -> candidate sibling test` chains when the candidate
|
||||
test is absent from the pull-request diff. The Markdown summary and JSON result are retained as
|
||||
the `forgotten-sibling-tests` workflow artifact for calibration before any blocking rollout.
|
||||
|
||||
Barrel re-exports and dynamic imports are resolution diagnostics only; they never create a
|
||||
blocking finding. Reviewed exceptions live in
|
||||
`config/quality/forgotten-sibling-allowlist.json`. Each entry must name the consumer and candidate
|
||||
test, give a specific rationale, and link a GitHub issue or pull request. Malformed entries fail
|
||||
closed. Exceptions cannot suppress a deleted candidate test or a diff that adds `.skip`/`.todo`;
|
||||
assertion weakening and other masking remain owned by the independently blocking
|
||||
`check:test-masking` gate.
|
||||
|
||||
### Job: `lint`
|
||||
|
||||
Runs on every PR to `main`. Blocks merge on failure.
|
||||
|
||||
@@ -260,6 +260,31 @@ it is unit-testable without a real Bottleneck limiter.
|
||||
|
||||
---
|
||||
|
||||
## 6. Slow-stream throughput watchdog (#9709)
|
||||
|
||||
The optional `resilienceSettings.streamRecovery.throughputWatchdog` guard detects
|
||||
an upstream that is still sending chunks but producing assistant output below the
|
||||
configured useful-output rate. It is deliberately distinct from the idle timeout:
|
||||
heartbeats and metadata reset neither timer and do not count as progress. It is also
|
||||
distinct from the hard attempt deadline (#9153), which remains an absolute safety
|
||||
ceiling regardless of output quality.
|
||||
|
||||
The watchdog requires a warm-up period followed by a complete rolling window before
|
||||
it can abort. It counts text deltas from Chat Completions and Responses API output
|
||||
events (a conservative UTF-8 byte proxy), ignores usage-only and empty events, and
|
||||
suspends judgement while tool-call or reasoning events are in flight. It is disabled
|
||||
by default and can be enabled with `STREAM_THROUGHPUT_WATCHDOG_ENABLED=true`; the
|
||||
window, warm-up, minimum rate, and minimum measurable output are bounded by the
|
||||
normal resilience-settings normalization layer.
|
||||
|
||||
When enabled, a watchdog abort is applied only to the active upstream attempt. Before
|
||||
any client-visible bytes, the existing same-account early-recovery path may reopen
|
||||
the attempt. After commit, the stream is never blindly replayed; only the existing
|
||||
safe mid-stream continuation contract can stitch a suffix. Finalization remains
|
||||
single-shot, so usage accounting and semaphore release are not duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Other Resilience Features
|
||||
|
||||
- **19 routing strategies** (priority, weighted, round-robin, context-relay, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, fusion, pipeline) — see [AUTO-COMBO.md](../routing/AUTO-COMBO.md).
|
||||
|
||||
@@ -161,13 +161,15 @@ The `memory_vec_meta` table (migration `073_memory_vec.sql`) stores:
|
||||
|
||||
## Settings extension
|
||||
|
||||
Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) in
|
||||
Nine embedding and vector fields are available in `MemorySettingsExtended` in
|
||||
`src/shared/schemas/memory.ts`, persisted via `src/lib/db/settings.ts`:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ------------------------ | -------------------------------------------------- | -------- | ------------------------------------------------ |
|
||||
| `embeddingSource` | `"remote" \| "static" \| "transformers" \| "auto"` | `"auto"` | Which embedding source to use |
|
||||
| `embeddingProviderModel` | `string \| null` | `null` | Provider/model in `provider/model` format |
|
||||
| `customBaseUrl` | `string \| null` | `null` | Memory-only OpenAI-compatible endpoint base URL |
|
||||
| `customModelId` | `string \| null` | `null` | Model ID sent to the custom endpoint |
|
||||
| `transformersEnabled` | `boolean` | `false` | Opt-in for Transformers.js (MiniLM, ~400MB) |
|
||||
| `staticEnabled` | `boolean` | `false` | Opt-in for static potion-base-8M local model |
|
||||
| `rerankEnabled` | `boolean` | `false` | Enable reranking step (adds +200-500ms/req) |
|
||||
@@ -176,6 +178,14 @@ Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) in
|
||||
|
||||
These are exposed via `GET /PUT /api/settings/memory` (schema `MemorySettingsExtendedSchema`).
|
||||
|
||||
For the `remote` source, Memory also accepts the optional `customBaseUrl` and
|
||||
`customModelId` settings. Together they select an OpenAI-compatible `/embeddings`
|
||||
endpoint and model without changing the global embedding registry. The endpoint is
|
||||
normalized before use and checked by the provider outbound URL policy: HTTP(S) is
|
||||
required, embedded credentials and query strings are rejected, and cloud-metadata
|
||||
addresses remain blocked. Empty values preserve the selected registry provider. Errors
|
||||
returned to the dashboard are sanitized and endpoint credentials are never logged.
|
||||
|
||||
> **TODO (D20):** Scope `global` (sharing memories across all API keys) is not
|
||||
> implemented in this release. It requires schema changes and a global retrieval
|
||||
> path. Track separately.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Radar Free-Model Catalog"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-07
|
||||
lastUpdated: 2026-08-08
|
||||
---
|
||||
|
||||
# Radar Free-Model Catalog
|
||||
|
||||
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
|
||||
> **Last updated:** 2026-08-07 — v3.8.50
|
||||
> **Last updated:** 2026-08-08 — v3.8.50
|
||||
|
||||
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
|
||||
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
|
||||
@@ -23,6 +23,23 @@ below.
|
||||
|
||||
---
|
||||
|
||||
## Delivery status in v3.8.50
|
||||
|
||||
The following status distinguishes what this OSS release implements from later Radar
|
||||
workstreams. It is a code-level status, not a promise that a particular hosted deployment
|
||||
or external integration is currently available.
|
||||
|
||||
| Area | Status in this release |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, non-destructive overlay, scheduler, and dashboard. |
|
||||
| Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. |
|
||||
| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by the server-side sync. Changing or clearing the key invalidates both entitlement-sensitive feed caches. |
|
||||
| Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. |
|
||||
| Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. |
|
||||
| Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. |
|
||||
|
||||
---
|
||||
|
||||
## Flag: `RADAR_ENABLED` (default off)
|
||||
|
||||
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
|
||||
@@ -67,7 +84,8 @@ When both are on, the sync path is:
|
||||
plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider
|
||||
configuration, or model traffic to the feed service.
|
||||
3. The response is verified, validated, and cached locally (see
|
||||
[Security model](#security-model)). Nothing else touches the network for Radar.
|
||||
[Security model](#security-model)). Radar has exactly two server-side network paths:
|
||||
`syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed.
|
||||
|
||||
The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`)
|
||||
that lets the feed service decide which tier to serve (see
|
||||
@@ -77,6 +95,9 @@ that lets the feed service decide which tier to serve (see
|
||||
helpers (`src/lib/db/encryption.ts`) used for provider credentials.
|
||||
- Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and
|
||||
**never echoed back** — the response returns a masked form (`omr_****abcd`).
|
||||
- Changing or clearing it atomically invalidates both the catalog and referrals caches. The
|
||||
next sync/read resolves the new entitlement server-side; saving a key does not itself make
|
||||
a network request or consume a single-use activation key.
|
||||
- Sent to the feed service as a Bearer token on the sync GET — nothing else about the
|
||||
key ever leaves the client.
|
||||
|
||||
@@ -102,10 +123,10 @@ pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
|
||||
`GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the
|
||||
client component never reads `process.env` itself.
|
||||
|
||||
| Var | Purpose |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
|
||||
| Var | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
|
||||
|
||||
Once a visitor has a key (`omr_` + 40 hex chars), the activation screen
|
||||
(`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary
|
||||
@@ -117,7 +138,7 @@ as a UX nicety; the server's Zod schema is the authoritative check either way. O
|
||||
key is set, the activation screen shows the masked form (`supporterKeyMasked` from
|
||||
`GET /api/radar/settings`) instead of an empty input, with a "change key" control to
|
||||
paste a new one — the raw key is never redisplayed. The two claim/plans buttons above
|
||||
remain the way to *obtain* a key in the first place; this input is where an operator
|
||||
remain the way to _obtain_ a key in the first place; this input is where an operator
|
||||
who already has one activates it.
|
||||
|
||||
---
|
||||
@@ -207,11 +228,11 @@ handle.
|
||||
### The served tier comes from a response header, not the signed body
|
||||
|
||||
The signed feed **body**'s `tier` field is always `"live"` — the feed service ships
|
||||
**one signed artifact per version**, so the body cannot carry a per-request tier
|
||||
without invalidating the Ed25519 signature (re-signing per request would defeat the
|
||||
point of a pinned, cacheable, verifiable artifact). The tier actually served for a
|
||||
given request is instead carried in the **`x-omniroute-feed-tier` response header**,
|
||||
decided server-side from the request's `Authorization` key.
|
||||
**two signed artifacts per version**: live includes current campaigns and community
|
||||
omits them. Each artifact is signed over its own exact bytes. The body still does not
|
||||
serve as the entitlement decision; the tier actually selected for a request is carried
|
||||
in the **`x-omniroute-feed-tier` response header**, decided server-side from the request's
|
||||
`Authorization` key.
|
||||
|
||||
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
|
||||
that resolves the tier a client should trust:
|
||||
@@ -223,7 +244,7 @@ that resolves the tier a client should trust:
|
||||
2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1
|
||||
yields nothing.
|
||||
3. The resolved tier is what gets cached and returned as `{ status: "updated",
|
||||
version, tier }` — this is the value the dashboard shows, never the raw body
|
||||
version, tier }` — this is the value the dashboard shows, never the raw body
|
||||
field.
|
||||
|
||||
---
|
||||
@@ -265,18 +286,18 @@ Every merged entry carries an `origin` field the UI renders as a badge:
|
||||
|
||||
Five local routes back the UI, all under `src/app/api/radar/`:
|
||||
|
||||
| Route | Method | Purpose |
|
||||
| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
|
||||
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
|
||||
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
|
||||
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
|
||||
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. |
|
||||
| Route | Method | Purpose |
|
||||
| ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
|
||||
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
|
||||
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
|
||||
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
|
||||
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. |
|
||||
|
||||
**Hard rule: these routes never proxy the feed service.** The browser only ever talks
|
||||
to the local OmniRoute server; `syncRadar()` is the single module in the whole client
|
||||
that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs
|
||||
server-side, never client-side. This keeps the feed URL and any supporter key
|
||||
to the local OmniRoute server. The two modules that touch the Radar service are
|
||||
`src/lib/radar/sync.ts` (catalog) and `src/lib/radar/referralsSync.ts` (referrals); both
|
||||
always run server-side, never client-side. This keeps the feed URL and any supporter key
|
||||
out of client-facing network traffic entirely.
|
||||
|
||||
All five routes return `404` when `RADAR_ENABLED` is off (see
|
||||
@@ -348,11 +369,13 @@ touches the network for referrals, mirroring `syncRadar()`'s contract exactly: f
|
||||
Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against
|
||||
`RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table
|
||||
(migration `142_radar_referrals_cache.sql`) — a table entirely separate from the
|
||||
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor (an
|
||||
incoming feed with a `generatedAt` no newer than the cached one is treated as `stale`
|
||||
and never overwrites the cache — guards against a replay of an older signed artifact)
|
||||
mirror the catalog sync's own `MAX_FEED_BYTES`/version-floor guards. Never throws —
|
||||
always returns a status object; errors never carry a stack trace in `reason`.
|
||||
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor reject an
|
||||
incoming feed older than the cached one, guarding against replay of an older signed
|
||||
artifact. An equal timestamp is accepted: the server intentionally gives the community
|
||||
and live referral variants the same deterministic `generatedAt`, so the signed payload
|
||||
and served tier can change after a supporter-key change without the underlying link set
|
||||
changing. Never throws — always returns a status object; errors never carry a stack trace
|
||||
in `reason`.
|
||||
|
||||
Two triggers keep the referrals cache warm, both independent of the catalog's own
|
||||
24h cadence:
|
||||
|
||||
@@ -30,6 +30,18 @@ See **[WEB-COOKIE-GUIDE.md](./WEB-COOKIE-GUIDE.md)** for general setup instructi
|
||||
|
||||
## Quick Start: Connect Your First Provider
|
||||
|
||||
### Optional first-run free-provider setup
|
||||
|
||||
The first-run wizard offers an explicit **Set up free providers** card. It derives the current
|
||||
eligible list from OmniRoute's no-auth provider registry, then lets you review and deselect each
|
||||
provider before confirming. OmniRoute shows the provider's caution notice and a link to its site
|
||||
so you can review third-party terms, privacy, availability, and rate limits first.
|
||||
|
||||
This action is optional: finishing the wizard never creates free-provider connections silently.
|
||||
It creates only providers that are still missing, leaves existing customized connections
|
||||
untouched, and reports created, already-configured, and failed providers individually. You can
|
||||
safely retry only the failures after a partial result.
|
||||
|
||||
### Option A: Free Provider (No Credit Card)
|
||||
|
||||
1. Open the dashboard at `http://localhost:20128`
|
||||
|
||||
@@ -340,6 +340,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con
|
||||
| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. |
|
||||
| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. |
|
||||
| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. |
|
||||
| `TLS_FINGERPRINT_PROVIDERS` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Comma-separated provider allowlist for the new proxied TLS routing (`open-sse/utils/proxyFetch.ts`). Direct TLS keeps its legacy behavior when unset; only these providers route through the Chrome-124 fingerprint bridge. |
|
||||
| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. |
|
||||
|
||||
### Scenarios
|
||||
@@ -468,6 +469,7 @@ detection above).
|
||||
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
|
||||
| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. |
|
||||
| `ANTIGRAVITY_CREDITS` | `off` | `open-sse/services/antigravityCredits.ts` | Google One AI credits policy: `off` never injects credits, `retry` injects once after an eligible quota 429, and `always` injects on the first request. |
|
||||
| `ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS` | `0` | `open-sse/translator/request/openai-to-gemini.ts` | Allow the Antigravity request translator to skip its strict CLI request-signature validation when the upstream refuses real signatures (debug/antiquated-CLI mode). Non-zero enables the bypass. |
|
||||
| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. |
|
||||
|
||||
### OAuth CLI Bridge (Internal)
|
||||
@@ -791,6 +793,7 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
|
||||
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
|
||||
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
|
||||
| `MEMORY_TYPED_DECAY_SWEEP_INTERVAL` | `0` (disabled) | Interval (seconds) for the optional periodic decay sweep in `src/lib/memory/typedDecay.ts`. `0`/unset = no periodic sweep. Doubly opt-in: also requires `MEMORY_TYPED_DECAY_ENABLED=true`. |
|
||||
| `OMNIROUTE_STRICT_SYSTEM_PROVIDERS` | _(unset)_ | Comma-separated provider ids (case-insensitive) that accept a `system` message **only at index 0** (`src/lib/memory/injection.ts`). For these, the cache-safe mid-array memory splice is unsafe in multi-turn conversations, so memory is merged/prepended as the leading system message instead. Defaults to only `xiaomi-mimo`/`mimo`; extend for self-hosted OpenAI-compatible endpoints (e.g. Qwen3.5+/3.6) whose chat template enforces the same single-leading-system-message constraint. |
|
||||
|
||||
### Low-RAM Docker Example
|
||||
|
||||
@@ -927,6 +930,11 @@ Anthropic-compatible provider instead.
|
||||
| `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. |
|
||||
| `STREAM_RECOVERY_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** transparent recovery of truncated upstream streams (free-claude-code port). Holds the opening SSE window up to `STREAM_RECOVERY.HOLDBACK_MS` (750 ms) so a _pre-commit_ cutoff — one that happens before any byte reaches the client — is re-opened and retried invisibly. **When to enable:** flaky/upstreams that frequently 0-byte-truncate at stream start; leave OFF if you cannot afford up to 750 ms of added time-to-first-token on every stream. Accepts `true`/`1`/`on`. Seeds the persisted Resilience setting; the Dashboard setting wins once set. |
|
||||
| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** mid-stream continuation (Fase 4.4) — after a _post-commit_ truncation (bytes already reached the client), re-request with the partial text as an assistant prefill and stitch the missing suffix. Plain-text OpenAI-compatible streams only; never fires with a tool call in flight. **When to enable:** long generations that get cut mid-answer and you accept the recovered tail arriving as one burst rather than token-by-token. Independent of `STREAM_RECOVERY_ENABLED` (different risk profile). Accepts `true`/`1`/`on`. |
|
||||
| `STREAM_THROUGHPUT_WATCHDOG_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` → `open-sse/services/throughputWatchdog.ts` | Opt-in active-stream useful-output watchdog. Detects streams that keep sending chunks but remain below the configured assistant-output rate; heartbeats, usage events, empty deltas, and tool/reasoning phases do not masquerade as progress. Separate from idle and hard-deadline timeouts. |
|
||||
| `STREAM_THROUGHPUT_WATCHDOG_WARMUP_MS` | `30000` | `src/lib/resilience/settings/normalize.ts` | Grace period before throughput evaluation, bounded to 0–600000 ms. |
|
||||
| `STREAM_THROUGHPUT_WATCHDOG_WINDOW_MS` | `30000` | `src/lib/resilience/settings/normalize.ts` | Rolling useful-output window, bounded to 1000–600000 ms; one complete window is required before abort. |
|
||||
| `STREAM_THROUGHPUT_WATCHDOG_MIN_BYTES_PER_SECOND` | `4` | `src/lib/resilience/settings/normalize.ts` | Minimum UTF-8 assistant-output byte rate (conservative token proxy), bounded to 1–1000000. |
|
||||
| `STREAM_THROUGHPUT_WATCHDOG_MIN_USEFUL_BYTES` | `1` | `src/lib/resilience/settings/normalize.ts` | Minimum non-zero useful-output sample considered measurable, bounded to 1–1000000 bytes. |
|
||||
| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. |
|
||||
| `HEALTHCHECK_JITTER_MIN_MS` | `500` | `src/lib/tokenHealthCheck.ts` | Minimum randomized jitter (ms) added on top of `HEALTHCHECK_STAGGER_MS` between provider token healthchecks, to prevent bursting (Issue #1220). |
|
||||
| `HEALTHCHECK_JITTER_MAX_MS` | `5000` | `src/lib/tokenHealthCheck.ts` | Maximum randomized jitter (ms) added on top of `HEALTHCHECK_STAGGER_MS` between provider token healthchecks, to prevent bursting (Issue #1220). |
|
||||
@@ -935,10 +943,9 @@ Anthropic-compatible provider instead.
|
||||
| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. |
|
||||
| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom token-saver proxy URL. The dashboard lifecycle (`api/headroom/*`) spawns a local `headroom-ai` CLI on loopback by default; override only to point at an external Docker sidecar proxy. |
|
||||
|
||||
### Stream-recovery tuning constants (not env vars)
|
||||
### Stream-recovery tuning constants
|
||||
|
||||
The two `STREAM_RECOVERY_*` flags above are the only operator-facing toggles. The
|
||||
recovery behavior is otherwise tuned by hardcoded constants in
|
||||
The recovery holdback behavior is tuned by hardcoded constants in
|
||||
`open-sse/config/constants.ts` (`STREAM_RECOVERY`), shown here for reference —
|
||||
changing them requires a code edit, not an env var:
|
||||
|
||||
@@ -1092,6 +1099,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. |
|
||||
| `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. |
|
||||
| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. |
|
||||
| `COMMANDCODE_API_URL` | `https://api.commandcode.ai` | `open-sse/services/usage/command-code.ts` | Base URL for the Command Code usage/quota upstream used by the smartphone quota-fetcher telemetry. Override for a self-hosted/alternative Command Code API. |
|
||||
| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. |
|
||||
| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). |
|
||||
| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | Idle socket timeout (ms) for proxied connections; idle sockets past this are torn down to avoid leaking half-open tunnels. |
|
||||
|
||||
@@ -7,7 +7,7 @@ lastUpdated: 2026-08-08
|
||||
# Guardrails
|
||||
|
||||
> **Source of truth:** `src/lib/guardrails/`
|
||||
> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-2: dashboard settings page, live stats, test action, and media-provider shortcuts)
|
||||
> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-3: Audio Bridge runtime and functional Audio settings tab)
|
||||
|
||||
Guardrails enforce safety, policy, and content transformations at the boundary
|
||||
between OmniRoute and upstream providers. Each guardrail can inspect (and
|
||||
@@ -20,12 +20,13 @@ request. Blocking is an explicit decision (`block: true`), never an accident.
|
||||
|
||||
## Built-in Guardrails
|
||||
|
||||
The registry auto-loads four guardrails in priority order on import
|
||||
The registry auto-loads five guardrails in priority order on import
|
||||
(see `registry.ts` → `registerDefaultGuardrails()`):
|
||||
|
||||
| Priority | Name | Stage(s) | File |
|
||||
| -------- | ------------------- | -------------- | --------------------- |
|
||||
| `5` | `vision-bridge` | `preCall` | `visionBridge.ts` |
|
||||
| `6` | `audio-bridge` | `preCall` | `audioBridge.ts` |
|
||||
| `10` | `pii-masker` | `pre` + `post` | `piiMasker.ts` |
|
||||
| `20` | `prompt-injection` | `preCall` | `promptInjection.ts` |
|
||||
| `95` | `credential-masker` | `pre` + `post` | `credentialMasker.ts` |
|
||||
@@ -113,8 +114,8 @@ The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema`
|
||||
`modalityBridgeVisionMode`, `modalityBridgeVisionModel`,
|
||||
`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`,
|
||||
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the
|
||||
`modalityBridgeCache*` trio, and the PR-3-reserved `modalityBridgeAudio*`
|
||||
group. Migration `141_modality_bridge_settings.sql` copies existing legacy
|
||||
`modalityBridgeCache*` trio, and the `modalityBridgeAudio*` group used by the
|
||||
Audio Bridge. Migration `141_modality_bridge_settings.sql` copies existing legacy
|
||||
`visionBridge*` values to the matching new keys (idempotent, never overwrites
|
||||
an operator-set `modalityBridge*` value); the legacy keys stay accepted as a
|
||||
read fallback for one release cycle.
|
||||
@@ -130,8 +131,8 @@ swap is already visible in the response body's `model` field.
|
||||
|
||||
`GET /api/modality-bridge/stats` (management auth, same tier as
|
||||
`GET /api/settings`) returns the in-memory per-modality counters
|
||||
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` (and the
|
||||
PR-3-reserved `audio`). Counters reset on process restart by design
|
||||
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` and `audio`.
|
||||
Counters reset on process restart by design
|
||||
(telemetry, not accounting).
|
||||
|
||||
#### Dashboard configuration
|
||||
@@ -139,11 +140,12 @@ PR-3-reserved `audio`). Counters reset on process restart by design
|
||||
The dedicated dashboard page is
|
||||
`/dashboard/settings/modality-bridge`. Its URL-addressable `Vision`, `Audio`,
|
||||
and `Video` tabs preserve query parameters while switching the `tab` value.
|
||||
The Vision tab is live: it exposes enablement, mode, model selection (including
|
||||
the automatic default), task-aware prompting, advanced timeout/image/cache
|
||||
limits, runtime counters, and a guarded sample request. Audio and Video are
|
||||
explicit placeholders: Audio is reserved for PR-3, while Video remains tracked
|
||||
in issue `#9760`.
|
||||
The Vision tab exposes enablement, mode, model selection (including the automatic
|
||||
default), task-aware prompting, advanced timeout/image/cache limits, runtime
|
||||
counters, and a guarded sample request. The Audio tab is also live: it exposes
|
||||
enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio
|
||||
counters, and an `input_audio` sample test. Video remains the explicit placeholder
|
||||
tracked in issue `#9760`.
|
||||
|
||||
The former Vision Bridge card under AI settings is a compatibility link to the
|
||||
new page; it no longer owns a second copy of the form. Media Providers also
|
||||
@@ -165,6 +167,64 @@ new mode/task-aware/cache defaults and the settings resolver live in
|
||||
`deps` constructor option so tests can inject fake `getSettings` and
|
||||
`callVisionModel` implementations.
|
||||
|
||||
### Audio Bridge (`audioBridge.ts`) — Modality Bridge PR-3
|
||||
|
||||
Intercepts audio-bearing chat requests before they reach a target that is not
|
||||
known to accept audio input. It never reroutes the chat request: audio parts are
|
||||
transcribed through the existing OpenAI-compatible multipart endpoint and the
|
||||
chosen chat model continues with text transcripts.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Resolve `supportsAudio` through `getResolvedModelCapabilities()`. Explicit
|
||||
provider-registry metadata wins, then static model metadata, then synced
|
||||
`modalities_input`. A declared input list without `audio` is `false`; no
|
||||
capability evidence remains `null`. Both `false` and `null` activate the
|
||||
conservative bridge, while `true` bypasses it.
|
||||
2. Resolve `modalityBridgeAudio*` settings and extract spliceable top-level
|
||||
audio parts from every message through the shared `detectMediaParts()`
|
||||
detector. Supported wire shapes are OpenAI `input_audio`, `audio_url`, and
|
||||
`source.media_type: "audio/*"`. Nested audio is detected for routing but not
|
||||
removed by the splice path. Work is capped by `modalityBridgeAudioMaxClips`;
|
||||
later parts stay untouched.
|
||||
3. Honor a configured `provider/model`, or let `selectAudioBridgeModel()` walk
|
||||
`AUDIO_TRANSCRIPTION_PROVIDERS` in stable catalog order and select the first
|
||||
model with a usable active provider credential.
|
||||
4. `callAudioTranscription()` converts base64/data-URI audio to a multipart
|
||||
`file`, or downloads a remote `audio_url` through the public-only outbound
|
||||
guard with DNS pinning and a 25 MB bound. It then POSTs the file and selected
|
||||
model to the local `/v1/audio/transcriptions` self-loop, authenticated with
|
||||
`resolveSelfLoopBearer()`. The existing transcription route performs normal
|
||||
credential lookup, cooldown/rate-limit handling, and provider dispatch.
|
||||
5. Successful calls replace their parts with `[Audio N]: <transcript>`. Calls
|
||||
run with `Promise.allSettled`: an individual failure preserves that original
|
||||
audio part (#4012 contract). If every call fails and the target is proven
|
||||
`supportsAudio === false`, the parts become
|
||||
`[Audio N]: (unavailable — no STT provider connected)` (#8430 contract). For
|
||||
an unknown target (`null`), an all-failure result stays untouched. A proven
|
||||
text-only target with no usable STT credential receives the same explicit
|
||||
stub without issuing a network call.
|
||||
|
||||
Successful transcripts use the process-wide Modality Bridge LRU/TTL cache. The
|
||||
key combines the audio reference, the stable `audio-transcription` operation
|
||||
label, and selected STT model; failures are never cached. Audio attempts update
|
||||
the shared `bridged`, `cacheHits`, `failures`, and `lastUsedAt` counters.
|
||||
Transformed responses carry
|
||||
`x-omniroute-modality-bridge: audio->text;model=<sttModel>;parts=<n>`; untouched
|
||||
requests do not receive an Audio Bridge segment.
|
||||
|
||||
Runtime settings are DB-backed and Zod-validated:
|
||||
|
||||
| Key | Default | Range |
|
||||
| ----------------------------- | ------- | -------------- |
|
||||
| `modalityBridgeAudioEnabled` | `true` | — |
|
||||
| `modalityBridgeAudioModel` | `""` | Auto or STT ID |
|
||||
| `modalityBridgeAudioTimeout` | `60000` | 1000–300000 |
|
||||
| `modalityBridgeAudioMaxClips` | `3` | 1–10 |
|
||||
|
||||
The shared cache remains controlled by `modalityBridgeCacheEnabled`,
|
||||
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`.
|
||||
|
||||
### PII Masker (`piiMasker.ts`)
|
||||
|
||||
Runs on **both** stages.
|
||||
@@ -370,8 +430,8 @@ Environment variables read by the built-in guardrails:
|
||||
| `PII_REDACTION_ENABLED` | `pii-masker` | When `true`, request PII is redacted (independent of injection mode). |
|
||||
| `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. |
|
||||
|
||||
The Vision Bridge reads runtime config from the DB-backed settings store
|
||||
(`getSettings()`), not env vars. The primary keys are
|
||||
The Modality Bridge guardrails read runtime config from the DB-backed settings
|
||||
store (`getSettings()`), not env vars. Vision's primary keys are
|
||||
`modalityBridgeVisionEnabled`, `modalityBridgeVisionMode`,
|
||||
`modalityBridgeVisionModel`, `modalityBridgeVisionTaskAware`,
|
||||
`modalityBridgeVisionPrompt`, `modalityBridgeVisionTimeout`,
|
||||
@@ -382,6 +442,11 @@ fallback; dashboard writes use the primary keys. Defaults and the fallback
|
||||
resolver live in `src/shared/constants/modalityBridgeDefaults.ts`, with legacy
|
||||
constants retained in `src/shared/constants/visionBridgeDefaults.ts`.
|
||||
|
||||
Audio uses `modalityBridgeAudioEnabled`, `modalityBridgeAudioModel`,
|
||||
`modalityBridgeAudioTimeout`, and `modalityBridgeAudioMaxClips`, plus the shared
|
||||
`modalityBridgeCache*` settings. Audio has no legacy-key fallback because these
|
||||
keys were introduced with the Modality Bridge schema.
|
||||
|
||||
## Custom Guardrails
|
||||
|
||||
```typescript
|
||||
@@ -419,9 +484,11 @@ Steps:
|
||||
|
||||
Use `resetGuardrailsForTests()` between tests to start from a known state.
|
||||
Pass `{ registerDefaults: false }` to start with an empty registry and
|
||||
register only the guardrails under test. The Vision Bridge guardrail accepts
|
||||
dependency injection (`deps.getSettings`, `deps.callVisionModel`) so tests can
|
||||
exercise the full flow without DB or network access.
|
||||
register only the guardrails under test. Vision Bridge accepts dependency
|
||||
injection (`deps.getSettings`, `deps.callVisionModel`); Audio Bridge exposes the
|
||||
equivalent seams for settings, capabilities, STT model selection, credential
|
||||
checks, and transcription. Tests can therefore exercise both flows without DB
|
||||
or network access.
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -430,6 +497,7 @@ exercise the full flow without DB or network access.
|
||||
prompt-injection and PII masking
|
||||
- `src/shared/constants/visionBridgeDefaults.ts` — Vision Bridge defaults and
|
||||
forced-bridge model list
|
||||
- `src/shared/constants/modalityBridgeDefaults.ts` — shared Vision/Audio runtime defaults
|
||||
- `docs/architecture/RESILIENCE_GUIDE.md` — orthogonal layer (circuit breaker, cooldowns)
|
||||
- `docs/reference/ENVIRONMENT.md` — full env var reference
|
||||
|
||||
|
||||
@@ -330,3 +330,15 @@ export const STREAM_RECOVERY = {
|
||||
BUFFER_MAX_BYTES: 65536,
|
||||
EARLY_RETRY_MAX: 4,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Active-stream quality watchdog defaults (#9709). This is separate from the
|
||||
* idle timeout (no chunks) and the absolute upstream-attempt deadline: it only
|
||||
* evaluates useful assistant output after warm-up plus one complete window.
|
||||
*/
|
||||
export const STREAM_THROUGHPUT_WATCHDOG = {
|
||||
WARMUP_MS: 30_000,
|
||||
WINDOW_MS: 30_000,
|
||||
MIN_USEFUL_BYTES_PER_SECOND: 4,
|
||||
MIN_USEFUL_BYTES: 1,
|
||||
} as const;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { notion_webProvider } from "./registry/notion-web/index.ts";
|
||||
import { anthropicProvider } from "./registry/anthropic/index.ts";
|
||||
import { sambanovaProvider } from "./registry/sambanova/index.ts";
|
||||
import { puterProvider } from "./registry/puter/index.ts";
|
||||
import { deepaiProvider } from "./registry/deepai/index.ts";
|
||||
import { upstageProvider } from "./registry/upstage/index.ts";
|
||||
import { nebiusProvider } from "./registry/nebius/index.ts";
|
||||
import { fireworksProvider } from "./registry/fireworks/index.ts";
|
||||
@@ -227,6 +228,33 @@ import { digitaloceanProvider } from "./registry/digitalocean/index.ts";
|
||||
import { hcnsecProvider } from "./registry/hcnsec/index.ts";
|
||||
import { promptqlProvider } from "./registry/promptql/index.ts";
|
||||
import { hyperagentProvider } from "./registry/hyperagent/index.ts";
|
||||
import { muse_codeProvider } from "./registry/muse-code/index.ts";
|
||||
import { zyloApiProvider } from "./registry/zylo-api/index.ts";
|
||||
import { poolsideProvider } from "./registry/poolside/index.ts";
|
||||
import { fastrouterProvider } from "./registry/fastrouter/index.ts";
|
||||
import { anyapiProvider } from "./registry/anyapi/index.ts";
|
||||
import { electronhubProvider } from "./registry/electronhub/index.ts";
|
||||
import { llmgatewayProvider } from "./registry/llmgateway/index.ts";
|
||||
import { llmKiwiProvider } from "./registry/llm-kiwi/index.ts";
|
||||
import { literouterProvider } from "./registry/literouter/index.ts";
|
||||
import { mnnAiProvider } from "./registry/mnn-ai/index.ts";
|
||||
import { meganovaAiProvider } from "./registry/meganova-ai/index.ts";
|
||||
import { mixlayerProvider } from "./registry/mixlayer/index.ts";
|
||||
import { spekaProvider } from "./registry/speka/index.ts";
|
||||
import { tokenreplyProvider } from "./registry/tokenreply/index.ts";
|
||||
import { yoloAutoProvider } from "./registry/yolo-auto/index.ts";
|
||||
import { dxntProvider } from "./registry/dxnt/index.ts";
|
||||
import { cloudcodeOneProvider } from "./registry/cloudcode-one/index.ts";
|
||||
import { ofoxaiProvider } from "./registry/ofoxai/index.ts";
|
||||
import { zerolimitaiProvider } from "./registry/zerolimitai/index.ts";
|
||||
import { chatanywhereProvider } from "./registry/chatanywhere/index.ts";
|
||||
import { helyxaiProvider } from "./registry/helyxai/index.ts";
|
||||
import { aurikoProvider } from "./registry/auriko/index.ts";
|
||||
import { poixeAiProvider } from "./registry/poixe-ai/index.ts";
|
||||
import { nagaAiProvider } from "./registry/naga-ai/index.ts";
|
||||
import { chatOripeProvider } from "./registry/chat-oripe/index.ts";
|
||||
import { freeinferenceProvider } from "./registry/freeinference/index.ts";
|
||||
import { freeAiProvider } from "./registry/free-ai/index.ts";
|
||||
|
||||
export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
aimlapi: aimlapiProvider,
|
||||
@@ -241,6 +269,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
sambanova: sambanovaProvider,
|
||||
puter: puterProvider,
|
||||
upstage: upstageProvider,
|
||||
deepai: deepaiProvider,
|
||||
nebius: nebiusProvider,
|
||||
fireworks: fireworksProvider,
|
||||
llamagate: llamagateProvider,
|
||||
@@ -455,5 +484,32 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
hcnsec: hcnsecProvider,
|
||||
promptql: promptqlProvider,
|
||||
hyperagent: hyperagentProvider,
|
||||
"muse-code": muse_codeProvider,
|
||||
"zylo-api": zyloApiProvider,
|
||||
unorouter: unorouterProvider,
|
||||
poolside: poolsideProvider,
|
||||
fastrouter: fastrouterProvider,
|
||||
anyapi: anyapiProvider,
|
||||
electronhub: electronhubProvider,
|
||||
llmgateway: llmgatewayProvider,
|
||||
"llm-kiwi": llmKiwiProvider,
|
||||
literouter: literouterProvider,
|
||||
"mnn-ai": mnnAiProvider,
|
||||
"meganova-ai": meganovaAiProvider,
|
||||
mixlayer: mixlayerProvider,
|
||||
speka: spekaProvider,
|
||||
tokenreply: tokenreplyProvider,
|
||||
"yolo-auto": yoloAutoProvider,
|
||||
dxnt: dxntProvider,
|
||||
"cloudcode-one": cloudcodeOneProvider,
|
||||
ofoxai: ofoxaiProvider,
|
||||
zerolimitai: zerolimitaiProvider,
|
||||
chatanywhere: chatanywhereProvider,
|
||||
helyxai: helyxaiProvider,
|
||||
auriko: aurikoProvider,
|
||||
"poixe-ai": poixeAiProvider,
|
||||
"naga-ai": nagaAiProvider,
|
||||
"chat-oripe": chatOripeProvider,
|
||||
freeinference: freeinferenceProvider,
|
||||
"free-ai": freeAiProvider,
|
||||
};
|
||||
|
||||
11
open-sse/config/providers/registry/auriko/index.ts
Normal file
11
open-sse/config/providers/registry/auriko/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const aurikoProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "auriko",
|
||||
alias: "auriko",
|
||||
baseUrl: "https://api.auriko.ai/v1/chat/completions",
|
||||
modelsUrl: "https://api.auriko.ai/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
12
open-sse/config/providers/registry/chat-oripe/index.ts
Normal file
12
open-sse/config/providers/registry/chat-oripe/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
// The upstream brand and hostname are ambiguous, so avoid unverified quota claims.
|
||||
export const chatOripeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "chat-oripe",
|
||||
alias: "chat-oripe",
|
||||
baseUrl: "https://api.oriper.com/v1/chat/completions",
|
||||
modelsUrl: "https://api.oriper.com/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
12
open-sse/config/providers/registry/chatanywhere/index.ts
Normal file
12
open-sse/config/providers/registry/chatanywhere/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
// International endpoint; audited free access is limited to non-commercial use.
|
||||
export const chatanywhereProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "chatanywhere",
|
||||
alias: "chatanywhere",
|
||||
baseUrl: "https://api.chatanywhere.org/v1/chat/completions",
|
||||
modelsUrl: "https://api.chatanywhere.org/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
20
open-sse/config/providers/registry/cloudcode-one/index.ts
Normal file
20
open-sse/config/providers/registry/cloudcode-one/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* CloudCode.ONE - OpenAI-compatible API with published free model aliases.
|
||||
*
|
||||
* The Anthropic-compatible endpoint is documented separately; this registry
|
||||
* covers the OpenAI-compatible API surface audited for this migration.
|
||||
*/
|
||||
export const cloudcodeOneProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "cloudcode-one",
|
||||
alias: "cloudcode-one",
|
||||
baseUrl: "https://api.cloudcode.one/v1/chat/completions",
|
||||
modelsUrl: "https://api.cloudcode.one/v1/models",
|
||||
models: [
|
||||
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "glm-4.6v-flash", name: "GLM 4.6V Flash" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
});
|
||||
13
open-sse/config/providers/registry/deepai/index.ts
Normal file
13
open-sse/config/providers/registry/deepai/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { RegistryEntry } from "../shared";
|
||||
|
||||
export const deepaiProvider: RegistryEntry = {
|
||||
id: "deepai",
|
||||
alias: "deepai",
|
||||
format: "custom",
|
||||
baseUrl: "https://api.deepai.org",
|
||||
authType: "apikey",
|
||||
authHeader: "api-key",
|
||||
models: [
|
||||
{ id: "text2img", name: "Text to Image" },
|
||||
],
|
||||
};
|
||||
17
open-sse/config/providers/registry/dxnt/index.ts
Normal file
17
open-sse/config/providers/registry/dxnt/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* DXNT - OpenAI-compatible API with a free account quota.
|
||||
*
|
||||
* Models are discovered from the provider's authenticated catalog rather than
|
||||
* copied into a static list, so account-specific availability remains intact.
|
||||
*/
|
||||
export const dxntProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "dxnt",
|
||||
alias: "dxnt",
|
||||
baseUrl: "https://www.dxnt.com/v1/chat/completions",
|
||||
modelsUrl: "https://www.dxnt.com/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/free-ai/index.ts
Normal file
11
open-sse/config/providers/registry/free-ai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const freeAiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "free-ai",
|
||||
alias: "free-ai",
|
||||
baseUrl: "https://api.free.ai/v1/chat/",
|
||||
modelsUrl: "https://api.free.ai/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/freeinference/index.ts
Normal file
11
open-sse/config/providers/registry/freeinference/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const freeinferenceProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "freeinference",
|
||||
alias: "freeinference",
|
||||
baseUrl: "https://freeinference.org/v1/chat/completions",
|
||||
modelsUrl: "https://freeinference.org/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/helyxai/index.ts
Normal file
11
open-sse/config/providers/registry/helyxai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const helyxaiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "helyxai",
|
||||
alias: "helyxai",
|
||||
baseUrl: "https://helyxai.space/v1/chat/completions",
|
||||
modelsUrl: "https://helyxai.space/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/literouter/index.ts
Normal file
11
open-sse/config/providers/registry/literouter/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const literouterProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "literouter",
|
||||
alias: "literouter",
|
||||
baseUrl: "https://api.literouter.com/v1/chat/completions",
|
||||
modelsUrl: "https://api.literouter.com/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/meganova-ai/index.ts
Normal file
11
open-sse/config/providers/registry/meganova-ai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const meganovaAiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "meganova-ai",
|
||||
alias: "meganova-ai",
|
||||
baseUrl: "https://api.meganova.ai/v1/chat/completions",
|
||||
modelsUrl: "https://api.meganova.ai/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/mixlayer/index.ts
Normal file
11
open-sse/config/providers/registry/mixlayer/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const mixlayerProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "mixlayer",
|
||||
alias: "mixlayer",
|
||||
baseUrl: "https://models.mixlayer.ai/v1/chat/completions",
|
||||
modelsUrl: "https://models.mixlayer.ai/v1/models",
|
||||
models: [{ id: "qwen/qwen3.5-4b-free", name: "Qwen 3.5 4B (free)" }],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/mnn-ai/index.ts
Normal file
11
open-sse/config/providers/registry/mnn-ai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const mnnAiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "mnn-ai",
|
||||
alias: "mnn-ai",
|
||||
baseUrl: "https://api.mnnai.ru/v1/chat/completions",
|
||||
modelsUrl: "https://api.mnnai.ru/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
106
open-sse/config/providers/registry/muse-code/index.ts
Normal file
106
open-sse/config/providers/registry/muse-code/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Muse Code CLI — Meta's agentic coding tool.
|
||||
*
|
||||
* Wire format: OpenAI Responses API (POST /responses).
|
||||
* Auth: Bearer token from META_API_KEY env var.
|
||||
* Reasoning efforts: xhigh/ultra -> high (handled generically).
|
||||
*
|
||||
* @see https://github.com/joymadhu49/muse-openrouter-shim
|
||||
*/
|
||||
export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "muse-code",
|
||||
alias: "mc",
|
||||
passthroughModels: true,
|
||||
defaultContextLength: 200000,
|
||||
models: [
|
||||
{
|
||||
id: "llama-4-maverick",
|
||||
name: "Llama 4 Maverick",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs", "logitBias"],
|
||||
},
|
||||
{
|
||||
id: "llama-4-scout",
|
||||
name: "Llama 4 Scout",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs", "logitBias"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-405b",
|
||||
name: "Llama 3.1 405B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-70b",
|
||||
name: "Llama 3.1 70B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-8b",
|
||||
name: "Llama 3.1 8B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-90b-vision",
|
||||
name: "Llama 3.2 90B Vision",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-11b-vision",
|
||||
name: "Llama 3.2 11B Vision",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
],
|
||||
});
|
||||
12
open-sse/config/providers/registry/naga-ai/index.ts
Normal file
12
open-sse/config/providers/registry/naga-ai/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
// Free access terms may permit data collection or training use; discover models dynamically.
|
||||
export const nagaAiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "naga-ai",
|
||||
alias: "naga-ai",
|
||||
baseUrl: "https://api.naga.ac/v1/chat/completions",
|
||||
modelsUrl: "https://api.naga.ac/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/ofoxai/index.ts
Normal file
11
open-sse/config/providers/registry/ofoxai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const ofoxaiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "ofoxai",
|
||||
alias: "ofoxai",
|
||||
baseUrl: "https://api.ofox.ai/v1/chat/completions",
|
||||
modelsUrl: "https://api.ofox.ai/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/poixe-ai/index.ts
Normal file
11
open-sse/config/providers/registry/poixe-ai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const poixeAiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "poixe-ai",
|
||||
alias: "poixe-ai",
|
||||
baseUrl: "https://api.poixe.com/v1/chat/completions",
|
||||
modelsUrl: "https://api.poixe.com/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/poolside/index.ts
Normal file
11
open-sse/config/providers/registry/poolside/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const poolsideProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "poolside",
|
||||
alias: "poolside",
|
||||
baseUrl: "https://inference.poolside.ai/v1/chat/completions",
|
||||
modelsUrl: "https://inference.poolside.ai/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/speka/index.ts
Normal file
11
open-sse/config/providers/registry/speka/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const spekaProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "speka",
|
||||
alias: "speka",
|
||||
baseUrl: "https://speka.me/v1/chat/completions",
|
||||
modelsUrl: "https://speka.me/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/tokenreply/index.ts
Normal file
11
open-sse/config/providers/registry/tokenreply/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const tokenreplyProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "tokenreply",
|
||||
alias: "tokenreply",
|
||||
baseUrl: "https://api.tokenreply.com/v1/chat/completions",
|
||||
modelsUrl: "https://api.tokenreply.com/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const unorouterProvider: RegistryEntry = {
|
||||
export const unorouterProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "unorouter",
|
||||
alias: "unorouter",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.unorouter.ai/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
defaultContextLength: 128000,
|
||||
models: [{ id: "auto", name: "Auto (Best Available)" }],
|
||||
baseUrl: "https://api.unorouter.com/v1/chat/completions",
|
||||
modelsUrl: "https://api.unorouter.com/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
};
|
||||
});
|
||||
|
||||
17
open-sse/config/providers/registry/yolo-auto/index.ts
Normal file
17
open-sse/config/providers/registry/yolo-auto/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Yolo-Auto - OpenAI-compatible API with a request-limited free tier.
|
||||
*
|
||||
* The catalog is kept intentionally small: the documented free-tier model is
|
||||
* seeded while passthrough discovery allows the service to publish updates.
|
||||
*/
|
||||
export const yoloAutoProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "yolo-auto",
|
||||
alias: "yolo-auto",
|
||||
baseUrl: "https://yolo-auto.com/v1/chat/completions",
|
||||
modelsUrl: "https://yolo-auto.com/v1/models",
|
||||
models: [{ id: "qwen3.6-35b-a3b", name: "Qwen 3.6 35B A3B" }],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/zerolimitai/index.ts
Normal file
11
open-sse/config/providers/registry/zerolimitai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const zerolimitaiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "zerolimitai",
|
||||
alias: "zerolimitai",
|
||||
baseUrl: "https://www.zerolimitai.com/api/v1/chat/completions",
|
||||
modelsUrl: "https://www.zerolimitai.com/api/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
11
open-sse/config/providers/registry/zylo-api/index.ts
Normal file
11
open-sse/config/providers/registry/zylo-api/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const zyloApiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "zylo-api",
|
||||
alias: "zylo",
|
||||
baseUrl: "https://api.zyloai.net/v1/chat/completions",
|
||||
modelsUrl: "https://api.zyloai.net/v1/models",
|
||||
models: [],
|
||||
passthroughModels: true,
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export interface RegistryModel {
|
||||
supportsReasoning?: boolean;
|
||||
supportedThinkingEfforts?: readonly string[];
|
||||
supportsVision?: boolean;
|
||||
supportsAudio?: boolean;
|
||||
supportsXHighEffort?: boolean;
|
||||
maxOutputTokens?: number;
|
||||
targetFormat?: string;
|
||||
|
||||
@@ -1037,6 +1037,13 @@ export async function handleChatCore({
|
||||
|
||||
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
||||
|
||||
// Preserve original body for cache signature — the body variable is mutated
|
||||
// multiple times below (sanitization, memory/skills injection) before the
|
||||
// cache store path runs at Phase 9.1 (non-streaming) / Phase 9.2 (streaming).
|
||||
// Without this snapshot, the write-time signature differs from the read-time
|
||||
// one, producing 0% hit rate. (#cache-signature-asymmetry)
|
||||
const bodyForCacheWrite = body;
|
||||
|
||||
// ── Phase 9.1: Semantic cache check (temp=0, any streaming mode) ──
|
||||
const cacheHit = await checkSemanticCache({
|
||||
semanticCacheEnabled,
|
||||
@@ -2976,6 +2983,8 @@ export async function handleChatCore({
|
||||
const okStatus = res.response.status >= 200 && res.response.status < 300;
|
||||
let streamRecoveryEnabled = false;
|
||||
let continueMidStreamEnabled = false;
|
||||
let throughputWatchdog =
|
||||
resolveResilienceSettings(null).streamRecovery.throughputWatchdog;
|
||||
if (okStatus) {
|
||||
try {
|
||||
// Reuse the request-consolidated settings read (see line ~2076) — no
|
||||
@@ -2990,6 +2999,7 @@ export async function handleChatCore({
|
||||
const goalOverride = !operatorExplicit && agentGoalPolicy.streamRecoveryEnabled;
|
||||
streamRecoveryEnabled = sr.enabled || goalOverride;
|
||||
continueMidStreamEnabled = sr.continueMidStream === true;
|
||||
throughputWatchdog = sr.throughputWatchdog;
|
||||
if (goalOverride && !sr.enabled) {
|
||||
log?.info?.(
|
||||
"AGENT_GOAL",
|
||||
@@ -2999,11 +3009,13 @@ export async function handleChatCore({
|
||||
} catch {
|
||||
streamRecoveryEnabled = false;
|
||||
continueMidStreamEnabled = false;
|
||||
throughputWatchdog =
|
||||
resolveResilienceSettings(null).streamRecovery.throughputWatchdog;
|
||||
}
|
||||
}
|
||||
|
||||
let clientBody: ReadableStream<Uint8Array>;
|
||||
if (streamRecoveryEnabled) {
|
||||
if (streamRecoveryEnabled || throughputWatchdog.enabled) {
|
||||
// Run the SAME upstream (same account/creds) with a given body and return
|
||||
// its 2xx stream, or null. Used both by the early-retry re-open (same body)
|
||||
// and the mid-stream continuation (assistant-prefilled body).
|
||||
@@ -3085,6 +3097,12 @@ export async function handleChatCore({
|
||||
"STREAM_RECOVERY",
|
||||
`mid-stream continuation attempt ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX}`
|
||||
),
|
||||
throughputWatchdog,
|
||||
onWatchdogAbort: () =>
|
||||
log?.warn?.(
|
||||
"STREAM_WATCHDOG",
|
||||
"active upstream stream stayed below the configured useful-output rate"
|
||||
),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -4557,7 +4575,7 @@ export async function handleChatCore({
|
||||
// ── Phase 9.1: Cache store (non-streaming, temp=0) ──
|
||||
storeSemanticCacheResponse({
|
||||
enabled: semanticCacheEnabled,
|
||||
body,
|
||||
body: bodyForCacheWrite,
|
||||
headers: clientRawRequest?.headers,
|
||||
translatedResponse,
|
||||
model,
|
||||
@@ -4911,7 +4929,7 @@ export async function handleChatCore({
|
||||
enabled: semanticCacheEnabled,
|
||||
streamStatus,
|
||||
streamResponseBody,
|
||||
body,
|
||||
body: bodyForCacheWrite,
|
||||
headers: clientRawRequest?.headers,
|
||||
model,
|
||||
apiKeyId: apiKeyInfo?.id ?? undefined,
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function handleFalAIImageEdit({
|
||||
const payload = await response.json();
|
||||
const normalizedBody =
|
||||
body.response_format === undefined ? { ...body, response_format: "b64_json" } : body;
|
||||
const imagesOut = await normalizeProviderImagePayload(payload, normalizedBody, log);
|
||||
const imagesOut = await normalizeProviderImagePayload(payload, normalizedBody, log, "b64_json");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model: editModel,
|
||||
|
||||
@@ -576,15 +576,21 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
|
||||
|
||||
/**
|
||||
* Normalize response ID to use chatcmpl- prefix.
|
||||
* Preserves numeric/short custom ids as their string form rather than
|
||||
* regenerating them — a passthrough numeric id (e.g. `123`) must stay `"123"`
|
||||
* so streaming clients can correlate chunks (#3427/#5776). Only a genuinely
|
||||
* missing/empty id gets a fresh `chatcmpl-` token.
|
||||
*/
|
||||
function normalizeResponseId(id: unknown): string {
|
||||
if (!id || typeof id !== "string") {
|
||||
if (!id || (typeof id !== "string" && typeof id !== "number")) {
|
||||
return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`;
|
||||
}
|
||||
// Already correct format
|
||||
if (id.startsWith("chatcmpl-")) return id;
|
||||
// Keep custom IDs but don't break them
|
||||
return id;
|
||||
const str = String(id);
|
||||
if (str === "") {
|
||||
return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`;
|
||||
}
|
||||
// Already correct format, or a custom/numeric id — keep it.
|
||||
return str;
|
||||
}
|
||||
|
||||
function normalizeResponsesId(id: unknown): string {
|
||||
|
||||
@@ -523,6 +523,19 @@ export function translateNonStreamingResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// #9971: a content-less-but-valid Claude body (thinking / redacted_thinking
|
||||
// / tool_use-only, or a truncated extended-thinking-only stream) has blocks
|
||||
// but no final text. Surfacing it here helps correlate a live VPS capture
|
||||
// with detectMalformedNonStream's clause; the content itself is valid output
|
||||
// (see detectMalformedNonStream), so this is observation, not a decision.
|
||||
if (textContent.length === 0 && process.env.DEBUG_CLAUDE_NONSTREAM === "true") {
|
||||
console.log(
|
||||
`[ClaudeNonStream] ${contentBlocks.length} content block(s), empty textContent ` +
|
||||
`(thinking=${thinkingContent.length}, toolCalls=${toolCalls.length}); ` +
|
||||
`content-less-but-valid body preserved (not empty_choices)`
|
||||
);
|
||||
}
|
||||
|
||||
const message: JsonRecord = { role: "assistant" };
|
||||
if (textContent) {
|
||||
message.content = textContent;
|
||||
|
||||
@@ -336,8 +336,10 @@ function buildExaRequest(
|
||||
query: params.query,
|
||||
numResults: params.maxResults,
|
||||
type: "auto",
|
||||
text: true,
|
||||
highlights: true,
|
||||
contents: {
|
||||
text: true,
|
||||
highlights: true,
|
||||
},
|
||||
};
|
||||
if (includes.length) body.includeDomains = includes;
|
||||
if (excludes.length) body.excludeDomains = excludes;
|
||||
|
||||
@@ -90,7 +90,10 @@ function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined {
|
||||
|
||||
function normalizeFalVideoResponse(payload: unknown) {
|
||||
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
||||
const video = record.video && typeof record.video === "object" ? record.video : null;
|
||||
const video =
|
||||
record.video && typeof record.video === "object"
|
||||
? (record.video as Record<string, unknown>)
|
||||
: null;
|
||||
const url = video && typeof video.url === "string" ? video.url.trim() : "";
|
||||
|
||||
if (!url) {
|
||||
|
||||
@@ -182,6 +182,16 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) {
|
||||
return virtualCombo;
|
||||
}
|
||||
|
||||
// Advertised `auto/*` ids whose template maps to no variant (auto/chat,
|
||||
// auto/best-chat, auto/pro-chat) still materialize via the default
|
||||
// (unconstrained) virtual combo rather than throwing "Unknown built-in".
|
||||
if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, modelStr)) {
|
||||
const virtualCombo = await createVirtualAutoCombo(undefined);
|
||||
virtualCombo.name = modelStr;
|
||||
virtualCombo.id = modelStr;
|
||||
return virtualCombo;
|
||||
}
|
||||
|
||||
// #4235 Phase B: `auto/<category>[:<tier>]` (e.g. auto/coding:fast, auto/vision).
|
||||
const parsed = parseAutoSuffix(suffix);
|
||||
if (parsed.valid) {
|
||||
|
||||
199
open-sse/services/imageCombo.ts
Normal file
199
open-sse/services/imageCombo.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Image Combo Strategy Execution
|
||||
*
|
||||
* Executes a full Combo strategy for image generation requests. Expands combo
|
||||
* targets via resolveComboTargets(), filters to images-capable targets, runs
|
||||
* each target via handleImageGeneration() using a priority strategy, provides
|
||||
* per-credential resolution, and returns the first success or last failure.
|
||||
*
|
||||
* #9239
|
||||
*/
|
||||
import { getComboByName, getCombos } from "@/lib/db/combos";
|
||||
import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts";
|
||||
import { getImageModelEntry, parseImageModel } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import {
|
||||
getProviderCredentialsWithQuotaPreflight,
|
||||
clearRecoveredProviderState,
|
||||
} from "@/sse/services/auth";
|
||||
import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit";
|
||||
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { calculateModalCost } from "@/lib/usage/costCalculator";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import * as logger from "@/sse/utils/logger";
|
||||
|
||||
/**
|
||||
* Execute a full combo strategy for an image generation request.
|
||||
*
|
||||
* 1. Resolve combo targets via resolveComboTargets.
|
||||
* 2. Filter to images-capable targets (those with an entry in the image registry).
|
||||
* 3. Iterate targets in priority order; for each target, resolve credentials and
|
||||
* call handleImageGeneration. Return the first success or the last failure.
|
||||
* 4. Attach combo name, selected target, and fallback count to response headers.
|
||||
*/
|
||||
export async function executeImageCombo(
|
||||
comboName: string,
|
||||
body: Record<string, unknown>,
|
||||
auth: {
|
||||
request: Request;
|
||||
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
|
||||
},
|
||||
startTime: number,
|
||||
log: typeof logger
|
||||
): Promise<Response> {
|
||||
// 1. Resolve combo targets
|
||||
const combo = await getComboByName(comboName);
|
||||
if (!combo) {
|
||||
// Model name is not a combo; the caller should handle this as a direct model
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`Combo not found: ${comboName}`
|
||||
);
|
||||
}
|
||||
|
||||
const allCombos = await getCombos();
|
||||
const targets = resolveComboTargets(combo as never, allCombos as never);
|
||||
if (!targets || targets.length === 0) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`Combo "${comboName}" has no usable targets`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Filter to images-capable targets
|
||||
const imageTargets = targets.filter((t) => {
|
||||
if (!t.modelStr) return false;
|
||||
const entry = getImageModelEntry(t.modelStr);
|
||||
return entry !== null;
|
||||
});
|
||||
|
||||
if (imageTargets.length === 0) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No images-capable targets in combo "${comboName}"`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Iterate targets in priority order (first healthy target wins)
|
||||
let lastError: { status: number; error: string } | null = null;
|
||||
let successResult: { data: unknown; provider: string; model: string } | null = null;
|
||||
let fallbackCount = 0;
|
||||
let selectedProvider = "";
|
||||
let selectedModel = "";
|
||||
|
||||
for (const target of imageTargets) {
|
||||
const { provider: targetProvider, model: targetModel } = parseImageModel(target.modelStr);
|
||||
if (!targetProvider) {
|
||||
lastError = { status: 400, error: `Invalid image model: ${target.modelStr}` };
|
||||
fallbackCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve provider credentials
|
||||
let credentials = null;
|
||||
try {
|
||||
credentials = await getProviderCredentialsWithQuotaPreflight(targetProvider);
|
||||
} catch {
|
||||
// DB unavailable — skip this target
|
||||
lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` };
|
||||
fallbackCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!credentials) {
|
||||
lastError = { status: 400, error: `No credentials for image provider: ${targetProvider}` };
|
||||
fallbackCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAllRateLimitedCredentials(credentials)) {
|
||||
lastError = {
|
||||
status: 429,
|
||||
error: `[${targetProvider}] All accounts rate limited`,
|
||||
};
|
||||
fallbackCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute image generation for this target
|
||||
const result = await handleImageGeneration({
|
||||
body: { ...body, model: target.modelStr },
|
||||
credentials,
|
||||
log,
|
||||
signal: auth.request?.signal || null,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
selectedProvider = targetProvider;
|
||||
selectedModel = target.modelStr;
|
||||
successResult = {
|
||||
data: result.data,
|
||||
provider: targetProvider,
|
||||
model: target.modelStr,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Classify the failure
|
||||
const status = result.status || 500;
|
||||
const error = typeof result.error === "string" ? result.error : "Image generation failed";
|
||||
|
||||
// Terminal failures (400 bad model, 403 banned, etc.) — stop iterating
|
||||
// Non-terminal failures (429, 5xx) — try next target
|
||||
if (status === 400 || status === 403 || status === 401) {
|
||||
return errorResponse(
|
||||
status,
|
||||
`[${targetProvider}] ${error}`
|
||||
);
|
||||
}
|
||||
|
||||
lastError = { status, error: `[${targetProvider}] ${error}` };
|
||||
fallbackCount += 1;
|
||||
}
|
||||
|
||||
// 4. Build response
|
||||
if (successResult) {
|
||||
const n = Math.max(
|
||||
Number(body.n) || 1,
|
||||
(
|
||||
successResult.data as { data?: { data?: unknown[] } }
|
||||
).data?.data?.length || 0
|
||||
);
|
||||
const costUsd = await calculateModalCost(
|
||||
"image",
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
{ n }
|
||||
);
|
||||
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
attachOmniRouteMetaHeaders(headers, {
|
||||
provider: selectedProvider,
|
||||
model: selectedModel,
|
||||
costUsd,
|
||||
latencyMs: Date.now() - startTime,
|
||||
requestId: generateRequestId(),
|
||||
strategy: "priority",
|
||||
fallbackAttempts: fallbackCount,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify((successResult.data as { data: unknown }).data),
|
||||
{ status: 200, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// All targets failed — return the last error
|
||||
const errorPayload = toJsonErrorPayload(
|
||||
lastError?.error || "All combo targets failed",
|
||||
"Image combo targets all failed"
|
||||
);
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: lastError?.status || 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
* without real sockets. The ReadableStream wiring lives in `createRecoverableStream`.
|
||||
*/
|
||||
import { STREAM_RECOVERY } from "../config/constants.ts";
|
||||
import {
|
||||
createThroughputWatchdog,
|
||||
ThroughputWatchdogError,
|
||||
type ThroughputWatchdogOptions,
|
||||
} from "./throughputWatchdog.ts";
|
||||
|
||||
export { ThroughputWatchdogError } from "./throughputWatchdog.ts";
|
||||
|
||||
/** Raised internally when an upstream stream ends without a terminal SSE marker. */
|
||||
export class TruncatedStreamError extends Error {
|
||||
@@ -123,7 +130,9 @@ const RETRYABLE_ERROR_NAMES = new Set(["TimeoutError", "BodyTimeoutError"]);
|
||||
* the executor retry/failover loop, not here.
|
||||
*/
|
||||
export function isRetryableStreamError(error: unknown): boolean {
|
||||
if (error instanceof TruncatedStreamError) return true;
|
||||
if (error instanceof TruncatedStreamError || error instanceof ThroughputWatchdogError) {
|
||||
return true;
|
||||
}
|
||||
if (!error || typeof error !== "object") return false;
|
||||
|
||||
const name = (error as { name?: unknown }).name;
|
||||
@@ -289,6 +298,10 @@ export interface RecoverableStreamOptions {
|
||||
maxContinuations?: number;
|
||||
/** Observability hook fired on each continuation attempt. */
|
||||
onContinue?: (attempt: number, assistantSoFar: string) => void;
|
||||
/** Opt-in active-stream output-quality watchdog. Disabled when omitted. */
|
||||
throughputWatchdog?: ThroughputWatchdogOptions;
|
||||
/** Sanitized observability hook fired before the active attempt is aborted. */
|
||||
onWatchdogAbort?: (error: ThroughputWatchdogError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,6 +325,7 @@ export function createRecoverableStream(
|
||||
let retries = 0;
|
||||
let finalized = false;
|
||||
let cancelled = false;
|
||||
let throughputWatchdog = createThroughputWatchdog(options.throughputWatchdog);
|
||||
|
||||
const runFinalize = () => {
|
||||
if (finalized) return;
|
||||
@@ -342,6 +356,7 @@ export function createRecoverableStream(
|
||||
if (!next) return false;
|
||||
reader = next.getReader();
|
||||
holdback.discard(); // reuse the (still-uncommitted) buffer for the new attempt
|
||||
throughputWatchdog = createThroughputWatchdog(options.throughputWatchdog);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -519,6 +534,32 @@ export function createRecoverableStream(
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
const watchdogDecision = throughputWatchdog.observe(value);
|
||||
if (watchdogDecision.abort) {
|
||||
const error = new ThroughputWatchdogError();
|
||||
options.onWatchdogAbort?.(error);
|
||||
if (!holdback.committed && (await tryReopen(error))) continue;
|
||||
if (holdback.committed) {
|
||||
try {
|
||||
await reader.cancel(error);
|
||||
} catch {
|
||||
// The active attempt may have closed while the watchdog was deciding.
|
||||
}
|
||||
if (await tryContinue(controller)) {
|
||||
runFinalize();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
runFinalize();
|
||||
controller.error(error);
|
||||
return;
|
||||
}
|
||||
flushHeld(controller);
|
||||
runFinalize();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (holdback.committed) {
|
||||
emit(controller, value);
|
||||
return;
|
||||
|
||||
175
open-sse/services/throughputWatchdog.ts
Normal file
175
open-sse/services/throughputWatchdog.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Deterministic quality watchdog for active SSE streams.
|
||||
*
|
||||
* Unlike the idle timeout, this only makes a decision after a warm-up period and
|
||||
* a complete rolling window. Heartbeats/metadata and tool/reasoning phases do not
|
||||
* count as assistant output (and tool/reasoning phases suspend judgement).
|
||||
*/
|
||||
|
||||
export interface ThroughputWatchdogOptions {
|
||||
enabled?: boolean;
|
||||
warmupMs?: number;
|
||||
windowMs?: number;
|
||||
minUsefulBytesPerSecond?: number;
|
||||
minUsefulBytes?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface ThroughputWatchdogDecision {
|
||||
abort: boolean;
|
||||
reason?: "throughput_too_low";
|
||||
usefulBytes: number;
|
||||
rateBytesPerSecond: number;
|
||||
protectedPhase: boolean;
|
||||
}
|
||||
|
||||
export class ThroughputWatchdogError extends Error {
|
||||
readonly code = "STREAM_THROUGHPUT_TOO_LOW";
|
||||
|
||||
constructor(message = "Upstream stream throughput remained below the configured minimum") {
|
||||
super(message);
|
||||
this.name = "ThroughputWatchdogError";
|
||||
}
|
||||
}
|
||||
|
||||
type ParsedEvent = { usefulBytes: number; protectedPhase: boolean };
|
||||
|
||||
function parseEvent(event: string): ParsedEvent {
|
||||
const lines = event.split(/\r?\n/);
|
||||
const eventName = lines
|
||||
.find((line) => /^event:\s*/i.test(line))
|
||||
?.replace(/^event:\s*/i, "")
|
||||
.trim();
|
||||
const data = lines
|
||||
.filter((line) => /^data:\s*/i.test(line))
|
||||
.map((line) => line.replace(/^data:\s*/i, "").trim())
|
||||
.join("\n");
|
||||
if (!data || data === "[DONE]") return { usefulBytes: 0, protectedPhase: false };
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(data);
|
||||
} catch {
|
||||
return { usefulBytes: 0, protectedPhase: false };
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const type = typeof record.type === "string" ? record.type : eventName;
|
||||
if (type && /(reasoning|thinking|tool|function_call)/i.test(type)) {
|
||||
return { usefulBytes: 0, protectedPhase: true };
|
||||
}
|
||||
|
||||
const choices = Array.isArray(record.choices) ? record.choices : [];
|
||||
let useful = "";
|
||||
let protectedPhase = false;
|
||||
for (const choice of choices) {
|
||||
const delta = (choice as Record<string, unknown>).delta;
|
||||
if (!delta || typeof delta !== "object") continue;
|
||||
const deltaRecord = delta as Record<string, unknown>;
|
||||
if (Array.isArray(deltaRecord.tool_calls) || deltaRecord.function_call) {
|
||||
protectedPhase = true;
|
||||
}
|
||||
for (const key of ["content", "text"]) {
|
||||
if (typeof deltaRecord[key] === "string") useful += deltaRecord[key] as string;
|
||||
}
|
||||
if (
|
||||
typeof deltaRecord.reasoning_content === "string" ||
|
||||
typeof deltaRecord.reasoning === "string"
|
||||
) {
|
||||
protectedPhase = true;
|
||||
}
|
||||
}
|
||||
|
||||
const outputText = typeof record.delta === "string" ? record.delta : undefined;
|
||||
if (outputText) useful += outputText;
|
||||
const nestedDelta = record.delta;
|
||||
if (nestedDelta && typeof nestedDelta === "object") {
|
||||
const nested = nestedDelta as Record<string, unknown>;
|
||||
const nestedType = typeof nested.type === "string" ? nested.type : "";
|
||||
if (/(reasoning|thinking|tool|function_call)/i.test(nestedType)) {
|
||||
protectedPhase = true;
|
||||
}
|
||||
if (typeof nested.text === "string") useful += nested.text;
|
||||
}
|
||||
const contentBlock = record.content_block;
|
||||
if (contentBlock && typeof contentBlock === "object") {
|
||||
const blockType = (contentBlock as Record<string, unknown>).type;
|
||||
if (typeof blockType === "string" && /(reasoning|thinking|tool_use)/i.test(blockType)) {
|
||||
protectedPhase = true;
|
||||
}
|
||||
}
|
||||
if (protectedPhase) useful = "";
|
||||
return {
|
||||
usefulBytes: useful ? new TextEncoder().encode(useful).byteLength : 0,
|
||||
protectedPhase,
|
||||
};
|
||||
}
|
||||
|
||||
export class ThroughputWatchdog {
|
||||
private readonly enabled: boolean;
|
||||
private readonly warmupMs: number;
|
||||
private readonly windowMs: number;
|
||||
private readonly minimumRate: number;
|
||||
private readonly minimumBytes: number;
|
||||
private readonly now: () => number;
|
||||
private startedAt: number | null = null;
|
||||
private buffer = "";
|
||||
private readonly decoder = new TextDecoder();
|
||||
private samples: Array<{ at: number; bytes: number }> = [];
|
||||
private protectedPhase = false;
|
||||
|
||||
constructor(options: ThroughputWatchdogOptions = {}) {
|
||||
this.enabled = options.enabled === true;
|
||||
this.warmupMs = Math.max(0, Math.floor(options.warmupMs ?? 30_000));
|
||||
this.windowMs = Math.max(1, Math.floor(options.windowMs ?? 30_000));
|
||||
this.minimumRate = Math.max(0, options.minUsefulBytesPerSecond ?? 1);
|
||||
this.minimumBytes = Math.max(1, Math.floor(options.minUsefulBytes ?? 1));
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
observe(chunk: Uint8Array | string): ThroughputWatchdogDecision {
|
||||
const at = this.now();
|
||||
if (this.startedAt === null) this.startedAt = at;
|
||||
if (!this.enabled) return this.decision(false, 0);
|
||||
this.buffer += typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true });
|
||||
const events = this.buffer.split(/\r?\n\r?\n/);
|
||||
this.buffer = events.pop() ?? "";
|
||||
let useful = 0;
|
||||
for (const event of events) {
|
||||
const parsed = parseEvent(event);
|
||||
useful += parsed.usefulBytes;
|
||||
if (parsed.protectedPhase) this.protectedPhase = true;
|
||||
if (parsed.usefulBytes > 0) this.protectedPhase = false;
|
||||
}
|
||||
if (useful > 0) this.samples.push({ at, bytes: useful });
|
||||
const cutoff = at - this.windowMs;
|
||||
this.samples = this.samples.filter((sample) => sample.at >= cutoff);
|
||||
const windowBytes = this.samples.reduce((sum, sample) => sum + sample.bytes, 0);
|
||||
const elapsed = at - (this.startedAt ?? at);
|
||||
const rate = windowBytes / Math.max(1, this.windowMs / 1000);
|
||||
const ready = elapsed >= this.warmupMs + this.windowMs;
|
||||
const measurable = windowBytes === 0 || windowBytes >= this.minimumBytes;
|
||||
const abort = ready && !this.protectedPhase && measurable && rate < this.minimumRate;
|
||||
return this.decision(abort, windowBytes, rate);
|
||||
}
|
||||
|
||||
private decision(
|
||||
abort: boolean,
|
||||
usefulBytes: number,
|
||||
rateBytesPerSecond = 0
|
||||
): ThroughputWatchdogDecision {
|
||||
return {
|
||||
abort,
|
||||
reason: abort ? "throughput_too_low" : undefined,
|
||||
usefulBytes,
|
||||
rateBytesPerSecond,
|
||||
protectedPhase: this.protectedPhase,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createThroughputWatchdog(
|
||||
options: ThroughputWatchdogOptions = {}
|
||||
): ThroughputWatchdog {
|
||||
return new ThroughputWatchdog(options);
|
||||
}
|
||||
@@ -211,7 +211,8 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
// `choices`. Without this branch every non-streaming Claude response (incl. plain text)
|
||||
// falls through to `empty_choices` → a false 502 (#5108, regression from #4942).
|
||||
if (body.type === "message" && Array.isArray(body.content)) {
|
||||
const hasOutput = (body.content as unknown[]).some((block) => {
|
||||
const content = body.content as unknown[];
|
||||
const hasOutput = content.some((block) => {
|
||||
// A malformed/partial provider response could carry a null (or non-object)
|
||||
// entry in `content`; guard before type-asserting so the detector never
|
||||
// throws on `null.type` (that would crash the whole non-stream classifier).
|
||||
@@ -229,16 +230,18 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Extended-thinking block: valid when it carries visible thinking text OR a
|
||||
// non-empty `signature` (cryptographic proof the thinking step ran, so it is a
|
||||
// valid completion even when the thinking text is "").
|
||||
if (
|
||||
b.type === "thinking" &&
|
||||
((typeof b.thinking === "string" && (b.thinking as string).length > 0) ||
|
||||
(typeof b.signature === "string" && (b.signature as string).length > 0))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Extended-thinking block: valid structural output whenever the model
|
||||
// entered the thinking phase, even with no visible thinking text and no
|
||||
// `signature`. #9971: the Claude Code OAuth upstream can truncate long
|
||||
// large-input+large-output generations around the ~3-min turn boundary,
|
||||
// leaving a content-less thinking-only body whose final text (and, when
|
||||
// cut mid-think, its signature) never arrived. The block's very presence
|
||||
// is proof the turn produced output upstream, so it is a valid
|
||||
// in-progress completion, NOT a genuinely empty terminal response.
|
||||
// (Previously only a non-empty `thinking` text OR `signature` counted —
|
||||
// #5108 — which misclassified these content-less bodies as empty_choices
|
||||
// → 502.)
|
||||
if (b.type === "thinking") return true;
|
||||
// Redacted thinking and tool_use are valid structural output.
|
||||
if (b.type === "redacted_thinking") return true;
|
||||
if (b.type === "tool_use" && typeof b.id === "string" && (b.id as string).length > 0) {
|
||||
@@ -246,7 +249,27 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return hasOutput ? null : "empty_choices";
|
||||
if (hasOutput) return null;
|
||||
|
||||
// No per-block output. Two distinct situations remain:
|
||||
// 1) A block IS present but invalid (e.g. text:"", a lone "(empty response)"
|
||||
// sentinel, or only null entries) — the model genuinely produced no
|
||||
// usable output. That is a MALFORMED-200 empty_choices regardless of
|
||||
// stop_reason (parity with the OpenAI content:"" path).
|
||||
// 2) `content: []` — no block at all. Only a genuinely *terminal* response
|
||||
// (a final stop_reason with no output) is empty_choices. #9971: a
|
||||
// truncated / non-terminal body — the Claude Code OAuth upstream cutting
|
||||
// a long generation mid-turn, or a content-less thinking-only stream
|
||||
// that never emitted a terminal event — carries content:[] with no
|
||||
// reachable end, so flagging it would turn an upstream truncation into a
|
||||
// false 502. Require a terminal stop_reason before calling a block-less
|
||||
// response genuinely empty.
|
||||
if (content.length === 0) {
|
||||
const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : "";
|
||||
const isTerminal = stopReason.length > 0;
|
||||
return isTerminal ? "empty_choices" : null;
|
||||
}
|
||||
return "empty_choices";
|
||||
}
|
||||
|
||||
// ── Chat Completions shape ──
|
||||
|
||||
@@ -1096,9 +1096,12 @@ async function patchedFetch(
|
||||
continue;
|
||||
}
|
||||
tagProxyUnreachable(error);
|
||||
const originalMsg = error instanceof Error ? error.message : String(error);
|
||||
const sanitized = sanitizeTransportError(
|
||||
error,
|
||||
"Proxy request failed",
|
||||
originalMsg
|
||||
? `Proxy request failed: ${originalMsg}`
|
||||
: "Proxy request failed",
|
||||
"PROXY_REQUEST_FAILED"
|
||||
);
|
||||
console.error(
|
||||
|
||||
@@ -1640,7 +1640,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
(parsed.choices.length === 1 &&
|
||||
parsed.choices[0]?.delta &&
|
||||
typeof parsed.choices[0].delta === "object" &&
|
||||
Object.keys(parsed.choices[0].delta).length === 0))
|
||||
Object.keys(parsed.choices[0].delta).length === 0 &&
|
||||
!parsed.choices[0]?.finish_reason))
|
||||
) {
|
||||
const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage;
|
||||
if (hasValidUsage(emptyChoicesUsage)) {
|
||||
@@ -1850,6 +1851,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
passthroughSawFinishReason = true;
|
||||
}
|
||||
|
||||
|
||||
if (isFinishChunk && passthroughHasToolCalls) {
|
||||
toolFinishTime = now;
|
||||
try {
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"check:known-symbols": "bun scripts/check/check-known-symbols.ts",
|
||||
"check:route-guard-membership": "node --import tsx scripts/check/check-route-guard-membership.ts",
|
||||
"check:test-discovery": "node scripts/check/check-test-discovery.mjs",
|
||||
"check:forgotten-sibling-tests": "node scripts/check/check-forgotten-sibling-tests.mjs",
|
||||
"check:mutation-test-coverage": "node scripts/check/check-mutation-test-coverage.mjs --strict",
|
||||
"check:complexity": "node scripts/check/check-complexity.mjs",
|
||||
"check:dead-code": "node scripts/check/check-dead-code.mjs",
|
||||
|
||||
293
scripts/check/check-forgotten-sibling-tests.mjs
Normal file
293
scripts/check/check-forgotten-sibling-tests.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { globSync } from "tinyglobby";
|
||||
|
||||
import { resolveImport } from "../quality/build-test-impact-map.mjs";
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"];
|
||||
const SOURCE_GLOBS = [
|
||||
"src/**/*.{ts,tsx,mts,js,mjs}",
|
||||
"open-sse/**/*.{ts,tsx,mts,js,mjs}",
|
||||
"bin/**/*.{ts,tsx,mts,js,mjs}",
|
||||
];
|
||||
const IGNORE = [
|
||||
"**/__tests__/**",
|
||||
"**/*.test.*",
|
||||
"**/*.spec.*",
|
||||
"**/fixtures/**",
|
||||
"**/generated/**",
|
||||
];
|
||||
const STATIC_IMPORT_RE =
|
||||
/(?:import|export)[^'"()]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
const TEST_MASK_RE =
|
||||
/^\+.*(?:\b(?:it|test|describe)\.(?:skip|todo)\b|\b(?:xit|xtest|xdescribe)\s*\()/;
|
||||
const REFERENCE_RE = /^(?:#\d+|https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/\d+)$/;
|
||||
|
||||
function normalize(file) {
|
||||
return file.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isProduction(file) {
|
||||
return (
|
||||
SOURCE_ROOTS.some((root) => file.startsWith(root)) &&
|
||||
!IGNORE.some((pattern) => {
|
||||
const token = pattern.replaceAll("**/", "").replaceAll("/**", "").replaceAll("*", "");
|
||||
return token && file.includes(token);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isBarrel(file, code) {
|
||||
return /(?:^|\/)index\.[cm]?[jt]sx?$/.test(file) && /\bexport\s+(?:\*|\{)/.test(code);
|
||||
}
|
||||
|
||||
function importEdges(root) {
|
||||
const edges = [];
|
||||
const files = globSync(SOURCE_GLOBS, { cwd: root, absolute: true, ignore: IGNORE });
|
||||
for (const absolute of files) {
|
||||
const consumer = normalize(path.relative(root, absolute));
|
||||
const code = fs.readFileSync(absolute, "utf8");
|
||||
for (const match of code.matchAll(STATIC_IMPORT_RE)) {
|
||||
const resolved = resolveImport(match[1] || match[2], absolute, root);
|
||||
if (resolved) {
|
||||
edges.push({
|
||||
module: normalize(path.relative(root, resolved)),
|
||||
consumer,
|
||||
kind: isBarrel(consumer, code) ? "barrel" : "static",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const match of code.matchAll(DYNAMIC_IMPORT_RE)) {
|
||||
const resolved = resolveImport(match[1], absolute, root);
|
||||
if (resolved) {
|
||||
edges.push({
|
||||
module: normalize(path.relative(root, resolved)),
|
||||
consumer,
|
||||
kind: "dynamic-import",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges.sort((a, b) =>
|
||||
`${a.module}\0${a.consumer}\0${a.kind}`.localeCompare(`${b.module}\0${b.consumer}\0${b.kind}`)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateAllowlist(value) {
|
||||
const entries = Array.isArray(value) ? value : value?.entries;
|
||||
if (!Array.isArray(entries))
|
||||
throw new Error("forgotten-sibling allowlist must contain an entries array");
|
||||
return entries.map((entry, index) => {
|
||||
for (const field of ["consumer", "candidateTest", "rationale", "reference"]) {
|
||||
if (typeof entry?.[field] !== "string" || !entry[field].trim()) {
|
||||
throw new Error(`forgotten-sibling allowlist entry ${index} requires ${field}`);
|
||||
}
|
||||
}
|
||||
if (entry.rationale.trim().length < 20) {
|
||||
throw new Error(`forgotten-sibling allowlist entry ${index} rationale must be specific`);
|
||||
}
|
||||
if (!REFERENCE_RE.test(entry.reference.trim())) {
|
||||
throw new Error(
|
||||
`forgotten-sibling allowlist entry ${index} reference must be a GitHub issue or PR`
|
||||
);
|
||||
}
|
||||
return {
|
||||
consumer: normalize(entry.consumer.trim()),
|
||||
candidateTest: normalize(entry.candidateTest.trim()),
|
||||
rationale: entry.rationale.trim(),
|
||||
reference: entry.reference.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function analyzeForgottenSiblingTests({
|
||||
root = DEFAULT_ROOT,
|
||||
changedEntries,
|
||||
impactMap,
|
||||
allowlist,
|
||||
changedSymbolsByFile = {},
|
||||
addedTestLines = [],
|
||||
}) {
|
||||
const changed = new Map(changedEntries.map((entry) => [normalize(entry.file), entry.status]));
|
||||
const changedModules = [...changed.keys()].filter(isProduction).sort();
|
||||
const maskingAdded = addedTestLines.some((line) => TEST_MASK_RE.test(line));
|
||||
const allow = new Map(
|
||||
allowlist.map((entry) => [`${entry.consumer}\0${entry.candidateTest}`, entry])
|
||||
);
|
||||
const findings = [];
|
||||
const diagnostics = [];
|
||||
const suppressed = [];
|
||||
const maskingRisks = [];
|
||||
|
||||
for (const edge of importEdges(root)) {
|
||||
if (!changedModules.includes(edge.module)) continue;
|
||||
const tests = [...new Set(impactMap.sources?.[edge.consumer] || [])].sort();
|
||||
if (edge.kind !== "static") {
|
||||
diagnostics.push({
|
||||
changedModule: edge.module,
|
||||
consumer: edge.consumer,
|
||||
kind: edge.kind,
|
||||
message: `${edge.kind} resolution is advisory and never blocks`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const candidateTest of tests) {
|
||||
const status = changed.get(candidateTest);
|
||||
const masking = status === "D" || (status && maskingAdded);
|
||||
if (masking) {
|
||||
maskingRisks.push({
|
||||
changedModule: edge.module,
|
||||
consumer: edge.consumer,
|
||||
candidateTest,
|
||||
reason:
|
||||
status === "D"
|
||||
? "candidate sibling test was deleted"
|
||||
: "candidate sibling test adds skip/todo masking",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (status) continue;
|
||||
const finding = {
|
||||
changedModule: edge.module,
|
||||
changedSymbols: [...(changedSymbolsByFile[edge.module] || [])].sort(),
|
||||
consumer: edge.consumer,
|
||||
candidateTest,
|
||||
reason: "candidate sibling test is absent from the PR diff",
|
||||
};
|
||||
const exception = allow.get(`${edge.consumer}\0${candidateTest}`);
|
||||
if (exception) suppressed.push({ ...finding, exception });
|
||||
else findings.push(finding);
|
||||
}
|
||||
}
|
||||
return { mode: "advisory", findings, diagnostics, suppressed, maskingRisks };
|
||||
}
|
||||
|
||||
function arg(name, fallback = "") {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function git(root, args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8" });
|
||||
}
|
||||
|
||||
function changedEntries(root, base) {
|
||||
return git(root, ["diff", "--name-status", "--diff-filter=ACMRD", `${base}...HEAD`])
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, ...files] = line.split("\t");
|
||||
return { status: status[0], file: files.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
function changedSymbols(root, base, entries) {
|
||||
const result = {};
|
||||
const declaration =
|
||||
/^\+\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/;
|
||||
for (const entry of entries.filter(({ file }) => isProduction(file))) {
|
||||
const diff = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", entry.file]);
|
||||
result[entry.file] = [
|
||||
...new Set(
|
||||
diff
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(declaration)?.[1])
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function markdown(result, base) {
|
||||
const lines = [
|
||||
"## Forgotten sibling tests (advisory)",
|
||||
"",
|
||||
`Base: \`${base}\``,
|
||||
`Unallowlisted findings: ${result.findings.length}`,
|
||||
`Reviewed exceptions: ${result.suppressed.length}`,
|
||||
`Resolution diagnostics: ${result.diagnostics.length}`,
|
||||
`Masking/deletion risks (owned by blocking sibling gates): ${result.maskingRisks.length}`,
|
||||
"",
|
||||
];
|
||||
if (result.findings.length) {
|
||||
lines.push("### Candidate tests absent from this diff", "");
|
||||
for (const item of result.findings) {
|
||||
const symbol = item.changedSymbols.length ? ` (${item.changedSymbols.join(", ")})` : "";
|
||||
lines.push(
|
||||
`- \`${item.changedModule}\`${symbol} -> \`${item.consumer}\` -> \`${item.candidateTest}\``
|
||||
);
|
||||
}
|
||||
lines.push("", "> Report-only calibration: these findings do not fail the job.", "");
|
||||
}
|
||||
for (const [heading, items] of [
|
||||
["Resolution diagnostics", result.diagnostics],
|
||||
["Test masking/deletion risks", result.maskingRisks],
|
||||
]) {
|
||||
if (!items.length) continue;
|
||||
lines.push(`### ${heading}`, "");
|
||||
for (const item of items)
|
||||
lines.push(
|
||||
`- \`${item.changedModule}\` -> \`${item.consumer}\`${item.candidateTest ? ` -> \`${item.candidateTest}\`` : ""}: ${item.reason || item.message}`
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const root = DEFAULT_ROOT;
|
||||
const base = arg(
|
||||
"--base",
|
||||
process.env.GITHUB_BASE_SHA ||
|
||||
(process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "HEAD~1")
|
||||
);
|
||||
const mapPath = arg("--impact-map", path.join(root, "config/quality/test-impact-map.json"));
|
||||
const allowlistPath = arg(
|
||||
"--allowlist",
|
||||
path.join(root, "config/quality/forgotten-sibling-allowlist.json")
|
||||
);
|
||||
const summaryPath = arg("--summary-file", "");
|
||||
const jsonPath = arg("--json-file", "");
|
||||
const entries = changedEntries(root, base);
|
||||
const impactMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
||||
const allowlist = validateAllowlist(JSON.parse(fs.readFileSync(allowlistPath, "utf8")));
|
||||
const addedTestLines = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", "tests/"])
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("+") && !line.startsWith("+++"));
|
||||
const result = analyzeForgottenSiblingTests({
|
||||
root,
|
||||
changedEntries: entries,
|
||||
impactMap,
|
||||
allowlist,
|
||||
changedSymbolsByFile: changedSymbols(root, base, entries),
|
||||
addedTestLines,
|
||||
});
|
||||
const report = markdown(result, base);
|
||||
process.stdout.write(report);
|
||||
for (const [target, contents] of [
|
||||
[summaryPath, report],
|
||||
[jsonPath, `${JSON.stringify(result, null, 2)}\n`],
|
||||
]) {
|
||||
if (!target) continue;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, contents);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`forgotten-sibling-tests: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ const IMPORT_RE =
|
||||
/(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
|
||||
|
||||
function resolveImport(spec, fromFile) {
|
||||
export function resolveImport(spec, fromFile, root = ROOT) {
|
||||
let base;
|
||||
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
|
||||
if (spec.startsWith("@/")) base = path.join(root, "src", spec.slice(2));
|
||||
else if (spec.startsWith("@omniroute/open-sse"))
|
||||
base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
|
||||
base = path.join(root, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
|
||||
else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec);
|
||||
else return null;
|
||||
for (const e of EXTS) {
|
||||
@@ -26,7 +26,7 @@ function resolveImport(spec, fromFile) {
|
||||
return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null;
|
||||
}
|
||||
|
||||
function sourceDepsOf(entry) {
|
||||
export function sourceDepsOf(entry, root = ROOT) {
|
||||
const seen = new Set();
|
||||
const stack = [entry];
|
||||
const sources = new Set();
|
||||
@@ -43,9 +43,9 @@ function sourceDepsOf(entry) {
|
||||
for (const m of code.matchAll(IMPORT_RE)) {
|
||||
const spec = m[1] || m[2] || m[3];
|
||||
if (!spec) continue;
|
||||
const r = resolveImport(spec, f);
|
||||
const r = resolveImport(spec, f, root);
|
||||
if (!r) continue;
|
||||
const rel = path.relative(ROOT, r);
|
||||
const rel = path.relative(root, r);
|
||||
if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel);
|
||||
stack.push(r);
|
||||
}
|
||||
@@ -59,27 +59,35 @@ function sourceDepsOf(entry) {
|
||||
// e2e/integration tests, which can't run under node:test (they 99-false-failed before).
|
||||
// Mirror EXACTLY the package.json `test:unit` / `test:unit:ci` globs (incl. memory,
|
||||
// usage, combo, dashboard, serial, and *.test.mjs). Drift here → false __RUN_ALL__.
|
||||
const testFiles = globSync(
|
||||
[
|
||||
"tests/unit/*.test.ts",
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts",
|
||||
"tests/unit/**/*.test.mjs",
|
||||
"tests/unit/dashboard/**/*.test.ts",
|
||||
// Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los.
|
||||
"tests/unit/serial/**/*.test.ts",
|
||||
],
|
||||
{ cwd: ROOT, absolute: true }
|
||||
);
|
||||
const map = {};
|
||||
for (const tf of testFiles) {
|
||||
const relTest = path.relative(ROOT, tf);
|
||||
for (const src of sourceDepsOf(tf)) {
|
||||
(map[src] ||= []).push(relTest);
|
||||
export function buildTestImpactMap(root = ROOT) {
|
||||
const testFiles = globSync(
|
||||
[
|
||||
"tests/unit/*.test.ts",
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts",
|
||||
"tests/unit/**/*.test.mjs",
|
||||
"tests/unit/dashboard/**/*.test.ts",
|
||||
// Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los.
|
||||
"tests/unit/serial/**/*.test.ts",
|
||||
],
|
||||
{ cwd: root, absolute: true }
|
||||
);
|
||||
const map = {};
|
||||
for (const tf of testFiles) {
|
||||
const relTest = path.relative(root, tf);
|
||||
for (const src of sourceDepsOf(tf, root)) {
|
||||
(map[src] ||= []).push(relTest);
|
||||
}
|
||||
}
|
||||
for (const k of Object.keys(map)) map[k].sort();
|
||||
return { generatedFrom: "import-graph", sources: map, testFileCount: testFiles.length };
|
||||
}
|
||||
|
||||
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
|
||||
const result = buildTestImpactMap();
|
||||
const { testFileCount, ...map } = result;
|
||||
const out = path.join(ROOT, "config/quality/test-impact-map.json");
|
||||
fs.writeFileSync(out, JSON.stringify(map, null, 2) + "\n");
|
||||
console.log(
|
||||
`test-impact-map: ${Object.keys(map.sources).length} source files mapped from ${testFileCount} test files`
|
||||
);
|
||||
}
|
||||
for (const k of Object.keys(map)) map[k].sort();
|
||||
const out = path.join(ROOT, "config/quality/test-impact-map.json");
|
||||
fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n");
|
||||
console.log(
|
||||
`test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files`
|
||||
);
|
||||
|
||||
@@ -589,11 +589,11 @@ async function main() {
|
||||
// killed a healthy suite and fabricated a false base-red. The ceiling's
|
||||
// purpose — turning a genuine hang (stuck SQLite handle = zero progress
|
||||
// forever) into a visible failure — survives at 100min.
|
||||
// TODO: measure on the idle .113 box and re-tighten to ~1.8× measured.
|
||||
// Measured on idle .113: unavailable (checkout not found). Tightened to 80min from 100min as a conservative step. TODO: re-measure on idle .113 and tighten to ~1.8× measured.
|
||||
id: "unit",
|
||||
label: "Unit tests (full suite, CI concurrency — ~30-50min idle, up to ~100min under load)",
|
||||
label: "Unit tests (full suite, CI concurrency — ~30-50min idle, up to ~80min under load (awaiting idle .113 measurement, #9532))",
|
||||
args: ["run", "test:unit:ci"],
|
||||
timeout: 100 * 60 * 1000,
|
||||
timeout: 80 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
id: "vitest",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
|
||||
|
||||
type Props = {
|
||||
settings: MemorySettingsExtended;
|
||||
onSave: (updates: Partial<MemorySettingsExtended>) => Promise<boolean>;
|
||||
saving?: boolean;
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (
|
||||
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function CustomEmbeddingEndpointFields({ settings, onSave, saving }: Props) {
|
||||
const t = useTranslations("memory");
|
||||
const [baseUrl, setBaseUrl] = useState(settings.customBaseUrl ?? "");
|
||||
const [modelId, setModelId] = useState(settings.customModelId ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const save = async () => {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const normalizedModelId = modelId.trim() || null;
|
||||
if ((baseUrl.trim() || normalizedModelId) && (!normalizedBaseUrl || !normalizedModelId)) {
|
||||
setError(t("embedding.customEndpointInvalid"));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const saved = await onSave({
|
||||
customBaseUrl: normalizedBaseUrl,
|
||||
customModelId: normalizedModelId,
|
||||
});
|
||||
if (!saved) {
|
||||
setError(t("embedding.customEndpointSaveFailed"));
|
||||
return;
|
||||
}
|
||||
setBaseUrl(normalizedBaseUrl ?? "");
|
||||
setModelId(normalizedModelId ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-border/60 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1">
|
||||
{t("embedding.customBaseUrlLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="http://localhost:8000/v1"
|
||||
disabled={saving}
|
||||
data-testid="embedding-custom-base-url"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1">
|
||||
{t("embedding.customModelIdLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
placeholder="my-embedding-model"
|
||||
disabled={saving}
|
||||
data-testid="embedding-custom-model-id"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t("embedding.customEndpointHelp")}</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
data-testid="embedding-custom-save"
|
||||
className="px-3 py-2 rounded-lg bg-violet-500 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("embedding.customEndpointSave")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
|
||||
import type { EmbeddingProviderListing } from "@/lib/memory/embedding/types";
|
||||
import CustomEmbeddingEndpointFields from "./CustomEmbeddingEndpointFields";
|
||||
|
||||
interface Props {
|
||||
settings: MemorySettingsExtended;
|
||||
@@ -101,10 +102,11 @@ export default function EmbeddingSourceSelector({ settings, providers, onSave, s
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
)),
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
<CustomEmbeddingEndpointFields settings={settings} onSave={onSave} saving={saving} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDisplayBaseUrl } from "@/shared/hooks";
|
||||
import { FreeProviderOnboardingCard } from "./steps/FreeProviderOnboardingCard";
|
||||
import { TierTour } from "./steps/TierTour";
|
||||
|
||||
const STEP_IDS = ["welcome", "tiers", "security", "provider", "test", "done"];
|
||||
@@ -357,6 +358,12 @@ export default function OnboardingWizard() {
|
||||
{currentStep.id === "provider" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-muted text-center">{t("providerDesc")}</p>
|
||||
<FreeProviderOnboardingCard />
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="h-px flex-1 bg-white/10" />
|
||||
<span>{t("freeProviders.orUseApiKey")}</span>
|
||||
<span className="h-px flex-1 bg-white/10" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{COMMON_PROVIDERS.map((p) => (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface FreeProviderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
website: string;
|
||||
caution: string;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
interface SetupResult {
|
||||
providerId: string;
|
||||
status: "created" | "skipped" | "failed";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
||||
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function FreeProviderOnboardingCard() {
|
||||
const t = useTranslations("onboarding.freeProviders");
|
||||
const [providers, setProviders] = useState<FreeProviderOption[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [results, setResults] = useState<SetupResult[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetch("/api/providers/free-onboarding")
|
||||
.then(async (response) => {
|
||||
const data = await readJson(response);
|
||||
if (!response.ok) throw new Error("load-failed");
|
||||
const options = Array.isArray(data.providers)
|
||||
? (data.providers as FreeProviderOption[])
|
||||
: [];
|
||||
if (!active) return;
|
||||
setProviders(options);
|
||||
setSelectedIds(options.map((provider) => provider.id).sort());
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(t("loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const failedIds = useMemo(
|
||||
() => results.filter((result) => result.status === "failed").map((result) => result.providerId),
|
||||
[results]
|
||||
);
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
setSelectedIds((current) =>
|
||||
current.includes(providerId)
|
||||
? current.filter((id) => id !== providerId)
|
||||
: [...current, providerId].sort()
|
||||
);
|
||||
setConfirmed(false);
|
||||
};
|
||||
|
||||
const submit = async (providerIds = selectedIds) => {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/providers/free-onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerIds, confirmed: true }),
|
||||
});
|
||||
const data = await readJson(response);
|
||||
if (!response.ok) throw new Error("setup-failed");
|
||||
setResults(Array.isArray(data.results) ? (data.results as SetupResult[]) : []);
|
||||
} catch {
|
||||
setError(t("setupFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-xs text-text-muted">{t("loading")}</p>;
|
||||
if (providers.length === 0 && !error) {
|
||||
return <p className="text-xs text-text-muted">{t("alreadyConfigured")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("title")}</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-text-muted">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto pr-1">
|
||||
{providers.map((provider) => (
|
||||
<label
|
||||
key={provider.id}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-white/10 bg-white/[0.03] p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(provider.id)}
|
||||
onChange={() => toggleProvider(provider.id)}
|
||||
className="mt-1 accent-primary"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-text-main">{provider.name}</span>
|
||||
<span className="mt-1 block text-[11px] leading-relaxed text-amber-300/90">
|
||||
{provider.caution}
|
||||
</span>
|
||||
<a
|
||||
href={provider.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1 inline-block text-[11px] text-primary hover:underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{t("reviewProviderSite")}
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
data-testid="free-provider-confirmation"
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>{t("confirmation")}</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{results.length > 0 && (
|
||||
<ul className="space-y-1 text-xs text-text-muted">
|
||||
{results.map((result) => (
|
||||
<li key={result.providerId}>
|
||||
{result.providerId}: {t(`result.${result.status}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
data-testid="setup-free-providers"
|
||||
type="button"
|
||||
disabled={!confirmed || selectedIds.length === 0 || submitting}
|
||||
onClick={() => void submit()}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? t("settingUp") : t("setupSelected")}
|
||||
</button>
|
||||
{failedIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit(failedIds)}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-xs text-text-main disabled:opacity-50"
|
||||
>
|
||||
{t("retryFailed")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import {
|
||||
firstProviderConnectionId,
|
||||
providerConnectionsRequestUrl,
|
||||
type RadarSetupConnection,
|
||||
} from "@/lib/radar/setupConnections";
|
||||
import type { RadarLocalizedText } from "@/lib/radar/feedSchema";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -14,17 +20,16 @@ import { Card } from "@/shared/components";
|
||||
* Localized text: either a plain string or an {en, pt?} object.
|
||||
* The renderer resolves the best locale with EN fallback (D25 compat).
|
||||
*/
|
||||
type LocalizedText = string | { en: string; pt?: string };
|
||||
|
||||
interface SetupInfo {
|
||||
keyUrl: string | null;
|
||||
steps: LocalizedText[];
|
||||
steps: RadarLocalizedText[];
|
||||
}
|
||||
|
||||
interface ProviderSetupData {
|
||||
provider: string;
|
||||
setup: SetupInfo | null;
|
||||
configured: boolean;
|
||||
connectionId: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -32,9 +37,9 @@ interface ProviderSetupData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve a LocalizedText to a display string. */
|
||||
function resolveText(text: LocalizedText, locale: string): string {
|
||||
function resolveText(text: RadarLocalizedText, locale: string): string {
|
||||
if (typeof text === "string") return text;
|
||||
if (locale === "pt" && text.pt) return text.pt;
|
||||
if (locale.toLowerCase().startsWith("pt") && text.pt) return text.pt;
|
||||
return text.en;
|
||||
}
|
||||
|
||||
@@ -44,9 +49,9 @@ function resolveText(text: LocalizedText, locale: string): string {
|
||||
|
||||
export default function RadarSetupPage() {
|
||||
const t = useTranslations("radarSetupPage");
|
||||
const locale = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const provider = searchParams.get("provider");
|
||||
const locale = "en"; // Could be derived from next-intl locale later
|
||||
|
||||
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -63,18 +68,25 @@ export default function RadarSetupPage() {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/radar/catalog");
|
||||
const [res, connectionsRes] = await Promise.all([
|
||||
fetch("/api/radar/catalog"),
|
||||
fetch(providerConnectionsRequestUrl(provider)),
|
||||
]);
|
||||
if (res.status === 404) {
|
||||
setError(t("flagDisabled"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!connectionsRes.ok) throw new Error(`HTTP ${connectionsRes.status}`);
|
||||
const data = await res.json();
|
||||
const connectionsData = (await connectionsRes.json()) as {
|
||||
connections?: RadarSetupConnection[];
|
||||
};
|
||||
|
||||
// Find ALL entries for this provider and extract setup from the first one that has it
|
||||
const providerEntries = data.entries.filter(
|
||||
(e: { provider: string }) => e.provider === provider,
|
||||
(e: { provider: string }) => e.provider === provider
|
||||
);
|
||||
|
||||
if (providerEntries.length === 0) {
|
||||
@@ -85,17 +97,19 @@ export default function RadarSetupPage() {
|
||||
|
||||
// Find setup info from feed entries (they carry the setup field)
|
||||
const entryWithSetup = providerEntries.find(
|
||||
(e: { setup?: SetupInfo | null }) => e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl),
|
||||
(e: { setup?: SetupInfo | null }) =>
|
||||
e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl)
|
||||
);
|
||||
|
||||
// Check if provider is configured (has connections)
|
||||
// We infer this from whether the provider exists in the catalog at all
|
||||
// The actual connection check would need a separate API — for now we show
|
||||
// the guide regardless
|
||||
const connectionId = firstProviderConnectionId(
|
||||
Array.isArray(connectionsData.connections) ? connectionsData.connections : [],
|
||||
provider
|
||||
);
|
||||
setSetupData({
|
||||
provider,
|
||||
setup: entryWithSetup?.setup ?? null,
|
||||
configured: false, // Will be enriched when connection-status API is available
|
||||
configured: connectionId !== null,
|
||||
connectionId,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("loadFailed"));
|
||||
@@ -109,15 +123,11 @@ export default function RadarSetupPage() {
|
||||
|
||||
// Test connection — uses the EXISTING connection-test endpoint
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!provider) return;
|
||||
if (!setupData?.connectionId) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
// The existing test endpoint is POST /api/providers/[id]/test
|
||||
// We need the connection ID — for now we use the provider ID as a proxy.
|
||||
// In a full implementation, the setup page would list connections for
|
||||
// this provider and test each one. Here we test the first connection.
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(provider)}/test`, {
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
@@ -136,7 +146,7 @@ export default function RadarSetupPage() {
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [provider, t]);
|
||||
}, [setupData?.connectionId, t]);
|
||||
|
||||
if (!provider) {
|
||||
return (
|
||||
@@ -165,9 +175,7 @@ export default function RadarSetupPage() {
|
||||
<p className="text-sm text-text-muted mt-1">{t("setupSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
@@ -248,15 +256,13 @@ export default function RadarSetupPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
disabled={testing || !setupData.connectionId}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg border border-violet-500 text-violet-400 hover:bg-violet-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? t("testing") : t("testButton")}
|
||||
</button>
|
||||
{testResult && (
|
||||
<span
|
||||
className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
<span className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Card, ModelSelectField, Toggle } from "@/shared/components";
|
||||
import type { ApiModel } from "@/shared/components/ModelSelectField";
|
||||
import {
|
||||
MODALITY_BRIDGE_DEFAULTS,
|
||||
resolveAudioBridgeRuntimeSettings,
|
||||
} from "@/shared/constants/modalityBridgeDefaults";
|
||||
|
||||
import ModalityBridgeAudioTestButton from "./ModalityBridgeAudioTestButton";
|
||||
import ModalityBridgeStatsRow from "./ModalityBridgeStatsRow";
|
||||
|
||||
interface AudioState {
|
||||
modalityBridgeAudioEnabled: boolean;
|
||||
modalityBridgeAudioModel: string;
|
||||
modalityBridgeAudioTimeout: number;
|
||||
modalityBridgeAudioMaxClips: number;
|
||||
}
|
||||
|
||||
function fromApi(data: Record<string, unknown>): AudioState {
|
||||
const runtime = resolveAudioBridgeRuntimeSettings(data);
|
||||
return {
|
||||
modalityBridgeAudioEnabled: runtime.enabled,
|
||||
modalityBridgeAudioModel: runtime.model,
|
||||
modalityBridgeAudioTimeout: runtime.timeoutMs,
|
||||
modalityBridgeAudioMaxClips: runtime.maxClips,
|
||||
};
|
||||
}
|
||||
|
||||
function asSettingsRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function clampNumber(raw: string, min: number, max: number, fallback: number): number {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback));
|
||||
}
|
||||
|
||||
export default function ModalityBridgeAudioTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [settings, setSettings] = useState<AudioState | null>(null);
|
||||
const isSttModel = useCallback(
|
||||
(model: ApiModel) => model.type === "audio" && model.subtype === "transcription",
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/settings")
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data: unknown) => {
|
||||
if (!cancelled) setSettings(fromApi(asSettingsRecord(data)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSettings(fromApi({}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const update = async (patch: Partial<AudioState>) => {
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (response.ok) {
|
||||
setSettings((previous) => (previous ? { ...previous, ...patch } : previous));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update Audio Bridge settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const setLocal = (patch: Partial<AudioState>) => {
|
||||
setSettings((previous) => (previous ? { ...previous, ...patch } : previous));
|
||||
};
|
||||
const commitNumber = (
|
||||
key: "modalityBridgeAudioTimeout" | "modalityBridgeAudioMaxClips",
|
||||
raw: string,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
) => {
|
||||
const value = clampNumber(raw, min, max, fallback);
|
||||
setLocal({ [key]: value });
|
||||
void update({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("modalityBridgeAudioTitle")}
|
||||
subtitle={t("modalityBridgeAudioDesc")}
|
||||
icon="graphic_eq"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeAudioEnabled}
|
||||
onChange={(checked) => void update({ modalityBridgeAudioEnabled: checked })}
|
||||
label={t("modalityBridgeAudioEnabled")}
|
||||
description={t("modalityBridgeAudioEnabledDesc")}
|
||||
/>
|
||||
|
||||
<ModelSelectField
|
||||
label={t("modalityBridgeAudioModel")}
|
||||
value={settings.modalityBridgeAudioModel}
|
||||
placeholder={t("modalityBridgeAudioModelAuto")}
|
||||
allowEmpty
|
||||
modelFilter={isSttModel}
|
||||
modelSource="catalog"
|
||||
onChange={(value) => void update({ modalityBridgeAudioModel: value })}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<details className="rounded-control border border-border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t("modalityBridgeAdvanced")}
|
||||
</summary>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<NumberField
|
||||
testId="modality-bridge-audio-timeout"
|
||||
label={t("modalityBridgeTimeoutMs")}
|
||||
min={1000}
|
||||
max={300000}
|
||||
value={settings.modalityBridgeAudioTimeout}
|
||||
onChange={(value) => setLocal({ modalityBridgeAudioTimeout: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeAudioTimeout",
|
||||
raw,
|
||||
1000,
|
||||
300000,
|
||||
MODALITY_BRIDGE_DEFAULTS.audioTimeoutMs
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
testId="modality-bridge-audio-max-clips"
|
||||
label={t("modalityBridgeAudioMaxClips")}
|
||||
min={1}
|
||||
max={10}
|
||||
value={settings.modalityBridgeAudioMaxClips}
|
||||
onChange={(value) => setLocal({ modalityBridgeAudioMaxClips: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeAudioMaxClips",
|
||||
raw,
|
||||
1,
|
||||
10,
|
||||
MODALITY_BRIDGE_DEFAULTS.audioMaxClips
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<ModalityBridgeStatsRow kind="audio" />
|
||||
<ModalityBridgeAudioTestButton />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
testId: string;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onBlur: (raw: string) => void;
|
||||
}
|
||||
|
||||
function NumberField({ testId, label, min, max, value, onChange, onBlur }: NumberFieldProps) {
|
||||
return (
|
||||
<label className="block text-sm font-medium">
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
data-testid={testId}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number.parseInt(event.currentTarget.value, 10) || 0)}
|
||||
onBlur={(event) => onBlur(event.currentTarget.value)}
|
||||
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const SAMPLE_INPUT = {
|
||||
model: "modality-bridge/self-test",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Transcribe this audio clip." },
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: {
|
||||
data: "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=",
|
||||
format: "wav",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const DISABLED_GUARDRAILS = [
|
||||
"vision-bridge",
|
||||
"pii-masker",
|
||||
"prompt-injection",
|
||||
"credential-masker",
|
||||
];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function findAudioMeta(value: unknown): Record<string, unknown> | null {
|
||||
const body = asRecord(value);
|
||||
if (!Array.isArray(body?.results)) return null;
|
||||
for (const entry of body.results) {
|
||||
const result = asRecord(entry);
|
||||
if (result?.guardrail === "audio-bridge") return asRecord(result.meta);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readErrorMessage(value: unknown): string | null {
|
||||
const error = asRecord(asRecord(value)?.error);
|
||||
return typeof error?.message === "string" ? error.message : null;
|
||||
}
|
||||
|
||||
export default function ModalityBridgeAudioTestButton() {
|
||||
const t = useTranslations("settings");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const runTest = async () => {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetch("/api/guardrails/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ input: SAMPLE_INPUT, disabledGuardrails: DISABLED_GUARDRAILS }),
|
||||
});
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(readErrorMessage(body) ?? `HTTP ${response.status}`);
|
||||
|
||||
const meta = findAudioMeta(body);
|
||||
if (typeof meta?.clipsProcessed === "number" && meta.clipsProcessed >= 1) {
|
||||
setResult(
|
||||
t("modalityBridgeAudioTestOk", {
|
||||
count: meta.clipsProcessed,
|
||||
model: String(meta.sttModel ?? "unknown"),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setResult(t("modalityBridgeAudioTestNoop"));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setResult(t("modalityBridgeAudioTestError", { message }));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-control border border-border px-3 py-2 text-sm font-medium hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={running}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{t(running ? "modalityBridgeAudioTestRunning" : "modalityBridgeAudioTestButton")}
|
||||
</button>
|
||||
{result && (
|
||||
<p className="text-xs text-text-muted" role="status">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import ModalityBridgeComingSoonTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeComingSoonTab";
|
||||
import ModalityBridgeAudioTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeAudioTab";
|
||||
import ModalityBridgeVisionTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab";
|
||||
|
||||
type TabId = "vision" | "audio" | "video";
|
||||
@@ -61,9 +62,7 @@ function ModalityBridgePageContent() {
|
||||
|
||||
<div id="modality-bridge-tabpanel" role="tabpanel">
|
||||
{activeTab === "vision" && <ModalityBridgeVisionTab />}
|
||||
{activeTab === "audio" && (
|
||||
<ModalityBridgeComingSoonTab bodyKey="modalityBridgeAudioComingSoon" />
|
||||
)}
|
||||
{activeTab === "audio" && <ModalityBridgeAudioTab />}
|
||||
{activeTab === "video" && (
|
||||
<ModalityBridgeComingSoonTab bodyKey="modalityBridgeVideoComingSoon" />
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function GET(request: Request) {
|
||||
id: model.id,
|
||||
name: model.name || model.root || model.id,
|
||||
type: model.type || "chat",
|
||||
...(typeof model.subtype === "string" ? { subtype: model.subtype } : {}),
|
||||
custom: model.custom === true,
|
||||
...(model.free === true ? { free: true } : {}),
|
||||
...(model.capabilities ? { capabilities: model.capabilities } : {}),
|
||||
|
||||
@@ -14,17 +14,35 @@ import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
* Returns a 424 (Failed Dependency) response with a clear, sanitized message
|
||||
* when the connection carries that flag; otherwise null (proceed normally).
|
||||
*/
|
||||
const STALE_ENCRYPTION_MESSAGE =
|
||||
"Stored API key cannot be decrypted (STORAGE_ENCRYPTION_KEY changed or unset). Re-enter the API key.";
|
||||
|
||||
export function buildStaleEncryptionKeyResponse(
|
||||
connection: { credentialDecryptFailed?: unknown } | null | undefined
|
||||
connection:
|
||||
| {
|
||||
credentialDecryptFailed?: unknown;
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): NextResponse | null {
|
||||
if (!connection || connection.credentialDecryptFailed !== true) return null;
|
||||
|
||||
// #9927 — surface WHICH credential failed plus the recovery path so the
|
||||
// dashboard points the operator at the account to re-authenticate instead of
|
||||
// a generic "API key cannot be decrypted".
|
||||
const provider = typeof connection.provider === "string" ? connection.provider : "";
|
||||
const id = typeof connection.id === "string" ? connection.id : "";
|
||||
const identity = [provider && `provider "${provider}"`, id && `connection ${id}`]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const message =
|
||||
`Stored credential${identity ? ` for ${identity}` : ""} cannot be decrypted ` +
|
||||
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
|
||||
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
|
||||
|
||||
// buildErrorBody sanitizes the message (Rule #12); override the type so the
|
||||
// client can key off the specific stale-encryption cause.
|
||||
const body = buildErrorBody(424, STALE_ENCRYPTION_MESSAGE);
|
||||
const body = buildErrorBody(424, message);
|
||||
body.error.type = "storage_encryption_stale";
|
||||
return NextResponse.json(body, { status: 424 });
|
||||
}
|
||||
|
||||
69
src/app/api/providers/free-onboarding/route.ts
Normal file
69
src/app/api/providers/free-onboarding/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createProviderConnection, getProviderConnections } from "@/lib/db/providers";
|
||||
import {
|
||||
getEligibleFreeOnboardingProviders,
|
||||
selectUnconfiguredFreeOnboardingProviders,
|
||||
setupFreeProviderConnections,
|
||||
withFreeProviderSetupLock,
|
||||
} from "@/lib/providers/freeOnboarding";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
const setupSchema = z.object({
|
||||
providerIds: z.array(z.string().trim().min(1)).min(1).max(20),
|
||||
confirmed: z.literal(true),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const candidates = getEligibleFreeOnboardingProviders();
|
||||
const connections = await getProviderConnections();
|
||||
return NextResponse.json({
|
||||
providers: selectUnconfiguredFreeOnboardingProviders(candidates, connections),
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to load free providers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(setupSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await withFreeProviderSetupLock(() =>
|
||||
setupFreeProviderConnections({
|
||||
requestedIds: validation.data.providerIds,
|
||||
candidates: getEligibleFreeOnboardingProviders(),
|
||||
listExisting: () => getProviderConnections(),
|
||||
create: (input) => createProviderConnection(input),
|
||||
})
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Ineligible free provider IDs:")) {
|
||||
return NextResponse.json(
|
||||
{ error: "One or more providers are not eligible" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: "Failed to set up free providers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1ImageGenerationSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
import { getComboByName } from "@/lib/db/combos";
|
||||
import { getAllCustomModels } from "@/lib/db/models";
|
||||
import { resolveProxyForConnection } from "@/lib/db/settings";
|
||||
import { resolveImageRouteModel } from "@/lib/images/imageRouteModel";
|
||||
@@ -116,6 +117,24 @@ async function postHandler(request, context) {
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// #9239: Detect combo name and divert to full image combo execution.
|
||||
// Checks before resolveImageRouteModel so we skip single-target flattening.
|
||||
if (body.model && typeof body.model === "string" && !body.model.includes("/")) {
|
||||
const combo = await getComboByName(body.model as string);
|
||||
if (combo) {
|
||||
const { executeImageCombo } = await import(
|
||||
"@omniroute/open-sse/services/imageCombo"
|
||||
);
|
||||
return executeImageCombo(
|
||||
body.model as string,
|
||||
body,
|
||||
{ request, policy },
|
||||
startTime,
|
||||
log
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #3205/#3215: resolve a combo/alias name (`image`) or a user-prefixed custom image
|
||||
// model (`myImg/gpt-image-2`) to its internal `<nodeId>/<model>` form so the
|
||||
// custom-model lookup and handler's resolvedProvider extraction resolve correctly.
|
||||
@@ -307,7 +326,9 @@ async function postHandler(request, context) {
|
||||
});
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error") as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
|
||||
87
src/app/api/v1/muse-code/models/route.ts
Normal file
87
src/app/api/v1/muse-code/models/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Muse Code CLI proprietary model catalog endpoint.
|
||||
*
|
||||
* Muse CLI calls GET /muse-code/models (or --base-url/muse-code/models)
|
||||
* to discover available models. Returns the proprietary Muse format:
|
||||
*
|
||||
* { object: "list", data: [{ id, object, created, owned_by, metadata }] }
|
||||
*
|
||||
* Each model's metadata includes: name, family, reasoning, tool_call,
|
||||
* modalities, limit, cost.
|
||||
*/
|
||||
|
||||
import { muse_codeProvider } from "@omniroute/open-sse/config/providers/registry/muse-code/index.ts";
|
||||
|
||||
const MUSECODE_TIMESTAMP = Math.floor(Date.now() / 1000);
|
||||
|
||||
interface MuseCodeModel {
|
||||
id: string;
|
||||
object: "model";
|
||||
created: number;
|
||||
owned_by: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
family: string;
|
||||
reasoning: boolean;
|
||||
tool_call: boolean;
|
||||
modalities: string[];
|
||||
limit: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
function buildModelCatalog(): MuseCodeModel[] {
|
||||
const data: MuseCodeModel[] = [];
|
||||
|
||||
for (const model of muse_codeProvider.models) {
|
||||
let family = "llama";
|
||||
if (model.id.includes("llama-4")) family = "llama-4";
|
||||
else if (model.id.includes("llama-3.3")) family = "llama-3.3";
|
||||
else if (model.id.includes("llama-3.2")) family = "llama-3.2";
|
||||
else if (model.id.includes("llama-3.1")) family = "llama-3.1";
|
||||
|
||||
const modalities: string[] = ["text"];
|
||||
if (model.supportsVision) modalities.push("image");
|
||||
|
||||
data.push({
|
||||
id: model.id,
|
||||
object: "model",
|
||||
created: MUSECODE_TIMESTAMP,
|
||||
owned_by: "meta",
|
||||
metadata: {
|
||||
name: model.name,
|
||||
family,
|
||||
reasoning: !!model.supportsReasoning,
|
||||
tool_call: !!model.toolCalling,
|
||||
modalities,
|
||||
limit: model.contextLength ?? 200_000,
|
||||
cost: model.id.includes("maverick") || model.id.includes("405b") ? 3 : 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Cache the catalog for the lifetime of the process — model list is static.
|
||||
const CATALOG = buildModelCatalog();
|
||||
const CATALOG_PAYLOAD = JSON.stringify({ object: "list", data: CATALOG }, null, 2);
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return new Response(CATALOG_PAYLOAD, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "تكوين مقدمي الخدمات"
|
||||
},
|
||||
"tierFlowDiagramAlt": "OmniRoute مخطط احتياطي ثلاثي الطبقات",
|
||||
"apiKeyMgmt": "إدارة مفتاح واجهة برمجة التطبيقات (API)"
|
||||
"apiKeyMgmt": "إدارة مفتاح واجهة برمجة التطبيقات (API)",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "المزودون",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "تجسير الفيديو (عينة الإطار + الترجمة) في قائمة الانتظار - راجع المشكلة #9760.",
|
||||
"modalityBridgeMovedTitle": "تم نقل Vision Bridge",
|
||||
"modalityBridgeMovedBody": "إعدادات Vision Bridge الآن موجودة في صفحة Modality Bridge المخصصة.",
|
||||
"modalityBridgeMovedCta": "فتح إعدادات جسر الوضعية"
|
||||
"modalityBridgeMovedCta": "فتح إعدادات جسر الوضعية",
|
||||
"modalityBridgeAudioTitle": "جسر الصوت",
|
||||
"modalityBridgeAudioDesc": "قم بنسخ الصوت باستخدام نموذج تحويل الكلام إلى نص قبل المتابعة مع نموذج النص المختار.",
|
||||
"modalityBridgeAudioEnabled": "تفعيل جسر الصوت",
|
||||
"modalityBridgeAudioEnabledDesc": "استبدل أجزاء الصوت بالنصوص عندما لا يمكن للنموذج المستهدف معالجة الصوت.",
|
||||
"modalityBridgeAudioModel": "نموذج تحويل الكلام إلى نص",
|
||||
"modalityBridgeAudioModelAuto": "تلقائي (أول مزود STT متصل)",
|
||||
"modalityBridgeAudioMaxClips": "أقصى عدد لمقاطع الصوت لكل طلب",
|
||||
"modalityBridgeAudioTestButton": "اختبر باستخدام صوت عينة",
|
||||
"modalityBridgeAudioTestRunning": "اختبار الصوت…",
|
||||
"modalityBridgeAudioTestOk": "جسر الصوت OK — تم نسخ {count} مقطع(ات) بواسطة {model}",
|
||||
"modalityBridgeAudioTestNoop": "لم يتم تفعيل جسر الصوت (قد يدعم الهدف الصوت، لم يتم الاتصال بمزود STT، أو أن الجسر معطل)",
|
||||
"modalityBridgeAudioTestError": "فشل اختبار الصوت: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "محرك آر تي كيه",
|
||||
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "Provayderləri konfiqurasiya edin"
|
||||
},
|
||||
"tierFlowDiagramAlt": "OmniRoute 3 səviyyəli ehtiyat diaqramı",
|
||||
"apiKeyMgmt": "API Açar İdarəetmə"
|
||||
"apiKeyMgmt": "API Açar İdarəetmə",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "Video bridging (frame sampling + captioning) arxa planda var — məsələyə baxın #9760.",
|
||||
"modalityBridgeMovedTitle": "Vision Bridge köçürüldü",
|
||||
"modalityBridgeMovedBody": "Vision Bridge parametrləri indi xüsusi Modality Bridge səhifəsində mövcuddur.",
|
||||
"modalityBridgeMovedCta": "Modallıq Körpüsü parametrlərini açın"
|
||||
"modalityBridgeMovedCta": "Modallıq Körpüsü parametrlərini açın",
|
||||
"modalityBridgeAudioTitle": "Səs Körpüsü",
|
||||
"modalityBridgeAudioDesc": "Seçilmiş mətn modelinə davam etməzdən əvvəl səsdən mətbə çevirmək üçün nitqdən mətbə modelindən istifadə edin.",
|
||||
"modalityBridgeAudioEnabled": "Səs Körpüsünü Aktivləşdirin",
|
||||
"modalityBridgeAudioEnabledDesc": "Hədəf model audio emal edə bilmədikdə audio hissələri transkriptlərlə əvəz edin.",
|
||||
"modalityBridgeAudioModel": "Nitqdan mətnə model",
|
||||
"modalityBridgeAudioModelAuto": "Avtomatik (ilk qoşulmuş STT təminatçısı)",
|
||||
"modalityBridgeAudioMaxClips": "Tələb başına maksimum audio kliplər",
|
||||
"modalityBridgeAudioTestButton": "Nümunə audio ilə test edin",
|
||||
"modalityBridgeAudioTestRunning": "Səsi test edir…",
|
||||
"modalityBridgeAudioTestOk": "Audio Bridge OK — {count} klip(ler) {model} tərəfindən transkribasiya edildi",
|
||||
"modalityBridgeAudioTestNoop": "Audio Bridge aktivləşmədi (hədəf audio dəstəkləyə bilər, STT provayderi qoşulmayıb, ya da körpü deaktivdir)",
|
||||
"modalityBridgeAudioTestError": "Səs testi uğursuz oldu: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "Конфигурирайте доставчици"
|
||||
},
|
||||
"tierFlowDiagramAlt": "3-степенна резервна диаграма на OmniRoute",
|
||||
"apiKeyMgmt": "API Key Mgmt"
|
||||
"apiKeyMgmt": "API Key Mgmt",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Доставчици",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "Видео свързване (извадка на кадри + надписи) е в списъка със задачи — вижте проблема #9760.",
|
||||
"modalityBridgeMovedTitle": "Vision Bridge преместен",
|
||||
"modalityBridgeMovedBody": "Настройките на Vision Bridge вече са налични на специализираната страница Modality Bridge.",
|
||||
"modalityBridgeMovedCta": "Отворете настройките на Modality Bridge"
|
||||
"modalityBridgeMovedCta": "Отворете настройките на Modality Bridge",
|
||||
"modalityBridgeAudioTitle": "Аудио Мост",
|
||||
"modalityBridgeAudioDesc": "Транскрибирайте аудио с модел за разпознаване на реч, преди да продължите с избрания текстов модел.",
|
||||
"modalityBridgeAudioEnabled": "Активирайте Audio Bridge",
|
||||
"modalityBridgeAudioEnabledDesc": "Заменете аудио частите с транскрипции, когато целевият модел не може да обработва аудио.",
|
||||
"modalityBridgeAudioModel": "Модел за преобразуване на реч в текст",
|
||||
"modalityBridgeAudioModelAuto": "Авто (първият свързан доставчик на STT)",
|
||||
"modalityBridgeAudioMaxClips": "Максимален брой аудио клипове на заявка",
|
||||
"modalityBridgeAudioTestButton": "Тест с примерен аудио файл",
|
||||
"modalityBridgeAudioTestRunning": "Тестване на аудио…",
|
||||
"modalityBridgeAudioTestOk": "Audio Bridge OK — {count} клип(а) транскрибирани от {model}",
|
||||
"modalityBridgeAudioTestNoop": "Audio Bridge не беше активиран (целта може да поддържа аудио, няма свързан STT доставчик или моста е деактивиран)",
|
||||
"modalityBridgeAudioTestError": "Тестът на звука не успя: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "প্রদানকারী কনফিগার করুন"
|
||||
},
|
||||
"tierFlowDiagramAlt": "OmniRoute 3-স্তরের ফলব্যাক ডায়াগ্রাম",
|
||||
"apiKeyMgmt": "API Key Mgmt"
|
||||
"apiKeyMgmt": "API Key Mgmt",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "ভিডিও ব্রিজিং (ফ্রেম স্যাম্পলিং + ক্যাপশনিং) ব্যাকলগে রয়েছে — সমস্যা #9760 দেখুন।",
|
||||
"modalityBridgeMovedTitle": "ভিশন ব্রিজ স্থানান্তরিত হয়েছে",
|
||||
"modalityBridgeMovedBody": "ভিশন ব্রিজের সেটিংস এখন নির্দিষ্ট মডালিটি ব্রিজ পৃষ্ঠায় লাইভ।",
|
||||
"modalityBridgeMovedCta": "মোডালিটি ব্রিজ সেটিংস খুলুন"
|
||||
"modalityBridgeMovedCta": "মোডালিটি ব্রিজ সেটিংস খুলুন",
|
||||
"modalityBridgeAudioTitle": "অডিও ব্রিজ",
|
||||
"modalityBridgeAudioDesc": "নির্বাচিত টেক্সট মডেলের সাথে এগিয়ে যাওয়ার আগে একটি স্পিচ-টু-টেক্সট মডেলের সাহায্যে অডিও ট্রান্সক্রাইব করুন।",
|
||||
"modalityBridgeAudioEnabled": "অডিও ব্রিজ সক্রিয় করুন",
|
||||
"modalityBridgeAudioEnabledDesc": "লক্ষ্য মডেল অডিও প্রক্রিয়া করতে না পারলে অডিও অংশগুলি ট্রান্সক্রিপ্টের সাথে প্রতিস্থাপন করুন।",
|
||||
"modalityBridgeAudioModel": "স্পিচ-টু-টেক্সট মডেল",
|
||||
"modalityBridgeAudioModelAuto": "অটো (প্রথম সংযুক্ত STT প্রদানকারী)",
|
||||
"modalityBridgeAudioMaxClips": "প্রতি অনুরোধে সর্বাধিক অডিও ক্লিপ",
|
||||
"modalityBridgeAudioTestButton": "নমুনা অডিওর সাথে পরীক্ষা করুন",
|
||||
"modalityBridgeAudioTestRunning": "অডিও পরীক্ষা করা হচ্ছে…",
|
||||
"modalityBridgeAudioTestOk": "অডিও ব্রিজ ঠিক আছে — {count} ক্লিপ(গুলি) {model} দ্বারা ট্রান্সক্রাইব করা হয়েছে",
|
||||
"modalityBridgeAudioTestNoop": "অডিও ব্রিজ সক্রিয় হয়নি (লক্ষ্য অডিও সমর্থন করতে পারে, কোন STT প্রদানকারী সংযুক্ত নয়, অথবা ব্রিজ নিষ্ক্রিয় রয়েছে)",
|
||||
"modalityBridgeAudioTestError": "অডিও পরীক্ষা ব্যর্থ: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "Nakonfigurujte poskytovatele"
|
||||
},
|
||||
"tierFlowDiagramAlt": "Třívrstvý záložní diagram OmniRoute",
|
||||
"apiKeyMgmt": "Správa API klíčů"
|
||||
"apiKeyMgmt": "Správa API klíčů",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Poskytovatelé",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "Video bridging (frame sampling + captioning) je na backlogu — viz problém #9760.",
|
||||
"modalityBridgeMovedTitle": "Vision Bridge přesunuto",
|
||||
"modalityBridgeMovedBody": "Nastavení Vision Bridge nyní žije na vyhrazené stránce Modality Bridge.",
|
||||
"modalityBridgeMovedCta": "Otevřít nastavení Modality Bridge"
|
||||
"modalityBridgeMovedCta": "Otevřít nastavení Modality Bridge",
|
||||
"modalityBridgeAudioTitle": "Audio Bridge",
|
||||
"modalityBridgeAudioDesc": "Přepište audio pomocí modelu převodu řeči na text, než pokračujete s vybraným textovým modelem.",
|
||||
"modalityBridgeAudioEnabled": "Povolit Audio Bridge",
|
||||
"modalityBridgeAudioEnabledDesc": "Nahraďte audio části přepisy, když cílový model nemůže zpracovat audio.",
|
||||
"modalityBridgeAudioModel": "Model pro převod řeči na text",
|
||||
"modalityBridgeAudioModelAuto": "Auto (první připojený poskytovatel STT)",
|
||||
"modalityBridgeAudioMaxClips": "Maximální počet audio klipů na požadavek",
|
||||
"modalityBridgeAudioTestButton": "Test s ukázkovým zvukem",
|
||||
"modalityBridgeAudioTestRunning": "Testování zvuku…",
|
||||
"modalityBridgeAudioTestOk": "Audio Bridge OK — {count} klip(y) přepsány pomocí {model}",
|
||||
"modalityBridgeAudioTestNoop": "Audio Bridge se nepodařilo aktivovat (cílové zařízení může podporovat audio, žádný poskytovatel STT není připojen, nebo je most deaktivován)",
|
||||
"modalityBridgeAudioTestError": "Test zvuku selhal: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "Konfigurer udbydere"
|
||||
},
|
||||
"tierFlowDiagramAlt": "OmniRoute 3-tiers fallback diagram",
|
||||
"apiKeyMgmt": "API Key Mgmt"
|
||||
"apiKeyMgmt": "API Key Mgmt",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Udbydere",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "Video brokering (rammesampling + undertekster) er på backloggen — se problem #9760.",
|
||||
"modalityBridgeMovedTitle": "Vision Bridge flyttet",
|
||||
"modalityBridgeMovedBody": "Vision Bridge-indstillinger er nu live på den dedikerede Modality Bridge-side.",
|
||||
"modalityBridgeMovedCta": "Åbn Modality Bridge-indstillinger"
|
||||
"modalityBridgeMovedCta": "Åbn Modality Bridge-indstillinger",
|
||||
"modalityBridgeAudioTitle": "Audio Bro",
|
||||
"modalityBridgeAudioDesc": "Transskriber lyd med en tale-til-tekst model, før du fortsætter med den valgte tekstmodel.",
|
||||
"modalityBridgeAudioEnabled": "Aktivér Audio Bridge",
|
||||
"modalityBridgeAudioEnabledDesc": "Erstat lyddele med transkriptioner, når målmodellen ikke kan behandle lyd.",
|
||||
"modalityBridgeAudioModel": "Tale-til-tekst model",
|
||||
"modalityBridgeAudioModelAuto": "Auto (første tilsluttede STT-udbyder)",
|
||||
"modalityBridgeAudioMaxClips": "Maksimalt antal lydklip pr. anmodning",
|
||||
"modalityBridgeAudioTestButton": "Test med prøveaudio",
|
||||
"modalityBridgeAudioTestRunning": "Tester lyd…",
|
||||
"modalityBridgeAudioTestOk": "Audio Bridge OK — {count} klip(klip) transskriberet af {model}",
|
||||
"modalityBridgeAudioTestNoop": "Audio Bridge blev ikke aktiveret (målet kan understøtte lyd, ingen STT-udbyder er tilsluttet, eller broen er deaktiveret)",
|
||||
"modalityBridgeAudioTestError": "Lydtest mislykkedes: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -4744,7 +4744,26 @@
|
||||
"configure": "Anbieter konfigurieren"
|
||||
},
|
||||
"tierFlowDiagramAlt": "OmniRoute 3-Stufen-Fallback-Diagramm",
|
||||
"apiKeyMgmt": "API-Schlüsselverwaltung"
|
||||
"apiKeyMgmt": "API-Schlüsselverwaltung",
|
||||
"freeProviders": {
|
||||
"title": "__MISSING__:Set up free providers",
|
||||
"description": "__MISSING__:Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "__MISSING__:Loading free providers...",
|
||||
"loadFailed": "__MISSING__:Could not load free providers.",
|
||||
"alreadyConfigured": "__MISSING__:All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "__MISSING__:Review provider site and terms",
|
||||
"confirmation": "__MISSING__:I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "__MISSING__:Set up selected providers",
|
||||
"settingUp": "__MISSING__:Setting up...",
|
||||
"setupFailed": "__MISSING__:Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "__MISSING__:Retry failed providers",
|
||||
"orUseApiKey": "__MISSING__:or connect with an API key",
|
||||
"result": {
|
||||
"created": "__MISSING__:created",
|
||||
"skipped": "__MISSING__:already configured",
|
||||
"failed": "__MISSING__:failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Anbieter",
|
||||
@@ -7684,7 +7703,19 @@
|
||||
"modalityBridgeVideoComingSoon": "Video-Bridging (Frame-Sampling + Untertitelung) steht auf der Warteliste – siehe Issue #9760.",
|
||||
"modalityBridgeMovedTitle": "Vision Bridge verschoben",
|
||||
"modalityBridgeMovedBody": "Die Vision Bridge-Einstellungen sind jetzt auf der speziellen Modality Bridge-Seite verfügbar.",
|
||||
"modalityBridgeMovedCta": "Modality Bridge-Einstellungen öffnen"
|
||||
"modalityBridgeMovedCta": "Modality Bridge-Einstellungen öffnen",
|
||||
"modalityBridgeAudioTitle": "Audio-Brücke",
|
||||
"modalityBridgeAudioDesc": "Transkribiere Audio mit einem Sprach-zu-Text-Modell, bevor du mit dem gewählten Textmodell fortfährst.",
|
||||
"modalityBridgeAudioEnabled": "Audio-Brücke aktivieren",
|
||||
"modalityBridgeAudioEnabledDesc": "Ersetzen Sie Audioabschnitte durch Transkripte, wenn das Zielmodell Audio nicht verarbeiten kann.",
|
||||
"modalityBridgeAudioModel": "Spracherkennungsmodell",
|
||||
"modalityBridgeAudioModelAuto": "Auto (erster verbundener STT-Anbieter)",
|
||||
"modalityBridgeAudioMaxClips": "Maximale Audio-Clips pro Anfrage",
|
||||
"modalityBridgeAudioTestButton": "Testen mit Beispielaudio",
|
||||
"modalityBridgeAudioTestRunning": "Audio wird getestet…",
|
||||
"modalityBridgeAudioTestOk": "Audio Bridge OK — {count} Clip(s) von {model} transkribiert",
|
||||
"modalityBridgeAudioTestNoop": "Audio Bridge wurde nicht aktiviert (das Ziel unterstützt möglicherweise Audio, kein STT-Anbieter ist verbunden oder die Brücke ist deaktiviert)",
|
||||
"modalityBridgeAudioTestError": "Audiotest fehlgeschlagen: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -4142,6 +4142,12 @@
|
||||
"providerModelLabel": "Provider / Model",
|
||||
"noRemoteProviders": "No providers with configured API key",
|
||||
"selectProviderModel": "Select a model",
|
||||
"customBaseUrlLabel": "Custom base URL (optional)",
|
||||
"customModelIdLabel": "Custom model ID (optional)",
|
||||
"customEndpointHelp": "Use an OpenAI-compatible /embeddings endpoint. Local endpoints are allowed; cloud metadata targets are blocked.",
|
||||
"customEndpointSave": "Apply custom endpoint",
|
||||
"customEndpointInvalid": "Enter both a valid HTTP(S) base URL and a model ID, or leave both fields empty.",
|
||||
"customEndpointSaveFailed": "The custom embedding endpoint could not be saved.",
|
||||
"staticEnabledLabel": "Enable Static Potion",
|
||||
"staticEnabledDesc": "Download and use potion-base-8M model locally",
|
||||
"transformersEnabledLabel": "Enable Transformers.js",
|
||||
@@ -4744,7 +4750,26 @@
|
||||
"configure": "Configure providers"
|
||||
},
|
||||
"tierFlowDiagramAlt": "OmniRoute 3-tier fallback diagram",
|
||||
"apiKeyMgmt": "API Key Management"
|
||||
"apiKeyMgmt": "API Key Management",
|
||||
"freeProviders": {
|
||||
"title": "Set up free providers",
|
||||
"description": "Optionally enable selected no-signup providers. Review each provider's notice and site before continuing; availability and rate limits are controlled by third parties.",
|
||||
"loading": "Loading free providers...",
|
||||
"loadFailed": "Could not load free providers.",
|
||||
"alreadyConfigured": "All eligible free providers are already configured.",
|
||||
"reviewProviderSite": "Review provider site and terms",
|
||||
"confirmation": "I reviewed these third-party providers and want OmniRoute to create the selected connections.",
|
||||
"setupSelected": "Set up selected providers",
|
||||
"settingUp": "Setting up...",
|
||||
"setupFailed": "Could not set up the selected providers. Nothing existing was changed.",
|
||||
"retryFailed": "Retry failed providers",
|
||||
"orUseApiKey": "or connect with an API key",
|
||||
"result": {
|
||||
"created": "created",
|
||||
"skipped": "already configured",
|
||||
"failed": "failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
@@ -4948,6 +4973,7 @@
|
||||
"baseUrlHint": "Required. Provider API base URL.",
|
||||
"iconUrlLabel": "Icon URL",
|
||||
"iconUrlHint": "Optional. Image URL shown as this provider's icon.",
|
||||
"iconUrlInvalid": "Invalid icon URL. Use an http(s):// or data:image/*;base64 URL.",
|
||||
"anthropicPrefixPlaceholder": "ac-prod",
|
||||
"openaiPrefixPlaceholder": "oc-prod",
|
||||
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
|
||||
@@ -7684,7 +7710,19 @@
|
||||
"modalityBridgeVideoComingSoon": "Video bridging (frame sampling + captioning) is on the backlog — see issue #9760.",
|
||||
"modalityBridgeMovedTitle": "Vision Bridge moved",
|
||||
"modalityBridgeMovedBody": "Vision Bridge settings now live in the dedicated Modality Bridge page.",
|
||||
"modalityBridgeMovedCta": "Open Modality Bridge settings"
|
||||
"modalityBridgeMovedCta": "Open Modality Bridge settings",
|
||||
"modalityBridgeAudioTitle": "Audio Bridge",
|
||||
"modalityBridgeAudioDesc": "Transcribe audio with a speech-to-text model before continuing with the chosen text model.",
|
||||
"modalityBridgeAudioEnabled": "Enable Audio Bridge",
|
||||
"modalityBridgeAudioEnabledDesc": "Replace audio parts with transcripts when the target model cannot process audio.",
|
||||
"modalityBridgeAudioModel": "Speech-to-text model",
|
||||
"modalityBridgeAudioModelAuto": "Auto (first connected STT provider)",
|
||||
"modalityBridgeAudioMaxClips": "Max audio clips per request",
|
||||
"modalityBridgeAudioTestButton": "Test with sample audio",
|
||||
"modalityBridgeAudioTestRunning": "Testing audio…",
|
||||
"modalityBridgeAudioTestOk": "Audio Bridge OK — {count} clip(s) transcribed by {model}",
|
||||
"modalityBridgeAudioTestNoop": "Audio Bridge did not activate (the target may support audio, no STT provider is connected, or the bridge is disabled)",
|
||||
"modalityBridgeAudioTestError": "Audio test failed: {message}"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user