+
+ {tc("loading")}
diff --git a/src/app/api/settings/purge-usage-history/route.ts b/src/app/api/settings/purge-usage-history/route.ts
index 12d1413567..7cedcf5e6c 100644
--- a/src/app/api/settings/purge-usage-history/route.ts
+++ b/src/app/api/settings/purge-usage-history/route.ts
@@ -54,6 +54,8 @@ export async function POST(request: Request) {
deletedRoutingDecisions: result.deletedRoutingDecisions,
deletedQuotaConsumption: result.deletedQuotaConsumption,
deletedTokenLedger: result.deletedTokenLedger,
+ deletedConversationTurnNodes: result.deletedConversationTurnNodes,
+ deletedAgenticConversations: result.deletedAgenticConversations,
errors: result.errors,
},
{ status: result.errors > 0 ? 500 : 200 }
diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts
index f0c0acfa4e..65c45d0268 100644
--- a/src/app/api/v1/audio/translations/route.ts
+++ b/src/app/api/v1/audio/translations/route.ts
@@ -19,6 +19,25 @@ import {
} from "@/app/api/v1/_shared/rateLimit";
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
+import { getComboByName, getCombos } from "@/lib/db/combos";
+import { getDatabaseSettings } from "@/lib/db/databaseSettings";
+import { handleComboChat } from "@omniroute/open-sse/services/combo.ts";
+import { log } from "@omniroute/open-sse/utils/logger.ts";
+
+/**
+ * Copy a multipart body, swapping only the `model` field. Combo fan-out needs one
+ * body per target, and the uploaded file part is reused as-is (a Blob can be read
+ * more than once).
+ */
+function withModel(formData: FormData, modelStr: string): FormData {
+ const next = new FormData();
+ for (const [key, value] of formData.entries()) {
+ if (key === "model") continue;
+ next.append(key, value as string | Blob);
+ }
+ next.set("model", modelStr);
+ return next;
+}
/**
* Handle CORS preflight
@@ -33,30 +52,14 @@ export async function OPTIONS() {
}
/**
- * POST /v1/audio/translations — translate audio to English text
- * OpenAI Whisper API compatible (multipart/form-data). Unlike
- * /v1/audio/transcriptions, output is always English regardless of the
- * source audio language.
+ * Translate with one concrete `provider/model` string. Split out of POST so combo
+ * fan-out can invoke it once per target.
*/
-export async function POST(request) {
- let formData;
- try {
- formData = await request.formData();
- } catch {
- return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart form data");
- }
-
- const startTime = Date.now();
-
- const model = formData.get("model");
- if (!model) {
- return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
- }
-
- // Enforce API key policies (model restrictions + budget limits)
- const policy = await enforceApiKeyPolicy(request, model as string);
- if (policy.rejection) return policy.rejection;
-
+async function translateWithModel(
+ formData: FormData,
+ modelStr: string,
+ startTime: number
+): Promise
{
// Translation is served by the transcription-capable nodes (Whisper-style
// endpoints expose both), plus general chat/responses gateways. Remote hosts are
// opt-in (default OFF).
@@ -65,14 +68,11 @@ export async function POST(request) {
"audio-transcriptions"
);
- const { provider, model: resolvedModel } = parseTranslationModel(
- model as string,
- dynamicProviders
- );
+ const { provider, model: resolvedModel } = parseTranslationModel(modelStr, dynamicProviders);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
- `Invalid translation model: ${model}. Use format: provider/model`
+ `Invalid translation model: ${modelStr}. Use format: provider/model`
);
}
@@ -84,6 +84,8 @@ export async function POST(request) {
let credentials = null;
if (providerConfig && providerConfig.authType !== "none") {
const credentialKey = providerConfig.credentialProviderId || provider;
+ // NOTE: the 2nd arg of this helper is `excludeConnectionId`, not "use this
+ // connection" — a combo target's connectionId must never be passed here.
credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
@@ -113,3 +115,67 @@ export async function POST(request) {
}
return response;
}
+
+/**
+ * POST /v1/audio/translations — translate audio to English text
+ * OpenAI Whisper API compatible (multipart/form-data). Unlike
+ * /v1/audio/transcriptions, output is always English regardless of the
+ * source audio language.
+ */
+export async function POST(request) {
+ let formData;
+ try {
+ formData = await request.formData();
+ } catch {
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart form data");
+ }
+
+ const startTime = Date.now();
+
+ const model = formData.get("model");
+ if (!model) {
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
+ }
+ const modelStr = String(model);
+
+ // Enforce API key policies (model restrictions + budget limits)
+ const policy = await enforceApiKeyPolicy(request, modelStr);
+ if (policy.rejection) return policy.rejection;
+
+ // A bare name (no "/") may be a combo. /v1/models advertises combos, and chat,
+ // embeddings and the sibling /v1/audio/transcriptions all resolve them —
+ // resolving here too keeps the catalog honest and frees callers from hardcoding
+ // a provider's internal model id.
+ if (!modelStr.includes("/")) {
+ try {
+ const combo = await getComboByName(modelStr);
+ if (combo) {
+ let allCombos: Awaited> = [];
+ try {
+ allCombos = await getCombos();
+ } catch {}
+ let settings = {};
+ try {
+ settings = getDatabaseSettings();
+ } catch {}
+
+ return handleComboChat({
+ body: { model: modelStr } as any,
+ combo: combo as any,
+ handleSingleModel: async (_reqBody: any, targetModelStr: string) =>
+ translateWithModel(withModel(formData, targetModelStr), targetModelStr, startTime),
+ isModelAvailable: undefined,
+ log,
+ settings,
+ allCombos: allCombos as any,
+ relayOptions: undefined,
+ signal: undefined,
+ } as any);
+ }
+ } catch (err) {
+ log.error("AUDIO", `Combo resolution failed for ${modelStr}: ${err}`);
+ }
+ }
+
+ return translateWithModel(formData, modelStr, startTime);
+}
diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts
index 63da01ed1b..31da197f94 100644
--- a/src/app/api/v1/images/edits/route.ts
+++ b/src/app/api/v1/images/edits/route.ts
@@ -21,11 +21,19 @@ import {
} from "@omniroute/open-sse/config/imageRegistry.ts";
import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
+import { getComboByName, getCombos } from "@/lib/db/combos";
+import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts";
+import {
+ runImageComboTargets,
+ type ImageComboDispatchResult,
+} from "@omniroute/open-sse/services/imageCombo.ts";
+import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import {
resolveImageRouteModel,
+ resolveImageModelPrefix,
extractImageEditInputFromJson,
validateCodexImageEditReferences,
} from "@/lib/images/imageRouteModel";
@@ -294,6 +302,286 @@ async function handleAdobeFireflyEditRequest(params: {
);
}
+/** Reference/prompt payload an edit dispatch needs, shared by single + combo paths. */
+interface ImageEditContext {
+ prompt: string;
+ size: string | null;
+ responseFormat: string | null;
+ images: Array<{ bytes: Buffer; mime: string }>;
+ imageBytes: Buffer | null;
+ imageMime: string | null;
+ imageInputCount: number;
+ allowedConnections: string[] | null;
+ request: Request;
+}
+
+/** A combo target that resolved to an edit-capable provider/node. */
+interface EditComboTarget {
+ modelStr: string;
+ parsed: ReturnType;
+ providerConfig: ReturnType | null;
+ /** Credential/connection lookup key (built-in provider id, or custom node id). */
+ credKey: string;
+}
+
+/**
+ * Decide whether a prefix-resolved combo target can service an image edit, and
+ * return the credential key to resolve it with. Mirrors postHandler's provider
+ * branches: codex-responses, fal-ai edit models, adobe-firefly, built-in
+ * openrouter, and custom OpenAI-compatible nodes are edit-capable; every other
+ * built-in provider is not (it exposes no OpenAI-compatible edit endpoint).
+ */
+function classifyImageEditTarget(
+ resolvedModel: string,
+ parsed: ReturnType,
+ providerConfig: ReturnType | null
+): { credKey: string } | null {
+ if (providerConfig) {
+ if (
+ providerConfig.format === "codex-responses" ||
+ providerConfig.format === "adobe-firefly-image" ||
+ (providerConfig.format === "fal-ai" && isFalImageEditModel(parsed.model)) ||
+ providerConfig.id === "openrouter"
+ ) {
+ return parsed.provider ? { credKey: parsed.provider } : null;
+ }
+ // Other built-in providers do not expose an OpenAI-compatible edit endpoint.
+ return null;
+ }
+ // Custom OpenAI-compatible node: prefix already rewritten to `/model`.
+ const slash = resolvedModel.indexOf("/");
+ if (slash > 0 && slash < resolvedModel.length - 1) {
+ return { credKey: resolvedModel.slice(0, slash) };
+ }
+ return null;
+}
+
+/**
+ * Dispatch a single edit-capable target with already-resolved credentials, and
+ * return a normalized {success,data,status,error}. Reuses the same provider
+ * handlers postHandler uses for the single-model path.
+ */
+async function dispatchImageEditTarget(
+ target: EditComboTarget,
+ credentials: unknown,
+ ctx: ImageEditContext
+): Promise {
+ const { parsed, providerConfig, modelStr } = target;
+ const { prompt, size, responseFormat, images, imageBytes, imageMime, request } = ctx;
+
+ // Built-in Codex — native Responses hosted tool for reference-image edits.
+ if (providerConfig?.format === "codex-responses") {
+ const modelEntry = getImageModelEntry(modelStr);
+ if (!modelEntry || modelEntry.provider !== "codex" || modelEntry.model !== parsed.model) {
+ return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: `Unsupported Codex image edit model: ${modelStr}` };
+ }
+ const imageValidationError = validateCodexImageEditReferences(images);
+ if (imageValidationError) {
+ return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: imageValidationError };
+ }
+ const credentialDetails = credentials as {
+ connectionId?: unknown;
+ providerSpecificData?: unknown;
+ };
+ if (isCodexFreePlan(credentialDetails.providerSpecificData)) {
+ return {
+ success: false,
+ status: HTTP_STATUS.BAD_REQUEST,
+ error: "Codex image editing requires a paid ChatGPT/Codex plan",
+ };
+ }
+ const connectionId =
+ typeof credentialDetails.connectionId === "string" ? credentialDetails.connectionId : null;
+ let proxyInfo = null;
+ if (connectionId) {
+ try {
+ proxyInfo = await resolveProxyForConnection(connectionId);
+ } catch {
+ log.debug("PROXY", `Failed to resolve proxy for image provider: ${parsed.provider}`);
+ }
+ }
+ const editImage = () =>
+ handleCodexImageEdit({
+ provider: parsed.provider,
+ model: parsed.model,
+ providerConfig,
+ body: {
+ prompt,
+ size: size ?? undefined,
+ response_format: responseFormat ?? undefined,
+ },
+ referenceImages: images,
+ credentials: credentials as never,
+ log,
+ signal: request.signal,
+ });
+ return (await (connectionId
+ ? runWithProxyContext(proxyInfo?.proxy || null, editImage).catch(() => ({
+ success: false as const,
+ status: HTTP_STATUS.SERVICE_UNAVAILABLE,
+ error: "Image edit proxy error",
+ }))
+ : editImage())) as ImageComboDispatchResult;
+ }
+
+ if (providerConfig?.format === "fal-ai" && isFalImageEditModel(parsed.model)) {
+ return (await handleFalAIImageEdit({
+ provider: parsed.provider,
+ model: parsed.model,
+ providerConfig,
+ body: { prompt, size: size ?? undefined, response_format: responseFormat ?? undefined, n: 1 },
+ images,
+ credentials: credentials as never,
+ log,
+ })) as ImageComboDispatchResult;
+ }
+
+ if (providerConfig?.format === "adobe-firefly-image") {
+ const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime);
+ if (dataUrls.length === 0) {
+ return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: "Missing required field: image" };
+ }
+ return (await handleAdobeFireflyImageGeneration({
+ provider: parsed.provider,
+ model: parsed.model,
+ providerConfig,
+ body: {
+ prompt,
+ size: size ?? undefined,
+ response_format: responseFormat ?? undefined,
+ n: 1,
+ image_url: dataUrls[0],
+ image: dataUrls.length === 1 ? dataUrls[0] : dataUrls,
+ image_urls: dataUrls,
+ images: dataUrls,
+ },
+ credentials: credentials as never,
+ log,
+ })) as ImageComboDispatchResult;
+ }
+
+ if (providerConfig?.id === "openrouter") {
+ return (await handleOpenRouterImageEdit({
+ provider: parsed.provider,
+ model: parsed.model,
+ baseUrl: providerConfig.baseUrl,
+ credentials: credentials as never,
+ prompt,
+ imageBytes,
+ imageMime,
+ size: size ?? undefined,
+ n: 1,
+ log,
+ })) as ImageComboDispatchResult;
+ }
+
+ // Custom OpenAI-compatible node: forward to {base_url}/images/edits.
+ const slash = modelStr.indexOf("/");
+ const customProviderId = slash > 0 ? modelStr.slice(0, slash) : null;
+ const customModel = slash > 0 ? modelStr.slice(slash + 1) : null;
+ if (!customProviderId || !customModel) {
+ return {
+ success: false,
+ status: HTTP_STATUS.BAD_REQUEST,
+ error: `Unknown image provider for model "${modelStr}"`,
+ };
+ }
+ return (await handleOpenAIImageEdit({
+ provider: customProviderId,
+ model: customModel,
+ credentials: credentials as never,
+ prompt,
+ imageBytes,
+ imageMime,
+ size,
+ responseFormat,
+ n: 1,
+ log,
+ })) as ImageComboDispatchResult;
+}
+
+/**
+ * #12547: run an image-edit request whose model is a bare combo/alias name over
+ * the combo's edit-capable targets, mirroring how /v1/images/generations diverts
+ * bare combos to executeImageCombo (#9239). A combo whose first target isn't
+ * edit-capable (or lacks credentials) now falls through to a later edit-capable
+ * target instead of flattening to the first target and hard-erroring.
+ */
+async function executeImageEditCombo(comboName: string, ctx: ImageEditContext): Promise {
+ const combo = await getComboByName(comboName);
+ if (!combo) {
+ 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`);
+ }
+
+ // Build the edit-capable target list (prefix-resolved). Non-edit-capable and
+ // retired targets are skipped here so the loop only iterates dispatchable ones.
+ const editTargets: EditComboTarget[] = [];
+ for (const t of targets) {
+ const raw =
+ typeof (t as { modelStr?: unknown }).modelStr === "string"
+ ? ((t as { modelStr: string }).modelStr as string)
+ : "";
+ if (!raw.trim()) continue;
+ let resolved: string;
+ try {
+ resolved = await resolveImageModelPrefix(raw);
+ } catch {
+ // retired provider / prefix — skip this target
+ continue;
+ }
+ const parsed = parseImageModel(resolved);
+ const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null;
+ const capability = classifyImageEditTarget(resolved, parsed, providerConfig);
+ if (!capability) continue;
+ editTargets.push({ modelStr: resolved, parsed, providerConfig, credKey: capability.credKey });
+ }
+
+ if (editTargets.length === 0) {
+ return errorResponse(
+ HTTP_STATUS.BAD_REQUEST,
+ `No image-edit-capable targets in combo "${comboName}"`
+ );
+ }
+
+ const run = await runImageComboTargets(editTargets, {
+ resolveProvider: (target) => ({ provider: target.credKey, model: target.parsed.model }),
+ resolveCredentials: (_provider, target) =>
+ getProviderCredentialsWithQuotaPreflight(
+ target.credKey,
+ null,
+ ctx.allowedConnections,
+ target.modelStr
+ ),
+ isRateLimited: isAllRateLimitedCredentials,
+ dispatch: ({ target, credentials }) => dispatchImageEditTarget(target, credentials, ctx),
+ onSuccess: async (credentials) => {
+ await clearRecoveredProviderState(credentials as never);
+ },
+ failureLabel: "Image edit failed",
+ });
+
+ if (run.outcome === "terminal") {
+ return errorResponse(run.status, `[${run.provider}] ${run.error}`);
+ }
+ if (run.outcome === "success") {
+ // Match the single-model edit path: return the provider payload directly.
+ return jsonResponse(run.data);
+ }
+ const errorPayload = toJsonErrorPayload(
+ run.lastError?.error || "All combo targets failed",
+ "Image edit combo targets all failed"
+ );
+ return new Response(JSON.stringify(errorPayload), {
+ status: run.lastError?.status || HTTP_STATUS.BAD_GATEWAY,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
async function postHandler(request: Request, _context?: unknown) {
let input: EditInput | null;
try {
@@ -345,6 +633,39 @@ async function postHandler(request: Request, _context?: unknown) {
const fullModel = model;
+ // #12547: a bare combo/alias name iterates the combo's edit-capable targets
+ // (mirrors generations' #9239 diversion, which runs before resolveImageRouteModel).
+ // Without this, resolveImageRouteModel flattens the combo to its first target, so a
+ // combo whose first target isn't edit-capable hard-errors even when a later target is.
+ if (!fullModel.includes("/")) {
+ let combo: unknown = null;
+ try {
+ combo = await getComboByName(fullModel);
+ } catch {
+ combo = null;
+ }
+ if (combo) {
+ const comboPolicy = await enforceApiKeyPolicy(request, fullModel);
+ if (comboPolicy.rejection) return comboPolicy.rejection;
+ const comboAllowedConnections =
+ comboPolicy.apiKeyInfo?.allowedConnections &&
+ comboPolicy.apiKeyInfo.allowedConnections.length > 0
+ ? comboPolicy.apiKeyInfo.allowedConnections
+ : null;
+ return executeImageEditCombo(fullModel, {
+ prompt,
+ size,
+ responseFormat,
+ images,
+ imageBytes,
+ imageMime,
+ imageInputCount,
+ allowedConnections: comboAllowedConnections,
+ request,
+ });
+ }
+ }
+
// Resolve combo/alias, custom-provider prefix, and built-in ids consistently with
// /v1/images/generations (#3215). Retirement is resolved before API-key policy
// so the same explicit provider request always receives the deterministic 410.
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index bc3296b89a..8727f7c644 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "مراقب الصحة",
"reportIssue": "الإبلاغ عن مشكلة",
"activeError": "{active} نشط · {errors} خطأ",
+ "topologyLegendActive": "نشط",
+ "topologyLegendRecent": "الأحدث",
+ "topologyLegendError": "خطأ",
"oauthLabel": "OAuth",
"apiKeyLabel": "مفتاح واجهة برمجة التطبيقات",
"requestsShort": "{count} طلب",
@@ -1819,11 +1822,11 @@
"updateStarted": "بدأ التحديث...",
"reloadingPageAutomatically": "جارٍ إعادة تحميل الصفحة تلقائيًا...",
"providerTopology": "طوبولوجيا الموفر",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "تحميل DMG (macOS)",
"downloadDmgDescription": "يتوفر إصدار جديد من تطبيق OmniRoute لسطح المكتب. يرجى تنزيل وتثبيت مثبت DMG لنظام macOS للتحديث (الحالي: v{version}).",
"downloadExe": "تحميل EXE (ويندوز)",
diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json
index faca0d6278..e9f01e9101 100644
--- a/src/i18n/messages/az.json
+++ b/src/i18n/messages/az.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "Səhifə avtomatik yenidən yüklənir...",
"providerTopology": "Provayder Topologiyası",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG-ni Yükləyin (macOS)",
"downloadDmgDescription": "OmniRoute masaüstü tətbiqinin yeni versiyası mövcuddur. Zəhmət olmasa, yeniləmək üçün macOS DMG quraşdırıcısını yükləyin və quraşdırın (hazırkı: v{version}).",
"downloadExe": "EXE-ni Yükləyin (Windows)",
diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json
index ef8c3023ff..1ed0ad3156 100644
--- a/src/i18n/messages/bg.json
+++ b/src/i18n/messages/bg.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Здравен монитор",
"reportIssue": "Докладвайте за проблем",
"activeError": "{active} активен · {errors} грешка",
+ "topologyLegendActive": "Активен",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Грешка",
"oauthLabel": "OAuth",
"apiKeyLabel": "API ключ",
"requestsShort": "{count} изискване",
@@ -1819,11 +1822,11 @@
"updateStarted": "Актуализацията започна...",
"reloadingPageAutomatically": "Страницата се презарежда автоматично...",
"providerTopology": "Топология на доставчика",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Изтеглете DMG (macOS)",
"downloadDmgDescription": "Налична е нова версия на настолната апликация OmniRoute. Моля, изтеглете и инсталирайте DMG инсталатора за macOS, за да актуализирате (текуща: v{version}).",
"downloadExe": "Изтеглете EXE (Windows)",
diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json
index 5906b9e495..1fe279b876 100644
--- a/src/i18n/messages/bn.json
+++ b/src/i18n/messages/bn.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "স্বয়ংক্রিয়ভাবে পৃষ্ঠা পুনরায় লোড হচ্ছে...",
"providerTopology": "প্রদানকারী টপোলজি",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG ডাউনলোড করুন (macOS)",
"downloadDmgDescription": "OmniRoute ডেস্কটপ অ্যাপের একটি নতুন সংস্করণ উপলব্ধ। আপডেট করতে দয়া করে macOS DMG ইনস্টলার ডাউনলোড এবং ইনস্টল করুন (বর্তমান: v{version})।",
"downloadExe": "EXE ডাউনলোড করুন (Windows)",
diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json
index 1290e1e172..e5eace9595 100644
--- a/src/i18n/messages/cs.json
+++ b/src/i18n/messages/cs.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitor stavu",
"reportIssue": "Nahlásit problém",
"activeError": "{active} aktivní · {errors} chyba",
+ "topologyLegendActive": "Aktivní",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Chyba",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Klíč",
"requestsShort": "{count} požadavků",
@@ -1819,11 +1822,11 @@
"updateStarted": "Aktualizace začala...",
"reloadingPageAutomatically": "Automatické opětovné načítání stránky...",
"providerTopology": "Topologie poskytovatele",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Stáhnout DMG (macOS)",
"downloadDmgDescription": "Nová verze desktopové aplikace OmniRoute je k dispozici. Prosím, stáhněte a nainstalujte macOS DMG instalátor pro aktualizaci (aktuální: v{version}).",
"downloadExe": "Stáhnout EXE (Windows)",
diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json
index d65eb7d96b..3c5d0ea371 100644
--- a/src/i18n/messages/da.json
+++ b/src/i18n/messages/da.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Sundhedsmonitor",
"reportIssue": "Rapportér problem",
"activeError": "{active} aktiv · {errors} fejl",
+ "topologyLegendActive": "Aktiv",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Fejl",
"oauthLabel": "OAuth",
"apiKeyLabel": "API nøgle",
"requestsShort": "{count} req",
@@ -1819,11 +1822,11 @@
"updateStarted": "Opdatering startet...",
"reloadingPageAutomatically": "Genindlæser siden automatisk...",
"providerTopology": "Udbydertopologi",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Download DMG (macOS)",
"downloadDmgDescription": "En ny version af OmniRoute desktopappen er tilgængelig. Download og installer venligst macOS DMG-installationsprogrammet for at opdatere (nuværende: v{version}).",
"downloadExe": "Download EXE (Windows)",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 9fd735d513..fdd781ec5b 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Gesundheitsmonitor",
"reportIssue": "Problem melden",
"activeError": "{active} aktiv · {errors} Fehler",
+ "topologyLegendActive": "Aktiv",
+ "topologyLegendRecent": "Zuletzt",
+ "topologyLegendError": "Fehler",
"oauthLabel": "OAuth",
"apiKeyLabel": "API-Schlüssel",
"requestsShort": "{count} Anfr.",
@@ -1819,11 +1822,11 @@
"updateStarted": "Aktualisierung gestartet...",
"reloadingPageAutomatically": "Seite wird automatisch neu geladen...",
"providerTopology": "Anbietertopologie",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "Letzte Anfragen",
+ "recentRequestsEmpty": "Noch keine Anfragen.",
+ "recentRequestsModel": "Modell",
+ "recentRequestsTokens": "Eingabe / Ausgabe",
+ "recentRequestsWhen": "Wann",
"downloadDmg": "DMG herunterladen (macOS)",
"downloadDmgDescription": "Eine neue Version der OmniRoute-Desktop-App ist verfügbar. Bitte laden Sie den macOS DMG-Installer herunter und installieren Sie ihn, um zu aktualisieren (aktuell: v{version}).",
"downloadExe": "EXE herunterladen (Windows)",
diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json
index c3abae12ee..0293177e6d 100644
--- a/src/i18n/messages/el.json
+++ b/src/i18n/messages/el.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Μοντέλο",
"recentRequestsTokens": "Είσοδος / Έξοδος",
"recentRequestsWhen": "Πότε",
+ "topologyLegendActive": "Ενεργό",
+ "topologyLegendRecent": "Πρόσφατα",
+ "topologyLegendError": "Σφάλμα",
"downloadDmg": "Λήψη DMG (macOS)",
"downloadDmgDescription": "Διατίθεται νέα έκδοση της εφαρμογής OmniRoute για επιτραπέζιους υπολογιστές. Παρακαλούμε κατεβάστε και εγκαταστήστε το πρόγραμμα εγκατάστασης DMG για macOS για να ενημερωθείτε (τρέχουσα: v{version}).",
"downloadExe": "Λήψη EXE (Windows)",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index ab6a8347bb..aef7ee8521 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "Active",
+ "topologyLegendRecent": "Recent",
+ "topologyLegendError": "Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 916769ea85..74b24e0612 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitor de salud",
"reportIssue": "Informar problema",
"activeError": "{active} activo · {errors} error",
+ "topologyLegendActive": "Activo",
+ "topologyLegendRecent": "Reciente",
+ "topologyLegendError": "Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "Clave API",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Actualización iniciada...",
"reloadingPageAutomatically": "Recargando página automáticamente...",
"providerTopology": "Topología del proveedor",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "Solicitudes recientes",
+ "recentRequestsEmpty": "Aún no hay solicitudes.",
+ "recentRequestsModel": "Modelo",
+ "recentRequestsTokens": "Entrada / Salida",
+ "recentRequestsWhen": "Cuándo",
"downloadDmg": "Descargar DMG (macOS)",
"downloadDmgDescription": "Una nueva versión de la aplicación de escritorio OmniRoute está disponible. Por favor, descarga e instala el instalador DMG de macOS para actualizar (actual: v{version}).",
"downloadExe": "Descargar EXE (Windows)",
diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json
index 0fce44c58b..0d594bc934 100644
--- a/src/i18n/messages/et.json
+++ b/src/i18n/messages/et.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Mudel",
"recentRequestsTokens": "Sisend / väljund",
"recentRequestsWhen": "Millal",
+ "topologyLegendActive": "Aktiivne",
+ "topologyLegendRecent": "Hiljutine",
+ "topologyLegendError": "Viga",
"downloadDmg": "Laadi alla DMG (macOS)",
"downloadDmgDescription": "Saadaval on OmniRoute’i töölauarakenduse uus versioon. Värskendamiseks laadige alla ja installige macOS-i DMG-paigaldusprogramm (praegune: v{version}).",
"downloadExe": "Laadi alla EXE (Windows)",
diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json
index 93897b3025..72143731a5 100644
--- a/src/i18n/messages/fa.json
+++ b/src/i18n/messages/fa.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "بارگیری مجدد صفحه به صورت خودکار...",
"providerTopology": "توپولوژی ارائه دهنده",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "دانلود DMG (macOS)",
"downloadDmgDescription": "نسخه جدیدی از برنامه دسکتاپ OmniRoute در دسترس است. لطفاً DMG نصبکننده macOS را دانلود و نصب کنید تا بهروزرسانی کنید (فعلی: v{version}).",
"downloadExe": "دانلود EXE (ویندوز)",
diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json
index 16f74b5129..904286fe8b 100644
--- a/src/i18n/messages/fi.json
+++ b/src/i18n/messages/fi.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Terveysmittari",
"reportIssue": "Ilmoita ongelmasta",
"activeError": "{active} aktiivinen · {errors} virhe",
+ "topologyLegendActive": "Aktiivinen",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Virhe",
"oauthLabel": "OAuth",
"apiKeyLabel": "API-avain",
"requestsShort": "{count} vaatimus",
@@ -1819,11 +1822,11 @@
"updateStarted": "Päivitys aloitettu...",
"reloadingPageAutomatically": "Ladataan sivua automaattisesti uudelleen...",
"providerTopology": "Palveluntarjoajan topologia",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Lataa DMG (macOS)",
"downloadDmgDescription": "Uusi versio OmniRoute-työpöytäsovelluksesta on saatavilla. Lataa ja asenna macOS DMG -asennustiedosto päivittääksesi (nykyinen: v{version}).",
"downloadExe": "Lataa EXE (Windows)",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index ac177d9460..ac9844ee43 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Moniteur de santé",
"reportIssue": "Signaler un problème",
"activeError": "{active} actif · Erreur {errors}",
+ "topologyLegendActive": "Actif",
+ "topologyLegendRecent": "Récent",
+ "topologyLegendError": "Erreur",
"oauthLabel": "OAuth",
"apiKeyLabel": "Clé API",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Mise à jour démarrée...",
"reloadingPageAutomatically": "Rechargement automatique de la page...",
"providerTopology": "Topologie du fournisseur",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "Requêtes récentes",
+ "recentRequestsEmpty": "Aucune requête pour le moment.",
+ "recentRequestsModel": "Modèle",
+ "recentRequestsTokens": "Entrée / Sortie",
+ "recentRequestsWhen": "Quand",
"downloadDmg": "Télécharger le DMG (macOS)",
"downloadDmgDescription": "Une nouvelle version de l'application de bureau OmniRoute est disponible. Téléchargez et installez le programme d'installation DMG macOS pour effectuer la mise à jour (version actuelle : v{version}).",
"downloadExe": "Télécharger l'EXE (Windows)",
diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json
index 0edc4d5eec..55201fab8b 100644
--- a/src/i18n/messages/ga.json
+++ b/src/i18n/messages/ga.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Samhail",
"recentRequestsTokens": "Isteach / Amach",
"recentRequestsWhen": "Cathain",
+ "topologyLegendActive": "Gníomhach",
+ "topologyLegendRecent": "Le déanaí",
+ "topologyLegendError": "Earráid",
"downloadDmg": "Íoslódáil DMG (macOS)",
"downloadDmgDescription": "Tá leagan nua den fheidhmchlár deisce OmniRoute ar fáil. Íoslódáil agus suiteáil an suiteálaí DMG macOS le nuashonrú (reatha: v{version}).",
"downloadExe": "Íoslódáil EXE (Windows)",
diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json
index 6ae331a26b..4661615232 100644
--- a/src/i18n/messages/gu.json
+++ b/src/i18n/messages/gu.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "પૃષ્ઠને આપમેળે ફરીથી લોડ કરી રહ્યું છે...",
"providerTopology": "પ્રદાતા ટોપોલોજી",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG ડાઉનલોડ કરો (macOS)",
"downloadDmgDescription": "ઓમ્નીરૂટ ડેસ્કટોપ એપ્લિકેશનનો નવો સંસ્કરણ ઉપલબ્ધ છે. કૃપા કરીને અપડેટ કરવા માટે macOS DMG ઇન્સ્ટોલર ડાઉનલોડ અને ઇન્સ્ટોલ કરો (વર્તમાન: v{version}).",
"downloadExe": "ડાઉનલોડ EXE (Windows)",
diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json
index c43184a1df..c56f707366 100644
--- a/src/i18n/messages/he.json
+++ b/src/i18n/messages/he.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "מוניטור בריאות",
"reportIssue": "דווח על בעיה",
"activeError": "{active} פעיל · שגיאה {errors}",
+ "topologyLegendActive": "פעיל",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "שגיאה",
"oauthLabel": "OAuth",
"apiKeyLabel": "מפתח API",
"requestsShort": "{count} בקשות",
@@ -1819,11 +1822,11 @@
"updateStarted": "העדכון התחיל...",
"reloadingPageAutomatically": "טוען מחדש את הדף באופן אוטומטי...",
"providerTopology": "טופולוגיה של ספק",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "הורד DMG (macOS)",
"downloadDmgDescription": "גרסה חדשה של אפליקציית OmniRoute למחשב שולחני זמינה. אנא הורד והתקן את מתקין ה-DMG של macOS כדי לעדכן (נוכחי: v{version}).",
"downloadExe": "הורד EXE (Windows)",
diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json
index df7c596dbc..bf45caf5ef 100644
--- a/src/i18n/messages/hi.json
+++ b/src/i18n/messages/hi.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "स्वास्थ्य मॉनिटर",
"reportIssue": "रिपोर्ट मुद्दा",
"activeError": "{active} सक्रिय · {errors} त्रुटि",
+ "topologyLegendActive": "सक्रिय",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "त्रुटि",
"oauthLabel": "OAuth",
"apiKeyLabel": "एपीआई कुंजी",
"requestsShort": "{count} अनुरोध",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "पृष्ठ स्वचालित रूप से पुनः लोड हो रहा है...",
"providerTopology": "प्रदाता टोपोलॉजी",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG डाउनलोड करें (macOS)",
"downloadDmgDescription": "OmniRoute डेस्कटॉप ऐप का एक नया संस्करण उपलब्ध है। कृपया अपडेट करने के लिए macOS DMG इंस्टॉलर डाउनलोड और इंस्टॉल करें (वर्तमान: v{version})।",
"downloadExe": "EXE डाउनलोड करें (Windows)",
diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json
index df1de35a5d..2a2a27864c 100644
--- a/src/i18n/messages/hr.json
+++ b/src/i18n/messages/hr.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Model",
"recentRequestsTokens": "Ulaz / Izlaz",
"recentRequestsWhen": "Kada",
+ "topologyLegendActive": "Aktivno",
+ "topologyLegendRecent": "Nedavno",
+ "topologyLegendError": "Greška",
"downloadDmg": "Preuzmi DMG (macOS)",
"downloadDmgDescription": "Dostupna je nova verzija OmniRoute desktop aplikacije. Preuzmite i instalirajte macOS DMG instalacijski paket za ažuriranje (trenutna verzija: v{version}).",
"downloadExe": "Preuzmi EXE (Windows)",
diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json
index d199204fd1..3bed874d82 100644
--- a/src/i18n/messages/hu.json
+++ b/src/i18n/messages/hu.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Egészségügyi Monitor",
"reportIssue": "Probléma bejelentése",
"activeError": "{active} aktív · {errors} hiba",
+ "topologyLegendActive": "Aktív",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Hiba",
"oauthLabel": "OAuth",
"apiKeyLabel": "API kulcs",
"requestsShort": "{count} igény",
@@ -1819,11 +1822,11 @@
"updateStarted": "Frissítés elindult...",
"reloadingPageAutomatically": "Oldal automatikus újratöltése...",
"providerTopology": "Szolgáltató topológia",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG letöltése (macOS)",
"downloadDmgDescription": "Új verzió érhető el az OmniRoute asztali alkalmazásból. Kérjük, töltse le és telepítse a macOS DMG telepítőt a frissítéshez (jelenlegi: v{version}).",
"downloadExe": "Letöltés EXE (Windows)",
diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json
index 4c7c940b4b..2f8ee3c21f 100644
--- a/src/i18n/messages/id.json
+++ b/src/i18n/messages/id.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Pemantau Kesehatan",
"reportIssue": "Laporkan masalah",
"activeError": "{active} aktif · kesalahan {errors}",
+ "topologyLegendActive": "Aktif",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Kesalahan",
"oauthLabel": "OAuth",
"apiKeyLabel": "Kunci API",
"requestsShort": "{count} permintaan",
@@ -1819,11 +1822,11 @@
"updateStarted": "Pembaruan dimulai...",
"reloadingPageAutomatically": "Memuat ulang halaman secara otomatis...",
"providerTopology": "Topologi Penyedia",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Unduh DMG (macOS)",
"downloadDmgDescription": "Versi baru dari aplikasi desktop OmniRoute tersedia. Silakan unduh dan instal penginstal DMG macOS untuk memperbarui (sekarang: v{version}).",
"downloadExe": "Unduh EXE (Windows)",
diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json
index c5cc75c684..b25e8169eb 100644
--- a/src/i18n/messages/it.json
+++ b/src/i18n/messages/it.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitoraggio della salute",
"reportIssue": "Segnala il problema",
"activeError": "{active} attivo · {errors} errore",
+ "topologyLegendActive": "Attivo",
+ "topologyLegendRecent": "Recente",
+ "topologyLegendError": "Errore",
"oauthLabel": "OAuth",
"apiKeyLabel": "Chiave API",
"requestsShort": "{count} richieste",
@@ -1819,11 +1822,11 @@
"updateStarted": "Aggiornamento avviato...",
"reloadingPageAutomatically": "Ricaricamento pagina automaticamente...",
"providerTopology": "Topologia del fornitore",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "Richieste recenti",
+ "recentRequestsEmpty": "Nessuna richiesta per ora.",
+ "recentRequestsModel": "Modello",
+ "recentRequestsTokens": "Ingresso / Uscita",
+ "recentRequestsWhen": "Quando",
"downloadDmg": "Scarica DMG (macOS)",
"downloadDmgDescription": "È disponibile una nuova versione dell'app desktop OmniRoute. Si prega di scaricare e installare il programma di installazione DMG per macOS per aggiornare (attuale: v{version}).",
"downloadExe": "Scarica EXE (Windows)",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 9a15eff936..3f718aaad2 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "ヘルスモニター",
"reportIssue": "問題を報告する",
"activeError": "{active} アクティブ · {errors} エラー",
+ "topologyLegendActive": "アクティブ",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "エラー",
"oauthLabel": "OAuth",
"apiKeyLabel": "APIキー",
"requestsShort": "{count} 件",
@@ -1819,11 +1822,11 @@
"updateStarted": "更新を開始しました...",
"reloadingPageAutomatically": "ページを自動的に再読み込みしています...",
"providerTopology": "プロバイダー トポロジ",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMGをダウンロード (macOS)",
"downloadDmgDescription": "OmniRouteデスクトップアプリの新しいバージョンが利用可能です。macOS DMGインストーラーをダウンロードしてインストールし、更新してください(現在のバージョン: v{version})。",
"downloadExe": "EXEをダウンロード (Windows)",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index d4d36ccba5..90ee52dfc9 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "상태 모니터",
"reportIssue": "문제 신고",
"activeError": "{active} 활성 · {errors} 오류",
+ "topologyLegendActive": "활성",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "오류",
"oauthLabel": "OAuth",
"apiKeyLabel": "API 키",
"requestsShort": "{count} 요청",
@@ -1819,11 +1822,11 @@
"updateStarted": "업데이트 시작됨...",
"reloadingPageAutomatically": "페이지를 자동으로 새로고침하는 중...",
"providerTopology": "공급자 토폴로지",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG 다운로드 (macOS)",
"downloadDmgDescription": "OmniRoute 데스크탑 앱의 새 버전이 출시되었습니다. 업데이트를 위해 macOS DMG 설치 프로그램을 다운로드하고 설치해 주십시오(현재: v{version}).",
"downloadExe": "EXE 다운로드 (Windows)",
diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json
index 0c6370bc56..b2b8e84166 100644
--- a/src/i18n/messages/lt.json
+++ b/src/i18n/messages/lt.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Modelis",
"recentRequestsTokens": "Į / Iš",
"recentRequestsWhen": "Kada",
+ "topologyLegendActive": "Aktyvus",
+ "topologyLegendRecent": "Naujausi",
+ "topologyLegendError": "Klaida",
"downloadDmg": "Atsisiųsti DMG (macOS)",
"downloadDmgDescription": "Yra nauja OmniRoute darbalaukio programos versija. Norėdami atnaujinti, atsisiųskite ir įdiekite macOS DMG diegimo failą (esama versija: v{version}).",
"downloadExe": "Atsisiųsti EXE (Windows)",
diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json
index bacd1f0062..e91c305bb1 100644
--- a/src/i18n/messages/lv.json
+++ b/src/i18n/messages/lv.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Modelis",
"recentRequestsTokens": "Iekšā / Ārā",
"recentRequestsWhen": "Kad",
+ "topologyLegendActive": "Aktīvs",
+ "topologyLegendRecent": "Nesenie",
+ "topologyLegendError": "Kļūda",
"downloadDmg": "Lejupielādēt DMG (macOS)",
"downloadDmgDescription": "Ir pieejama jauna OmniRoute galddatora lietotnes versija. Lūdzu, lejupielādējiet un instalējiet macOS DMG instalatoru, lai atjauninātu (pašreizējā: v{version}).",
"downloadExe": "Lejupielādēt EXE (Windows)",
diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json
index 26abe41a3f..bacb7c00ba 100644
--- a/src/i18n/messages/mr.json
+++ b/src/i18n/messages/mr.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "पृष्ठ स्वयंचलितपणे रीलोड करत आहे...",
"providerTopology": "प्रदाता टोपोलॉजी",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG डाउनलोड करा (macOS)",
"downloadDmgDescription": "OmniRoute डेस्कटॉप अॅपचा एक नवीन आवृत्ती उपलब्ध आहे. कृपया अद्यतन करण्यासाठी macOS DMG इंस्टॉलर डाउनलोड आणि स्थापित करा (सध्याचे: v{version}).",
"downloadExe": "EXE डाउनलोड करा (Windows)",
diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json
index d09067ec3b..3ff0f00703 100644
--- a/src/i18n/messages/ms.json
+++ b/src/i18n/messages/ms.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Pemantau Kesihatan",
"reportIssue": "Laporkan isu",
"activeError": "{active} aktif · {errors} ralat",
+ "topologyLegendActive": "Aktif",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "ralat",
"oauthLabel": "OAuth",
"apiKeyLabel": "Kunci API",
"requestsShort": "{count} permintaan",
@@ -1819,11 +1822,11 @@
"updateStarted": "Kemas kini bermula...",
"reloadingPageAutomatically": "Memuat semula halaman secara automatik...",
"providerTopology": "Topologi Pembekal",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Muat Turun DMG (macOS)",
"downloadDmgDescription": "Versi baru aplikasi desktop OmniRoute tersedia. Sila muat turun dan pasang pemasang DMG macOS untuk mengemas kini (semasa: v{version}).",
"downloadExe": "Muat Turun EXE (Windows)",
diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json
index 70d5c0f1fd..df7cefd3ea 100644
--- a/src/i18n/messages/mt.json
+++ b/src/i18n/messages/mt.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Mudell",
"recentRequestsTokens": "Dħul / Ħruġ",
"recentRequestsWhen": "Meta",
+ "topologyLegendActive": "Attiv",
+ "topologyLegendRecent": "Riċenti",
+ "topologyLegendError": "Żball",
"downloadDmg": "Niżżel id-DMG (macOS)",
"downloadDmgDescription": "Verżjoni ġdida tal-app tad-desktop OmniRoute hija disponibbli. Jekk jogħġbok niżżel u installa l-installatur DMG għal macOS biex taġġorna (attwali: v{version}).",
"downloadExe": "Niżżel l-EXE (Windows)",
diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json
index 897eac642b..94c15eae94 100644
--- a/src/i18n/messages/nl.json
+++ b/src/i18n/messages/nl.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Gezondheidsmonitor",
"reportIssue": "Probleem melden",
"activeError": "{active} actief · {errors} fout",
+ "topologyLegendActive": "Actief",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Fout",
"oauthLabel": "OAuth",
"apiKeyLabel": "API-sleutel",
"requestsShort": "{count} vereisten",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update gestart...",
"reloadingPageAutomatically": "Pagina automatisch herladen...",
"providerTopology": "Provider-topologie",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Download DMG (macOS)",
"downloadDmgDescription": "Er is een nieuwe versie van de OmniRoute desktopapp beschikbaar. Download en installeer alstublieft de macOS DMG-installatieprogramma om bij te werken (huidig: v{version}).",
"downloadExe": "Download EXE (Windows)",
diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json
index f6e6519c8a..65f7ff2dd7 100644
--- a/src/i18n/messages/no.json
+++ b/src/i18n/messages/no.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Helsemonitor",
"reportIssue": "Rapporter problem",
"activeError": "{active} aktiv · {errors} feil",
+ "topologyLegendActive": "Aktiv",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Feil",
"oauthLabel": "OAuth",
"apiKeyLabel": "API-nøkkel",
"requestsShort": "{count} req",
@@ -1819,11 +1822,11 @@
"updateStarted": "Oppdatering startet...",
"reloadingPageAutomatically": "Laster siden automatisk på nytt...",
"providerTopology": "Leverandørtopologi",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Last ned DMG (macOS)",
"downloadDmgDescription": "En ny versjon av OmniRoute skrivebordsappen er tilgjengelig. Vennligst last ned og installer macOS DMG-installasjonsprogrammet for å oppdatere (nåværende: v{version}).",
"downloadExe": "Last ned EXE (Windows)",
diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json
index 3f8f2b49ae..9179c0f80b 100644
--- a/src/i18n/messages/phi.json
+++ b/src/i18n/messages/phi.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitor ng Kalusugan",
"reportIssue": "Iulat ang isyu",
"activeError": "{active} aktibo · {errors} error",
+ "topologyLegendActive": "Aktibo",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} mga kahilingan",
@@ -1819,11 +1822,11 @@
"updateStarted": "Nagsimula ang pag-update...",
"reloadingPageAutomatically": "Awtomatikong nire-reload ang page...",
"providerTopology": "Topology ng Provider",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "I-download ang DMG (macOS)",
"downloadDmgDescription": "Isang bagong bersyon ng OmniRoute desktop app ang available. Mangyaring i-download at i-install ang macOS DMG installer upang mag-update (kasalukuyan: v{version}).",
"downloadExe": "I-download ang EXE (Windows)",
diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json
index e986a46958..67d62723fc 100644
--- a/src/i18n/messages/pl.json
+++ b/src/i18n/messages/pl.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitor stanu",
"reportIssue": "Zgłoś problem",
"activeError": "{active} aktywne · {errors} błąd",
+ "topologyLegendActive": "Aktywne",
+ "topologyLegendRecent": "Ostatnie",
+ "topologyLegendError": "Błąd",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} żądań",
@@ -1819,11 +1822,11 @@
"updateStarted": "Rozpoczęto aktualizację...",
"reloadingPageAutomatically": "Automatyczne przeładowywanie strony...",
"providerTopology": "Topologia Provider",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Pobierz DMG (macOS)",
"downloadDmgDescription": "Dostępna jest nowa wersja aplikacji desktopowej OmniRoute. Proszę pobrać i zainstalować instalator DMG dla macOS, aby zaktualizować (aktualna: v{version}).",
"downloadExe": "Pobierz EXE (Windows)",
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index 5156955d9e..1976e9a16d 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -1808,6 +1808,9 @@
"healthMonitor": "Monitor de Saúde",
"reportIssue": "Reportar problema",
"activeError": "{active} ativo · {errors} erro",
+ "topologyLegendActive": "Ativo",
+ "topologyLegendRecent": "Recente",
+ "topologyLegendError": "Erro",
"oauthLabel": "OAuth",
"apiKeyLabel": "Chave de API",
"requestsShort": "{count} reqs",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index ded9223ecd..0aa621b2a1 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitor de Saúde",
"reportIssue": "Informar problema",
"activeError": "{active} ativo · Erro {errors}",
+ "topologyLegendActive": "Ativo",
+ "topologyLegendRecent": "Recente",
+ "topologyLegendError": "Erro",
"oauthLabel": "OAuth",
"apiKeyLabel": "Chave de API",
"requestsShort": "{count} requisitos",
@@ -1819,11 +1822,11 @@
"updateStarted": "Atualização iniciada...",
"reloadingPageAutomatically": "Recarregando a página automaticamente...",
"providerTopology": "Topologia do provedor",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "Pedidos recentes",
+ "recentRequestsEmpty": "Ainda não há pedidos.",
+ "recentRequestsModel": "Modelo",
+ "recentRequestsTokens": "Entrada / Saída",
+ "recentRequestsWhen": "Quando",
"downloadDmg": "Transferir DMG (macOS)",
"downloadDmgDescription": "Uma nova versão da aplicação de desktop OmniRoute está disponível. Por favor, faça o download e instale o instalador DMG para macOS para atualizar (atual: v{version}).",
"downloadExe": "Descarregar EXE (Windows)",
diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json
index aefd886507..a55d9eee8b 100644
--- a/src/i18n/messages/ro.json
+++ b/src/i18n/messages/ro.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Monitor de sănătate",
"reportIssue": "Raportați problema",
"activeError": "{active} activ · {errors} eroare",
+ "topologyLegendActive": "Activ",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Eroare",
"oauthLabel": "OAuth",
"apiKeyLabel": "Cheia API",
"requestsShort": "{count} solicită",
@@ -1819,11 +1822,11 @@
"updateStarted": "Actualizarea a început...",
"reloadingPageAutomatically": "Se reîncarcă pagina automat...",
"providerTopology": "Topologia furnizorului",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Descarcă DMG (macOS)",
"downloadDmgDescription": "O nouă versiune a aplicației desktop OmniRoute este disponibilă. Vă rugăm să descărcați și să instalați programul de instalare DMG pentru macOS pentru a actualiza (curent: v{version}).",
"downloadExe": "Descarcă EXE (Windows)",
diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json
index 39593d9562..b4637e534a 100644
--- a/src/i18n/messages/ru.json
+++ b/src/i18n/messages/ru.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Монитор здоровья",
"reportIssue": "Сообщить о проблеме",
"activeError": "{active} активен · {errors} ошибка",
+ "topologyLegendActive": "Активный",
+ "topologyLegendRecent": "Недавнее",
+ "topologyLegendError": "Ошибка",
"oauthLabel": "OAuth",
"apiKeyLabel": "API-ключ",
"requestsShort": "{count} требуется",
@@ -1819,11 +1822,11 @@
"updateStarted": "Обновление начато...",
"reloadingPageAutomatically": "Автоматическая перезагрузка страницы...",
"providerTopology": "Топология провайдера",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Скачать DMG (macOS)",
"downloadDmgDescription": "Доступна новая версия настольного приложения OmniRoute. Пожалуйста, загрузите и установите установщик DMG для macOS, чтобы обновить (текущая: v{version}).",
"downloadExe": "Скачать EXE (Windows)",
diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json
index cf207dca50..501d36a395 100644
--- a/src/i18n/messages/sk.json
+++ b/src/i18n/messages/sk.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Nahlásiť problém",
"activeError": "{active} aktívny · {errors} chyba",
+ "topologyLegendActive": "Aktívne",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Chyba",
"oauthLabel": "OAuth",
"apiKeyLabel": "API kľúč",
"requestsShort": "{count} req",
@@ -1819,11 +1822,11 @@
"updateStarted": "Aktualizácia spustená...",
"reloadingPageAutomatically": "Automaticky sa znova načítava stránka...",
"providerTopology": "Topológia poskytovateľa",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Stiahnuť DMG (macOS)",
"downloadDmgDescription": "Nová verzia desktopovej aplikácie OmniRoute je k dispozícii. Prosím, stiahnite a nainštalujte inštalátor DMG pre macOS na aktualizáciu (aktuálna: v{version}).",
"downloadExe": "Stiahnuť EXE (Windows)",
diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json
index e8f192e07c..6266e4d2df 100644
--- a/src/i18n/messages/sl.json
+++ b/src/i18n/messages/sl.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Model",
"recentRequestsTokens": "Vhod / izhod",
"recentRequestsWhen": "Čas",
+ "topologyLegendActive": "Aktivno",
+ "topologyLegendRecent": "Nedavno",
+ "topologyLegendError": "Napaka",
"downloadDmg": "Prenesi DMG (macOS)",
"downloadDmgDescription": "Na voljo je nova različica namizne aplikacije OmniRoute. Za posodobitev prenesite in namestite namestitveni program DMG za macOS (trenutno: v{version}).",
"downloadExe": "Prenesi EXE (Windows)",
diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json
index 21dd809eca..29049d46cc 100644
--- a/src/i18n/messages/sr.json
+++ b/src/i18n/messages/sr.json
@@ -1824,6 +1824,9 @@
"recentRequestsModel": "Модел",
"recentRequestsTokens": "Улаз / Излаз",
"recentRequestsWhen": "Када",
+ "topologyLegendActive": "Активно",
+ "topologyLegendRecent": "Недавно",
+ "topologyLegendError": "Грешка",
"downloadDmg": "Преузми DMG (macOS)",
"downloadDmgDescription": "Доступна је нова верзија OmniRoute десктоп апликације. Молимо преузмите и инсталирајте macOS DMG инсталер да бисте ажурирали (тренутно: v{version}).",
"downloadExe": "Преузми EXE (Windows)",
diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json
index b15de5fa3e..d251e49453 100644
--- a/src/i18n/messages/sv.json
+++ b/src/i18n/messages/sv.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Hälsoövervakare",
"reportIssue": "Rapportera problem",
"activeError": "{active} aktiv · {errors} fel",
+ "topologyLegendActive": "Aktiv",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Fel",
"oauthLabel": "OAuth",
"apiKeyLabel": "API-nyckel",
"requestsShort": "{count} krav",
@@ -1819,11 +1822,11 @@
"updateStarted": "Uppdatering startade...",
"reloadingPageAutomatically": "Laddar om sidan automatiskt...",
"providerTopology": "Leverantörstopologi",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Ladda ner DMG (macOS)",
"downloadDmgDescription": "En ny version av OmniRoute-skrivbordsappen är tillgänglig. Vänligen ladda ner och installera macOS DMG-installationsprogrammet för att uppdatera (nuvarande: v{version}).",
"downloadExe": "Ladda ner EXE (Windows)",
diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json
index 1c81e25a00..de2e0bd5a2 100644
--- a/src/i18n/messages/sw.json
+++ b/src/i18n/messages/sw.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "Inapakia upya ukurasa kiotomatiki...",
"providerTopology": "Topolojia ya mtoaji",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Pakua DMG (macOS)",
"downloadDmgDescription": "Toleo jipya la programu ya desktop ya OmniRoute linapatikana. Tafadhali pakua na sakinisha msanidi wa DMG wa macOS ili kusasisha (sasa: v{version}).",
"downloadExe": "Pakua EXE (Windows)",
diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json
index 7313728f83..bf97cd4ede 100644
--- a/src/i18n/messages/ta.json
+++ b/src/i18n/messages/ta.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "தானாக பக்கத்தை மீண்டும் ஏற்றுகிறது...",
"providerTopology": "வழங்குநர் இடவியல்",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG ஐ பதிவிறக்கம் செய்யவும் (macOS)",
"downloadDmgDescription": "OmniRoute டெஸ்க்டாப் செயலியின் புதிய பதிப்பு கிடைக்கிறது. தயவுசெய்து புதுப்பிக்க macOS DMG நிறுவுநரை பதிவிறக்கம் செய்து நிறுவவும் (தற்போதைய: v{version}).",
"downloadExe": "EXE பதிவிறக்கம் (Windows)",
diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json
index 9c8fefb5ac..abbfbe3fc2 100644
--- a/src/i18n/messages/te.json
+++ b/src/i18n/messages/te.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "పేజీని స్వయంచాలకంగా రీలోడ్ చేస్తోంది...",
"providerTopology": "ప్రొవైడర్ టోపాలజీ",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG డౌన్లోడ్ చేయండి (macOS)",
"downloadDmgDescription": "ఒక కొత్త సంచిక OmniRoute డెస్క్టాప్ యాప్ అందుబాటులో ఉంది. దయచేసి నవీకరించడానికి macOS DMG ఇన్స్టాలర్ను డౌన్లోడ్ చేసి ఇన్స్టాల్ చేయండి (ప్రస్తుత: v{version}).",
"downloadExe": "EXE డౌన్లోడ్ చేయండి (విండోస్)",
diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json
index 67cb1ca41c..d7a972ec27 100644
--- a/src/i18n/messages/th.json
+++ b/src/i18n/messages/th.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "การตรวจสุขภาพ",
"reportIssue": "รายงานปัญหา",
"activeError": "{active} ใช้งานอยู่ · ข้อผิดพลาด {errors}",
+ "topologyLegendActive": "ใช้งานอยู่",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "เกิดข้อผิดพลาด",
"oauthLabel": "OAuth",
"apiKeyLabel": "คีย์ API",
"requestsShort": "{count} ความต้องการ",
@@ -1819,11 +1822,11 @@
"updateStarted": "เริ่มการอัพเดต...",
"reloadingPageAutomatically": "กำลังโหลดหน้าซ้ำโดยอัตโนมัติ...",
"providerTopology": "โทโพโลยีของผู้ให้บริการ",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "ดาวน์โหลด DMG (macOS)",
"downloadDmgDescription": "มีเวอร์ชันใหม่ของแอปเดสก์ท็อป OmniRoute พร้อมใช้งาน กรุณาดาวน์โหลดและติดตั้งตัวติดตั้ง macOS DMG เพื่อทำการอัปเดต (ปัจจุบัน: v{version}).",
"downloadExe": "ดาวน์โหลด EXE (Windows)",
diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json
index b034c56c9f..7892a3f00b 100644
--- a/src/i18n/messages/tr.json
+++ b/src/i18n/messages/tr.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Sağlık Monitörü",
"reportIssue": "Sorunu bildir",
"activeError": "{active} aktif · {errors} hata",
+ "topologyLegendActive": "Aktif",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Hata",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Anahtarı",
"requestsShort": "{count} istek",
@@ -1819,11 +1822,11 @@
"updateStarted": "Güncelleme başladı...",
"reloadingPageAutomatically": "Sayfa otomatik olarak yeniden yükleniyor...",
"providerTopology": "Sağlayıcı Topolojisi",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG İndir (macOS)",
"downloadDmgDescription": "OmniRoute masaüstü uygulamasının yeni bir sürümü mevcut. Lütfen güncellemek için macOS DMG yükleyicisini indirin ve kurun (mevcut: v{version}).",
"downloadExe": "EXE İndir (Windows)",
diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json
index f006bcf52b..e86f5fd0a8 100644
--- a/src/i18n/messages/uk-UA.json
+++ b/src/i18n/messages/uk-UA.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Монітор здоров'я",
"reportIssue": "Повідомити про проблему",
"activeError": "{active} активний · {errors} помилка",
+ "topologyLegendActive": "Активний",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "Помилка",
"oauthLabel": "OAuth",
"apiKeyLabel": "Ключ API",
"requestsShort": "{count} вимагається",
@@ -1819,11 +1822,11 @@
"updateStarted": "Оновлення розпочато...",
"reloadingPageAutomatically": "Автоматичне перезавантаження сторінки...",
"providerTopology": "Топологія провайдера",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "Завантажити DMG (macOS)",
"downloadDmgDescription": "Доступна нова версія настільного додатку OmniRoute. Будь ласка, завантажте та встановіть установник DMG для macOS, щоб оновити (поточна: v{version}).",
"downloadExe": "Завантажити EXE (Windows)",
diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json
index 3ac0fe80c9..9f240aa287 100644
--- a/src/i18n/messages/ur.json
+++ b/src/i18n/messages/ur.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error",
+ "topologyLegendActive": "__MISSING__:Active",
+ "topologyLegendRecent": "__MISSING__:Recent",
+ "topologyLegendError": "__MISSING__:Error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
@@ -1819,11 +1822,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "صفحہ خودکار طور پر دوبارہ لوڈ ہو رہا ہے...",
"providerTopology": "فراہم کنندہ ٹوپولوجی",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "DMG ڈاؤن لوڈ کریں (macOS)",
"downloadDmgDescription": "OmniRoute ڈیسک ٹاپ ایپ کا نیا ورژن دستیاب ہے۔ براہ کرم اپ ڈیٹ کرنے کے لیے macOS DMG انسٹالر ڈاؤن لوڈ اور انسٹال کریں (موجودہ: v{version})۔",
"downloadExe": "EXE ڈاؤن لوڈ کریں (ونڈوز)",
diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json
index b6d7e2f640..155d41ce84 100644
--- a/src/i18n/messages/vi.json
+++ b/src/i18n/messages/vi.json
@@ -1808,6 +1808,9 @@
"healthMonitor": "Trình theo dõi tình trạng",
"reportIssue": "Báo cáo sự cố",
"activeError": "{active} đang hoạt động · {errors} lỗi",
+ "topologyLegendActive": "Đang hoạt động",
+ "topologyLegendRecent": "Gần đây",
+ "topologyLegendError": "Lỗi",
"oauthLabel": "OAuth",
"apiKeyLabel": "Khóa API",
"requestsShort": "{count} yêu cầu",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 6454daa09b..346831b001 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "健康监测",
"reportIssue": "报告问题",
"activeError": "{active} 有效 · {errors} 错误",
+ "topologyLegendActive": "启用中",
+ "topologyLegendRecent": "最近",
+ "topologyLegendError": "错误",
"oauthLabel": "OAuth",
"apiKeyLabel": "API密钥",
"requestsShort": "{count} 次请求",
@@ -1819,11 +1822,11 @@
"updateStarted": "更新已开始...",
"reloadingPageAutomatically": "自动重新加载页面...",
"providerTopology": "提供者拓扑",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "下载 DMG (macOS)",
"downloadDmgDescription": "OmniRoute 桌面应用程序的新版本可用。请下载并安装 macOS DMG 安装程序以进行更新(当前版本:v{version})。",
"downloadExe": "下载 EXE(Windows)",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 7c335e49bf..63f8e394bc 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -1807,6 +1807,9 @@
"healthMonitor": "健康監測",
"reportIssue": "報告問題",
"activeError": "{active} 有效 · {errors} 錯誤",
+ "topologyLegendActive": "啟用中",
+ "topologyLegendRecent": "最近",
+ "topologyLegendError": "錯誤",
"oauthLabel": "OAuth",
"apiKeyLabel": "API金鑰",
"requestsShort": "{count} 次請求",
@@ -1819,11 +1822,11 @@
"updateStarted": "更新已開始...",
"reloadingPageAutomatically": "自動重新載入頁面...",
"providerTopology": "提供者拓撲",
- "recentRequests": "Recent Requests",
- "recentRequestsEmpty": "No requests yet.",
- "recentRequestsModel": "Model",
- "recentRequestsTokens": "In / Out",
- "recentRequestsWhen": "When",
+ "recentRequests": "__MISSING__:Recent Requests",
+ "recentRequestsEmpty": "__MISSING__:No requests yet.",
+ "recentRequestsModel": "__MISSING__:Model",
+ "recentRequestsTokens": "__MISSING__:In / Out",
+ "recentRequestsWhen": "__MISSING__:When",
"downloadDmg": "下載 DMG (macOS)",
"downloadDmgDescription": "OmniRoute 桌面應用程式的新版本已經可用。請下載並安裝 macOS DMG 安裝程式以進行更新(目前版本:v{version})。",
"downloadExe": "下載 EXE (Windows)",
diff --git a/src/lib/cloudAgent/db.ts b/src/lib/cloudAgent/db.ts
index 9d7f539078..7a93cef62e 100644
--- a/src/lib/cloudAgent/db.ts
+++ b/src/lib/cloudAgent/db.ts
@@ -121,7 +121,10 @@ export function updateCloudAgentTask(
WHERE id = @id
`
).run({ id, ...validUpdates });
- emitAgentTaskUpdated("cloud-agent", id, (validUpdates.status as string) ?? "updated");
+ // Publish the row's real status: an update that only touches result/activities/error must
+ // not fabricate a state the canvas has never heard of. No row means nothing was written.
+ const state = (validUpdates.status as string | undefined) ?? getCloudAgentTaskById(id)?.status;
+ if (state) emitAgentTaskUpdated("cloud-agent", id, state);
}
export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null {
diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts
index 0cb93c8fee..ed5930cbb7 100644
--- a/src/lib/config/runtimeSettings.ts
+++ b/src/lib/config/runtimeSettings.ts
@@ -323,10 +323,11 @@ async function applyBackgroundDegradationSection(backgroundDegradation: JsonReco
setBackgroundDegradationConfig({
enabled: backgroundDegradation.enabled === true,
- degradationMap: {
- ...getDefaultDegradationMap(),
- ...normalizeStringRecord(backgroundDegradation.degradationMap),
- },
+ // #12424: a present stored record is authoritative for degradationMap — do NOT back-fill
+ // defaults, or a key the user deleted (absent from the stored map) resurrects on every
+ // apply/restart. Mirrors detectionPatterns below, which already treats a present stored
+ // value as authoritative and only falls back to defaults when it is empty.
+ degradationMap: normalizeStringRecord(backgroundDegradation.degradationMap),
detectionPatterns:
normalizeStringArray(backgroundDegradation.detectionPatterns).length > 0
? normalizeStringArray(backgroundDegradation.detectionPatterns)
diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts
index a837909df7..01e84a7964 100644
--- a/src/lib/db/cleanup.ts
+++ b/src/lib/db/cleanup.ts
@@ -13,6 +13,7 @@ import {
deleteAllFromTable,
deleteCallLogArtifacts,
deleteFromTableBefore,
+ deleteFromTableBeforeInBatches,
tableExists,
type DeleteByPeriodTarget,
} from "./cleanup/usagePurge";
@@ -430,6 +431,103 @@ export async function cleanupCcrBlocks(): Promise {
return result;
}
+/**
+ * Clean up conversation_turn_nodes older than the call-log retention window (#12453).
+ *
+ * The nodes are identity-only: the transcript view resolves each turn's display
+ * content from the call_logs row `last_correlation_id` points at. Once
+ * cleanupCallLogs purges that row the node can never render again, so the two
+ * tables share the dashboard database setting `retention.callLogs` instead of
+ * a knob of their own; `CALL_LOG_RETENTION_DAYS` configures the separate
+ * compliance cleanup path and does not override this window. Deleting an old
+ * node only affects reconnect anchors: a conversation resumed after the window
+ * mints a new id, which is already the documented anchor-miss behavior of
+ * resolveConversationId. `last_seen_at` has no index (migration 156), so
+ * each DELETE is a table scan. Bounded batches yield between writes so an
+ * existing large table cannot park the event loop for the whole cleanup pass.
+ */
+export async function cleanupConversationTurnNodes(): Promise {
+ const retention = getRetentionSettings();
+
+ const retentionDays = retention.callLogs;
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
+ const cutoffISO = cutoffDate.toISOString();
+
+ const result: CleanupResult = { deleted: 0, errors: 0 };
+
+ try {
+ result.deleted = await deleteFromTableBeforeInBatches(
+ { table: "conversation_turn_nodes", column: "last_seen_at", cutoff: "iso" },
+ cutoffISO
+ );
+
+ console.log(
+ `[Cleanup] Deleted ${result.deleted} conversation_turn_nodes older than ${retentionDays} days`
+ );
+ } catch (err: unknown) {
+ console.error("[Cleanup] Error cleaning conversation_turn_nodes:", err);
+ result.errors++;
+ }
+
+ return result;
+}
+
+/**
+ * Sweep agentic_conversations left without any conversation_turn_nodes (#12453).
+ *
+ * Runs after cleanupConversationTurnNodes so a root whose whole chain just
+ * expired goes in the same pass. The indexed `last_seen_at` predicate bounds
+ * the NOT EXISTS probe to roots that are already past the retention window.
+ * Deletion is batched for the same event-loop fairness guarantee as the
+ * preceding node cleanup.
+ */
+export async function cleanupAgenticConversations(): Promise {
+ const db = getDbInstance();
+ const retention = getRetentionSettings();
+
+ const retentionDays = retention.callLogs;
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
+ const cutoffISO = cutoffDate.toISOString();
+
+ const result: CleanupResult = { deleted: 0, errors: 0 };
+
+ try {
+ if (!tableExists("agentic_conversations") || !tableExists("conversation_turn_nodes")) {
+ return result;
+ }
+
+ const stmt = db.prepare(
+ `DELETE FROM agentic_conversations
+ WHERE rowid IN (
+ SELECT rowid FROM agentic_conversations
+ WHERE last_seen_at < ?
+ AND NOT EXISTS (
+ SELECT 1 FROM conversation_turn_nodes n
+ WHERE n.conversation_id = agentic_conversations.id
+ )
+ LIMIT 10000
+ )`
+ );
+ while (true) {
+ const batch = stmt.run(cutoffISO).changes;
+ result.deleted += batch;
+ if (batch < 10_000) break;
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+
+ console.log(
+ `[Cleanup] Deleted ${result.deleted} orphaned agentic_conversations older than ${retentionDays} days`
+ );
+ } catch (err: unknown) {
+ console.error("[Cleanup] Error cleaning agentic_conversations:", err);
+ result.errors++;
+ }
+
+ return result;
+}
+
/**
* Run all cleanup functions if auto-cleanup is enabled.
*/
@@ -463,6 +561,8 @@ export async function runAutoCleanup(): Promise<{
compressionRunTelemetry: await cleanupCompressionRunTelemetry(),
proxyLogs: await cleanupProxyLogs(),
ccrBlocks: await cleanupCcrBlocks(),
+ conversationTurnNodes: await cleanupConversationTurnNodes(),
+ agenticConversations: await cleanupAgenticConversations(),
};
const totalDeleted = Object.values(results).reduce((sum, r) => sum + r.deleted, 0);
@@ -588,6 +688,8 @@ export interface ResetUsageHistoryResult extends CleanupResult {
deletedRoutingDecisions: number;
deletedQuotaConsumption: number;
deletedTokenLedger: number;
+ deletedConversationTurnNodes: number;
+ deletedAgenticConversations: number;
}
function isResetUsageHistoryPeriod(period: string): period is ResetUsageHistoryPeriod {
@@ -604,10 +706,13 @@ function isResetUsageHistoryPeriod(period: string): period is ResetUsageHistoryP
* first, since the whole point is to wipe the data the user selected.
*
* @param period - One of {@link RESET_USAGE_HISTORY_PERIODS}. `"all"` wipes
- * every row in all three tables; any other value deletes rows strictly
- * older than `now - period`. Throws on an invalid period.
+ * every reset target, including conversation identity metadata; any other
+ * value deletes only time-scoped usage/log rows older than `now - period`.
+ * Throws on an invalid period.
*/
-const RESET_TARGETS: Array = [
+const RESET_TARGETS: Array<
+ DeleteByPeriodTarget & { resultKey: keyof ResetUsageHistoryResult; allOnly?: boolean }
+> = [
{ table: "usage_history", column: "timestamp", cutoff: "iso", resultKey: "deletedUsageHistory" },
{
table: "daily_usage_summary",
@@ -660,6 +765,20 @@ const RESET_TARGETS: Array {
@@ -684,6 +803,8 @@ export async function resetUsageHistory(period: string): Promise {
- switch (target.cutoff) {
- case "date":
- return cutoffIso.slice(0, 10);
- case "dateHour":
- return `${cutoffIso.slice(0, 10)} ${cutoffIso.slice(11, 13)}:00:00`;
- case "epochMs":
- return new Date(cutoffIso).getTime();
- case "epochSeconds":
- return Math.floor(new Date(cutoffIso).getTime() / 1000);
- case "iso":
- default:
- return cutoffIso;
- }
- })();
-
return getDbInstance()
.prepare(`DELETE FROM ${target.table} WHERE ${target.column} < ?`)
- .run(cutoff).changes;
+ .run(cutoffValue(target, cutoffIso)).changes;
+}
+
+export async function deleteFromTableBeforeInBatches(
+ target: DeleteByPeriodTarget,
+ cutoffIso: string
+): Promise {
+ if (!tableExists(target.table)) return 0;
+
+ const statement = getDbInstance().prepare(
+ `DELETE FROM ${target.table}
+ WHERE rowid IN (
+ SELECT rowid FROM ${target.table}
+ WHERE ${target.column} < ?
+ LIMIT ?
+ )`
+ );
+ const cutoff = cutoffValue(target, cutoffIso);
+ let deleted = 0;
+
+ while (true) {
+ const batch = statement.run(cutoff, DELETE_BATCH_SIZE).changes;
+ deleted += batch;
+ if (batch < DELETE_BATCH_SIZE) return deleted;
+ await new Promise((resolve) => setImmediate(resolve));
+ }
}
export function collectCallLogArtifactsBefore(cutoffIso: string): string[] {
diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts
index 8bb5fba4aa..12085a633a 100644
--- a/src/lib/db/gamification.ts
+++ b/src/lib/db/gamification.ts
@@ -162,6 +162,21 @@ export function addXp(apiKeyId: string, action: string, amount: number, metadata
)
.run(apiKeyId, action, amount, metadata ?? null);
+ // Durable per-key/per-action counter (#12546). xp_audit_log is pruned by
+ // retention.xpAuditLog (default 30 days), so counting action-count badge
+ // progress directly off that table silently reset every "lifetime" milestone.
+ // Increment a durable counter here, alongside the audit insert, using the same
+ // per-row weight getActionCount() reads: the metadata `amount` when present
+ // (token_share stores the shared amount there), otherwise 1.
+ db()
+ .prepare(
+ `INSERT INTO xp_action_counts (api_key_id, action, count, updated_at)
+ VALUES (?, ?, COALESCE(CAST(json_extract(?, '$.amount') AS INTEGER), 1), datetime('now'))
+ ON CONFLICT(api_key_id, action)
+ DO UPDATE SET count = count + excluded.count, updated_at = datetime('now')`
+ )
+ .run(apiKeyId, action, metadata ?? null);
+
db()
.prepare(
`INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at)
@@ -207,10 +222,17 @@ export function updateLevel(apiKeyId: string, level: number): void {
// ──────────────── Badges ────────────────
-export function unlockBadge(apiKeyId: string, badgeId: string): void {
- db()
+/**
+ * Award a badge to an API key. Idempotent on the `(api_key_id, badge_id)` primary key.
+ *
+ * @returns `true` when this call inserted the badge, `false` when it was already earned.
+ * Callers that pay the `badge_unlock` XP reward key off this so a badge is paid once.
+ */
+export function unlockBadge(apiKeyId: string, badgeId: string): boolean {
+ const result = db()
.prepare(`INSERT OR IGNORE INTO user_badges (api_key_id, badge_id) VALUES (?, ?)`)
.run(apiKeyId, badgeId);
+ return result.changes > 0;
}
/**
@@ -228,6 +250,24 @@ export function hasBadge(apiKeyId: string, badgeId: string): boolean {
return !!row;
}
+/**
+ * Whether `xp_audit_log` already holds an entry for this action on the current UTC day.
+ *
+ * `created_at` is written by the table default `datetime('now')` as
+ * `"YYYY-MM-DD HH:MM:SS"` (UTC), so a lexical compare against `date('now')` selects
+ * today's rows. Used as the once-per-day guard for daily rewards such as `streak_bonus`.
+ */
+export function hasXpActionToday(apiKeyId: string, action: string): boolean {
+ const row = db()
+ .prepare(
+ `SELECT 1 FROM xp_audit_log
+ WHERE api_key_id = ? AND action = ? AND created_at >= date('now')
+ LIMIT 1`
+ )
+ .get(apiKeyId, action);
+ return !!row;
+}
+
export function getBadges(apiKeyId: string): UserBadge[] {
const rows = db()
.prepare(
diff --git a/src/lib/db/migrations/176_xp_action_counts.sql b/src/lib/db/migrations/176_xp_action_counts.sql
new file mode 100644
index 0000000000..5b2acd9b4e
--- /dev/null
+++ b/src/lib/db/migrations/176_xp_action_counts.sql
@@ -0,0 +1,31 @@
+-- Migration 176: Durable per-key/per-action counters for gamification (#12546)
+--
+-- getActionCount() (src/lib/gamification/badges.ts) and checkActionCountBadges()
+-- (src/lib/gamification/events.ts) used to count rows directly in xp_audit_log,
+-- which cleanupXpAuditLog() prunes by retention.xpAuditLog (default 30 days). So
+-- the "lifetime" action-count milestones (First Token, Token Consumer, …) were
+-- really "requests in the last 30 days" and were lost once the audit rows aged
+-- out. This table keeps a durable running total per (api_key_id, action) that the
+-- retention prune never touches — mirroring how user_levels.total_xp is a durable
+-- aggregate rather than a live COUNT over xp_audit_log.
+
+CREATE TABLE IF NOT EXISTS xp_action_counts (
+ api_key_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ count INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (api_key_id, action)
+) WITHOUT ROWID;
+
+-- Backfill current lifetime totals from whatever xp_audit_log rows survive today.
+-- Uses the same per-row weight getActionCount() applied: the metadata `amount`
+-- when present (token_share records the shared amount there), otherwise 1.
+-- INSERT OR IGNORE keeps the migration idempotent if it is ever re-executed.
+INSERT OR IGNORE INTO xp_action_counts (api_key_id, action, count, updated_at)
+SELECT
+ api_key_id,
+ action,
+ SUM(COALESCE(CAST(json_extract(metadata, '$.amount') AS INTEGER), 1)) AS count,
+ datetime('now')
+FROM xp_audit_log
+GROUP BY api_key_id, action;
diff --git a/src/lib/gamification/badges.ts b/src/lib/gamification/badges.ts
index 4111489d71..b7095c8202 100644
--- a/src/lib/gamification/badges.ts
+++ b/src/lib/gamification/badges.ts
@@ -319,7 +319,15 @@ type BadgeCriteria =
// ─── Helper: Action Count ────────────────────────────────────────────────────
/**
- * Get the total count of a specific action for an API key from the XP audit log.
+ * Get the durable lifetime count of a specific action for an API key.
+ *
+ * Reads the durable `xp_action_counts` counter (#12546) rather than counting
+ * rows in `xp_audit_log`. The audit log is pruned by `retention.xpAuditLog`
+ * (default 30 days), so counting it directly turned every "lifetime"
+ * action-count milestone into "actions in the last 30 days". The counter is
+ * incremented in `addXp()` alongside each audit insert and is never touched by
+ * the retention prune, so `checkActionCountBadges()` (events.ts) and this
+ * function now agree on the same durable source.
*/
async function getActionCount(apiKeyId: string, action: string): Promise {
const { getDbInstance } = await import("../db/core");
@@ -327,14 +335,7 @@ async function getActionCount(apiKeyId: string, action: string): Promise
const row = db
.prepare(
- `SELECT COALESCE(SUM(
- CASE WHEN metadata IS NOT NULL
- THEN CAST(json_extract(metadata, '$.amount') AS INTEGER)
- ELSE 1
- END
- ), 0) AS total
- FROM xp_audit_log
- WHERE api_key_id = ? AND action = ?`
+ `SELECT count AS total FROM xp_action_counts WHERE api_key_id = ? AND action = ?`
)
.get(apiKeyId, action) as { total: number } | undefined;
diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts
index 9bd52a8d24..3560037a26 100644
--- a/src/lib/gamification/events.ts
+++ b/src/lib/gamification/events.ts
@@ -5,6 +5,7 @@
*/
import { logger } from "../../../open-sse/utils/logger.ts";
+import { calculateLevel, XP_REWARDS } from "./xp";
const log = logger("GAMIFICATION");
@@ -57,23 +58,19 @@ export async function emitGamificationEvent(params: {
const { addXp } = await import("../db/gamification");
addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined);
- // Update level
- const { getXp, updateLevel } = await import("../db/gamification");
- const xp = getXp(apiKeyId);
- if (xp) {
- const { calculateLevel } = await import("./xp");
- const newLevel = calculateLevel(xp.totalXp);
- if (newLevel !== xp.currentLevel) {
- updateLevel(apiKeyId, newLevel);
- log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel });
- }
- }
+ await syncLevel(apiKeyId);
}
// 2. Update streak
if (action === "request") {
- const { updateStreak } = await import("./streaks");
- const streak = await updateStreak(apiKeyId);
+ const { advanceStreak } = await import("./streaks");
+ const { currentStreak: streak, extended } = await advanceStreak(apiKeyId);
+
+ // Pay the documented streak_bonus (XP_REWARDS: per consecutive streak day, multiplied
+ // by streak length) on the one request per UTC day that extends the streak.
+ if (extended) {
+ await awardStreakBonus(apiKeyId, streak);
+ }
// Check streak badges
if (streak >= 365) {
@@ -112,6 +109,54 @@ export async function emitGamificationEvent(params: {
}
}
+/**
+ * Recompute the level from total XP and persist it when it changed.
+ * Runs after every award so bonus XP (streaks, badges) also counts toward level-ups.
+ */
+async function syncLevel(apiKeyId: string): Promise {
+ const { getXp, updateLevel } = await import("../db/gamification");
+ const xp = getXp(apiKeyId);
+ if (!xp) return;
+ const newLevel = calculateLevel(xp.totalXp);
+ if (newLevel !== xp.currentLevel) {
+ updateLevel(apiKeyId, newLevel);
+ log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel });
+ }
+}
+
+/**
+ * Award a bonus reward (`streak_bonus`, `badge_unlock`) through the same path as action XP:
+ * `xp_audit_log` + `user_levels` via addXp, level sync, and the global/weekly/monthly
+ * leaderboard scopes. Idempotency is the caller's responsibility.
+ */
+async function awardBonusXp(
+ apiKeyId: string,
+ action: "streak_bonus" | "badge_unlock",
+ amount: number,
+ metadata: Record
+): Promise {
+ const { addXp } = await import("../db/gamification");
+ addXp(apiKeyId, action, amount, JSON.stringify(metadata));
+ await syncLevel(apiKeyId);
+
+ const { updateScore } = await import("./leaderboard");
+ await updateScore(apiKeyId, "global", amount);
+ await updateScore(apiKeyId, "weekly", amount);
+ await updateScore(apiKeyId, "monthly", amount);
+ log.info("events.bonus_awarded", { apiKeyId, action, amount, ...metadata });
+}
+
+/**
+ * Pay `streak_bonus × streak` once per UTC day. The `xp_audit_log` same-day check and the
+ * insert run synchronously with no await in between, so two requests racing at the day
+ * boundary cannot both pay.
+ */
+async function awardStreakBonus(apiKeyId: string, streak: number): Promise {
+ const { hasXpActionToday } = await import("../db/gamification");
+ if (hasXpActionToday(apiKeyId, "streak_bonus")) return;
+ await awardBonusXp(apiKeyId, "streak_bonus", XP_REWARDS.streak_bonus * streak, { streak });
+}
+
/**
* Get XP amount for an action.
*/
@@ -130,20 +175,28 @@ function getXpForAction(action: string): number {
}
/**
- * Check and unlock a specific badge.
+ * Check and unlock a specific badge, paying the documented `badge_unlock` XP once per badge.
+ *
+ * @param rewardable - `false` for recognition-only unlocks (Radar supporter): the caller
+ * supplies a one-way identity, so the unlock neither earns XP nor logs the identity.
*/
async function checkAndUnlockBadge(
apiKeyId: string,
badgeId: string,
- logIdentity = true
+ rewardable = true
): Promise {
const { unlockBadge, hasBadge } = await import("../db/gamification");
// #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is
// empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on
// every request.
if (!hasBadge(apiKeyId, badgeId)) {
- unlockBadge(apiKeyId, badgeId);
- log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId });
+ // unlockBadge is INSERT OR IGNORE on the (api_key_id, badge_id) primary key; only the call
+ // that actually inserts the row pays, so concurrent unlocks cannot double-pay.
+ const inserted = unlockBadge(apiKeyId, badgeId);
+ log.info("events.badge_unlocked", rewardable ? { apiKeyId, badgeId } : { badgeId });
+ if (inserted && rewardable) {
+ await awardBonusXp(apiKeyId, "badge_unlock", XP_REWARDS.badge_unlock, { badgeId });
+ }
// Look up badge details from badge_definitions
const { getDbInstance } = await import("../db/core");
@@ -172,14 +225,18 @@ async function checkActionCountBadges(apiKeyId: string, action: string): Promise
const { getDbInstance } = await import("../db/core");
const db = getDbInstance();
- // Count total actions of this type
+ // Read the durable per-key/per-action counter (#12546), the same source
+ // getActionCount() (badges.ts) reads. Counting xp_audit_log directly here
+ // undercounted every "lifetime" milestone once the retention prune
+ // (cleanupXpAuditLog, default 30 days) aged the rows out. The counter is
+ // maintained in addXp() alongside the audit insert and survives the prune.
const row = db
.prepare(
- "SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
+ "SELECT COALESCE(count, 0) AS count FROM xp_action_counts WHERE api_key_id = ? AND action = ?"
)
- .get(apiKeyId, action) as { count: number };
+ .get(apiKeyId, action) as { count: number } | undefined;
- const count = row.count;
+ const count = row?.count ?? 0;
// Badge thresholds
const thresholds: Record> = {
diff --git a/src/lib/gamification/streaks.ts b/src/lib/gamification/streaks.ts
index 4406375ac1..9c9303375c 100644
--- a/src/lib/gamification/streaks.ts
+++ b/src/lib/gamification/streaks.ts
@@ -157,7 +157,39 @@ export async function getAggregateStreak(): Promise<
* console.log(count); // 8
*/
export async function updateStreak(apiKeyId: string): Promise {
- if (isBuildPhase || isCloud) return 0;
+ const { currentStreak } = await advanceStreak(apiKeyId);
+ return currentStreak;
+}
+
+/**
+ * Result of {@link advanceStreak}.
+ */
+export interface StreakAdvance {
+ /** Current consecutive active days after this call */
+ currentStreak: number;
+ /**
+ * `true` only on the call that extended the streak onto a new consecutive day
+ * (yesterday was active, today was not yet counted). `false` when today was
+ * already counted, when a new streak starts at 1, or when streaks are disabled.
+ */
+ extended: boolean;
+}
+
+/**
+ * Same as {@link updateStreak}, but also reports whether this call extended the
+ * streak onto a new consecutive day. The award pipeline uses `extended` to pay
+ * the `streak_bonus` reward once per UTC day; repeated requests on the same day
+ * see `extended: false` because the record already carries today's date.
+ *
+ * @param apiKeyId - The API key identifier
+ * @returns The new streak count and whether it just extended
+ *
+ * @example
+ * const { currentStreak, extended } = await advanceStreak("key_abc123");
+ * if (extended) console.log(`day ${currentStreak} of the streak`);
+ */
+export async function advanceStreak(apiKeyId: string): Promise {
+ if (isBuildPhase || isCloud) return { currentStreak: 0, extended: false };
const db = getDbInstance() as unknown as DbLike;
const today = todayUtc();
@@ -165,19 +197,13 @@ export async function updateStreak(apiKeyId: string): Promise {
// Already counted today
if (streak.lastActiveDate === today) {
- return streak.currentStreak;
+ return { currentStreak: streak.currentStreak, extended: false };
}
const yesterday = yesterdayUtc();
- let newStreak: number;
-
- if (streak.lastActiveDate === yesterday) {
- // Consecutive day — extend streak
- newStreak = streak.currentStreak + 1;
- } else {
- // Streak broken or first activity — start fresh
- newStreak = 1;
- }
+ const extended = streak.lastActiveDate === yesterday;
+ // Consecutive day — extend streak; otherwise streak broken or first activity — start fresh
+ const newStreak = extended ? streak.currentStreak + 1 : 1;
const newData: StreakData = {
currentStreak: newStreak,
@@ -192,5 +218,5 @@ export async function updateStreak(apiKeyId: string): Promise {
JSON.stringify(newData)
);
- return newStreak;
+ return { currentStreak: newStreak, extended };
}
diff --git a/src/lib/guardrails/videoBridgeContactSheet.ts b/src/lib/guardrails/videoBridgeContactSheet.ts
index 4fdf18e258..69b76e932b 100644
--- a/src/lib/guardrails/videoBridgeContactSheet.ts
+++ b/src/lib/guardrails/videoBridgeContactSheet.ts
@@ -1,4 +1,8 @@
-import { decodeJpegFrameDataUri, estimateJpegFrameBytes } from "./videoBridgeFrameContract";
+import {
+ JPEG_FRAME_DATA_URI_PREFIX,
+ decodeJpegFrameDataUri,
+ estimateJpegFrameBytes,
+} from "./videoBridgeFrameContract";
import { VIDEO_FRAME_MAX_BYTES } from "./videoBridgeRuntime";
export interface ContactSheetFrame {
@@ -120,7 +124,7 @@ export async function buildVideoContactSheet(
if (signal.aborted) throw new Error("Video contact sheet was aborted");
if (output.byteLength > MAX_SHEET_BYTES) return fallback(frames);
return {
- dataUri: `data:image/jpeg;base64,${output.toString("base64")}`,
+ dataUri: `${JPEG_FRAME_DATA_URI_PREFIX}${output.toString("base64")}`,
frames: frames.map((frame) => ({ ...frame })),
height: rows * TILE_SIZE,
timestamps: frames.map((frame) => frame.timestampSeconds),
diff --git a/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts b/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts
index 94728e9b98..5b70f0d6d3 100644
--- a/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts
+++ b/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts
@@ -22,6 +22,7 @@ import {
type VideoDrilldownPutValue,
type VideoDrilldownResult,
} from "./videoBridgeDrilldown";
+import { JPEG_FRAME_DATA_URI_PREFIX } from "./videoBridgeFrameContract";
export type VideoDrilldownVariant = "preview" | "standard" | "detail";
@@ -170,7 +171,7 @@ async function shrinkFrameForVariant(
.toBuffer();
const metadata = await sharp(resized).metadata();
return {
- dataUri: `data:image/jpeg;base64,${resized.toString("base64")}`,
+ dataUri: `${JPEG_FRAME_DATA_URI_PREFIX}${resized.toString("base64")}`,
height: metadata.height ?? frame.height,
timestampSeconds: frame.timestampSeconds,
width: metadata.width ?? frame.width,
diff --git a/src/lib/guardrails/videoBridgeFrameContract.ts b/src/lib/guardrails/videoBridgeFrameContract.ts
index 996c3c5ea3..a4c2af69e3 100644
--- a/src/lib/guardrails/videoBridgeFrameContract.ts
+++ b/src/lib/guardrails/videoBridgeFrameContract.ts
@@ -24,5 +24,6 @@ export function decodeJpegFrameDataUri(dataUri: string): Buffer {
export function estimateJpegFrameBytes(dataUri: string): number {
const encoded = matchJpegFrame(dataUri);
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
- return Math.floor((encoded.length * 3) / 4) - padding;
+ // Padding-only payloads (e.g. "=") pass the charset pattern; never report a negative size.
+ return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding);
}
diff --git a/src/lib/guardrails/videoBridgeRuntime.ts b/src/lib/guardrails/videoBridgeRuntime.ts
index 7099acd231..36954ed5ea 100644
--- a/src/lib/guardrails/videoBridgeRuntime.ts
+++ b/src/lib/guardrails/videoBridgeRuntime.ts
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
+import { JPEG_FRAME_DATA_URI_PREFIX } from "./videoBridgeFrameContract";
+
const execFileAsync = promisify(execFile);
export interface VideoCommandOptions {
@@ -997,7 +999,7 @@ export async function extractVideoFramesFromBytes(
return {
durationSeconds: metadata.durationSeconds,
frames: frameFiles.map((frame, index) => ({
- dataUri: `data:image/jpeg;base64,${frameBytes[index].toString("base64")}`,
+ dataUri: `${JPEG_FRAME_DATA_URI_PREFIX}${frameBytes[index].toString("base64")}`,
timestampSeconds: frame.timestampSeconds,
})),
sampling: frameFiles.sampling,
diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts
index 88ace23020..8271d21c5b 100644
--- a/src/shared/constants/featureFlagDefinitions.ts
+++ b/src/shared/constants/featureFlagDefinitions.ts
@@ -622,7 +622,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
key: "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES",
label: "Auto-Sync Claude Code Profiles",
description:
- "After a provider model sync, automatically (re)write ~/.claude/profiles//settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default.",
+ "After a provider model sync, automatically (re)write ~/.claude/profiles/''/settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default.",
descriptionI18nKey: "featureFlagOmnirouteAutoSyncClaudeProfilesDescription",
category: "cli",
defaultValue: "false",
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index 0a5b3fafbe..fcd565133a 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -1489,6 +1489,7 @@ async function handleSingleModelChat(
model,
sourceFormat,
targetFormat,
+ customModelTargetFormat,
extendedContext,
apiFormat,
} = resolved;
@@ -1940,7 +1941,11 @@ async function handleSingleModelChat(
runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null,
extendedContext,
modelApiFormat: apiFormat,
- modelTargetFormat: targetFormat,
+ // Only a model's explicit DB override may cross this boundary as
+ // modelInfo.targetFormat. The effective targetFormat above was
+ // resolved without credentials; forwarding it would let a stale
+ // provider-id fallback override the credential-aware resolution.
+ modelTargetFormat: customModelTargetFormat,
providerProfile,
cachedSettings: runtimeOptions.cachedSettings,
skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false,
diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts
index 17e3a0f08a..c29300b30d 100644
--- a/src/sse/handlers/chatHelpers.ts
+++ b/src/sse/handlers/chatHelpers.ts
@@ -338,7 +338,15 @@ export async function resolveModelOrError(
log.info("ROUTING", `Provider: ${provider}, Model: ${model}${ctxTag}`);
}
- return { provider, model, sourceFormat, targetFormat, extendedContext, apiFormat };
+ return {
+ provider,
+ model,
+ sourceFormat,
+ targetFormat,
+ customModelTargetFormat,
+ extendedContext,
+ apiFormat,
+ };
}
export async function checkPipelineGates(
diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts
index 191462d93c..e2303c86b9 100644
--- a/tests/integration/chat-pipeline.test.ts
+++ b/tests/integration/chat-pipeline.test.ts
@@ -21,6 +21,7 @@ const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
const { encodeSkillToolName } = await import("../../src/lib/skills/injection.ts");
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
+const providerNodeRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts");
const { initTranslators } = await import("../../open-sse/translator/index.ts");
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts");
@@ -550,6 +551,93 @@ test("chat pipeline handles OpenAI passthrough with valid API key auth", async (
assert.equal(json.choices[0].message.content, "OpenAI passthrough");
});
+test("#11884 chat pipeline sends a custom node's edited Chat API type upstream", async () => {
+ // Mirror POST /api/provider-nodes: the generated node id embeds the API type chosen at
+ // creation time, so a node created as Responses keeps "responses" in its id forever.
+ const providerId = "openai-compatible-responses-11884";
+ const prefix = "edited-node-11884";
+ const baseUrl = "https://edited-node-11884.example.invalid/v1";
+ const nodeName = "Edited node 11884";
+ await providersDb.createProviderNode({
+ id: providerId,
+ type: "openai-compatible",
+ name: nodeName,
+ prefix,
+ apiType: "responses",
+ baseUrl,
+ });
+ await seedConnection(providerId, {
+ apiKey: "sk-edited-node-11884",
+ providerSpecificData: { baseUrl, apiType: "responses" },
+ });
+
+ // The operator edits the node from Responses to Chat through the real route, which also
+ // rewrites the connection's saved apiType.
+ const editResponse = await providerNodeRoute.PUT(
+ new Request(`http://localhost/api/provider-nodes/${providerId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: nodeName, prefix, apiType: "chat", baseUrl }),
+ }),
+ { params: Promise.resolve({ id: providerId }) }
+ );
+ assert.equal(editResponse.status, 200);
+ const [connection] = (await providersDb.getProviderConnections({
+ provider: providerId,
+ })) as Array<{
+ providerSpecificData?: { apiType?: unknown };
+ }>;
+ assert.equal(connection?.providerSpecificData?.apiType, "chat");
+
+ const apiKey = await seedApiKey();
+ const fetchCalls: FetchCall[] = [];
+ globalThis.fetch = async (url, init: RequestInit = {}) => {
+ const call: FetchCall = {
+ url: String(url),
+ method: init.method || "GET",
+ headers: toPlainHeaders(init.headers),
+ body: init.body ? JSON.parse(String(init.body)) : null,
+ };
+ fetchCalls.push(call);
+ if (!call.url.startsWith(baseUrl)) {
+ throw new Error(`unexpected upstream call: ${call.method} ${call.url}`);
+ }
+ return buildOpenAIResponse("Edited node reply", "edited-model");
+ };
+
+ const response = await handleChat(
+ buildRequest({
+ authKey: apiKey.key,
+ body: {
+ model: `${prefix}/edited-model`,
+ stream: false,
+ messages: [{ role: "user", content: "Hello edited node" }],
+ },
+ })
+ );
+
+ const json = (await response.json()) as { choices: Array<{ message: { content: string } }> };
+ assert.ok(fetchCalls.length >= 1, "expected an upstream request");
+ const upstream = fetchCalls[0];
+ assert.equal(upstream.method, "POST");
+ assert.equal(upstream.url, `${baseUrl}/chat/completions`);
+ assert.equal(upstream.headers.Authorization, "Bearer sk-edited-node-11884");
+ assert.deepEqual(
+ upstream.body.messages,
+ [{ role: "user", content: "Hello edited node" }],
+ "the saved Chat API type must produce a Chat Completions body"
+ );
+ assert.equal(
+ upstream.body.input,
+ undefined,
+ "the stale Responses API type from the node id must not shape the upstream body"
+ );
+ assert.equal(upstream.body.model, "edited-model");
+ assert.equal(fetchCalls.length, 1, "exactly one upstream request");
+ assert.equal(response.status, 200);
+ assert.equal(json.choices[0].message.content, "Edited node reply");
+});
+
test("chat pipeline persists Codex responses cache and reasoning tokens to call logs", async () => {
await seedConnection("codex", { apiKey: "sk-codex-primary" });
const fetchCalls = [];
diff --git a/tests/unit/12509-gemini-prefixitems.test.ts b/tests/unit/12509-gemini-prefixitems.test.ts
new file mode 100644
index 0000000000..43ea7ab015
--- /dev/null
+++ b/tests/unit/12509-gemini-prefixitems.test.ts
@@ -0,0 +1,141 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { buildGeminiTools } from "../../open-sse/translator/helpers/geminiToolsSanitizer.ts";
+import { GEMINI_UNSUPPORTED_SCHEMA_KEYS } from "../../open-sse/translator/helpers/geminiHelper.ts";
+
+// Issue #12509: Gemini rejects the JSON-Schema-2020-12 tuple keyword `prefixItems` in
+// function_declarations parameter schemas with HTTP 400
+// `Unknown name "prefixItems" at 'tools[0].function_declarations[1].parameters.properties[5]
+// .value.properties[0].value.items': Cannot find field.` — the same class of error already
+// fixed for `uniqueItems` (#9617), `multipleOf`, `strict` and `encrypted` in
+// GEMINI_UNSUPPORTED_SCHEMA_KEYS (open-sse/translator/helpers/geminiHelper.ts).
+
+type GeminiFunctionDeclaration = { name: string; parameters: Record };
+
+function declarationsOf(tools: unknown[]): GeminiFunctionDeclaration[] {
+ const geminiTools = buildGeminiTools(tools) as Array<{
+ functionDeclarations?: GeminiFunctionDeclaration[];
+ }> | null;
+ assert.ok(geminiTools, "expected buildGeminiTools to return a tools array");
+ return geminiTools.flatMap((tool) => tool.functionDeclarations ?? []);
+}
+
+function assertNoPrefixItems(tools: unknown[]): GeminiFunctionDeclaration[] {
+ const declarations = declarationsOf(tools);
+ const serialized = JSON.stringify(declarations);
+ assert.equal(
+ serialized.includes("prefixItems"),
+ false,
+ `prefixItems leaked into the Gemini payload (would trigger upstream 400 "Unknown name \\"prefixItems\\""): ${serialized}`
+ );
+ return declarations;
+}
+
+// The reporter's shape: a tuple nested under `items` — an array of `[start_line, end_line]`
+// ranges, i.e. `properties.ranges.items.prefixItems`.
+const nestedTupleParameters = {
+ type: "object",
+ properties: {
+ file_path: { type: "string" },
+ ranges: {
+ type: "array",
+ description: "Line ranges to read",
+ items: {
+ type: "array",
+ prefixItems: [{ type: "integer" }, { type: "integer" }],
+ items: false,
+ minItems: 2,
+ maxItems: 2,
+ },
+ },
+ },
+ required: ["file_path", "ranges"],
+};
+
+test("buildGeminiTools strips prefixItems nested under items (OpenAI tool shape, issue #12509)", () => {
+ const [declaration] = assertNoPrefixItems([
+ {
+ type: "function",
+ function: {
+ name: "read_ranges",
+ description: "tuple-typed array parameter nested under items",
+ parameters: nestedTupleParameters,
+ },
+ },
+ ]);
+
+ const ranges = (declaration.parameters.properties as Record>)
+ .ranges;
+ assert.equal(ranges.type, "array");
+ const inner = ranges.items as Record;
+ assert.equal(inner.type, "array");
+ assert.ok(inner.items && typeof inner.items === "object", "inner array keeps an items schema");
+});
+
+test("buildGeminiTools strips prefixItems from a Claude input_schema (issue #12509)", () => {
+ const [declaration] = assertNoPrefixItems([
+ {
+ name: "read_ranges",
+ description: "Claude Messages tool shape",
+ input_schema: nestedTupleParameters,
+ },
+ ]);
+ assert.equal(declaration.name, "read_ranges");
+});
+
+test("buildGeminiTools strips a top-level prefixItems tuple and keeps a usable items schema (issue #12509)", () => {
+ const [declaration] = assertNoPrefixItems([
+ {
+ type: "function",
+ function: {
+ name: "read_range",
+ description: "single [start_line, end_line] tuple",
+ parameters: {
+ type: "object",
+ properties: {
+ range: {
+ type: "array",
+ prefixItems: [{ type: "integer" }, { type: "integer" }],
+ },
+ },
+ required: ["range"],
+ },
+ },
+ },
+ ]);
+
+ const range = (declaration.parameters.properties as Record>)
+ .range;
+ assert.equal(range.type, "array");
+ assert.ok(range.items && typeof range.items === "object", "Gemini requires items on arrays");
+});
+
+test("buildGeminiTools strips prefixItems that sits next to a regular items schema (issue #12509)", () => {
+ const [declaration] = assertNoPrefixItems([
+ {
+ type: "function",
+ function: {
+ name: "pair",
+ description: "tuple keyword as a sibling of a regular items schema",
+ parameters: {
+ type: "object",
+ properties: {
+ pair: {
+ type: "array",
+ prefixItems: [{ type: "string" }],
+ items: { type: "string" },
+ },
+ },
+ },
+ },
+ },
+ ]);
+
+ const pair = (declaration.parameters.properties as Record>).pair;
+ assert.deepEqual(pair.items, { type: "string" });
+});
+
+test("prefixItems is registered in GEMINI_UNSUPPORTED_SCHEMA_KEYS (issue #12509)", () => {
+ assert.ok(GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("prefixItems"));
+});
diff --git a/tests/unit/agents-channel-publish.test.ts b/tests/unit/agents-channel-publish.test.ts
index 3c5f64df12..29dae16517 100644
--- a/tests/unit/agents-channel-publish.test.ts
+++ b/tests/unit/agents-channel-publish.test.ts
@@ -132,7 +132,9 @@ test("a throwing agent.task.updated listener does not break A2ATaskManager.creat
// ── (b) cloud-agent DB writers ──────────────────────────────────────────────────────────
-function makeTaskRow(overrides: Partial[0]> = {}) {
+function makeTaskRow(
+ overrides: Partial[0]> = {}
+) {
const now = new Date().toISOString();
return {
id: `task-${Math.random().toString(36).slice(2)}`,
@@ -192,9 +194,10 @@ test("updateCloudAgentTask emits agent.task.updated with the new status", () =>
}
});
-test("updateCloudAgentTask without a status field emits state 'updated'", () => {
+test("updateCloudAgentTask without a status field emits the row's current status", () => {
const row = makeTaskRow({ status: "queued" });
cloudAgentDb.insertCloudAgentTask(row);
+ cloudAgentDb.updateCloudAgentTask(row.id, { status: "running" });
const events: AgentTaskUpdatedPayload[] = [];
const unsubscribe = on("agent.task.updated", (payload) => events.push(payload));
@@ -204,7 +207,19 @@ test("updateCloudAgentTask without a status field emits state 'updated'", () =>
assert.equal(events.length, 1);
assert.equal(events[0].source, "cloud-agent");
assert.equal(events[0].taskId, row.id);
- assert.equal(events[0].state, "updated");
+ assert.equal(events[0].state, "running");
+ } finally {
+ unsubscribe();
+ }
+});
+
+test("updateCloudAgentTask on an unknown id does not emit (nothing was written)", () => {
+ const events: AgentTaskUpdatedPayload[] = [];
+ const unsubscribe = on("agent.task.updated", (payload) => events.push(payload));
+ try {
+ cloudAgentDb.updateCloudAgentTask("task-does-not-exist", { result: "partial output" });
+
+ assert.equal(events.length, 0);
} finally {
unsubscribe();
}
diff --git a/tests/unit/agnes-provider.test.ts b/tests/unit/agnes-provider.test.ts
index bc15ab9e8c..2b0930eaa6 100644
--- a/tests/unit/agnes-provider.test.ts
+++ b/tests/unit/agnes-provider.test.ts
@@ -224,7 +224,7 @@ test("agnes registers Video V2.0 on the current video_id job contract", () => {
assert.ok(getAllVideoModels().some((model) => model.id === "agnes/agnes-video-v2.0"));
});
-test("agnes Video V2.0 submits with Bearer auth and polls by video_id", async () => {
+test("agnes Video V2.0 submits with Bearer auth and polls by video_id and model_name", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const calls: Array<{
@@ -309,7 +309,7 @@ test("agnes Video V2.0 submits with Bearer auth and polls by video_id", async ()
},
});
assert.deepEqual(calls[1], {
- url: "https://apihub.agnes-ai.com/agnesapi?video_id=video-123",
+ url: "https://apihub.agnes-ai.com/agnesapi?video_id=video-123&model_name=agnes-video-v2.0",
method: "GET",
headers: {
"Content-Type": "application/json",
diff --git a/tests/unit/audio-translations-combo-resolution.test.ts b/tests/unit/audio-translations-combo-resolution.test.ts
new file mode 100644
index 0000000000..aa4defe26a
--- /dev/null
+++ b/tests/unit/audio-translations-combo-resolution.test.ts
@@ -0,0 +1,131 @@
+// Regression test: /v1/audio/translations must resolve combo names.
+//
+// /v1/models advertises combos, and /v1/chat/completions, /v1/embeddings,
+// /v1/audio/transcriptions (#9134), /v1/audio/speech and /v1/videos/generations
+// (#10469) all resolve them — but the translation route still treated the model
+// string as a literal `provider/model` id only. A combo name therefore came back as
+// `400 Invalid translation model: . Use format: provider/model`, so any
+// client populating a model picker from /v1/models offered an option the endpoint
+// rejected, and callers had to hardcode the provider's internal model id.
+//
+// This asserts the combo is expanded to its target before dispatch (observed at the
+// upstream fetch: URL and multipart `model`), that a literal provider/model id still
+// dispatches directly, and that an unknown bare name keeps the format hint.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-audio-translations-combo-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const { createCombo } = await import("../../src/lib/db/combos.ts");
+const { createProviderNode } = await import("../../src/lib/db/providers.ts");
+const route = await import("../../src/app/api/v1/audio/translations/route.ts");
+
+const originalFetch = globalThis.fetch;
+
+test.after(() => {
+ globalThis.fetch = originalFetch;
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+/** Minimal but structurally valid WAV so nothing rejects the upload shape. */
+function makeWav(): Blob {
+ const dataLen = 1600;
+ const b = Buffer.alloc(44 + dataLen);
+ b.write("RIFF", 0, "ascii");
+ b.writeUInt32LE(36 + dataLen, 4);
+ b.write("WAVE", 8, "ascii");
+ b.write("fmt ", 12, "ascii");
+ b.writeUInt32LE(16, 16);
+ b.writeUInt16LE(1, 20);
+ b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(16000, 24);
+ b.writeUInt32LE(32000, 28);
+ b.writeUInt16LE(2, 32);
+ b.writeUInt16LE(16, 34);
+ b.write("data", 36, "ascii");
+ b.writeUInt32LE(dataLen, 40);
+ return new Blob([b], { type: "audio/wav" });
+}
+
+function translationRequest(model: string) {
+ const fd = new FormData();
+ fd.set("model", model);
+ fd.set("file", makeWav(), "t.wav");
+ return new Request("http://localhost/v1/audio/translations", { method: "POST", body: fd });
+}
+
+/** Capture every upstream call: URL plus the decoded multipart body the handler built. */
+function captureUpstream(): Array<{ url: string; body: string }> {
+ const calls: Array<{ url: string; body: string }> = [];
+ globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit = {}) => {
+ calls.push({
+ url: String(url),
+ body: new TextDecoder().decode(init.body as Uint8Array),
+ });
+ return new Response(JSON.stringify({ text: "ok" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }) as typeof fetch;
+ return calls;
+}
+
+test.before(async () => {
+ await createProviderNode({
+ id: "openai-compatible-audio-translations-test",
+ type: "openai-compatible",
+ name: "Local STT",
+ prefix: "localstt",
+ apiType: "audio-transcriptions",
+ baseUrl: "http://localhost:9000/v1",
+ } as Parameters[0]);
+
+ await createCombo({
+ name: "traducao",
+ strategy: "priority",
+ models: [{ provider: "localstt", model: "whisper-1" }],
+ } as Parameters[0]);
+});
+
+test("a combo name is expanded to its target instead of being rejected", async () => {
+ const calls = captureUpstream();
+
+ const res = await route.POST(translationRequest("traducao"));
+ const body = await res.text();
+
+ assert.equal(res.status, 200, `combo name must not be rejected — got: ${body}`);
+ assert.deepEqual(JSON.parse(body), { text: "ok" });
+ assert.equal(calls.length, 1, `expected exactly one upstream call, got ${calls.length}`);
+ assert.equal(calls[0].url, "http://localhost:9000/v1/audio/translations");
+ assert.match(calls[0].body, /name="model"\r\n\r\nwhisper-1\r\n/);
+ assert.doesNotMatch(calls[0].body, /name="model"\r\n\r\ntraducao\r\n/);
+});
+
+test("a literal provider/model id still dispatches directly", async () => {
+ const calls = captureUpstream();
+
+ const res = await route.POST(translationRequest("localstt/whisper-1"));
+
+ assert.equal(res.status, 200);
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].url, "http://localhost:9000/v1/audio/translations");
+ assert.match(calls[0].body, /name="model"\r\n\r\nwhisper-1\r\n/);
+});
+
+test("an unknown bare name is still rejected with the format hint", async () => {
+ const calls = captureUpstream();
+
+ const res = await route.POST(translationRequest("definitely-not-a-combo-or-model"));
+ const body = await res.text();
+
+ assert.equal(res.status, 400);
+ assert.match(body, /Invalid translation model/);
+ assert.equal(calls.length, 0);
+});
diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts
index 710bb6f21c..21ac50fa27 100644
--- a/tests/unit/chat-helpers.test.ts
+++ b/tests/unit/chat-helpers.test.ts
@@ -24,6 +24,9 @@ const { getCircuitBreaker, resetAllCircuitBreakers, STATE } =
await import("../../src/shared/utils/circuitBreaker.ts");
// DATA_DIR must be fixed before these modules load; keep this test seam dynamic.
const { setTlsClientForTest } = await import("../../open-sse/utils/proxyFetch.ts");
+const { resolveChatCoreTargetFormat } =
+ await import("../../open-sse/handlers/chatCore/targetFormat.ts");
+const { FORMATS } = await import("../../open-sse/translator/formats.ts");
type ApiErrorJson = {
error?: {
@@ -259,6 +262,59 @@ test("resolveModelOrError honors a custom-model targetFormat override even when
assert.equal(result.targetFormat, "claude");
});
+test("#11884 configured Chat API type wins after custom-node model resolution", async () => {
+ const provider = "openai-compatible-responses-11884";
+ const prefix = "custom-chat-11884";
+ const model = "chat-only-model";
+
+ await providersDb.createProviderNode({
+ id: provider,
+ type: "openai-compatible",
+ name: "Custom Chat 11884",
+ prefix,
+ apiType: "chat",
+ baseUrl: "https://chat-only.example.invalid/v1",
+ });
+ const connection = await seedConnection(provider, {
+ providerSpecificData: { apiType: "chat" },
+ });
+ const modelsDb = await import("../../src/lib/db/models.ts");
+ await modelsDb.addCustomModel(provider, model, "Chat-only model", "manual", "chat-completions", [
+ "chat",
+ ]);
+
+ const firstResolution = await resolveModelOrError(
+ `${prefix}/${model}`,
+ { model: `${prefix}/${model}`, messages: [{ role: "user", content: "hello" }] },
+ "/v1/chat/completions"
+ );
+ assert.equal(firstResolution.error, undefined);
+
+ // Before #11884's fix the resolver exposed only its credential-blind effective
+ // targetFormat, so the dispatcher necessarily forwarded that value as though it
+ // were a model override. The fixed contract exposes the explicit model override
+ // separately; keep the fallback here so this regression test still exercises the
+ // broken production path when run against the parent revision.
+ const forwardedModelOverride =
+ "customModelTargetFormat" in firstResolution
+ ? firstResolution.customModelTargetFormat
+ : firstResolution.targetFormat;
+ const finalResolution = resolveChatCoreTargetFormat({
+ provider: firstResolution.provider,
+ resolvedModel: firstResolution.model,
+ apiFormat: firstResolution.apiFormat,
+ sourceFormat: firstResolution.sourceFormat,
+ customModelTargetFormat: forwardedModelOverride,
+ providerSpecificData: connection.providerSpecificData,
+ });
+
+ assert.equal(
+ finalResolution.targetFormat,
+ FORMATS.OPENAI,
+ "the stored Chat API type must not be shadowed by a stale Responses fallback"
+ );
+});
+
test("checkPipelineGates blocks providers with an open circuit breaker", async () => {
const breaker = getCircuitBreaker("openai");
breaker.state = STATE.OPEN;
diff --git a/tests/unit/check-model-lifecycle-gate.test.ts b/tests/unit/check-model-lifecycle-gate.test.ts
index d717ffabe4..3a9d649f7f 100644
--- a/tests/unit/check-model-lifecycle-gate.test.ts
+++ b/tests/unit/check-model-lifecycle-gate.test.ts
@@ -2,7 +2,7 @@
* Unit coverage for the #11503 drift gate (`scripts/check/check-model-lifecycle.mjs`).
*
* The gate's value is that it goes red when a hand-maintained routing table starts
- * pointing at a model the vendor retired, so each of its three checks is exercised here
+ * pointing at a model the vendor retired, so each of its four checks is exercised here
* against small fixtures rather than against the live catalog (which would make the test
* a duplicate of the gate run itself, and red for reasons unrelated to the logic).
*/
@@ -14,6 +14,7 @@ import {
findRetiredFitnessRows,
findBadAliasTargets,
findUnforwardedRetiredIds,
+ findRetiredDegradationRows,
} from "../../scripts/check/check-model-lifecycle.mjs";
const RETIRED = new Set(["dead-model-1", "dead-model-2", "gpt-5.2-codex"]);
@@ -93,3 +94,32 @@ describe("check-model-lifecycle: (c) routable retired ids", () => {
);
});
});
+
+describe("check-model-lifecycle: (d) DEFAULT_DEGRADATION_MAP rows", () => {
+ it("flags a retired source id as a dead row", () => {
+ const violations = findRetiredDegradationRows({ "dead-model-1": "live-1" }, RETIRED);
+ assert.equal(violations.length, 1);
+ assert.match(violations[0], /retired the source id; checkLifecycle rejects it/);
+ });
+
+ it("flags a retired target id", () => {
+ const violations = findRetiredDegradationRows({ "live-1": "dead-model-1" }, RETIRED);
+ assert.equal(violations.length, 1);
+ assert.match(violations[0], /retired the target id/);
+ });
+
+ it("reports both ends when source and target are retired", () => {
+ const violations = findRetiredDegradationRows({ "dead-model-1": "dead-model-2" }, RETIRED);
+ assert.equal(violations.length, 2);
+ });
+
+ it("treats a vendor-prefixed source as retired when its bare form is", () => {
+ const violations = findRetiredDegradationRows({ "openai/gpt-5.2-codex": "live-1" }, RETIRED);
+ assert.equal(violations.length, 1);
+ });
+
+ it("passes for a map of live ids", () => {
+ assert.deepEqual(findRetiredDegradationRows({ "live-1": "live-2" }, RETIRED), []);
+ assert.deepEqual(findRetiredDegradationRows({}, RETIRED), []);
+ });
+});
diff --git a/tests/unit/compression/compression-worker.test.ts b/tests/unit/compression/compression-worker.test.ts
index 0ca4cbd453..93265c91e7 100644
--- a/tests/unit/compression/compression-worker.test.ts
+++ b/tests/unit/compression/compression-worker.test.ts
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { after, describe, it } from "node:test";
+import { Worker } from "node:worker_threads";
import {
isCompressionWorkerEligible,
isStrictlySerializable,
@@ -136,6 +137,40 @@ describe("compression worker execution", () => {
}
});
+ it("terminates an idle worker instead of only dropping it from the pool", async () => {
+ const spawned = new Set();
+ const terminated: Promise[] = [];
+ const originalPostMessage = Worker.prototype.postMessage;
+ const originalTerminate = Worker.prototype.terminate;
+ Worker.prototype.postMessage = function (this: Worker, ...args) {
+ spawned.add(this);
+ return originalPostMessage.apply(this, args);
+ };
+ Worker.prototype.terminate = function (this: Worker) {
+ const exit = originalTerminate.call(this);
+ terminated.push(exit);
+ return exit;
+ };
+ const messagePorts = () =>
+ process.getActiveResourcesInfo().filter((resource) => resource === "MessagePort").length;
+ const portsBefore = messagePorts();
+ const pool = new CompressionWorkerPool({ size: 1, idleMs: 50 });
+ try {
+ await pool.run(body, "stacked", { config });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ assert.equal(spawned.size, 1);
+ assert.equal(terminated.length, 1, "idle eviction must terminate the worker thread");
+ await Promise.all(terminated);
+ assert.ok(messagePorts() <= portsBefore, "idle eviction must not retain the worker's port");
+ } finally {
+ Worker.prototype.postMessage = originalPostMessage;
+ Worker.prototype.terminate = originalTerminate;
+ await pool.close();
+ // Reap anything the pool forgot so a regression fails instead of hanging the runner.
+ await Promise.all([...spawned].map((worker) => worker.terminate().catch(() => undefined)));
+ }
+ });
+
it("keeps the parent event loop responsive while two workers overlap", async () => {
const largeBody = {
messages: Array.from({ length: 400 }, (_, index) => ({
diff --git a/tests/unit/db-cleanup-conversation-nodes-12453.test.ts b/tests/unit/db-cleanup-conversation-nodes-12453.test.ts
new file mode 100644
index 0000000000..a285fbc1f7
--- /dev/null
+++ b/tests/unit/db-cleanup-conversation-nodes-12453.test.ts
@@ -0,0 +1,190 @@
+/**
+ * Issue #12453 — conversation_turn_nodes / agentic_conversations have no
+ * retention path, so storage.sqlite grows without bound (1.15M node rows,
+ * ~775 MB in four days on one busy coding-agent workload).
+ *
+ * The identity nodes only make sense while the call_logs row their
+ * last_correlation_id points at still exists, so both tables follow the
+ * existing `retention.callLogs` window instead of getting a knob of their own.
+ *
+ * These tests call the REAL cleanup functions against a real SQLite adapter
+ * seeded with test rows, exactly like telemetry-auto-cleanup-6848.test.ts.
+ *
+ * DATA_DIR isolation is self-contained (mkdtempSync below), not dependent on
+ * the test:unit harness's `--import ./tests/_setup/isolateDataDir.ts`: this
+ * file runs real DELETEs through getDbInstance(), which resolves to the
+ * developer's ~/.omniroute/storage.sqlite when DATA_DIR is unset. Do NOT
+ * remove the DATA_DIR override below.
+ */
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-12453-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const { cleanupConversationTurnNodes, cleanupAgenticConversations, runAutoCleanup } =
+ await import("../../src/lib/db/cleanup.ts");
+const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts");
+const { getUserDatabaseSettings } = await import("../../src/lib/db/databaseSettings.ts");
+
+test.after(() => {
+ resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+const DAY_MS = 86_400_000;
+const RETENTION_DAYS = getUserDatabaseSettings().retention.callLogs;
+const OLD = new Date(Date.now() - (RETENTION_DAYS + 1) * DAY_MS).toISOString();
+const RECENT = new Date().toISOString();
+
+function insertConversation(id: string, lastSeenAt: string): void {
+ getDbInstance()!
+ .prepare(
+ `INSERT INTO agentic_conversations
+ (id, api_key_id, fingerprint_hash, last_message_count, last_messages_hash, turn_count, first_seen_at, last_seen_at)
+ VALUES (?, 'key1', 'fp', 0, '', 1, ?, ?)`
+ )
+ .run(id, lastSeenAt, lastSeenAt);
+}
+
+function insertNode(id: string, conversationId: string, lastSeenAt: string): void {
+ getDbInstance()!
+ .prepare(
+ `INSERT INTO conversation_turn_nodes
+ (id, conversation_id, parent_id, role, content_hash, last_correlation_id, first_seen_at, last_seen_at)
+ VALUES (?, ?, NULL, 'user', 'hash', 'corr', ?, ?)`
+ )
+ .run(id, conversationId, lastSeenAt, lastSeenAt);
+}
+
+function count(table: string): number {
+ const row = getDbInstance()!.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as {
+ cnt: number;
+ };
+ return row.cnt;
+}
+
+function ids(table: string): string[] {
+ const rows = getDbInstance()!.prepare(`SELECT id FROM ${table} ORDER BY id`).all() as Array<{
+ id: string;
+ }>;
+ return rows.map((r) => r.id);
+}
+
+test.beforeEach(() => {
+ const db = getDbInstance()!;
+ db.exec("DELETE FROM conversation_turn_nodes");
+ db.exec("DELETE FROM agentic_conversations");
+});
+
+test("#12453 cleanupConversationTurnNodes: deletes nodes older than the call-log retention window", async () => {
+ insertConversation("conv_a", RECENT);
+ insertNode("old-1", "conv_a", OLD);
+ insertNode("old-2", "conv_a", OLD);
+ insertNode("old-3", "conv_a", OLD);
+ insertNode("recent-1", "conv_a", RECENT);
+ insertNode("recent-2", "conv_a", RECENT);
+
+ const result = await cleanupConversationTurnNodes();
+
+ assert.strictEqual(result.deleted, 3);
+ assert.strictEqual(result.errors, 0);
+ assert.deepStrictEqual(ids("conversation_turn_nodes"), ["recent-1", "recent-2"]);
+});
+
+test("#12453 cleanupConversationTurnNodes: yields between bounded delete batches", async () => {
+ insertConversation("conv_bulk", OLD);
+ const db = getDbInstance()!;
+ const insert = db.prepare(
+ `INSERT INTO conversation_turn_nodes
+ (id, conversation_id, parent_id, role, content_hash, last_correlation_id, first_seen_at, last_seen_at)
+ VALUES (?, 'conv_bulk', NULL, 'user', 'hash', 'corr', ?, ?)`
+ );
+ db.transaction(() => {
+ for (let i = 0; i < 10_001; i++) insert.run(`bulk-${i}`, OLD, OLD);
+ })();
+
+ let eventLoopTurnObserved = false;
+ setImmediate(() => {
+ eventLoopTurnObserved = true;
+ });
+
+ const result = await cleanupConversationTurnNodes();
+
+ assert.strictEqual(result.deleted, 10_001);
+ assert.strictEqual(result.errors, 0);
+ assert.strictEqual(count("conversation_turn_nodes"), 0);
+ assert.strictEqual(eventLoopTurnObserved, true, "cleanup should yield after a full batch");
+});
+
+test("#12453 cleanupAgenticConversations: sweeps stale conversations that have no nodes left", async () => {
+ // Stale and orphaned: every node already expired -> must go.
+ insertConversation("conv_orphan_old", OLD);
+ // Stale but still anchored by a live node -> must stay.
+ insertConversation("conv_anchored", OLD);
+ insertNode("live-1", "conv_anchored", RECENT);
+ // Fresh root whose nodes are not written yet (createConversation runs before
+ // the node insert in the same request) -> must stay.
+ insertConversation("conv_fresh_no_nodes", RECENT);
+
+ const result = await cleanupAgenticConversations();
+
+ assert.strictEqual(result.deleted, 1);
+ assert.strictEqual(result.errors, 0);
+ assert.deepStrictEqual(ids("agentic_conversations"), ["conv_anchored", "conv_fresh_no_nodes"]);
+ assert.strictEqual(count("conversation_turn_nodes"), 1);
+});
+
+test("#12453 nodes expire first, then the conversation they anchored is swept in the same pass", async () => {
+ insertConversation("conv_dead", OLD);
+ insertNode("dead-1", "conv_dead", OLD);
+ insertNode("dead-2", "conv_dead", OLD);
+
+ // Conversation-only sweep must not touch a root that still has (old) nodes.
+ const first = await cleanupAgenticConversations();
+ assert.strictEqual(first.deleted, 0);
+ assert.strictEqual(count("agentic_conversations"), 1);
+
+ const nodes = await cleanupConversationTurnNodes();
+ assert.strictEqual(nodes.deleted, 2);
+
+ const second = await cleanupAgenticConversations();
+ assert.strictEqual(second.deleted, 1);
+ assert.strictEqual(count("agentic_conversations"), 0);
+});
+
+test("#12453 runAutoCleanup: registers both tables and reports them in results", async () => {
+ insertConversation("conv_x", OLD);
+ insertNode("x-1", "conv_x", OLD);
+ insertConversation("conv_y", RECENT);
+ insertNode("y-1", "conv_y", RECENT);
+
+ const summary = await runAutoCleanup();
+
+ assert.ok(summary.results.conversationTurnNodes, "conversationTurnNodes missing from results");
+ assert.ok(summary.results.agenticConversations, "agenticConversations missing from results");
+ assert.strictEqual(summary.results.conversationTurnNodes.deleted, 1);
+ assert.strictEqual(summary.results.agenticConversations.deleted, 1);
+ assert.strictEqual(summary.results.conversationTurnNodes.errors, 0);
+ assert.strictEqual(summary.results.agenticConversations.errors, 0);
+ assert.deepStrictEqual(ids("conversation_turn_nodes"), ["y-1"]);
+ assert.deepStrictEqual(ids("agentic_conversations"), ["conv_y"]);
+});
+
+test("#12453 cleanupAgenticConversations: missing node table is a safe no-op", async () => {
+ insertConversation("conv_without_table", OLD);
+ const db = getDbInstance()!;
+ db.exec("ALTER TABLE conversation_turn_nodes RENAME TO conversation_turn_nodes_unavailable");
+
+ try {
+ const result = await cleanupAgenticConversations();
+ assert.deepStrictEqual(result, { deleted: 0, errors: 0 });
+ assert.strictEqual(count("agentic_conversations"), 1);
+ } finally {
+ db.exec("ALTER TABLE conversation_turn_nodes_unavailable RENAME TO conversation_turn_nodes");
+ }
+});
diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts
index facd6ba68b..befff78723 100644
--- a/tests/unit/executor-antigravity.test.ts
+++ b/tests/unit/executor-antigravity.test.ts
@@ -36,6 +36,7 @@ type ChatCompletionPayload = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
+ completion_tokens_details?: { reasoning_tokens: number };
};
};
@@ -482,6 +483,33 @@ test("AntigravityExecutor.collectStreamToResponse turns SSE Gemini chunks into a
});
});
+test("AntigravityExecutor.collectStreamToResponse preserves upstream thought token usage", async () => {
+ const executor = new AntigravityExecutor();
+ const response = new Response(
+ 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"Done"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3,"thoughtsTokenCount":7,"totalTokenCount":15}}}\n\n',
+ {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ }
+ );
+
+ const result = await executor.collectStreamToResponse(
+ response,
+ "gemini-3.7-pro-high",
+ "https://example.com",
+ { Authorization: "Bearer ag-token" },
+ { request: {} }
+ );
+ const payload = (await result.response.json()) as ChatCompletionPayload;
+
+ assert.deepEqual(payload.usage, {
+ prompt_tokens: 5,
+ completion_tokens: 10,
+ total_tokens: 15,
+ completion_tokens_details: { reasoning_tokens: 7 },
+ });
+});
+
test("AntigravityExecutor.collectStreamToResponse converts textual tool call SSE to structured tool_calls", async () => {
const executor = new AntigravityExecutor();
const response = new Response(
diff --git a/tests/unit/executor-devin-cli-agentic-acp.test.ts b/tests/unit/executor-devin-cli-agentic-acp.test.ts
index 3300983904..827c6aa137 100644
--- a/tests/unit/executor-devin-cli-agentic-acp.test.ts
+++ b/tests/unit/executor-devin-cli-agentic-acp.test.ts
@@ -12,7 +12,7 @@ process.env.DEVIN_AGENTIC_HOME = process.env.HOME;
fs.mkdirSync(process.env.HOME, { recursive: true });
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
-const { assertLocalAcpUrl, buildDevinChildEnv, DevinCliAgenticExecutor } =
+const { assertLocalAcpUrl, buildDevinChildEnv, DevinCliAgenticExecutor, isIsolatedDevinHome } =
await import("../../open-sse/executors/devin-cli-agentic.ts");
const { devin_cli_agenticProvider } =
await import("../../open-sse/config/providers/registry/devin-cli-agentic/index.ts");
@@ -67,6 +67,38 @@ test("Devin child environment is allowlisted and requires an isolated home", ()
);
});
+test("Devin isolated-home check accepts Windows sandbox paths (#12405)", () => {
+ // CI unit tests run on Linux, where path.isAbsolute() rejects "C:\\..." before the
+ // sandbox check runs, so the pure helper is exercised directly with Windows strings.
+ for (const home of [
+ "C:\\Users\\example\\.sandbox\\home",
+ "C:\\Users\\example\\.sandbox\\devin-sandbox\\home",
+ "D:/omniroute/.sandbox/home",
+ "\\\\server\\share\\.sandbox\\home",
+ "/home/bridge",
+ "/opt/omniroute/.sandbox/unit-home",
+ ]) {
+ assert.equal(isIsolatedDevinHome(home), true, `accepts ${home}`);
+ }
+ for (const home of [
+ "C:\\Users\\example",
+ "C:\\Users\\example\\devin-sandbox",
+ "C:\\Users\\example\\.sandbox",
+ "C:\\Users\\example\\sandbox\\home",
+ "/tmp/outside",
+ "/home/bridge2",
+ "",
+ ]) {
+ assert.equal(isIsolatedDevinHome(home), false, `rejects ${home}`);
+ }
+ // Absoluteness is still enforced by the caller, not by the sandbox-segment helper.
+ assert.throws(
+ () =>
+ buildDevinChildEnv({}, { PATH: "/usr/bin", DEVIN_AGENTIC_HOME: "relative/.sandbox/home" }),
+ /inside the bridge sandbox/
+ );
+});
+
test("Devin child environment derives only the trusted bridge proxy", () => {
const isolatedHome = path.join(process.cwd(), ".sandbox", "unit-home");
const trustedProxy = "http://network-guard:8080";
diff --git a/tests/unit/feature-flags-doc-sync-static.test.ts b/tests/unit/feature-flags-doc-sync-static.test.ts
new file mode 100644
index 0000000000..8275529e38
--- /dev/null
+++ b/tests/unit/feature-flags-doc-sync-static.test.ts
@@ -0,0 +1,100 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { FEATURE_FLAG_DEFINITIONS } from "../../src/shared/constants/featureFlagDefinitions.ts";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = join(__dirname, "..", "..");
+
+/**
+ * docs/reference/FEATURE_FLAGS.md promises that its catalog matches
+ * FEATURE_FLAG_DEFINITIONS "1:1". Keep that promise checkable: every flag the
+ * code defines must be a table row with the same type and default, every table
+ * row must be a real flag, and the per-category / total counts must match.
+ */
+const doc = readFileSync(join(root, "docs/reference/FEATURE_FLAGS.md"), "utf8");
+const catalog = doc.slice(doc.indexOf("## Flag Catalog"), doc.indexOf("## Toggling Flags"));
+
+interface DocRow {
+ key: string;
+ type: string;
+ defaultValue: string;
+ restart: boolean;
+ category: string;
+}
+
+function parseCatalog(): DocRow[] {
+ const rows: DocRow[] = [];
+ let category = "";
+ for (const line of catalog.split("\n")) {
+ const heading = line.match(/^### (\w+) \(\d+\)/);
+ if (heading) {
+ category = heading[1].toLowerCase();
+ continue;
+ }
+ const cells = line.match(/^\| `([A-Z0-9_]+)` +\| (\w+) +\| ([^|]+?) +\|(.*)$/);
+ if (!cells) continue;
+ rows.push({
+ key: cells[1],
+ type: cells[2],
+ defaultValue: cells[3].replace(/`/g, ""),
+ restart: /^ *✓ *\|/.test(cells[4]),
+ category,
+ });
+ }
+ return rows;
+}
+
+const docRows = parseCatalog();
+const docByKey = new Map(docRows.map((row) => [row.key, row]));
+
+test("every defined feature flag has a catalog row in FEATURE_FLAGS.md", () => {
+ const missing = FEATURE_FLAG_DEFINITIONS.filter((d) => !docByKey.has(d.key)).map((d) => d.key);
+ assert.deepEqual(
+ missing,
+ [],
+ `flags defined in featureFlagDefinitions.ts but absent from the doc: ${missing.join(", ")}`
+ );
+});
+
+test("every catalog row in FEATURE_FLAGS.md is a defined feature flag", () => {
+ const known = new Set(FEATURE_FLAG_DEFINITIONS.map((d) => d.key));
+ const extra = docRows.filter((row) => !known.has(row.key)).map((row) => row.key);
+ assert.deepEqual(
+ extra,
+ [],
+ `doc rows that are not feature flags (env-only knobs belong in ENVIRONMENT.md): ${extra.join(", ")}`
+ );
+});
+
+test("catalog rows carry the code's category, type, default and restart hint", () => {
+ const mismatches: string[] = [];
+ for (const def of FEATURE_FLAG_DEFINITIONS) {
+ const row = docByKey.get(def.key);
+ if (!row) continue;
+ if (row.category !== def.category)
+ mismatches.push(`${def.key}: category doc=${row.category} code=${def.category}`);
+ if (row.type !== def.type) mismatches.push(`${def.key}: type doc=${row.type} code=${def.type}`);
+ if (row.defaultValue !== def.defaultValue)
+ mismatches.push(`${def.key}: default doc=${row.defaultValue} code=${def.defaultValue}`);
+ if (row.restart !== def.requiresRestart)
+ mismatches.push(`${def.key}: requiresRestart doc=${row.restart} code=${def.requiresRestart}`);
+ }
+ assert.deepEqual(mismatches, []);
+});
+
+test("category headings and the total match the number of defined flags", () => {
+ const perCategory = new Map();
+ for (const def of FEATURE_FLAG_DEFINITIONS) {
+ perCategory.set(def.category, (perCategory.get(def.category) ?? 0) + 1);
+ }
+ for (const [, name, count] of catalog.matchAll(/^### (\w+) \((\d+)\)/gm)) {
+ assert.equal(Number(count), perCategory.get(name.toLowerCase()), `heading count for ${name}`);
+ }
+ const total = catalog.match(/^(\d+) flags across (\d+) categories/m);
+ assert.ok(total, "expected an ' flags across categories' summary line");
+ assert.equal(Number(total[1]), FEATURE_FLAG_DEFINITIONS.length, "total flag count");
+ assert.equal(Number(total[2]), perCategory.size, "category count");
+});
diff --git a/tests/unit/gamification/action-count-durable-12546.test.ts b/tests/unit/gamification/action-count-durable-12546.test.ts
new file mode 100644
index 0000000000..61f302bae0
--- /dev/null
+++ b/tests/unit/gamification/action-count-durable-12546.test.ts
@@ -0,0 +1,96 @@
+/**
+ * #12546 — Action-count badges must survive xp_audit_log retention pruning.
+ *
+ * Regression guard for the durable per-key/per-action counter (Option A,
+ * endorsed by the maintainer). Before the fix, both getActionCount()
+ * (src/lib/gamification/badges.ts) and checkActionCountBadges()
+ * (src/lib/gamification/events.ts) counted rows directly in xp_audit_log, which
+ * cleanupXpAuditLog() prunes by retention.xpAuditLog (default 30 days). So on a
+ * default install a user who crossed a lifetime milestone lost the badge as soon
+ * as the audit rows aged out — the "lifetime" milestones were really
+ * "requests in the last 30 days".
+ *
+ * Each test drives real activity through addXp(), ages the audit rows past the
+ * retention window, runs the ACTUAL prune (cleanupXpAuditLog), and only then
+ * evaluates the badge. The durable counter must keep the badge unlockable.
+ *
+ * RED on base: the audit rows are gone, the count reads 0/1, the milestone
+ * badge never unlocks. GREEN with the fix: the durable counter still reads the
+ * lifetime total.
+ */
+import { describe, it, before } from "node:test";
+import assert from "node:assert/strict";
+import { addXp, hasBadge } from "../../../src/lib/db/gamification";
+import { evaluateBadges, seedBuiltinBadges } from "../../../src/lib/gamification/badges";
+import { emitGamificationEvent } from "../../../src/lib/gamification/events";
+import { cleanupXpAuditLog } from "../../../src/lib/db/cleanup";
+import { getDbInstance } from "../../../src/lib/db/core";
+
+// token-consumer requires 1,000 lifetime "request" actions. Using a milestone
+// well above 1 keeps the discriminant robust: a single fresh event emitted after
+// the prune can never satisfy it from the (empty) audit log alone.
+const CONSUMER_THRESHOLD = 1000;
+
+function seedLifetimeRequests(apiKeyId: string, n: number): void {
+ for (let i = 0; i < n; i++) {
+ addXp(apiKeyId, "request", 1);
+ }
+}
+
+function ageAndPruneAuditLog(apiKeyId: string): void {
+ const db = getDbInstance();
+ // Push the audit rows well past the default 30-day retention window.
+ db.prepare("UPDATE xp_audit_log SET created_at = datetime('now', '-60 days') WHERE api_key_id = ?").run(
+ apiKeyId
+ );
+}
+
+describe("#12546 action-count badges survive xp_audit_log pruning", () => {
+ before(async () => {
+ await seedBuiltinBadges();
+ });
+
+ it("evaluateBadges() still unlocks the lifetime milestone after the audit log is pruned", async () => {
+ const key = `dc-eval-${Date.now()}`;
+ const db = getDbInstance();
+
+ seedLifetimeRequests(key, CONSUMER_THRESHOLD);
+ ageAndPruneAuditLog(key);
+
+ const pruneResult = await cleanupXpAuditLog();
+ assert.ok(pruneResult.deleted >= CONSUMER_THRESHOLD, "the prune must have deleted the aged rows");
+
+ const remaining = db
+ .prepare("SELECT COUNT(*) AS c FROM xp_audit_log WHERE api_key_id = ?")
+ .get(key) as { c: number };
+ assert.equal(remaining.c, 0, "sanity: no audit rows remain for this key after the prune");
+
+ // getActionCount() (the function named in the issue) is exercised through
+ // evaluateBadges(). With the durable counter it still reads the lifetime
+ // total; against the pruned audit log it reads 0.
+ const unlocked = await evaluateBadges(key, "request");
+ assert.ok(
+ unlocked.includes("token-consumer"),
+ "token-consumer must unlock from the durable counter after the audit log is pruned"
+ );
+ });
+
+ it("checkActionCountBadges() (via emitGamificationEvent) still unlocks the milestone after pruning", async () => {
+ const key = `dc-emit-${Date.now()}`;
+
+ seedLifetimeRequests(key, CONSUMER_THRESHOLD);
+ ageAndPruneAuditLog(key);
+ await cleanupXpAuditLog();
+
+ // A single fresh request. On base this leaves exactly one audit row, so the
+ // COUNT(*) source reads 1 (< 1000) and the badge stays locked. With the fix,
+ // checkActionCountBadges() reads the durable counter (>= 1000) and unlocks.
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+
+ assert.equal(
+ hasBadge(key, "token-consumer"),
+ true,
+ "token-consumer must unlock via the events.ts path from the durable counter"
+ );
+ });
+});
diff --git a/tests/unit/gamification/events.test.ts b/tests/unit/gamification/events.test.ts
index 0e2a9b6ed3..41a21b474e 100644
--- a/tests/unit/gamification/events.test.ts
+++ b/tests/unit/gamification/events.test.ts
@@ -1,6 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { emitGamificationEvent } from "../../../src/lib/gamification/events";
+import { XP_REWARDS } from "../../../src/lib/gamification/xp";
import { getDbInstance } from "../../../src/lib/db/core";
describe("Gamification Events", () => {
@@ -107,7 +108,10 @@ describe("Gamification Events", () => {
await emitGamificationEvent({ apiKeyId: key, action: "request" });
assert.equal(countRequestRows(key), 1);
- assert.equal(leaderboardScore(key), 1);
+ // The very first request also unlocks the "first-token" badge, and badge unlocks now
+ // pay XP_REWARDS.badge_unlock through the same leaderboard path. The gate only governs
+ // the action award, so the score is the 1 XP action plus the badge bonus.
+ assert.equal(leaderboardScore(key), 1 + XP_REWARDS.badge_unlock);
cleanup(key);
});
diff --git a/tests/unit/gamification/streak-badge-xp.test.ts b/tests/unit/gamification/streak-badge-xp.test.ts
new file mode 100644
index 0000000000..21eea0815d
--- /dev/null
+++ b/tests/unit/gamification/streak-badge-xp.test.ts
@@ -0,0 +1,212 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { emitGamificationEvent } from "../../../src/lib/gamification/events";
+import { advanceStreak, getStreak } from "../../../src/lib/gamification/streaks";
+import { XP_REWARDS } from "../../../src/lib/gamification/xp";
+import { addXp, getXp, unlockBadge } from "../../../src/lib/db/gamification";
+import { getDbInstance } from "../../../src/lib/db/core";
+
+// `XP_REWARDS` documents `streak_bonus` ("per consecutive streak day, multiplied by streak
+// length") and `badge_unlock`, but the award pipeline never paid either: events.ts kept a
+// private reward table without them, updateStreak() did not report whether the streak had
+// just extended, and checkAndUnlockBadge() unlocked badges without XP. These tests pin the
+// documented rewards and their idempotency guards (once per UTC day, once per badge).
+
+const MS_PER_DAY = 86_400_000;
+const STREAK_NS = "gamification:streaks";
+
+function utcDate(offsetDays: number): string {
+ return new Date(Date.now() - offsetDays * MS_PER_DAY).toISOString().split("T")[0];
+}
+
+function seedStreak(apiKeyId: string, currentStreak: number, lastActiveDaysAgo: number): void {
+ const lastActiveDate = utcDate(lastActiveDaysAgo);
+ getDbInstance()
+ .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
+ .run(
+ STREAK_NS,
+ apiKeyId,
+ JSON.stringify({
+ currentStreak,
+ longestStreak: currentStreak,
+ lastActiveDate,
+ streakStartDate: utcDate(lastActiveDaysAgo + currentStreak - 1),
+ })
+ );
+}
+
+function auditRows(
+ apiKeyId: string,
+ action: string
+): Array<{ xp_earned: number; metadata: string | null }> {
+ return getDbInstance()
+ .prepare("SELECT xp_earned, metadata FROM xp_audit_log WHERE api_key_id = ? AND action = ?")
+ .all(apiKeyId, action) as Array<{ xp_earned: number; metadata: string | null }>;
+}
+
+function auditTotal(apiKeyId: string): number {
+ const row = getDbInstance()
+ .prepare("SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ?")
+ .get(apiKeyId) as { total: number };
+ return row.total;
+}
+
+function leaderboardScore(apiKeyId: string, scope: string): number {
+ const row = getDbInstance()
+ .prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = ?")
+ .get(apiKeyId, scope) as { score: number } | undefined;
+ return row?.score ?? 0;
+}
+
+function cleanup(apiKeyId: string): void {
+ const db = getDbInstance();
+ db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(apiKeyId);
+ db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(apiKeyId);
+ db.prepare("DELETE FROM user_badges WHERE api_key_id = ?").run(apiKeyId);
+ db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId);
+ db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(STREAK_NS, apiKeyId);
+}
+
+describe("streak bonus XP", () => {
+ it("advanceStreak reports whether the streak extended today", async () => {
+ const key = `sb-advance-${Date.now()}`;
+ try {
+ seedStreak(key, 1, 1);
+ const first = await advanceStreak(key);
+ assert.deepEqual(first, { currentStreak: 2, extended: true });
+ const second = await advanceStreak(key);
+ assert.deepEqual(second, { currentStreak: 2, extended: false }, "same day is a no-op");
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("pays streak_bonus x streak length on the day the streak extends", async () => {
+ const key = `sb-pay-${Date.now()}`;
+ try {
+ seedStreak(key, 1, 1); // active yesterday → today's request extends to 2
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+
+ const rows = auditRows(key, "streak_bonus");
+ assert.equal(rows.length, 1, "exactly one streak_bonus audit row");
+ assert.equal(rows[0].xp_earned, XP_REWARDS.streak_bonus * 2);
+ assert.deepEqual(JSON.parse(rows[0].metadata ?? "{}"), { streak: 2 });
+ assert.equal((await getStreak(key)).currentStreak, 2);
+
+ const total = auditTotal(key);
+ assert.equal(getXp(key)?.totalXp, total, "user_levels.total_xp matches the audit log");
+ assert.equal(leaderboardScore(key, "global"), total, "global leaderboard credits the bonus");
+ assert.equal(leaderboardScore(key, "weekly"), total);
+ assert.equal(leaderboardScore(key, "monthly"), total);
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("pays the bonus once per UTC day even when requests repeat", async () => {
+ const key = `sb-once-${Date.now()}`;
+ try {
+ seedStreak(key, 4, 1);
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+
+ const rows = auditRows(key, "streak_bonus");
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].xp_earned, XP_REWARDS.streak_bonus * 5);
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("does not pay on the first day of a streak or after a broken streak", async () => {
+ const fresh = `sb-fresh-${Date.now()}`;
+ const broken = `sb-broken-${Date.now()}`;
+ try {
+ await emitGamificationEvent({ apiKeyId: fresh, action: "request" });
+ assert.equal(auditRows(fresh, "streak_bonus").length, 0, "day 1 is not a consecutive day");
+
+ seedStreak(broken, 6, 3); // last active three days ago → streak resets to 1
+ await emitGamificationEvent({ apiKeyId: broken, action: "request" });
+ assert.equal((await getStreak(broken)).currentStreak, 1);
+ assert.equal(auditRows(broken, "streak_bonus").length, 0);
+ } finally {
+ cleanup(fresh);
+ cleanup(broken);
+ }
+ });
+});
+
+describe("badge unlock XP", () => {
+ it("unlockBadge reports whether a new row was inserted", () => {
+ const key = `bu-insert-${Date.now()}`;
+ try {
+ assert.equal(unlockBadge(key, "first-token"), true);
+ assert.equal(unlockBadge(key, "first-token"), false, "INSERT OR IGNORE → no new row");
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("pays badge_unlock once per badge when the pipeline unlocks it", async () => {
+ const key = `bu-pay-${Date.now()}`;
+ try {
+ await emitGamificationEvent({ apiKeyId: key, action: "request" }); // → first-token
+ await emitGamificationEvent({ apiKeyId: key, action: "request" }); // already earned
+
+ const rows = auditRows(key, "badge_unlock");
+ assert.equal(rows.length, 1, "exactly one badge_unlock audit row");
+ assert.equal(rows[0].xp_earned, XP_REWARDS.badge_unlock);
+ assert.deepEqual(JSON.parse(rows[0].metadata ?? "{}"), { badgeId: "first-token" });
+
+ const total = auditTotal(key);
+ assert.equal(total, 2 * XP_REWARDS.request + XP_REWARDS.badge_unlock);
+ assert.equal(getXp(key)?.totalXp, total);
+ assert.equal(leaderboardScore(key, "global"), total);
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("pays the streak badge and the streak bonus from the same request", async () => {
+ const key = `bu-streak-${Date.now()}`;
+ try {
+ seedStreak(key, 2, 1); // → 3 today: daily-user badge + bonus
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+
+ const badgeRows = auditRows(key, "badge_unlock");
+ const unlocked = badgeRows.map((r) => JSON.parse(r.metadata ?? "{}").badgeId).sort();
+ assert.deepEqual(unlocked, ["daily-user", "first-token"]);
+ assert.equal(auditRows(key, "streak_bonus")[0]?.xp_earned, XP_REWARDS.streak_bonus * 3);
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("recomputes the level after bonus XP, not only after the action XP", async () => {
+ const key = `bu-level-${Date.now()}`;
+ try {
+ // Level 2 needs 282 XP. 280 + 1 (request) = 281 stays level 1; the first-token
+ // badge_unlock XP crosses the threshold, so the level must be synced after it.
+ addXp(key, "request", 280);
+ assert.equal(getXp(key)?.currentLevel, 1);
+ await emitGamificationEvent({ apiKeyId: key, action: "request" });
+ assert.equal(getXp(key)?.totalXp, 280 + XP_REWARDS.request + XP_REWARDS.badge_unlock);
+ assert.equal(getXp(key)?.currentLevel, 2);
+ } finally {
+ cleanup(key);
+ }
+ });
+
+ it("keeps the radar_supporter recognition path free of XP", async () => {
+ const identity = `bu-radar-${Date.now()}`;
+ try {
+ await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" });
+ assert.equal(auditRows(identity, "badge_unlock").length, 0);
+ assert.equal(getXp(identity), null);
+ assert.equal(leaderboardScore(identity, "global"), 0);
+ } finally {
+ cleanup(identity);
+ }
+ });
+});
diff --git a/tests/unit/guardrails/videoBridgeFrameContract.test.ts b/tests/unit/guardrails/videoBridgeFrameContract.test.ts
index 4391b2e98a..a4751c40e3 100644
--- a/tests/unit/guardrails/videoBridgeFrameContract.test.ts
+++ b/tests/unit/guardrails/videoBridgeFrameContract.test.ts
@@ -1,5 +1,8 @@
import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
import test from "node:test";
+import { fileURLToPath } from "node:url";
import {
JPEG_FRAME_DATA_URI_PREFIX,
@@ -33,3 +36,38 @@ test("estimates decoded bytes without decoding, accounting for padding", () => {
assert.equal(estimateJpegFrameBytes(uri), Buffer.byteLength(source));
}
});
+
+test("never estimates below zero for degenerate padding-only payloads (#12323)", () => {
+ // The charset-only pattern admits these; the estimate must clamp instead of going to -1.
+ for (const encoded of ["=", "==", "A=", "A=="]) {
+ const uri = `${JPEG_FRAME_DATA_URI_PREFIX}${encoded}`;
+ const estimate = estimateJpegFrameBytes(uri);
+ assert.ok(estimate >= 0, `${JSON.stringify(encoded)} estimated ${estimate}`);
+ assert.ok(
+ estimate >= decodeJpegFrameDataUri(uri).byteLength,
+ `${JSON.stringify(encoded)} estimate is not an upper bound`
+ );
+ }
+ assert.equal(estimateJpegFrameBytes(`${JPEG_FRAME_DATA_URI_PREFIX}=`), 0);
+ assert.equal(estimateJpegFrameBytes(`${JPEG_FRAME_DATA_URI_PREFIX}==`), 0);
+});
+
+test("encode sites build frame data URIs from JPEG_FRAME_DATA_URI_PREFIX (#12323)", () => {
+ const guardrailsDir = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../src/lib/guardrails"
+ );
+ for (const file of [
+ "videoBridgeContactSheet.ts",
+ "videoBridgeRuntime.ts",
+ "videoBridgeDrilldownLifecycle.ts",
+ ]) {
+ const source = fs.readFileSync(path.join(guardrailsDir, file), "utf8");
+ assert.doesNotMatch(source, /data:image\/jpeg;base64,/, `${file} hardcodes the JPEG prefix`);
+ assert.match(
+ source,
+ /\bJPEG_FRAME_DATA_URI_PREFIX\b/,
+ `${file} does not use the shared prefix`
+ );
+ }
+});
diff --git a/tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts b/tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts
new file mode 100644
index 0000000000..12beeb7119
--- /dev/null
+++ b/tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts
@@ -0,0 +1,135 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { parse } from "@formatjs/icu-messageformat-parser";
+import { createTranslator } from "next-intl";
+import i18nConfig from "../../config/i18n.json" with { type: "json" };
+
+const { FEATURE_FLAG_DEFINITIONS } =
+ await import("../../src/shared/constants/featureFlagDefinitions.ts");
+
+const MESSAGES_DIR = path.resolve("src/i18n/messages");
+const FLAG_KEY = "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES";
+const MESSAGE_KEY = `definitions.${FLAG_KEY}.description`;
+const RAW_PATH = "profiles//";
+const QUOTED_PATH = "profiles/''/";
+const ENTITY_PATH = "profiles/<name>/";
+const RENDERED_PATH = "~/.claude/profiles//settings.json";
+
+/**
+ * Regression guard for #12505 (INVALID_MESSAGE: UNCLOSED_TAG on the Feature
+ * Flags page). The `featureFlags.definitions.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES.description`
+ * message carried a literal `~/.claude/profiles//settings.json` path.
+ * next-intl parses `` as a rich-text tag, no tag element is ever passed
+ * by `FeatureFlagsGrid.tsx` (plain `t()`), so the message failed to compile and
+ * the card fell back to the raw key in every locale.
+ *
+ * Fix: the placeholder is wrapped in ICU single quotes (`''`) so the
+ * angle brackets render literally. HTML entities are not an option here: the
+ * value is a real file path shown to the user, and `t()` returns entities
+ * verbatim (`<name>` would be displayed as-is).
+ */
+
+function flatten(obj: Record, prefix = ""): Record {
+ const out: Record = {};
+ for (const k of Object.keys(obj)) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ const v = obj[k];
+ if (v && typeof v === "object" && !Array.isArray(v)) {
+ Object.assign(out, flatten(v as Record, key));
+ } else {
+ out[key] = v;
+ }
+ }
+ return out;
+}
+
+describe(`i18n — ${FLAG_KEY} description UNCLOSED_TAG regression (#12505)`, () => {
+ const localeFiles = fs
+ .readdirSync(MESSAGES_DIR)
+ .filter((f) => f.endsWith(".json"))
+ .sort();
+ const expectedCount = i18nConfig.locales.length;
+
+ function readDescription(file: string): string {
+ const raw = fs.readFileSync(path.join(MESSAGES_DIR, file), "utf8");
+ assert.notEqual(raw.charCodeAt(0), 0xfeff, `${file}: starts with BOM (U+FEFF)`);
+ const flat = flatten(JSON.parse(raw) as Record);
+ const value = flat[`featureFlags.${MESSAGE_KEY}`];
+ assert.equal(typeof value, "string", `${file}: featureFlags.${MESSAGE_KEY} must be a string`);
+ return value as string;
+ }
+
+ it(`the description exists in all ${expectedCount} locales`, () => {
+ assert.equal(localeFiles.length, expectedCount);
+ for (const file of localeFiles) {
+ readDescription(file);
+ }
+ });
+
+ it("every locale value parses as an ICU message (no unclosed tag)", () => {
+ const failures: string[] = [];
+ for (const file of localeFiles) {
+ try {
+ parse(readDescription(file), { captureLocation: false, shouldParseSkeletons: true });
+ } catch (error) {
+ failures.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ assert.deepEqual(failures, [], `ICU parse failures: ${failures.slice(0, 5).join("; ")}`);
+ });
+
+ it("every locale wraps the profile path placeholder in ICU single quotes", () => {
+ const offenders: string[] = [];
+ for (const file of localeFiles) {
+ const value = readDescription(file);
+ if (value.includes(RAW_PATH)) offenders.push(`${file}: raw ${RAW_PATH}`);
+ if (value.includes(ENTITY_PATH)) offenders.push(`${file}: entity ${ENTITY_PATH}`);
+ if (!value.includes(QUOTED_PATH)) offenders.push(`${file}: missing ${QUOTED_PATH}`);
+ }
+ assert.deepEqual(offenders, [], offenders.slice(0, 10).join(", "));
+ });
+
+ it("createTranslator renders the literal path in every locale without INVALID_MESSAGE", () => {
+ const errors: string[] = [];
+ const wrong: string[] = [];
+ for (const file of localeFiles) {
+ const locale = file.replace(/\.json$/, "");
+ const messages = JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, file), "utf8"));
+ const t = createTranslator({
+ locale,
+ messages,
+ namespace: "featureFlags",
+ onError: (err: { code?: string; originalMessage?: string; message?: string }) => {
+ errors.push(`${locale}: ${err.code}: ${err.originalMessage ?? err.message}`);
+ },
+ });
+ assert.ok(t.has(MESSAGE_KEY), `${locale}: t.has(${MESSAGE_KEY}) must be true`);
+ const rendered = t(MESSAGE_KEY);
+ if (!rendered.includes(RENDERED_PATH)) {
+ wrong.push(`${locale}: ${rendered.slice(0, 80)}`);
+ }
+ }
+ assert.deepEqual(errors, [], `next-intl errors: ${errors.slice(0, 5).join("; ")}`);
+ assert.deepEqual(
+ wrong,
+ [],
+ `rendered text lost the literal path: ${wrong.slice(0, 5).join("; ")}`
+ );
+ });
+
+ it("the TypeScript default description parses and uses the same quoting", () => {
+ const flag = FEATURE_FLAG_DEFINITIONS.find((f) => f.key === FLAG_KEY);
+ assert.ok(flag, `${FLAG_KEY} must be defined`);
+ assert.doesNotThrow(() =>
+ parse(flag.description, { captureLocation: false, shouldParseSkeletons: true })
+ );
+ assert.ok(flag.description.includes(QUOTED_PATH), `default must contain ${QUOTED_PATH}`);
+ assert.equal(
+ flag.description.includes(RAW_PATH),
+ false,
+ `default must not contain ${RAW_PATH}`
+ );
+ });
+});
diff --git a/tests/unit/i18n-home-recent-requests-topology-legend.test.ts b/tests/unit/i18n-home-recent-requests-topology-legend.test.ts
new file mode 100644
index 0000000000..f9a9fdbd73
--- /dev/null
+++ b/tests/unit/i18n-home-recent-requests-topology-legend.test.ts
@@ -0,0 +1,134 @@
+import assert from "node:assert/strict";
+import { readFileSync, readdirSync } from "node:fs";
+import { test } from "node:test";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+const MESSAGES_DIR = path.join(repoRoot, "src", "i18n", "messages");
+const PLACEHOLDER_PREFIX = "__MISSING__:";
+
+function readMessages(locale: string): Record {
+ return JSON.parse(readFileSync(path.join(MESSAGES_DIR, `${locale}.json`), "utf8")) as Record<
+ string,
+ unknown
+ >;
+}
+
+function getMessage(messages: Record, dottedKey: string): unknown {
+ return dottedKey.split(".").reduce((value, segment) => {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+ return (value as Record)[segment];
+ }, messages);
+}
+
+const allLocales = readdirSync(MESSAGES_DIR)
+ .filter((file) => file.endsWith(".json"))
+ .map((file) => file.slice(0, -".json".length));
+
+// The home "Recent Requests" panel (#10900) shipped its five catalog keys as verbatim English
+// copies in 39 of 41 non-English locales, so the widget rendered in English on every
+// translated dashboard (title, "Model", "In / Out", "When", empty state). The topology legend
+// borrowed `settings.recent` (the memory-retrieval window label, also an English copy) and
+// `analytics.modelStatusError`, which mixed languages and casing ("Activo · Recent · error").
+const RECENT_REQUESTS_KEYS = [
+ "home.recentRequests",
+ "home.recentRequestsEmpty",
+ "home.recentRequestsModel",
+ "home.recentRequestsTokens",
+ "home.recentRequestsWhen",
+];
+const TOPOLOGY_LEGEND_KEYS = [
+ "home.topologyLegendActive",
+ "home.topologyLegendRecent",
+ "home.topologyLegendError",
+];
+const HOME_WIDGET_KEYS = [...RECENT_REQUESTS_KEYS, ...TOPOLOGY_LEGEND_KEYS];
+
+// Locales that must carry a real translation, never an English copy nor a placeholder.
+const TRANSLATED_LOCALES = ["es", "pt", "pt-BR", "fr", "de", "it", "vi"];
+// Genuine cognates: the correct translation happens to spell exactly like the English value.
+const COGNATES = new Set([
+ "es.home.topologyLegendError",
+ // "Model" is the correct Croatian and Slovenian word; there is nothing to translate.
+ "hr.home.recentRequestsModel",
+ "sl.home.recentRequestsModel",
+]);
+
+test("home widget keys exist as non-empty strings in every locale catalog", () => {
+ assert.ok(allLocales.length >= 42, `expected the 42 locale catalogs, found ${allLocales.length}`);
+ for (const locale of allLocales) {
+ const messages = readMessages(locale);
+ for (const key of HOME_WIDGET_KEYS) {
+ const value = getMessage(messages, key);
+ assert.equal(typeof value, "string", `${locale}.${key} must exist`);
+ assert.notEqual((value as string).trim(), "", `${locale}.${key} must not be empty`);
+ }
+ }
+});
+
+test("home widget keys are translated (not English copies) in the maintained locales", () => {
+ const en = readMessages("en");
+ for (const locale of TRANSLATED_LOCALES) {
+ const messages = readMessages(locale);
+ for (const key of HOME_WIDGET_KEYS) {
+ const value = getMessage(messages, key) as string;
+ const english = getMessage(en, key) as string;
+ assert.ok(
+ !value.startsWith(PLACEHOLDER_PREFIX),
+ `${locale}.${key} must not be a ${PLACEHOLDER_PREFIX} placeholder`
+ );
+ if (COGNATES.has(`${locale}.${key}`)) continue;
+ assert.notEqual(value, english, `${locale}.${key} must not be the verbatim English value`);
+ }
+ }
+});
+
+test("no locale keeps a silent English copy of the Recent Requests keys", () => {
+ // A verbatim copy of the English value is invisible to every i18n gate (it counts as
+ // "covered"); either translate it or mark it __MISSING__ so the pipeline can see it.
+ const en = readMessages("en");
+ for (const locale of allLocales) {
+ if (locale === "en") continue;
+ const messages = readMessages(locale);
+ for (const key of RECENT_REQUESTS_KEYS) {
+ const value = getMessage(messages, key) as string;
+ const english = getMessage(en, key) as string;
+ assert.ok(
+ value !== english ||
+ value.startsWith(PLACEHOLDER_PREFIX) ||
+ COGNATES.has(`${locale}.${key}`),
+ `${locale}.${key} is a verbatim English copy ("${english}")`
+ );
+ }
+ }
+});
+
+test("topology legend reads its labels from the home namespace, not memory settings", () => {
+ const source = readFileSync(
+ path.join(repoRoot, "src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx"),
+ "utf8"
+ );
+ assert.doesNotMatch(source, /tSettings\("recent"\)/, "legend must not borrow settings.recent");
+ assert.doesNotMatch(
+ source,
+ /tAnalytics\("modelStatusError"\)/,
+ "legend must not borrow analytics.modelStatusError"
+ );
+ for (const key of ["topologyLegendActive", "topologyLegendRecent", "topologyLegendError"]) {
+ assert.match(source, new RegExp(`t\\("${key}"\\)`), `legend must use home.${key}`);
+ }
+});
+
+test("topology legend casing matches across languages in the maintained locales", () => {
+ // The legend is a row of three labels; they must share capitalisation within a locale.
+ for (const locale of ["en", ...TRANSLATED_LOCALES]) {
+ const messages = readMessages(locale);
+ const labels = TOPOLOGY_LEGEND_KEYS.map((key) => getMessage(messages, key) as string);
+ const upperInitial = labels.map((label) => /^\p{Lu}/u.test(label));
+ assert.ok(
+ upperInitial.every((flag) => flag === upperInitial[0]),
+ `${locale} legend mixes capitalisation: ${JSON.stringify(labels)}`
+ );
+ }
+});
diff --git a/tests/unit/image-combo-edits-fallback-12547.test.ts b/tests/unit/image-combo-edits-fallback-12547.test.ts
new file mode 100644
index 0000000000..16054db7ce
--- /dev/null
+++ b/tests/unit/image-combo-edits-fallback-12547.test.ts
@@ -0,0 +1,219 @@
+// #12547 (diegosouzapw endorsed): /v1/images/edits must iterate a combo's targets
+// the same way /v1/images/generations does (#9239), so a combo whose FIRST target
+// isn't edit-capable (or lacks credentials) falls through to a later edit-capable
+// target instead of flattening to the first target and hard-erroring.
+//
+// Before this change: /v1/images/edits resolved a bare combo name to its first
+// target via resolveSingleImageComboTarget() and dispatched only that one. A combo
+// like ["openai/gpt-image-2", "openrouter/..."] hard-errored ("Image edit is not
+// supported for built-in provider openai") even though the OpenRouter target could
+// have serviced the edit. Missing credentials on the first target were likewise a
+// hard 401 for the whole request.
+//
+// After this change: the edits route diverts bare combos through the same shared
+// runImageComboTargets loop generations uses, filtered to edit-capable targets.
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-image-combo-edits-12547-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-combo-edits-12547-secret";
+process.env.JWT_SECRET = process.env.JWT_SECRET || "image-combo-edits-12547-jwt";
+
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
+const { executeImageCombo } = await import("../../open-sse/services/imageCombo.ts");
+const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
+
+interface ErrorResponseBody {
+ error: { message: string; code?: string };
+}
+interface ImageResponseBody {
+ data: Array<{ b64_json?: string; url?: string }>;
+}
+
+const originalFetch = globalThis.fetch;
+
+async function resetStorage() {
+ globalThis.fetch = originalFetch;
+ apiKeysDb.resetApiKeyState();
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+ v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
+}
+
+function seedOpenRouterConnection() {
+ return providersDb.createProviderConnection({
+ provider: "openrouter",
+ authType: "apikey",
+ name: "openrouter-combo-edit",
+ apiKey: "sk-or-combo-edit-12547",
+ isActive: true,
+ testStatus: "active",
+ rateLimitedUntil: null,
+ });
+}
+
+function dataUrlPng(bytes: number[]): string {
+ return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
+}
+
+const REF_A = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
+
+function editRequest(model: string, images: string[] = [REF_A]): Request {
+ return new Request("http://localhost/api/v1/images/edits", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model, prompt: "add a red hat", images }),
+ });
+}
+
+/** Mock a successful OpenRouter unified-Image-API edit response. */
+function mockOpenRouterSuccess(): void {
+ globalThis.fetch = async () =>
+ new Response(
+ JSON.stringify({
+ data: [{ b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64") }],
+ }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+}
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(() => {
+ globalThis.fetch = originalFetch;
+ apiKeysDb.resetApiKeyState();
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+// ---------------------------------------------------------------------------
+// Discriminant #1 — first target is NOT edit-capable, a later one is.
+// RED on base (400 "not supported for built-in provider openai"); GREEN with fix.
+// ---------------------------------------------------------------------------
+test("#12547 edits combo falls through a non-edit-capable first target to a later one", async () => {
+ await seedOpenRouterConnection();
+ mockOpenRouterSuccess();
+ await combosDb.createCombo({
+ name: "edit-fallback-combo",
+ strategy: "priority",
+ // openai/gpt-image-2 is a built-in provider with NO OpenAI-compatible edit
+ // endpoint (the single-model path hard-errors on it); the openrouter target can edit.
+ models: ["openai/gpt-image-2", "openrouter/google/gemini-3.1-flash-image-preview"],
+ });
+
+ const response = await imageEditRoute.POST(editRequest("edit-fallback-combo"));
+ const body = (await response.json()) as ImageResponseBody;
+
+ assert.equal(response.status, 200, "must fall through to the edit-capable openrouter target");
+ assert.ok(body.data?.[0]?.b64_json, "edit returns an image payload from the later target");
+});
+
+// ---------------------------------------------------------------------------
+// Discriminant #2 — first target IS edit-capable but lacks credentials.
+// Matching generations, missing credentials is a SKIP (not a hard 401). A later
+// credentialed target services the edit.
+// RED on base (401 "No credentials for provider: codex"); GREEN with fix.
+// ---------------------------------------------------------------------------
+test("#12547 edits combo skips an edit-capable first target missing credentials", async () => {
+ await seedOpenRouterConnection(); // only openrouter is credentialed; codex is not
+ mockOpenRouterSuccess();
+ await combosDb.createCombo({
+ name: "edit-skip-nocreds-combo",
+ strategy: "priority",
+ models: ["codex/gpt-5.6-sol", "openrouter/google/gemini-3.1-flash-image-preview"],
+ });
+
+ const response = await imageEditRoute.POST(editRequest("edit-skip-nocreds-combo"));
+ const body = (await response.json()) as ImageResponseBody;
+
+ assert.equal(response.status, 200, "missing creds on the first target must skip, not 401");
+ assert.ok(body.data?.[0]?.b64_json, "edit returns an image payload from the credentialed target");
+});
+
+// ---------------------------------------------------------------------------
+// Guard — a combo with no edit-capable target reports a clear 400 (no stack leak).
+// ---------------------------------------------------------------------------
+test("#12547 edits combo with no edit-capable targets returns a clean 400", async () => {
+ globalThis.fetch = async () => {
+ throw new Error("No edit-capable target must never reach upstream");
+ };
+ await combosDb.createCombo({
+ name: "no-edit-capable-combo",
+ strategy: "priority",
+ // openai + a chat model: neither exposes an OpenAI-compatible edit endpoint.
+ models: ["openai/gpt-image-2", "openai/gpt-4o"],
+ });
+
+ const response = await imageEditRoute.POST(editRequest("no-edit-capable-combo"));
+ const body = (await response.json()) as ErrorResponseBody;
+
+ assert.equal(response.status, 400);
+ assert.match(body.error.message, /No image-edit-capable targets/);
+ assert.ok(!body.error.message.includes("at /"), "no stack trace leak");
+});
+
+// ---------------------------------------------------------------------------
+// /v1/images/generations behavior is unchanged by the shared-loop extraction.
+// The generation combo path still filters non-image targets and reports the
+// image-capable-but-uncredentialed error (not the filtering error).
+// ---------------------------------------------------------------------------
+function createLog() {
+ const record = () => () => 0;
+ return { info: record(), warn: record(), error: record(), debug: record() };
+}
+
+test("#12547 generations combo still rejects a chat-only combo with 'No images-capable targets'", async () => {
+ await combosDb.createCombo({
+ name: "gen-chat-only-combo",
+ strategy: "priority",
+ models: ["openai/gpt-4o"],
+ });
+
+ const response = await executeImageCombo(
+ "gen-chat-only-combo",
+ { model: "gen-chat-only-combo", prompt: "a cat" },
+ {
+ request: new Request("http://localhost/v1/images/generations", { method: "POST" }),
+ policy: { apiKeyInfo: { id: "k", name: "k" } },
+ },
+ Date.now(),
+ createLog() as never
+ );
+ assert.equal(response.status, 400);
+ const body = (await response.json()) as ErrorResponseBody;
+ assert.match(JSON.stringify(body), /No images-capable targets/);
+});
+
+test("#12547 generations combo still surfaces missing credentials for image targets", async () => {
+ await combosDb.createCombo({
+ name: "gen-img-no-conn-combo",
+ strategy: "priority",
+ models: ["openai/gpt-image-2", "openai/gpt-image-1.5"],
+ });
+
+ const response = await executeImageCombo(
+ "gen-img-no-conn-combo",
+ { model: "gen-img-no-conn-combo", prompt: "a cat", n: 1 },
+ {
+ request: new Request("http://localhost/v1/images/generations", { method: "POST" }),
+ policy: { apiKeyInfo: { id: "k", name: "k" } },
+ },
+ Date.now(),
+ createLog() as never
+ );
+ assert.equal(response.status, 400);
+ const body = (await response.json()) as ErrorResponseBody;
+ // Image-capable targets were found (so NOT the filtering error); the failure is credentials.
+ assert.ok(!JSON.stringify(body).includes("No images-capable targets"));
+});
diff --git a/tests/unit/model-lifecycle-degradation-map.test.ts b/tests/unit/model-lifecycle-degradation-map.test.ts
new file mode 100644
index 0000000000..0b59ab732a
--- /dev/null
+++ b/tests/unit/model-lifecycle-degradation-map.test.ts
@@ -0,0 +1,56 @@
+/**
+ * Follow-up to #11503 / #11507: `DEFAULT_DEGRADATION_MAP` (backgroundTaskDetector.ts) is the
+ * third hand-maintained routing table that names model ids, and it was outside the
+ * retired-model gate. A retired *source* is a dead row — `checkLifecycle` answers 410
+ * `model_shutdown` before `resolveBackgroundTaskRedirect` runs — and a retired *target*
+ * is normally rejected with 410 when lifecycle validation runs again after the redirect,
+ * unless alias resolution maps it to an accepted id.
+ *
+ * Table-driven over the production default map and the checked-in lifecycle snapshot, mirroring
+ * `model-deprecation-aliases-11503.test.ts`, so a new dead row fails by name.
+ */
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { getDefaultDegradationMap } from "../../open-sse/services/backgroundTaskDetector.ts";
+import { isVendorRetiredId } from "../../open-sse/services/modelLifecycle.ts";
+
+const lifecycle = JSON.parse(
+ readFileSync(
+ fileURLToPath(new URL("../../config/quality/model-lifecycle.json", import.meta.url)),
+ "utf8"
+ )
+) as { retired: Record };
+
+const retiredIds = new Set(
+ Object.entries(lifecycle.retired)
+ .filter(([, entry]) => entry.status === "retired")
+ .map(([id]) => id.toLowerCase())
+);
+
+describe("DEFAULT_DEGRADATION_MAP names no retired model id", () => {
+ const rows = Object.entries(getDefaultDegradationMap());
+
+ it("has rows to check", () => {
+ assert.ok(rows.length > 0);
+ });
+
+ for (const [source, target] of rows) {
+ it(`degrades from ${source}, an id the vendor has not retired`, () => {
+ assert.ok(
+ !retiredIds.has(source.toLowerCase()),
+ `"${source}" → "${target}" is dead: the vendor has retired "${source}", so checkLifecycle rejects the request before the background redirect runs`
+ );
+ assert.equal(isVendorRetiredId(source), false);
+ });
+
+ it(`degrades ${source} to ${target}, an id the vendor has not retired`, () => {
+ assert.ok(
+ !retiredIds.has(target.toLowerCase()),
+ `"${source}" → "${target}" forwards background tasks to "${target}", which the vendor has retired`
+ );
+ assert.equal(isVendorRetiredId(target), false);
+ });
+ }
+});
diff --git a/tests/unit/rerank-providers-5332.test.ts b/tests/unit/rerank-providers-5332.test.ts
index 20a0ad74ef..c7e39a1fb9 100644
--- a/tests/unit/rerank-providers-5332.test.ts
+++ b/tests/unit/rerank-providers-5332.test.ts
@@ -69,3 +69,37 @@ test("#5332 deepinfra response omits document text when return_documents=false",
assert.equal(out.results[0].document, undefined);
assert.equal(out.results[0].index, 1);
});
+
+// ─── NVIDIA must honor return_documents like its deepinfra/voyage siblings ──
+
+test("#5332 nvidia response omits document text when return_documents=false", () => {
+ const cfg = getRerankProvider("nvidia");
+ const out = transformResponseFromProvider(
+ cfg,
+ { id: "r1", rankings: [{ index: 0, logit: 0.8, text: "a" }] },
+ { documents: ["a"], return_documents: false }
+ );
+ assert.equal(out.results[0].document, undefined);
+ assert.equal(out.results[0].index, 0);
+ assert.equal(out.results[0].relevance_score, 0.8);
+});
+
+test("#5332 nvidia response includes document text when return_documents is true", () => {
+ const cfg = getRerankProvider("nvidia");
+ const out = transformResponseFromProvider(
+ cfg,
+ { id: "r1", rankings: [{ index: 1, logit: 0.4, text: "b" }] },
+ { documents: ["a", "b"], return_documents: true }
+ );
+ assert.equal(out.results[0].document.text, "b");
+});
+
+test("#5332 nvidia response includes document text when return_documents is omitted", () => {
+ const cfg = getRerankProvider("nvidia");
+ const out = transformResponseFromProvider(
+ cfg,
+ { id: "r1", rankings: [{ index: 0, logit: 0.9, text: "a" }] },
+ { documents: ["a"] }
+ );
+ assert.equal(out.results[0].document.text, "a");
+});
diff --git a/tests/unit/rerank-voyage-7809.test.ts b/tests/unit/rerank-voyage-7809.test.ts
index 208fad237c..059c964d25 100644
--- a/tests/unit/rerank-voyage-7809.test.ts
+++ b/tests/unit/rerank-voyage-7809.test.ts
@@ -223,3 +223,47 @@ test("#7809 voyage response adapter handles empty data array", () => {
const out = transformResponseFromProvider(cfg, { data: [] }, { documents: ["a", "b"] });
assert.deepEqual(out.results, []);
});
+
+// ─── top_k must never exceed the surviving document count ──────────────────
+// The handler normalizes `top_n: top_n || documents.length` BEFORE the adapter
+// runs, so a caller that omits top_n and sends an exact empty string yields
+// top_k > documents.length — which Voyage rejects with HTTP 400.
+
+test("#7809 voyage request adapter clamps top_k to the surviving document count", () => {
+ const cfg = getRerankProvider("voyage-ai");
+ const out = transformRequestForProvider(cfg, {
+ model: "rerank-2.5-lite",
+ query: "teste",
+ documents: ["a", "", "b"],
+ // Mirrors the handler's `top_n: top_n || documents.length` when the caller omits top_n.
+ top_n: 3,
+ return_documents: true,
+ });
+ assert.deepEqual(out.documents, ["a", "b"]);
+ assert.equal(out.top_k, 2, "top_k must not exceed the number of documents actually sent");
+});
+
+test("#7809 voyage request adapter clamps an explicit oversized top_n", () => {
+ const cfg = getRerankProvider("voyage-ai");
+ const out = transformRequestForProvider(cfg, {
+ model: "rerank-2.5-lite",
+ query: "teste",
+ documents: ["a", "", "", "b"],
+ top_n: 10,
+ return_documents: true,
+ });
+ assert.deepEqual(out.documents, ["a", "b"]);
+ assert.equal(out.top_k, 2);
+});
+
+test("#7809 voyage request adapter keeps a legitimate top_n below the document count", () => {
+ const cfg = getRerankProvider("voyage-ai");
+ const out = transformRequestForProvider(cfg, {
+ model: "rerank-2.5-lite",
+ query: "teste",
+ documents: ["a", "b", "c"],
+ top_n: 2,
+ return_documents: true,
+ });
+ assert.equal(out.top_k, 2);
+});
diff --git a/tests/unit/settings/background-degradation-deletions-12424.test.ts b/tests/unit/settings/background-degradation-deletions-12424.test.ts
new file mode 100644
index 0000000000..f5b157a723
--- /dev/null
+++ b/tests/unit/settings/background-degradation-deletions-12424.test.ts
@@ -0,0 +1,57 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bgdeg-12424-"));
+
+const { applyRuntimeSettings, resetRuntimeSettingsStateForTests } = await import(
+ "../../../src/lib/config/runtimeSettings.ts"
+);
+const {
+ getBackgroundDegradationConfig,
+ getDefaultDegradationMap,
+ getDefaultDetectionPatterns,
+ setBackgroundDegradationConfig,
+} = await import("../../../open-sse/services/backgroundTaskDetector.ts");
+
+// Issue #12424: deleting a built-in background-degradation entry through the dashboard
+// did not persist — the runtime loader merged defaults *under* the stored map, so a key
+// the user removed (absent from the stored record) was indistinguishable from one never
+// touched and always came back on the next apply/restart.
+test("stored degradationMap that omits a default key does not resurrect it (#12424)", async () => {
+ resetRuntimeSettingsStateForTests();
+ setBackgroundDegradationConfig({
+ enabled: false,
+ degradationMap: getDefaultDegradationMap(),
+ detectionPatterns: getDefaultDetectionPatterns(),
+ });
+
+ const defaults = getDefaultDegradationMap();
+ const deletedKey = "gpt-5";
+ const keptKey = "gpt-4o";
+ assert.ok(
+ defaults[deletedKey] && defaults[keptKey],
+ "fixture assumes these default keys exist in DEFAULT_DEGRADATION_MAP"
+ );
+
+ // The stored map is every default except the one the user deleted.
+ const stored: Record = { ...defaults };
+ delete stored[deletedKey];
+
+ await applyRuntimeSettings(
+ { backgroundDegradation: JSON.stringify({ enabled: true, degradationMap: stored }) },
+ { force: true, source: "test" }
+ );
+
+ const applied = getBackgroundDegradationConfig().degradationMap;
+
+ // The entries the user kept still apply…
+ assert.equal(applied[keptKey], defaults[keptKey], "a kept default entry still applies");
+ // …and the one they deleted stays deleted instead of being back-filled from defaults.
+ assert.ok(
+ !(deletedKey in applied),
+ `deleted default '${deletedKey}' must not be re-added from defaults`
+ );
+});
diff --git a/tests/unit/ui/api-manager-loading-status-12066.test.tsx b/tests/unit/ui/api-manager-loading-status-12066.test.tsx
new file mode 100644
index 0000000000..48e5e33901
--- /dev/null
+++ b/tests/unit/ui/api-manager-loading-status-12066.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+(
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+).IS_REACT_ACT_ENVIRONMENT = true;
+
+const translate = (key: string) => key;
+vi.mock("next-intl", () => ({
+ useLocale: () => "en",
+ useTranslations: () => Object.assign(translate, { has: () => false, rich: translate }),
+}));
+
+const { default: ApiManagerPageClient } =
+ await import("@/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient");
+
+const roots: Array<{ root: ReturnType; container: HTMLDivElement }> = [];
+
+function mountPage() {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ roots.push({ root, container });
+ act(() => root.render());
+ return container;
+}
+
+afterEach(() => {
+ for (const { root, container } of roots.splice(0)) {
+ act(() => root.unmount());
+ container.remove();
+ }
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe("API manager loading gate accessibility (#12066)", () => {
+ it("exposes a busy polite status while the initial /api/keys fetch is pending", () => {
+ // Never settles: the page stays on its skeleton gate for the whole test.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => new Promise(() => undefined))
+ );
+
+ const container = mountPage();
+ const status = container.querySelector('[role="status"]');
+
+ expect(status).not.toBeNull();
+ expect(status?.getAttribute("aria-live")).toBe("polite");
+ expect(status?.getAttribute("aria-busy")).toBe("true");
+ // The only text in the accessibility tree during the gate is the loading label.
+ expect(status?.textContent).toContain("loading");
+ // The skeleton cards themselves stay decorative.
+ expect(container.querySelectorAll('[aria-hidden="true"]').length).toBeGreaterThan(0);
+ });
+
+ it("drops the loading status once /api/keys has settled", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({ ok: true, json: async () => ({}) }))
+ );
+
+ const container = mountPage();
+ for (let i = 0; i < 40 && container.querySelector('[role="status"]'); i++) {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ });
+ }
+
+ expect(container.querySelector('[role="status"]')).toBeNull();
+ expect(container.querySelector("h1")).not.toBeNull();
+ });
+});
diff --git a/tests/unit/usage-history-reset.test.ts b/tests/unit/usage-history-reset.test.ts
index 01f0d86d4e..86d254193c 100644
--- a/tests/unit/usage-history-reset.test.ts
+++ b/tests/unit/usage-history-reset.test.ts
@@ -58,6 +58,24 @@ test.after(() => {
}
});
+test("purge usage API exposes every conversation reset counter", () => {
+ const routeSource = fs.readFileSync(
+ path.join(process.cwd(), "src/app/api/settings/purge-usage-history/route.ts"),
+ "utf8"
+ );
+
+ assert.match(
+ routeSource,
+ /deletedConversationTurnNodes:\s*result\.deletedConversationTurnNodes/,
+ "the API response should expose deleted conversation nodes"
+ );
+ assert.match(
+ routeSource,
+ /deletedAgenticConversations:\s*result\.deletedAgenticConversations/,
+ "the API response should expose deleted conversation roots"
+ );
+});
+
test("resetUsageHistory: 'all' wipes usage_history, daily_usage_summary, and hourly_usage_summary; a period only deletes rows older than the cutoff; an invalid period throws", async () => {
setup();
try {
@@ -103,6 +121,17 @@ test("resetUsageHistory: 'all' wipes usage_history, daily_usage_summary, and hou
"INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
).run("combo-test", "Test Combo", "{}", recentIso, recentIso);
+ db.prepare(
+ `INSERT INTO agentic_conversations
+ (id, api_key_id, fingerprint_hash, last_message_count, last_messages_hash, turn_count, first_seen_at, last_seen_at)
+ VALUES ('conversation-test', 'key-test', 'fp', 0, '', 1, ?, ?)`
+ ).run(recentIso, recentIso);
+ db.prepare(
+ `INSERT INTO conversation_turn_nodes
+ (id, conversation_id, parent_id, role, content_hash, last_correlation_id, first_seen_at, last_seen_at)
+ VALUES ('turn-test', 'conversation-test', NULL, 'user', 'hash', 'recent-call', ?, ?)`
+ ).run(recentIso, recentIso);
+
db.prepare("INSERT INTO usage_history (provider, model, timestamp) VALUES (?, ?, ?)").run(
"openai",
"gpt-test",
@@ -240,6 +269,16 @@ test("resetUsageHistory: 'all' wipes usage_history, daily_usage_summary, and hou
assert.equal(countRows(db, "provider_nodes"), 1, "provider config should survive reset");
assert.equal(countRows(db, "api_keys"), 1, "API keys should survive reset");
assert.equal(countRows(db, "combos"), 1, "combos should survive reset");
+ assert.equal(
+ countRows(db, "conversation_turn_nodes"),
+ 1,
+ "a timed reset should preserve conversation identity nodes"
+ );
+ assert.equal(
+ countRows(db, "agentic_conversations"),
+ 1,
+ "a timed reset should preserve conversation roots"
+ );
assert.equal(countRows(db, "usage_history"), 1, "recent usage_history row should survive");
assert.equal(countRows(db, "call_logs"), 1, "recent call_logs row should survive");
@@ -310,6 +349,16 @@ test("resetUsageHistory: 'all' wipes usage_history, daily_usage_summary, and hou
1,
"'all' should delete remaining call artifact"
);
+ assert.equal(
+ allResult.deletedConversationTurnNodes,
+ 1,
+ "'all' should delete conversation identity nodes"
+ );
+ assert.equal(
+ allResult.deletedAgenticConversations,
+ 1,
+ "'all' should delete conversation roots"
+ );
assert.equal(
fs.existsSync(recentArtifactPath),
false,
@@ -331,6 +380,16 @@ test("resetUsageHistory: 'all' wipes usage_history, daily_usage_summary, and hou
0,
"'all' should empty hourly_usage_summary"
);
+ assert.equal(
+ countRows(db, "conversation_turn_nodes"),
+ 0,
+ "'all' should empty conversation_turn_nodes"
+ );
+ assert.equal(
+ countRows(db, "agentic_conversations"),
+ 0,
+ "'all' should empty agentic_conversations"
+ );
assert.equal(countRows(db, "provider_nodes"), 1, "provider config should still survive 'all'");
assert.equal(countRows(db, "api_keys"), 1, "API keys should still survive 'all'");
assert.equal(countRows(db, "combos"), 1, "combos should still survive 'all'");
diff --git a/tests/unit/video-custom-provider-route.test.ts b/tests/unit/video-custom-provider-route.test.ts
index 9c10d6f2cb..a963b7710a 100644
--- a/tests/unit/video-custom-provider-route.test.ts
+++ b/tests/unit/video-custom-provider-route.test.ts
@@ -213,7 +213,9 @@ test("video route dispatches submit→poll job flow for custom model with agnes-
headers: { "content-type": "application/json" },
});
}
- if (stringUrl === "https://custom.example.com/agnesapi?video_id=video-123") {
+ if (
+ stringUrl === "https://custom.example.com/agnesapi?video_id=video-123&model_name=job-video-v1"
+ ) {
return createResponse(
JSON.stringify({
status: "completed",
@@ -256,7 +258,10 @@ test("video route dispatches submit→poll job flow for custom model with agnes-
prompt: "a cat playing piano",
});
assert.equal(calls[1].method, "GET");
- assert.equal(calls[1].url, "https://custom.example.com/agnesapi?video_id=video-123");
+ assert.equal(
+ calls[1].url,
+ "https://custom.example.com/agnesapi?video_id=video-123&model_name=job-video-v1"
+ );
});
test("video route returns 502 when job preset reports failed status", async () => {