From 5c9163b0fb4b8f3058d1475be70fc9c2629b056f Mon Sep 17 00:00:00 2001 From: Jay Ongg Date: Thu, 13 Aug 2026 22:57:07 -0700 Subject: [PATCH 1/3] Initial commit --- open-sse/services/autoCombo/virtualFactory.ts | 59 ++- scripts/ad-hoc/dump-auto-combos.ts | 52 +++ .../dashboard/combos/AutoComboCatalog.tsx | 70 ++- src/app/(dashboard)/dashboard/combos/page.tsx | 426 ++++++++++-------- src/app/api/combos/duplicate/route.ts | 167 +++++++ src/i18n/messages/ar.json | 7 +- src/i18n/messages/az.json | 7 +- src/i18n/messages/bg.json | 7 +- src/i18n/messages/bn.json | 7 +- src/i18n/messages/cs.json | 7 +- src/i18n/messages/da.json | 7 +- src/i18n/messages/de.json | 7 +- src/i18n/messages/en.json | 9 +- src/i18n/messages/es.json | 7 +- src/i18n/messages/fa.json | 7 +- src/i18n/messages/fi.json | 7 +- src/i18n/messages/fr.json | 7 +- src/i18n/messages/gu.json | 7 +- src/i18n/messages/he.json | 7 +- src/i18n/messages/hi.json | 7 +- src/i18n/messages/hu.json | 7 +- src/i18n/messages/id.json | 7 +- src/i18n/messages/in.json | 7 +- src/i18n/messages/it.json | 7 +- src/i18n/messages/ja.json | 7 +- src/i18n/messages/ko.json | 7 +- src/i18n/messages/mr.json | 7 +- src/i18n/messages/ms.json | 7 +- src/i18n/messages/nl.json | 7 +- src/i18n/messages/no.json | 7 +- src/i18n/messages/phi.json | 7 +- src/i18n/messages/pl.json | 7 +- src/i18n/messages/pt-BR.json | 7 +- src/i18n/messages/pt.json | 7 +- src/i18n/messages/ro.json | 7 +- src/i18n/messages/ru.json | 7 +- src/i18n/messages/sk.json | 7 +- src/i18n/messages/sv.json | 7 +- src/i18n/messages/sw.json | 7 +- src/i18n/messages/ta.json | 7 +- src/i18n/messages/te.json | 7 +- src/i18n/messages/th.json | 7 +- src/i18n/messages/tr.json | 7 +- src/i18n/messages/uk-UA.json | 7 +- src/i18n/messages/ur.json | 7 +- src/i18n/messages/vi.json | 7 +- src/i18n/messages/zh-CN.json | 7 +- src/i18n/messages/zh-TW.json | 7 +- .../combos-duplicate-resolution-audit.test.ts | 54 +++ tests/unit/combos-duplicate-route.test.ts | 287 ++++++++++++ tests/unit/snapshot-weights.test.ts | 243 ++++++++++ 51 files changed, 1412 insertions(+), 249 deletions(-) create mode 100644 scripts/ad-hoc/dump-auto-combos.ts create mode 100644 src/app/api/combos/duplicate/route.ts create mode 100644 tests/unit/combos-duplicate-resolution-audit.test.ts create mode 100644 tests/unit/combos-duplicate-route.test.ts create mode 100644 tests/unit/snapshot-weights.test.ts diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 87e1006a8e82..c0c760f32f87 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -20,6 +20,7 @@ import { type AutoCategory, type AutoTier, } from "./suffixComposition"; +import { classifyTier } from "../tierResolver"; import type { AutoVariant } from "./autoPrefix"; import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily"; import { getHiddenModelsByProvider } from "@/models"; @@ -560,6 +561,61 @@ export async function prepareVirtualAutoComboInputs( }; } +/** + * Score candidates at snapshot time using available data (capabilities, tier) + * and the mode-pack's dominant factors. Runtime telemetry (p95 latency, quota + * remaining) is not available during combo creation — this uses static signals only. + * + * Returns a map from modelStr → normalized weight score [0, 1]. + */ +export function computeSnapshotWeights( + candidates: readonly VirtualAutoComboCandidate[], + weights: ScoringWeights +): Map { + const scores = new Map(); + for (const c of candidates) { + let score = 0; + + // taskFit: reasoning + vision capable models score higher when taskFit is weighted + if (weights.taskFit > 0) { + if (c.resolvedReasoning || c.resolvedSupportsThinking) score += weights.taskFit * 0.6; + if (c.resolvedSupportsVision) score += weights.taskFit * 0.3; + } + + // stability: models with richer capabilities are assumed more stable + if (weights.stability > 0) { + const capabilityCount = + Number(c.resolvedReasoning ?? false) + + Number(c.resolvedSupportsThinking ?? false) + + Number(c.resolvedSupportsVision ?? false); + score += weights.stability * Math.min(capabilityCount / 2, 1); + } + + // Tier-based scoring (single classifyTier call covers both checks) + let tierInfo: { tier: string } | null = null; + if (weights.tierPriority > 0 || weights.costInv > 0) { + try { + tierInfo = classifyTier(c.provider, c.model); + } catch { + // fall through with zero + } + } + if (tierInfo && weights.tierPriority > 0 && tierInfo.tier === "premium") + score += weights.tierPriority; + if (tierInfo && weights.costInv > 0 && tierInfo.tier === "free") score += weights.costInv; + + // latencyInv: all candidates get a base score when latency matters + // (no runtime data at snapshot time, so equal baseline) + if (weights.latencyInv > 0) score += weights.latencyInv * 0.5; + + // health + quota: no runtime telemetry at snapshot time → neutral baseline + score += (weights.health + weights.quota) * 0.5; + + scores.set(c.modelStr, Math.min(score, 1)); + } + return scores; +} + function clonePreparedCandidates( candidates: readonly VirtualAutoComboCandidate[] ): VirtualAutoComboCandidate[] { @@ -728,6 +784,7 @@ export async function createVirtualAutoComboFromPrepared( } const providerPool = [...new Set(effectivePool.map((c) => c.provider))]; + const snapshotScores = computeSnapshotWeights(effectivePool, weights); const models = effectivePool.map((candidate, index) => ({ id: `virtual-auto-${variant || "default"}-${index + 1}-${candidate.provider}`, kind: "model" as const, @@ -737,7 +794,7 @@ export async function createVirtualAutoComboFromPrepared( ...(candidate.allowedConnectionIds ? { allowedConnectionIds: candidate.allowedConnectionIds } : {}), - weight: 1, + weight: snapshotScores.get(candidate.modelStr) ?? 1, label: candidate.provider, })); const autoConfig = { diff --git a/scripts/ad-hoc/dump-auto-combos.ts b/scripts/ad-hoc/dump-auto-combos.ts new file mode 100644 index 000000000000..0e3c35201156 --- /dev/null +++ b/scripts/ad-hoc/dump-auto-combos.ts @@ -0,0 +1,52 @@ +/** + * One-shot diagnostic: resolve every built-in auto-combo template and dump the + * resulting candidate pool, weight pack, and config as JSON for inspection. + * + * Run from repo root: + * node --import tsx/esm scripts/ad-hoc/dump-auto-combos.ts > _tasks/research/auto-combos-snapshot.json + */ + +const { AUTO_TEMPLATE_VARIANTS, AUTO_SUFFIX_VARIANTS, AUTO_FAMILY_IDS } = + await import("@omniroute/open-sse/services/autoCombo/builtinCatalog"); +const { createBuiltinAutoCombo, prepareBuiltinAutoComboInputs } = + await import("@omniroute/open-sse/services/autoCombo/builtinCatalog"); + +// Prepares the candidate pool once (DB reads: connections, settings, capabilities) +const prepared = await prepareBuiltinAutoComboInputs(); + +const allTemplates: string[] = []; +allTemplates.push(...Object.keys(AUTO_TEMPLATE_VARIANTS)); +allTemplates.push(...AUTO_SUFFIX_VARIANTS); +allTemplates.push(...AUTO_FAMILY_IDS); + +const results: Array<{ + template: string; + candidateCount: number; + models: string[]; + weightPack: Record; + explorationRate: number; +}> = []; + +for (const name of allTemplates) { + try { + const suffix = name.slice("auto/".length); + const combo = await createBuiltinAutoCombo(name, suffix, prepared as never); + results.push({ + template: name, + candidateCount: combo.models.length, + models: combo.models.map((m) => m.model ?? `${m.providerId}/unknown`), + weightPack: combo.weights ?? {}, + explorationRate: combo.explorationRate, + }); + } catch (err) { + results.push({ + template: name, + candidateCount: 0, + models: [], + weightPack: {}, + explorationRate: 0, + }); + } +} + +console.log(JSON.stringify(results, null, 2)); diff --git a/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx b/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx index a072fa8f1b35..c3d695514878 100644 --- a/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx +++ b/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx @@ -1,19 +1,66 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; -import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types"; +import { AUTO_COMBO_TEMPLATES, type AutoComboTemplate } from "@/domain/assessment/types"; // Informational catalog of zero-config auto-routing combos. // Auto combos are resolved at request time by the chat handler based on the // currently connected providers / models — they have no row in the combos // table, so they were previously invisible in the UI. This panel surfaces // the static catalog (name, intent, categories, tiers, strategy) so users -// can discover the auto/ prefix without reading source. -export default function AutoComboCatalog() { +// can discover the auto/ prefix without reading source. Duplicate icon lets +// you materialize a snapshot into an editable static combo you can customize. +export default function AutoComboCatalog({ + onComboCreated, +}: { + onComboCreated?: (comboId: string) => void; +}) { const t = useTranslations("combos"); const [open, setOpen] = useState(false); + const [duplicatingName, setDuplicatingName] = useState(null); + + const handleDuplicateTemplate = useCallback( + async (template: AutoComboTemplate) => { + if ( + !confirm( + `${t("duplicateAutoComboConfirm", { name: template.name })}\n\n${t("duplicateAutoComboSnapshotMsg")}` + ) + ) + return; + + setDuplicatingName(template.name); + try { + const res = await fetch("/api/combos/duplicate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: template.name, strategy: template.strategy }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert( + `${t("duplicateAutoComboFailedPrefix")} ${data.error || t("duplicateAutoComboUnknownError")}` + ); + return; + } + + const combo = await res.json(); + + // Notify parent page to re-fetch combos so the new card renders. + onComboCreated?.(String(combo.id)); + } catch (err) { + console.error("Error duplicating auto-combo:", err); + alert( + `${t("duplicateAutoComboFailedPrefix")} ${err instanceof Error ? err.message : t("duplicateAutoComboUnknownError")}` + ); + } finally { + setDuplicatingName(null); + } + }, + [t, onComboCreated] + ); return ( @@ -44,8 +91,21 @@ export default function AutoComboCatalog() { {AUTO_COMBO_TEMPLATES.map((tpl) => (
+ +
{tpl.name} diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 8d3615131914..1fe9f581f90e 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -877,6 +877,15 @@ export default function CombosPage() { } }; + const handleComboCreated = async (comboId: string) => { + await fetchData(); + // Wait for React to re-render the new card, then scroll it into view. + setTimeout(() => { + const el = document.querySelector(`[data-testid="combo-card-${comboId}"]`); + if (el) el.scrollIntoView({ behavior: "auto", block: "center" }); + }, 0); + }; + const handleDelete = async (id) => { if (!confirm(t("deleteConfirm"))) return; try { @@ -1102,7 +1111,7 @@ export default function CombosPage() {
- +
-
- - -
- -
- - -
+
+ + +
-
- - -
-
+
+ + +
- {builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION && - selectedBuilderConnections.length > 1 ? ( -
- -
- {selectedBuilderConnections.map((connection) => { - const checked = builderAllowedConnectionIds.includes(connection.id); - return ( - - ); - })} +
+ + +
-

- {getI18nOrFallback( - t, - "builderRestrictAccountsHint", - "Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts." - )} -

-
- ) : null} - - {isExpertMode ? ( -
- - {builderHasDuplicate && ( - - {getI18nOrFallback( - t, - "builderDuplicateExact", - "This exact provider/model/account step is already in the combo." - )} - - )} -
- ) : ( -
-

- {getI18nOrFallback(t, "builderPreview", "Current step preview")} -

-

- {builderCandidateStep - ? formatModelDisplay(builderCandidateStep) - : getI18nOrFallback( + + {builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION && + selectedBuilderConnections.length > 1 ? ( +

+
- )} - -
- -
- - -
-
+
+ ) : ( +
+

+ {getI18nOrFallback(t, "builderPreview", "Current step preview")} +

+

+ {builderCandidateStep + ? formatModelDisplay(builderCandidateStep) + : getI18nOrFallback( + t, + "previewNextStep", + "Choose provider and model to preview the next step." + )} +

+
+ + {builderHasDuplicate && ( + + {getI18nOrFallback( + t, + "builderDuplicateExact", + "This exact provider/model/account step is already in the combo." + )} + + )} +
+
+ )} + +
+ +
+ + +
+
)} diff --git a/src/app/api/combos/duplicate/route.ts b/src/app/api/combos/duplicate/route.ts new file mode 100644 index 000000000000..7f0a91476f4c --- /dev/null +++ b/src/app/api/combos/duplicate/route.ts @@ -0,0 +1,167 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getCombos, createCombo } from "@/lib/db/combos"; +import { normalizeComboModels } from "@/lib/combos/steps"; +import { + AUTO_FAMILY_IDS, + resolveBuiltinAutoSpec, +} from "@omniroute/open-sse/services/autoCombo/builtinCatalog"; +import { AutoVariant } from "@omniroute/open-sse/services/autoCombo/autoPrefix"; +import { AutoComboSpec } from "@omniroute/open-sse/services/autoCombo/virtualFactory"; +import { MODEL_FAMILIES, ModelFamily } from "@omniroute/open-sse/services/autoCombo/modelFamily"; + +// POST /api/combos/duplicate - Resolve an auto-combo into a static combo snapshot. +// Takes an auto/* template name, resolves its candidate pool using the same logic as +// createVirtualAutoCombo(), then creates a persistent editable combo with those models. +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const body = await request.json(); + const { name, strategy } = body as { name?: string; strategy?: string }; + + if (!name || typeof name !== "string") { + return NextResponse.json( + { error: 'Missing required field: "name" (e.g. auto/best-coding)' }, + { status: 400 } + ); + } + + const { createVirtualAutoCombo } = + await import("@omniroute/open-sse/services/autoCombo/virtualFactory"); + + // Resolve the variant/spec using the same logic as builtinCatalog. + const suffix = name.slice("auto/".length); + const resolved = resolveBuiltinAutoSpec(name, suffix); + + let variant: AutoVariant | undefined; + let spec: AutoComboSpec | undefined; + + if ("category" in resolved) { + // Category/tier path (e.g. auto/best-vision → { category: "vision" }) + spec = { + category: resolved.category, + ...(resolved.tier ? { tier: resolved.tier } : {}), + }; + } else if (resolved.variant !== undefined) { + // Variant path (e.g. auto/best-coding → variant "coding") + variant = resolved.variant ?? undefined; + spec = name === "auto/best-free" ? { tier: "free" as const } : undefined; + } + // Family suffixes (auto/glm, etc.) — resolveBuiltinAutoSpec returns + // { variant: undefined } for them, so fall through to MODEL_FAMILIES check. + if (!variant && !spec) { + const candidate = suffix as ModelFamily; + if (MODEL_FAMILIES.includes(candidate)) { + spec = { family: candidate }; + } + } + + // Reject unknown templates early instead of silently passing bad data downstream. + if (!variant && !spec) { + return NextResponse.json( + { error: `Unknown auto-combo template: "${name}"` }, + { status: 422 } + ); + } + + // Materialize the virtual auto-combo to get resolved models. + // includeResolvedCapabilities is required so computeSnapshotWeights can + // differentiate candidates by vision/reasoning capabilities at snapshot time. + const { prepareVirtualAutoComboInputs, createVirtualAutoComboFromPrepared } = + await import("@omniroute/open-sse/services/autoCombo/virtualFactory"); + + const prepared = await prepareVirtualAutoComboInputs({ + includeResolvedCapabilities: true, + }); + + const virtualCombo = await createVirtualAutoComboFromPrepared(prepared, variant, spec); + + if (!Array.isArray(virtualCombo.models) || virtualCombo.models.length === 0) { + return NextResponse.json( + { error: "No connected providers/models match this auto-combo template" }, + { status: 422 } + ); + } + + // Convert virtual combo models into static combo step format. + // Use simple string entries (e.g. "provider/model") so normalizeComboModels + // handles provider extraction and ID generation — same path as handleCreate. + const rawModels = virtualCombo.models.map( + (m: { model?: string; providerId?: string; weight?: number }, index: number) => ({ + id: `auto-duplicate-${name}-${index + 1}`, + kind: "model", + model: m.model || `${m.providerId}/unknown`, + weight: m.weight ?? 1, + }) + ); + + // Normalize models the same way /api/combos POST does (via normalizeComboModels). + const allCombos = await getCombos(); + const normalizedModels = normalizeComboModels(rawModels, { + comboName: `static-${name.replace("auto/", "")}`, + allCombos: allCombos as never, + }); + + if (normalizedModels.length === 0) { + return NextResponse.json( + { error: "No valid models resolved from this auto-combo template" }, + { status: 422 } + ); + } + + // Normalize scored weights so they sum to exactly 100. + const totalWeight = normalizedModels.reduce((s, m) => s + (m.weight ?? 0), 0); + if (totalWeight > 0 && normalizedModels.length > 0) { + for (const m of normalizedModels) { + m.weight = Math.max(1, Math.floor(((m.weight ?? 0) / totalWeight) * 100)); + } + let remainder = 100 - normalizedModels.reduce((s, m) => s + m.weight, 0); + for (let i = 0; i < normalizedModels.length && remainder > 0; i++) { + normalizedModels[i].weight++; + remainder--; + } + } + + // Generate a unique combo name using "{old name} copy" convention (same as static combos). + const baseName = `static-${name.replace("auto/", "")}`; + const existingNames = new Set(allCombos.map((c: any) => c.name)); + let newName = `${baseName} copy`; + let counter = 1; + while (existingNames.has(newName)) { + counter++; + newName = `${baseName} copy ${counter}`; + } + + // Capture the mode-pack weights from the virtual combo config so the snapshot + // preserves the scoring profile (quality-first, ship-fast, etc.) at creation time. + const weightPack = virtualCombo.weights ?? virtualCombo.autoConfig?.weights; + + // Create the static combo using the template's strategy. + const comboStrategy = strategy || "priority"; + const snapshotDate = new Date().toISOString(); + const comboData = await createCombo({ + name: newName, + models: normalizedModels, + strategy: comboStrategy, + description: `${name} @ ${snapshotDate}`, + config: { sourceAutoCombo: name, weightPack }, + version: 2, + }); + + return NextResponse.json(comboData, { status: 201 }); + } catch (error) { + console.error("Error duplicating auto-combo:", error); + return NextResponse.json( + { + error: "Failed to duplicate auto-combo", + details: + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : String(error), + }, + { status: 500 } + ); + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 9e9899497428..1c343e48361b 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3713,7 +3713,12 @@ "errorDescription": "لم نتمكن من تحميل بيانات المجموعة في الوقت الحالي. تحقق من اتصالك وحاول مرة أخرى.", "errorId": "معرّف الخطأ: {id}", "errorRetry": "حاول مرة أخرى", - "comboLabel": "كومبو" + "comboLabel": "كومبو", + "duplicateAutoComboConfirm": "إنشاء مجموعة ثابتة من \"{name}\"؟", + "duplicateAutoComboSnapshotMsg": "سيؤدي هذا إلى التقاط المزودين/النماذج المتصلة حاليًا التي تطابق هذا القالب في مجموعة قابلة للتحرير.", + "duplicateAutoComboFailedPrefix": "فشل تكرار المجموعة التلقائية:", + "duplicateAutoComboUnknownError": "خطأ غير معروف", + "duplicateAutoComboTitle": "إنشاء مجموعة ثابتة من {name}" }, "costs": { "title": "التكاليف", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 47a03d8d7cb6..eaafe9e70514 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -3713,7 +3713,12 @@ "errorDescription": "Hazırda kombinasiyalı məlumatları yükləyə bilmirik. Bağlantınızı yoxlayın və yenidən cəhd edin.", "errorId": "Xəta ID: {id}", "errorRetry": "Yenidən Cəhd Et", - "comboLabel": "Kombinasiya" + "comboLabel": "Kombinasiya", + "duplicateAutoComboConfirm": "\"{name}\"-dan statik kombo yaratmaq?", + "duplicateAutoComboSnapshotMsg": "Bu, bu şablona uyğun olan hazırkı qoşulmuş provayderləri/modeləri redaktə oluna bilən kombo kimi saxlayacaq.", + "duplicateAutoComboFailedPrefix": "Avtokombo kopyalanması uğursuz oldu:", + "duplicateAutoComboUnknownError": "Naməlum xəta", + "duplicateAutoComboTitle": "{name}-dan statik kombo yarat" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index cf8c6bc8f157..8f9f7a586854 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -3713,7 +3713,12 @@ "errorDescription": "Не успяхме да заредим данните за комбинирането в момента. Проверете връзката си и опитайте отново.", "errorId": "Идентификатор на грешка: {id}", "errorRetry": "Опитай отново", - "comboLabel": "Комбо" + "comboLabel": "Комбо", + "duplicateAutoComboConfirm": "Да създадете статично комбо от \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Това ще направи моментна снимка на текущо свързаните доставчици/модели, които съвпадат с този шаблон, в редактируемо комбо.", + "duplicateAutoComboFailedPrefix": "Неуспешно копиране на автоматично комбо:", + "duplicateAutoComboUnknownError": "Неизвестна грешка", + "duplicateAutoComboTitle": "Създайте статично комбо от {name}" }, "costs": { "title": "Разходи", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e483932fa478..1407bfe54dd8 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -3713,7 +3713,12 @@ "errorDescription": "আমরা এখন কম্বো ডেটা লোড করতে পারিনি। আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।", "errorId": "ত্রুটি আইডি: {id}", "errorRetry": "পুনরায় চেষ্টা করুন", - "comboLabel": "কম্বো" + "comboLabel": "কম্বো", + "duplicateAutoComboConfirm": "\"{name}\" থেকে একটি স্ট্যাটিক কম্বো তৈরি করবেন?", + "duplicateAutoComboSnapshotMsg": "এটি এই টেমপ্লেটের সাথে মিলে যায় এমন বর্তমান সংযুক্ত প্রদানকারী/মডেলগুলিকে একটি সম্পাদনযোগ্য কম্বোতে স্ন্যাপশট নেবে।", + "duplicateAutoComboFailedPrefix": "অটোকম্বো ডুপ্লিকেশন ব্যর্থ:", + "duplicateAutoComboUnknownError": "অজানা ত্রুটি", + "duplicateAutoComboTitle": "{name} থেকে একটি স্ট্যাটিক কম্বো তৈরি করুন" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 0609644a0e73..d743334ba969 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -3713,7 +3713,12 @@ "errorDescription": "Nyní se nám nepodařilo načíst data pro kombinaci. Zkontrolujte své připojení a zkuste to znovu.", "errorId": "Chyba ID: {id}", "errorRetry": "Zkusit znovu", - "comboLabel": "Kombinace" + "comboLabel": "Kombinace", + "duplicateAutoComboConfirm": "Vytvořit statickou kombinaci z \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Tím se zachytí aktuálně připojení poskytovatelé/modely, které odpovídají této šabloně, do upravitelné kombinace.", + "duplicateAutoComboFailedPrefix": "Duplikace automatické kombinace selhala:", + "duplicateAutoComboUnknownError": "Neznámá chyba", + "duplicateAutoComboTitle": "Vytvořte statickou kombinaci z {name}" }, "costs": { "title": "Náklady", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 380bc7cf8329..1cc77d97075b 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -3713,7 +3713,12 @@ "errorDescription": "Vi kunne ikke indlæse kombinationsdata lige nu. Tjek din forbindelse og prøv igen.", "errorId": "Fejl ID: {id}", "errorRetry": "Prøv igen", - "comboLabel": "Kombination" + "comboLabel": "Kombination", + "duplicateAutoComboConfirm": "Opret en statisk kombination fra \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Dette vil tage et øjebliksbillede af de aktuelt tilsluttede udbydere/modeller, der matcher denne skabelon, i en redigerbar kombination.", + "duplicateAutoComboFailedPrefix": "Kopiering af auto-kombination mislykkedes:", + "duplicateAutoComboUnknownError": "Ukendt fejl", + "duplicateAutoComboTitle": "Opret en statisk kombination fra {name}" }, "costs": { "title": "Omkostninger", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index aa515ce09a20..99e851931935 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3713,7 +3713,12 @@ "errorDescription": "Wir konnten die Kombinationsdaten momentan nicht laden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "errorId": "Fehler-ID: {id}", "errorRetry": "Versuche es erneut", - "comboLabel": "Kombination" + "comboLabel": "Kombination", + "duplicateAutoComboConfirm": "Eine statische Kombination aus \"{name}\" erstellen?", + "duplicateAutoComboSnapshotMsg": "Dadurch werden die aktuell verbundenen Anbieter/Modelle, die dieser Vorlage entsprechen, in einer bearbeitbaren Kombination gespeichert.", + "duplicateAutoComboFailedPrefix": "Automatische Kombination konnte nicht dupliziert werden:", + "duplicateAutoComboUnknownError": "Unbekannter Fehler", + "duplicateAutoComboTitle": "Erstelle eine statische Kombination aus {name}" }, "costs": { "title": "Kosten", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5ee194979caf..c6458a228601 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3713,7 +3713,12 @@ "errorDescription": "We could not load combo data right now. Check your connection and try again.", "errorId": "Error ID: {id}", "errorRetry": "Try Again", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Create a static combo from \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "This will snapshot the currently connected providers/models that match this template into an editable combo.", + "duplicateAutoComboFailedPrefix": "Failed to duplicate auto-combo:", + "duplicateAutoComboUnknownError": "Unknown error", + "duplicateAutoComboTitle": "Create a static combo from {name}" }, "costs": { "title": "Costs", @@ -13625,4 +13630,4 @@ "updateFailed": "Failed to update (HTTP {status})", "networkError": "Network error — could not update provider exposure setting" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index afb6a8317cf3..3a0d58929d40 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3713,7 +3713,12 @@ "errorDescription": "No pudimos cargar los datos del combo en este momento. Verifica tu conexión y vuelve a intentarlo.", "errorId": "Error ID: {id}", "errorRetry": "Inténtalo de nuevo", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "¿Crear una combinación estática de \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Esto capturará los proveedores/modelos conectados actualmente que coincidan con esta plantilla en una combinación editable.", + "duplicateAutoComboFailedPrefix": "Error al duplicar la combinación automática:", + "duplicateAutoComboUnknownError": "Error desconocido", + "duplicateAutoComboTitle": "Crear una combinación estática de {name}" }, "costs": { "title": "Costos", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index f8939025e2e9..780dd99a581c 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -3713,7 +3713,12 @@ "errorDescription": "در حال حاضر نمی‌توانیم داده‌های ترکیبی را بارگذاری کنیم. اتصال خود را بررسی کنید و دوباره تلاش کنید.", "errorId": "شناسه خطا: {id}", "errorRetry": "دوباره تلاش کنید", - "comboLabel": "ترکیب" + "comboLabel": "ترکیب", + "duplicateAutoComboConfirm": "ایجاد یک ترکیب ثابت از \"{name}\"؟", + "duplicateAutoComboSnapshotMsg": "این ارائه‌دهندگان/مدل‌های متصل فعلی که با این قالب مطابقت دارند را در یک ترکیب قابل ویرایش ذخیره می‌کند.", + "duplicateAutoComboFailedPrefix": "تکرار ترکیب خودکار ناموفق بود:", + "duplicateAutoComboUnknownError": "خطای ناشناخته", + "duplicateAutoComboTitle": "ایجاد یک ترکیب ثابت از {name}" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index fa32b59a5ed6..b5b4413255a4 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -3713,7 +3713,12 @@ "errorDescription": "Emme voi ladata yhdistelmädataa juuri nyt. Tarkista yhteytesi ja yritä uudelleen.", "errorId": "Virhe ID: {id}", "errorRetry": "Yritä uudelleen", - "comboLabel": "Yhdistelmä" + "comboLabel": "Yhdistelmä", + "duplicateAutoComboConfirm": "Luodaanko staattinen yhdistelmä \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Tämä tallentaa tämän mallin mukaiset tällä hetkellä yhdistetyt tarjoajat/mallit muokattavaan yhdistelmään.", + "duplicateAutoComboFailedPrefix": "Automaattisen yhdistelmän kaksoiskappaleen luonti epäonnistui:", + "duplicateAutoComboUnknownError": "Tuntematon virhe", + "duplicateAutoComboTitle": "Luo staattinen yhdistelmä kohteesta {name}" }, "costs": { "title": "Kustannukset", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 06c74246417a..8d96731b3df9 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3713,7 +3713,12 @@ "errorDescription": "Les données des combos ne peuvent pas être chargées pour le moment. Vérifiez votre connexion et réessayez.", "errorId": "ID d'erreur : {id}", "errorRetry": "Réessayer", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Créer une combinaison statique à partir de \"{name}\" ?", + "duplicateAutoComboSnapshotMsg": "Cela capturera les fournisseurs/modèles actuellement connectés qui correspondent à ce modèle dans une combinaison modifiable.", + "duplicateAutoComboFailedPrefix": "Échec de la duplication de la combinaison automatique :", + "duplicateAutoComboUnknownError": "Erreur inconnue", + "duplicateAutoComboTitle": "Créer une combinaison statique à partir de {name}" }, "costs": { "title": "Coûts", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 024bd04ff227..bc94b31ece55 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -3713,7 +3713,12 @@ "errorDescription": "અમે હાલમાં કોમ્બો ડેટા લોડ કરી શક્યા નથી. તમારી કનેક્શન તપાસો અને ફરી પ્રયાસ કરો.", "errorId": "ભૂલ આઈડી: {id}", "errorRetry": "ફરીથી પ્રયાસ કરો", - "comboLabel": "કોમ્બો" + "comboLabel": "કોમ્બો", + "duplicateAutoComboConfirm": "\"{name}\" માંથી સ્ટેટિક કોમ્બો બનાવશો?", + "duplicateAutoComboSnapshotMsg": "આ ટેમપ્લેટ સાથે મેચ થતા વર્તમાન જોડાયેલા પ્રદાતાઓ/મોડેલને સંપાદનીય કોમ્બોમાં સ્નેપશોટ લેશે.", + "duplicateAutoComboFailedPrefix": "ઓટોકોમ્બો ડુપ્લિકેટ કરવામાં નિષ્ફળ:", + "duplicateAutoComboUnknownError": "અજ્ઞાત ભૂલ", + "duplicateAutoComboTitle": "{name} માંથી સ્ટેટિક કોમ્બો બનાવો" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index b6dc7a1b22fa..a03f5aff440b 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -3713,7 +3713,12 @@ "errorDescription": "לא הצלחנו לטעון את נתוני הקומבו כרגע. בדוק את החיבור שלך ונסה שוב.", "errorId": "שגיאת מזהה: {id}", "errorRetry": "נסה שוב", - "comboLabel": "קומבו" + "comboLabel": "קומבו", + "duplicateAutoComboConfirm": "ליצור קומבו סטטי מ־\"{name}\"?", + "duplicateAutoComboSnapshotMsg": "פעולה זו תצלם את הספקים/מודלים המחוברים כעת התואמים לתבנית הזו לקומבו ניתן לעריכה.", + "duplicateAutoComboFailedPrefix": "כשל בהעתיק קומבו אוטומטי:", + "duplicateAutoComboUnknownError": "שגיאה לא ידועה", + "duplicateAutoComboTitle": "צור קומבו סטטי מ־{name}" }, "costs": { "title": "עלויות", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index bc0a9783778d..8fa913d9e491 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -3713,7 +3713,12 @@ "errorDescription": "हम अभी कॉम्बो डेटा लोड नहीं कर सके। कृपया अपनी कनेक्शन की जांच करें और फिर से प्रयास करें।", "errorId": "त्रुटि आईडी: {id}", "errorRetry": "फिर से प्रयास करें", - "comboLabel": "कॉम्बो" + "comboLabel": "कॉम्बो", + "duplicateAutoComboConfirm": "\"{name}\" से एक स्थैतिक कॉम्बो बनाएं?", + "duplicateAutoComboSnapshotMsg": "यह इस टेम्पलेट से मेल खाने वाले वर्तमान जुड़े प्रदाताओं/मॉडल को संपादन योग्य कॉम्बो में स्नैपशॉट लेगा।", + "duplicateAutoComboFailedPrefix": "ऑटोकॉम्बो डुप्लिकेट करने में विफल:", + "duplicateAutoComboUnknownError": "अज्ञात त्रुटि", + "duplicateAutoComboTitle": "{name} से एक स्थैतिक कॉम्बो बनाएं" }, "costs": { "title": "लागत", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 777acfc59d57..c73fd2eb7d30 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -3713,7 +3713,12 @@ "errorDescription": "Jelenleg nem tudtuk betölteni a kombinált adatokat. Ellenőrizze a kapcsolatát, és próbálja újra.", "errorId": "Hibaazonosító: {id}", "errorRetry": "Próbáld újra", - "comboLabel": "Kombó" + "comboLabel": "Kombó", + "duplicateAutoComboConfirm": "Létrehoz egy statikus kombinációt a(z) \"{name}\"-ból?", + "duplicateAutoComboSnapshotMsg": "Ez rögzíti az éppen csatlakoztatott szolgáltatókat/modelleket, amelyek megfelelnek ennek a sablonnak, egy szerkeszthető kombinációba.", + "duplicateAutoComboFailedPrefix": "Az automatikus kombináció duplikálása sikertelen:", + "duplicateAutoComboUnknownError": "Ismeretlen hiba", + "duplicateAutoComboTitle": "Hozzon létre statikus kombinációt a(z) {name}-ból" }, "costs": { "title": "Költségek", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 41dbb6c12acc..d8f9cadc5814 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -3713,7 +3713,12 @@ "errorDescription": "Kami tidak dapat memuat data kombinasi saat ini. Periksa koneksi Anda dan coba lagi.", "errorId": "Error ID: {id}", "errorRetry": "Coba Lagi", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Buat kombo statis dari \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Ini akan mengambil snapshot penyedia/model yang terhubung saat ini yang cocok dengan templat ini ke dalam kombo yang dapat diedit.", + "duplicateAutoComboFailedPrefix": "Gagal menduplikasi kombo otomatis:", + "duplicateAutoComboUnknownError": "Kesalahan tidak diketahui", + "duplicateAutoComboTitle": "Buat kombo statis dari {name}" }, "costs": { "title": "Biaya", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index d856f0511dda..921ebb72623d 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -3713,7 +3713,12 @@ "errorDescription": "Kami tidak dapat memuat data kombinasi saat ini. Periksa koneksi Anda dan coba lagi.", "errorId": "ID Kesalahan: {id}", "errorRetry": "Coba Lagi", - "comboLabel": "Kombinasi" + "comboLabel": "Kombinasi", + "duplicateAutoComboConfirm": "Buat kombo statis dari \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Ini akan mengambil snapshot penyedia/model yang terhubung saat ini yang cocok dengan templat ini ke dalam kombo yang dapat diedit.", + "duplicateAutoComboFailedPrefix": "Gagal menduplikasi kombo otomatis:", + "duplicateAutoComboUnknownError": "Kesalahan tidak diketahui", + "duplicateAutoComboTitle": "Buat kombo statis dari {name}" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index f7cdce01d4e2..309d23874475 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -3713,7 +3713,12 @@ "errorDescription": "Non siamo riusciti a caricare i dati del combo in questo momento. Controlla la tua connessione e riprova.", "errorId": "ID Errore: {id}", "errorRetry": "Riprova", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Creare una combinazione statica da \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Questo catturerà i fornitori/modelli attualmente connessi che corrispondono a questo modello in una combinazione modificabile.", + "duplicateAutoComboFailedPrefix": "Duplicazione della combinazione automatica fallita:", + "duplicateAutoComboUnknownError": "Errore sconosciuto", + "duplicateAutoComboTitle": "Crea una combinazione statica da {name}" }, "costs": { "title": "Costi", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 13503227f431..19f6288ca80b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3713,7 +3713,12 @@ "errorDescription": "現在、コンボデータを読み込むことができません。接続を確認して、再試行してください。", "errorId": "エラー ID: {id}", "errorRetry": "もう一度試してください", - "comboLabel": "コンボ" + "comboLabel": "コンボ", + "duplicateAutoComboConfirm": "\"{name}\"から静的コンボを作成しますか?", + "duplicateAutoComboSnapshotMsg": "このテンプレートに一致する現在接続されているプロバイダー/モデルを編集可能なコンボとしてスナップショットします。", + "duplicateAutoComboFailedPrefix": "オートコンボの複製に失敗しました:", + "duplicateAutoComboUnknownError": "不明なエラー", + "duplicateAutoComboTitle": "{name}から静的コンボを作成" }, "costs": { "title": "コスト", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 258e5b9c7d91..21862f5b0050 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3713,7 +3713,12 @@ "errorDescription": "현재 콤보 데이터를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.", "errorId": "오류 ID: {id}", "errorRetry": "다시 시도해 주세요", - "comboLabel": "콤보" + "comboLabel": "콤보", + "duplicateAutoComboConfirm": "\"{name}\"에서 정적 콤보를 만드시겠습니까?", + "duplicateAutoComboSnapshotMsg": "이 템플릿과 일치하는 현재 연결된 제공자/모델을 편집 가능한 콤보로 스냅샷합니다.", + "duplicateAutoComboFailedPrefix": "자동 콤보 복제 실패:", + "duplicateAutoComboUnknownError": "알 수 없는 오류", + "duplicateAutoComboTitle": "{name}에서 정적 콤보 만들기" }, "costs": { "title": "비용", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index e64bf7fbbe0c..942eb8a7af37 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -3713,7 +3713,12 @@ "errorDescription": "आम्ही सध्या कॉम्बो डेटा लोड करू शकत नाही. तुमचा कनेक्शन तपासा आणि पुन्हा प्रयत्न करा.", "errorId": "त्रुटी आयडी: {id}", "errorRetry": "पुन्हा प्रयत्न करा", - "comboLabel": "कॉम्बो" + "comboLabel": "कॉम्बो", + "duplicateAutoComboConfirm": "\"{name}\" मधून स्थिर कॉम्बो तयार करायचा?", + "duplicateAutoComboSnapshotMsg": "या टेम्पलेटशी जुळणारे सध्या कनेक्ट केलेले प्रदाता/मॉडेल्स संपादनयोग्य कॉम्बोमध्ये स्नॅपशॉट घेईल.", + "duplicateAutoComboFailedPrefix": "ऑटोकॉम्बो डुप्लिकेट करण्यात अपयश:", + "duplicateAutoComboUnknownError": "अज्ञात त्रुटी", + "duplicateAutoComboTitle": "{name} मधून स्थिर कॉम्बो तयार करा" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 14971e35d743..79bce646b76e 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -3713,7 +3713,12 @@ "errorDescription": "Kami tidak dapat memuatkan data combo buat masa ini. Semak sambungan anda dan cuba lagi.", "errorId": "Ralat ID: {id}", "errorRetry": "Cuba Lagi", - "comboLabel": "Gabungan" + "comboLabel": "Gabungan", + "duplicateAutoComboConfirm": "Cipta kombo statik dari \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Ini akan mengambil snapshot penyedia/model yang disambungkan semasa yang sepadan dengan templat ini ke dalam kombo yang boleh diedit.", + "duplicateAutoComboFailedPrefix": "Gagal menduplikasi kombo automatik:", + "duplicateAutoComboUnknownError": "Ralat tidak diketahui", + "duplicateAutoComboTitle": "Cipta kombo statik dari {name}" }, "costs": { "title": "Kos", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3063a4237ae4..18babc4531f8 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -3713,7 +3713,12 @@ "errorDescription": "We konden de combogegevens op dit moment niet laden. Controleer je verbinding en probeer het opnieuw.", "errorId": "Fout-ID: {id}", "errorRetry": "Probeer het opnieuw", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Een statische combinatie maken van \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Dit maakt een momentopname van de momenteel verbonden providers/modellen die overeenkomen met dit sjabloon in een bewerkbare combinatie.", + "duplicateAutoComboFailedPrefix": "Automatische combinatie dupliceren mislukt:", + "duplicateAutoComboUnknownError": "Onbekende fout", + "duplicateAutoComboTitle": "Maak een statische combinatie van {name}" }, "costs": { "title": "Kosten", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index eb14cdadbe7b..92b21b74c543 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -3713,7 +3713,12 @@ "errorDescription": "Vi kunne ikke laste inn kombinasjonsdata akkurat nå. Sjekk tilkoblingen din og prøv igjen.", "errorId": "Feil-ID: {id}", "errorRetry": "Prøv igjen", - "comboLabel": "Kombinasjon" + "comboLabel": "Kombinasjon", + "duplicateAutoComboConfirm": "Opprett en statisk kombinasjon fra \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Dette vil ta et øyeblikksbilde av de nåvørende tilkoblede leverandørene/modellene som matcher denne malen i en redigerbar kombinasjon.", + "duplicateAutoComboFailedPrefix": "Duplisering av auto-kombinasjon mislyktes:", + "duplicateAutoComboUnknownError": "Ukjent feil", + "duplicateAutoComboTitle": "Opprett en statisk kombinasjon fra {name}" }, "costs": { "title": "Kostnader", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index ffe828d87bee..58a5d0fd239c 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -3713,7 +3713,12 @@ "errorDescription": "Hindi namin ma-load ang combo data sa ngayon. Suriin ang iyong koneksyon at subukan muli.", "errorId": "Error ID: {id}", "errorRetry": "Subukan Muli", - "comboLabel": "Kumbinasyon" + "comboLabel": "Kumbinasyon", + "duplicateAutoComboConfirm": "Gumawa ng static combo mula sa \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Ito ay kukuha ng snapshot ng kasalukuyang nakakonektang mga provider/model na tugma sa template na ito sa isang editable na combo.", + "duplicateAutoComboFailedPrefix": "Nabigo ang pag-duplicate ng auto-combo:", + "duplicateAutoComboUnknownError": "Hindi alam na error", + "duplicateAutoComboTitle": "Gumawa ng static combo mula sa {name}" }, "costs": { "title": "Mga gastos", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index ee53ffade7f9..070aeb7ce578 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -3713,7 +3713,12 @@ "errorDescription": "Nie mogliśmy teraz załadować danych combo. Sprawdź swoje połączenie i spróbuj ponownie.", "errorId": "Identyfikator błędu: {id}", "errorRetry": "Spróbuj ponownie", - "comboLabel": "Kombinacja" + "comboLabel": "Kombinacja", + "duplicateAutoComboConfirm": "Utworzyć statyczną kombinację z \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Spowoduje to przechwycenie aktualnie połączonych dostawców/modeli pasujących do tego szablonu w edytowalnej kombinacji.", + "duplicateAutoComboFailedPrefix": "Nie udało się skopiować automatycznej kombinacji:", + "duplicateAutoComboUnknownError": "Nieznany błąd", + "duplicateAutoComboTitle": "Utwórz statyczną kombinację z {name}" }, "costs": { "title": "Koszty", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index f85fbba50de5..f9596c117910 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -3713,7 +3713,12 @@ "errorDescription": "Não conseguimos carregar os dados do combo no momento. Verifique sua conexão e tente novamente.", "errorId": "ID de Erro: {id}", "errorRetry": "Tente Novamente", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Criar uma combinação estática de \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Isso irá capturar os provedores/modelos atualmente conectados que correspondem a este modelo em uma combinação editável.", + "duplicateAutoComboFailedPrefix": "Falha ao duplicar a combinação automática:", + "duplicateAutoComboUnknownError": "Erro desconhecido", + "duplicateAutoComboTitle": "Criar uma combinação estática de {name}" }, "costs": { "title": "Custos", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ea789f8b49e9..cc0736ea38fa 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3713,7 +3713,12 @@ "errorDescription": "Não conseguimos carregar os dados do combo neste momento. Verifique a sua ligação e tente novamente.", "errorId": "ID de Erro: {id}", "errorRetry": "Tente Novamente", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Criar uma combinação estática de \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Isto irá capturar os provedores/modelos atualmente conectados que correspondem a este modelo em uma combinação editável.", + "duplicateAutoComboFailedPrefix": "Falha ao duplicar a combinação automática:", + "duplicateAutoComboUnknownError": "Erro desconhecido", + "duplicateAutoComboTitle": "Criar uma combinação estática de {name}" }, "costs": { "title": "Custos", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index d0c93437d62f..79453bd70d47 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -3713,7 +3713,12 @@ "errorDescription": "Nu am putut încărca datele combo în acest moment. Verifică-ți conexiunea și încearcă din nou.", "errorId": "ID eroare: {id}", "errorRetry": "Încearcă din nou", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Creați o combinație statică din \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Acesta va captura furnizorii/modelurile conectate în prezent care se potrivesc cu acest șablon într-o combinație editabilă.", + "duplicateAutoComboFailedPrefix": "Duplicarea combinației automate a eșuat:", + "duplicateAutoComboUnknownError": "Eroare necunoscută", + "duplicateAutoComboTitle": "Creați o combinație statică din {name}" }, "costs": { "title": "Costuri", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 86be068570e0..e1b7e406fb9a 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -3713,7 +3713,12 @@ "errorDescription": "Мы не смогли загрузить данные комбо в данный момент. Проверьте ваше соединение и попробуйте снова.", "errorId": "Идентификатор ошибки: {id}", "errorRetry": "Попробуйте снова", - "comboLabel": "Комбо" + "comboLabel": "Комбо", + "duplicateAutoComboConfirm": "Создать статическое комбо из \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Это создаст снимок текущих подключенных провайдеров/моделей, соответствующих этому шаблону, в редактируемом комбо.", + "duplicateAutoComboFailedPrefix": "Не удалось дублировать автоматическое комбо:", + "duplicateAutoComboUnknownError": "Неизвестная ошибка", + "duplicateAutoComboTitle": "Создать статическое комбо из {name}" }, "costs": { "title": "Затраты", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index de0085e0f689..3d8e726d20e3 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -3713,7 +3713,12 @@ "errorDescription": "Momentálne sa nám nepodarilo načítať údaje kombinácie. Skontrolujte svoje pripojenie a skúste to znova.", "errorId": "Chyba ID: {id}", "errorRetry": "Skúste znova", - "comboLabel": "Kombinácia" + "comboLabel": "Kombinácia", + "duplicateAutoComboConfirm": "Vytvoriť staticú kombináciu z \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Tým sa zachytí aktuálne pripojení poskytovatelia/modely, ktoré zodpovedajú tejto šablóne, do upraviteľnej kombinácie.", + "duplicateAutoComboFailedPrefix": "Duplikácia automatickej kombinácie zlyhala:", + "duplicateAutoComboUnknownError": "Neznáma chyba", + "duplicateAutoComboTitle": "Vytvorte staticú kombináciu z {name}" }, "costs": { "title": "náklady", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index ece012880e20..a5f6b4da61df 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -3713,7 +3713,12 @@ "errorDescription": "Vi kunde inte ladda combo-data just nu. Kontrollera din anslutning och försök igen.", "errorId": "Fel-ID: {id}", "errorRetry": "Försök igen", - "comboLabel": "Kombination" + "comboLabel": "Kombination", + "duplicateAutoComboConfirm": "Skapa en statisk kombination från \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Detta kommer att ta en ögonblicksbild av de för närvarande anslutna leverantörerna/modellerna som matchar denna mall i en redigerbar kombination.", + "duplicateAutoComboFailedPrefix": "Kopiering av automatisk kombination misslyckades:", + "duplicateAutoComboUnknownError": "Okänt fel", + "duplicateAutoComboTitle": "Skapa en statisk kombination från {name}" }, "costs": { "title": "Kostnader", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 1dbc109a2827..26ec9aafbd16 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -3713,7 +3713,12 @@ "errorDescription": "Hatuwezi kupakia data ya combo kwa sasa. Angalia muunganisho wako na ujaribu tena.", "errorId": "Kosa ID: {id}", "errorRetry": "Jaribu Tena", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Tengeneza combo thabiti kutoka \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Hii itachukua picha ya watoa huduma/watolei sambazwa sasa yanayolingana na kioo hiki katika combo inayoedit.", + "duplicateAutoComboFailedPrefix": "Imeshindwa kuiga combo ya otomatiki:", + "duplicateAutoComboUnknownError": "Hitilafai isiyojulikana", + "duplicateAutoComboTitle": "Tengeneza combo thabiti kutoka {name}" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 46844ad9e11a..40c591a2b8a6 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -3713,7 +3713,12 @@ "errorDescription": "நாங்கள் தற்போது கம்போ தரவுகளை ஏற்ற முடியவில்லை. உங்கள் இணைப்பை சரிபார்க்கவும் மற்றும் மீண்டும் முயற்சிக்கவும்.", "errorId": "பிழை அடையாளம்: {id}", "errorRetry": "மீண்டும் முயற்சி செய்", - "comboLabel": "கொம்போ" + "comboLabel": "கொம்போ", + "duplicateAutoComboConfirm": "\"{name}\" இலிருந்து நிலையான கம்போ உருவாக்கவா?", + "duplicateAutoComboSnapshotMsg": "இந்த டெம்ப்ளேட்டுடன் பொருந்தும் தற்போதைய இணைக்கப்பட்ட வழங்குநர்கள்/மாதிரிகளை திருத்தக்கூடிய கம்போவில் எடுக்கும்.", + "duplicateAutoComboFailedPrefix": "ஆட்டோகம்போ நகலெடுத்தல் தோல்வியடைந்தது:", + "duplicateAutoComboUnknownError": "அறியப்படாத பிழை", + "duplicateAutoComboTitle": "{name} இலிருந்து நிலையான கம்போ உருவாக்கவும்" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 0945649c7d8d..53a2cf504305 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -3713,7 +3713,12 @@ "errorDescription": "మేము ప్రస్తుతం కాంబో డేటాను లోడ్ చేయలేకపోయాము. మీ కనెక్షన్‌ను తనిఖీ చేసి మళ్లీ ప్రయత్నించండి.", "errorId": "లోపం ID: {id}", "errorRetry": "మరలా ప్రయత్నించండి", - "comboLabel": "కాంబో" + "comboLabel": "కాంబో", + "duplicateAutoComboConfirm": "\"{name}\" నుండి స్థిర కంబో సృష్టించాలా?", + "duplicateAutoComboSnapshotMsg": "ఈ టెంప్లేట్‌తో సరిపోయే ప్రస్తుత కనెక్ట్ చేసిన ప్రొవైడర్లు/మోడల్‌లను ఎడిటబుల్ కంబోలో స్నాప్‌షాట్ తీసుకుంటుంది.", + "duplicateAutoComboFailedPrefix": "ఆటోకంబో డూప్లికేట్ చేయడంలో విఫలమైంది:", + "duplicateAutoComboUnknownError": "తెలియని దోషం", + "duplicateAutoComboTitle": "{name} నుండి స్థిర కంబో సృష్టించండి" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index e408c398f9a0..cf867208381c 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -3713,7 +3713,12 @@ "errorDescription": "ไม่สามารถโหลดข้อมูลคอมโบได้ในขณะนี้ กรุณาตรวจสอบการเชื่อมต่อของคุณและลองอีกครั้ง.", "errorId": "รหัสข้อผิดพลาด: {id}", "errorRetry": "ลองอีกครั้ง", - "comboLabel": "คอมโบ" + "comboLabel": "คอมโบ", + "duplicateAutoComboConfirm": "สร้างคอมโบคงที่จาก \"{name}\" หรือไม่?", + "duplicateAutoComboSnapshotMsg": "สิ่งนี้จะจับภาพผู้ให้บริการ/โมเดลที่เชื่อมต่ออยู่ในปัจจุบันซึ่งตรงกับเทมเพลตนี้ลงในคอมโบที่แก้ไขได้", + "duplicateAutoComboFailedPrefix": "ล้มเหลวในการทำสำเนาคอมโบอัตโนมัติ:", + "duplicateAutoComboUnknownError": "ข้อผิดพลาดที่ไม่ทราบสาเหตุ", + "duplicateAutoComboTitle": "สร้างคอมโบคงที่จาก {name}" }, "costs": { "title": "ค่าใช้จ่าย", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index f70d112a9f50..ab0ddb48c0bc 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -3713,7 +3713,12 @@ "errorDescription": "Şu anda kombinasyon verilerini yükleyemedik. Bağlantınızı kontrol edin ve tekrar deneyin.", "errorId": "Hata Kimliği: {id}", "errorRetry": "Tekrar Dene", - "comboLabel": "Kombinasyon" + "comboLabel": "Kombinasyon", + "duplicateAutoComboConfirm": "\"{name}\"-den statik bir kombinasyon oluşturulsun mu?", + "duplicateAutoComboSnapshotMsg": "Bu, bu şablona uygun olarak şu anda bağlı olan sağlayıcıları/modelleri düzenlenebilir bir kombinasyonda yakalayacaktır.", + "duplicateAutoComboFailedPrefix": "Otomatik kombinasyon kopyalanamadı:", + "duplicateAutoComboUnknownError": "Bilinmeyen hata", + "duplicateAutoComboTitle": "{name}-den statik bir kombinasyon oluştur" }, "costs": { "title": "Maliyetler", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index dbd6010a4a36..ecf5e0c1cbdb 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -3713,7 +3713,12 @@ "errorDescription": "Ми не змогли завантажити дані комбо прямо зараз. Перевірте своє з'єднання та спробуйте ще раз.", "errorId": "Ідентифікатор помилки: {id}", "errorRetry": "Спробуйте ще раз", - "comboLabel": "Комбо" + "comboLabel": "Комбо", + "duplicateAutoComboConfirm": "Створити статичне комбо з \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Це зробить знімок поточно підключених постачальників/моделей, які відповідають цьому шаблону, у редаговане комбо.", + "duplicateAutoComboFailedPrefix": "Не вдалося дублювати автоматичне комбо:", + "duplicateAutoComboUnknownError": "Невідома помилка", + "duplicateAutoComboTitle": "Створити статичне комбо з {name}" }, "costs": { "title": "Витрати", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index e6b409cff070..473aac3d3f1e 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -3713,7 +3713,12 @@ "errorDescription": "ہم اس وقت کومبو ڈیٹا لوڈ نہیں کر سکے۔ اپنی کنکشن چیک کریں اور دوبارہ کوشش کریں۔", "errorId": "خرابی کی شناخت: {id}", "errorRetry": "پھر کوشش کریں", - "comboLabel": "کمبو" + "comboLabel": "کمبو", + "duplicateAutoComboConfirm": "\"{name}\" سے ایک جامد کمبو بنائیں؟", + "duplicateAutoComboSnapshotMsg": "یہ اس ٹیمپلیٹ سے ملنے والے موجودہ منسلک فراہم کنندگان/ماڈلز کو ایڈیٹ ایبل کمبو میں اسنیپ شاট لے گا۔", + "duplicateAutoComboFailedPrefix": "آٹو کمبو ڈپلیکیٹ کرنے میں ناکام:", + "duplicateAutoComboUnknownError": "نامعلوم خرابی", + "duplicateAutoComboTitle": "{name} سے ایک جامد کمبو بنائیں" }, "costs": { "title": "Costs", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 8cb05728d640..2858c50a3e87 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -3713,7 +3713,12 @@ "errorDescription": "Hiện không thể tải dữ liệu combo. Hãy kiểm tra kết nối rồi thử lại.", "errorId": "ID lỗi: {id}", "errorRetry": "Thử lại", - "comboLabel": "Combo" + "comboLabel": "Combo", + "duplicateAutoComboConfirm": "Tạo một tổ hợp tĩnh từ \"{name}\"?", + "duplicateAutoComboSnapshotMsg": "Điều này sẽ chụp lại các nhà cung cấp/mô hình đang kết nối hiện tại phù hợp với mẫu này vào một tổ hợp có thể chỉnh sửa.", + "duplicateAutoComboFailedPrefix": "Không thể sao chép tổ hợp tự động:", + "duplicateAutoComboUnknownError": "Lỗi không xác định", + "duplicateAutoComboTitle": "Tạo tổ hợp tĩnh từ {name}" }, "costs": { "title": "Chi phí", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 3d8de6f6b215..3bdba3b59526 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3713,7 +3713,12 @@ "errorDescription": "我们现在无法加载组合数据。请检查您的连接并重试。", "errorId": "错误 ID: {id}", "errorRetry": "再试一次", - "comboLabel": "组合" + "comboLabel": "组合", + "duplicateAutoComboConfirm": "从\"{name}\"创建静态组合?", + "duplicateAutoComboSnapshotMsg": "这将把与此模板匹配的当前连接提供商/模型快照到可编辑的组合中。", + "duplicateAutoComboFailedPrefix": "复制自动组合失败:", + "duplicateAutoComboUnknownError": "未知错误", + "duplicateAutoComboTitle": "从{name}创建静态组合" }, "costs": { "title": "成本", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 488312f384d9..086919c4ead7 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3713,7 +3713,12 @@ "errorDescription": "目前無法加載組合數據。請檢查您的連接並重試。", "errorId": "錯誤 ID: {id}", "errorRetry": "再試一次", - "comboLabel": "組合" + "comboLabel": "組合", + "duplicateAutoComboConfirm": "從\"{name}\"建立靜態組合?", + "duplicateAutoComboSnapshotMsg": "這將把與此模板匹配的目前連線提供者/模型快照到可編輯的組合中。", + "duplicateAutoComboFailedPrefix": "複製自動組合失敗:", + "duplicateAutoComboUnknownError": "未知錯誤", + "duplicateAutoComboTitle": "從{name}建立靜態組合" }, "costs": { "title": "成本", diff --git a/tests/unit/combos-duplicate-resolution-audit.test.ts b/tests/unit/combos-duplicate-resolution-audit.test.ts new file mode 100644 index 000000000000..ea304e8a8573 --- /dev/null +++ b/tests/unit/combos-duplicate-resolution-audit.test.ts @@ -0,0 +1,54 @@ +/** + * Audit: every built-in auto-combo template must resolve to SOME spec or variant + * (not an empty object that produces a full unfiltered pool). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-duplicate-audit-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "dup-audit-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const { AUTO_TEMPLATE_VARIANTS, AUTO_SUFFIX_VARIANTS, AUTO_FAMILY_IDS } = + await import("@omniroute/open-sse/services/autoCombo/builtinCatalog"); +const { resolveBuiltinAutoSpec } = + await import("@omniroute/open-sse/services/autoCombo/builtinCatalog"); + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch {} +}); + +const ALL_TEMPLATES = [ + ...Object.keys(AUTO_TEMPLATE_VARIANTS), + ...AUTO_SUFFIX_VARIANTS, + ...AUTO_FAMILY_IDS, +]; + +test("every template resolves to a non-empty spec or variant (not bare unfiltered pool)", async () => { + for (const name of ALL_TEMPLATES) { + const suffix = name.slice("auto/".length); + const r = resolveBuiltinAutoSpec(name, suffix); + + // auto/chat-style templates legitimately return {variant: undefined} — that IS the "unconstrained" spec. + if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, name)) { + continue; + } + // Family IDs handled by duplicate route fallback + if (AUTO_FAMILY_IDS.includes(name)) { + continue; + } + + assert.ok( + JSON.stringify(r) !== "{}", + `${name} resolved to empty spec — would produce full unfiltered pool` + ); + } +}); diff --git a/tests/unit/combos-duplicate-route.test.ts b/tests/unit/combos-duplicate-route.test.ts new file mode 100644 index 000000000000..456dc02a9dfd --- /dev/null +++ b/tests/unit/combos-duplicate-route.test.ts @@ -0,0 +1,287 @@ +/** + * Unit tests for POST /api/combos/duplicate (Rule #18). + * + * Coverage: auth gate, input validation (400), success response shape (201) + * including weight distribution and naming convention, config fields, + * and error sanitization (no stack traces in responses). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "os"; +import path from "node:path"; + +// ── DB / auth setup ─────────────────────────────────────────────────────────── + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combos-duplicate-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "combos-duplicate-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +// Route loaded AFTER env is set +const duplicateRoute = await import("../../src/app/api/combos/duplicate/route.ts"); + +function makePostRequest(url: string, body: unknown, apiKey?: string): Request { + return new Request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify(body), + }); +} + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +// ── Auth gate ───────────────────────────────────────────────────────────────── + +test("POST /api/combos/duplicate returns 401/403 when auth is required and no token", async () => { + await settingsDb.updateSettings({ requireLogin: true }); + process.env.INITIAL_PASSWORD = "test-password-dup"; + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/best-coding", + }); + const res = await duplicateRoute.POST(req as never); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + + await settingsDb.updateSettings({ requireLogin: false }); + delete process.env.INITIAL_PASSWORD; +}); + +test("POST /api/combos/duplicate passes auth with valid management API key", async () => { + await settingsDb.updateSettings({ requireLogin: true }); + process.env.INITIAL_PASSWORD = "test-password-dup-key"; + const { key } = await apiKeysDb.createApiKey("dup-test", "machine-dup", ["manage"]); + + // Invalid body (empty name) — auth should pass, business logic rejects + const req = makePostRequest("http://localhost/api/combos/duplicate", { name: "" }, key); + const res = await duplicateRoute.POST(req as never); + + assert.ok( + !(res.status === 401 || res.status === 403), + `Auth should have passed with valid key, got ${res.status}` + ); + assert.equal(res.status, 400); + + await settingsDb.updateSettings({ requireLogin: false }); + delete process.env.INITIAL_PASSWORD; +}); + +// ── Input validation ──────────────────────────────────────────────────────── + +test("POST /api/combos/duplicate returns 400 when name is missing", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", {}); + const res = await duplicateRoute.POST(req as never); + const body = await res.json(); + + assert.equal(res.status, 400); + assert.ok( + typeof body.error === "string" && body.error.length > 0, + "should return an error message" + ); +}); + +test("POST /api/combos/duplicate returns 400 when name is not a string", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: 123, + }); + const res = await duplicateRoute.POST(req as never); + + assert.equal(res.status, 400); +}); + +test("POST /api/combos/duplicate returns 400 when name is empty string", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: "", + }); + const res = await duplicateRoute.POST(req as never); + + assert.equal(res.status, 400); +}); + +// ── Success response shape (when models match) ─────────────────────────────── + +test("POST /api/combos/duplicate returns valid combo with correct naming and weights on success", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/best-coding", + }); + const res = await duplicateRoute.POST(req as never); + const body = await res.json(); + + if (res.status === 201) { + // --- Naming convention: starts with static- and ends with "copy" (or "copy N") --- + assert.ok( + typeof body.name === "string" && body.name.length > 0, + "response should contain combo name" + ); + assert.ok( + body.name.startsWith("static-"), + `Combo name should start with 'static-', got: ${body.name}` + ); + assert.ok( + /copy(\s+\d+)?$/.test(body.name), + `Combo name should end with 'copy' or 'copy N', got: ${body.name}` + ); + + // --- Default strategy is "priority" --- + assert.equal(body.strategy, "priority", "default strategy should be priority"); + + // --- Models array present and weights sum to exactly 100 --- + assert.ok(Array.isArray(body.models), "response should include models array"); + const totalWeight = body.models.reduce( + (sum: number, m: { weight?: number }) => sum + (m.weight ?? 0), + 0 + ); + assert.equal(totalWeight, 100, `model weights must sum to exactly 100, got ${totalWeight}`); + + // Every model has a positive weight + for (const m of body.models) { + assert.ok(m.weight > 0, `each model weight must be positive, got ${m.weight}`); + } + } else if (res.status === 422) { + // No models matched — still valid behavior in a clean test DB + ok(); + } else { + throw new Error(`Unexpected status: ${res.status}`); + } + + function ok() {} +}); + +test("POST /api/combos/duplicate uses custom strategy when provided", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/best-coding", + strategy: "round-robin", + }); + const res = await duplicateRoute.POST(req as never); + const body = await res.json(); + + if (res.status === 201) { + assert.equal(body.strategy, "round-robin"); + } else if (res.status === 422) { + ok(); + } else { + throw new Error(`Unexpected status: ${res.status}`); + } + + function ok() {} +}); + +test("POST /api/combos/duplicate created combo has sourceAutoCombo, weightPack in config and version 2", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/best-coding", + }); + const res = await duplicateRoute.POST(req as never); + const body = await res.json(); + + if (res.status === 201) { + assert.ok( + body.config && typeof body.config.sourceAutoCombo === "string", + "config should contain sourceAutoCombo reference" + ); + assert.equal(body.config.sourceAutoCombo, "auto/best-coding"); + assert.equal(body.version, 2); + + // Description includes template name + ISO timestamp (e.g. "auto/best-coding @ 2026-...") + assert.ok( + typeof body.description === "string" && body.description.startsWith("auto/best-coding @ "), + `description should be 'templateName @ timestamp', got: ${body.description}` + ); + + // weightPack is captured from the virtual combo's scoring config at creation time + assert.ok( + typeof body.config.weightPack === "object" && body.config.weightPack !== null, + "config should include weightPack" + ); + const wp = body.config.weightPack; + assert.ok( + typeof wp.taskFit === "number" || typeof wp.health === "number", + "weightPack should contain scoring coefficients" + ); + } else if (res.status === 422) { + ok(); + } + + function ok() {} +}); + +test("POST /api/combos/duplicate generates unique name when duplicate exists", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + // Create two duplicates — second should have a different name ("copy 1") + const req1 = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/best-coding", + }); + const res1 = await duplicateRoute.POST(req1 as never); + + if (res1.status !== 201) ok(); + else { + const body1 = await res1.json(); + + const req2 = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/best-coding", + }); + const res2 = await duplicateRoute.POST(req2 as never); + const body2 = await res2.json(); + + if (res2.status === 201) { + assert.ok( + body1.name !== body2.name, + `duplicate combo should get a unique name. Both got: ${body1.name}` + ); + // Second one ends with "copy 1" or higher + assert.ok( + /copy\s+[\d]+$/.test(body2.name), + `second duplicate should end with 'copy N', got: ${body2.name}` + ); + } else if (res2.status === 422) { + ok(); // no models matched for second call either + } else { + throw new Error(`Unexpected status on second call: ${res2.status}`); + } + } + + function ok() {} +}); + +// ── Error response does not leak stack traces ─────────────────────────────── + +test("POST /api/combos/duplicate error responses do not contain stack traces", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = makePostRequest("http://localhost/api/combos/duplicate", { + name: "auto/nonexistent-template-xyz", + }); + const res = await duplicateRoute.POST(req as never); + const body = await res.json(); + + assert.ok(!JSON.stringify(body).includes("at "), "error response must not leak stack traces"); +}); diff --git a/tests/unit/snapshot-weights.test.ts b/tests/unit/snapshot-weights.test.ts new file mode 100644 index 000000000000..4f271bbc326d --- /dev/null +++ b/tests/unit/snapshot-weights.test.ts @@ -0,0 +1,243 @@ +/** + * Unit tests for computeSnapshotWeights (Rule #18). + * + * Coverage: scoring differentiation by capabilities (reasoning, vision), + * tier-based bonuses (premium vs free), and neutral baselines for health/quota. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { computeSnapshotWeights } = + await import("@omniroute/open-sse/services/autoCombo/virtualFactory"); + +function makeCandidate( + modelStr: string, + opts: { + vision?: boolean; + reasoning?: boolean; + thinking?: boolean; + provider?: string; + model?: string; + } = {} +) { + return { + provider: opts.provider ?? "test-provider", + connectionId: null as const, + model: opts.model ?? modelStr.split("/")[1] ?? modelStr, + modelStr, + costPer1MTokens: 0, + resolvedSupportsVision: !!opts.vision, + resolvedReasoning: !!opts.reasoning, + resolvedSupportsThinking: !!opts.thinking, + }; +} + +// ── Basic structure ──────────────────────────────────────────────────────── + +test("computeSnapshotWeights returns a Map with one entry per candidate", () => { + const candidates = [makeCandidate("p/m1"), makeCandidate("p/m2")]; + const weights = { + taskFit: 0, + stability: 0, + tierPriority: 0, + costInv: 0, + latencyInv: 0, + health: 0.5, + quota: 0.5, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.equal(scores.size, 2); + assert.ok(scores.has("p/m1")); + assert.ok(scores.has("p/m2")); +}); + +// ── Capability-based differentiation (taskFit) ──────────────────────────── + +test("computeSnapshotWeights gives higher scores to reasoning-capable models when taskFit is weighted", () => { + const candidates = [ + makeCandidate("p/reasoning-model", { reasoning: true }), + makeCandidate("p/plain-model"), + ]; + const weights = { + taskFit: 1, + stability: 0, + tierPriority: 0, + costInv: 0, + latencyInv: 0, + health: 0.5, + quota: 0.5, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.ok( + scores.get("p/reasoning-model") > scores.get("p/plain-model"), + `reasoning model should score higher: ${scores.get("p/reasoning-model")} vs ${scores.get("p/plain-model")}` + ); +}); + +test("computeSnapshotWeights gives higher scores to vision-capable models when taskFit is weighted", () => { + const candidates = [ + makeCandidate("p/vision-model", { vision: true }), + makeCandidate("p/plain-model"), + ]; + const weights = { + taskFit: 1, + stability: 0, + tierPriority: 0, + costInv: 0, + latencyInv: 0, + health: 0.5, + quota: 0.5, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.ok( + scores.get("p/vision-model") > scores.get("p/plain-model"), + `vision model should score higher: ${scores.get("p/vision-model")} vs ${scores.get("p/plain-model")}` + ); +}); + +test("computeSnapshotWeights gives highest scores to models with both reasoning and vision", () => { + const candidates = [ + makeCandidate("p/full-capable", { reasoning: true, vision: true }), + makeCandidate("p/reasoning-only", { reasoning: true }), + makeCandidate("p/vision-only", { vision: true }), + makeCandidate("p/plain"), + ]; + // Low baseline so taskFit differentiation survives clamping + const weights = { + taskFit: 1, + stability: 0, + tierPriority: 0, + costInv: 0, + latencyInv: 0, + health: 0.1, + quota: 0.1, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.ok( + scores.get("p/full-capable") > scores.get("p/reasoning-only"), + `full-capable should beat reasoning-only: ${scores.get("p/full-capable")} vs ${scores.get("p/reasoning-only")}` + ); + assert.ok( + scores.get("p/reasoning-only") > scores.get("p/plain"), + "reasoning-only should beat plain" + ); +}); + +// ── Capability-based differentiation (stability) ────────────────────────── + +test("computeSnapshotWeights gives higher stability score to models with more capabilities", () => { + const candidates = [ + makeCandidate("p/rich-model", { reasoning: true, vision: true }), + makeCandidate("p/one-cap", { reasoning: true }), + makeCandidate("p/plain"), + ]; + // Low baseline so stability differentiation isn't masked by clamping at 1 + const weights = { + taskFit: 0, + stability: 0.8, + tierPriority: 0, + costInv: 0, + latencyInv: 0, + health: 0.1, + quota: 0.1, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.ok( + scores.get("p/rich-model") > scores.get("p/one-cap"), + `rich model should score higher on stability: ${scores.get("p/rich-model")} vs ${scores.get("p/one-cap")}` + ); +}); + +// ── LatencyInv and health/quota baselines ──────────────────────────────── + +test("computeSnapshotWeights gives equal latencyInv baseline when no runtime data", () => { + const candidates = [makeCandidate("p/m1"), makeCandidate("p/m2")]; + // Zero out health+quota so the latencyInv contribution is visible without clamping + const weights = { + taskFit: 0, + stability: 0, + tierPriority: 0, + costInv: 0, + latencyInv: 1, + health: 0, + quota: 0, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.equal( + scores.get("p/m1"), + scores.get("p/m2"), + "all candidates get equal baseline when no runtime telemetry" + ); +}); + +// ── Score clamping ──────────────────────────────────────────────────────── + +test("computeSnapshotWeights clamps scores to max 1", () => { + const candidates = [makeCandidate("p/m1", { reasoning: true, vision: true })]; + const weights = { + taskFit: 10, + stability: 10, + tierPriority: 10, + costInv: 10, + latencyInv: 10, + health: 10, + quota: 10, + }; + + const scores = computeSnapshotWeights(candidates, weights); + + assert.ok(scores.get("p/m1") <= 1, `score should be clamped to ≤1, got ${scores.get("p/m1")}`); +}); + +// ── Different mode-packs produce different weight profiles ──────────────── + +test("computeSnapshotWeights produces different relative weights for quality-first vs ship-fast", () => { + const candidates = [ + makeCandidate("p/full-capable", { reasoning: true, vision: true }), + makeCandidate("p/plain"), + ]; + + // quality-first: taskFit and stability are dominant → full-capable scores much higher + const weightsQuality = { + taskFit: 0.25, + stability: 0.2, + tierPriority: 0.1, + costInv: 0, + latencyInv: 0.1, + health: 0.15, + quota: 0.1, + }; + const scoresQ = computeSnapshotWeights(candidates, weightsQuality); + + // ship-fast: taskFit and stability are lower → gap is smaller + const weightsFast = { + taskFit: 0.1, + stability: 0.1, + tierPriority: 0.15, + costInv: 0, + latencyInv: 0.2, + health: 0.2, + quota: 0.2, + }; + const scoresF = computeSnapshotWeights(candidates, weightsFast); + + const gapQ = (scoresQ.get("p/full-capable") ?? 0) - (scoresQ.get("p/plain") ?? 0); + const gapF = (scoresF.get("p/full-capable") ?? 0) - (scoresF.get("p/plain") ?? 0); + + assert.ok( + gapQ > gapF, + `quality-first should widen the capability gap more than ship-fast: ${gapQ.toFixed(3)} vs ${gapF.toFixed(3)}` + ); +}); From 653cc01db83a44a5b57a6c2320c2151ad0f2754d Mon Sep 17 00:00:00 2001 From: Jay Ongg Date: Fri, 14 Aug 2026 17:25:39 -0700 Subject: [PATCH 2/3] remove the word "copy" from the outputted name. --- src/app/api/combos/duplicate/route.ts | 6 +++--- tests/unit/combos-duplicate-route.test.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/api/combos/duplicate/route.ts b/src/app/api/combos/duplicate/route.ts index 7f0a91476f4c..808031cb3724 100644 --- a/src/app/api/combos/duplicate/route.ts +++ b/src/app/api/combos/duplicate/route.ts @@ -124,14 +124,14 @@ export async function POST(request: Request) { } } - // Generate a unique combo name using "{old name} copy" convention (same as static combos). + // Generate a unique combo name based on the template (no "copy" appellation). const baseName = `static-${name.replace("auto/", "")}`; const existingNames = new Set(allCombos.map((c: any) => c.name)); - let newName = `${baseName} copy`; + let newName = baseName; let counter = 1; while (existingNames.has(newName)) { counter++; - newName = `${baseName} copy ${counter}`; + newName = `${baseName} ${counter}`; } // Capture the mode-pack weights from the virtual combo config so the snapshot diff --git a/tests/unit/combos-duplicate-route.test.ts b/tests/unit/combos-duplicate-route.test.ts index 456dc02a9dfd..447c26583b68 100644 --- a/tests/unit/combos-duplicate-route.test.ts +++ b/tests/unit/combos-duplicate-route.test.ts @@ -143,8 +143,8 @@ test("POST /api/combos/duplicate returns valid combo with correct naming and wei `Combo name should start with 'static-', got: ${body.name}` ); assert.ok( - /copy(\s+\d+)?$/.test(body.name), - `Combo name should end with 'copy' or 'copy N', got: ${body.name}` + /^static-[\w-]+(\s+\d+)?$/.test(body.name), + `Combo name should be 'static-