mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat: add expert combo configuration mode (#1547)
This commit is contained in:
@@ -244,7 +244,7 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve
|
||||
- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI
|
||||
- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next
|
||||
- **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**)
|
||||
- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets
|
||||
- **Structured Combo Builder** — Build combos with guided steps or expert single-page editing, including explicit provider + model + account selection, repeated providers, fixed-account targets, and direct model entry
|
||||
- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use
|
||||
- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
|
||||
function runNextBuild() {
|
||||
return new Promise((resolve) => {
|
||||
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
|
||||
const child = spawn(process.execPath, [nextBin, "build"], {
|
||||
const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: resolveNextBuildEnv(process.env),
|
||||
@@ -97,6 +97,10 @@ function runNextBuild() {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
|
||||
return baseEnv.OMNIROUTE_USE_TURBOPACK === "1" ? "--turbopack" : "--webpack";
|
||||
}
|
||||
|
||||
export function resolveNextBuildEnv(baseEnv = process.env) {
|
||||
return {
|
||||
...baseEnv,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@ import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import useThemeStore, { COLOR_THEMES } from "@/store/themeStore";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
COMBO_CONFIG_MODE_SETTING_KEY,
|
||||
normalizeComboConfigMode,
|
||||
type ComboConfigMode,
|
||||
} from "@/shared/constants/comboConfigMode";
|
||||
import {
|
||||
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
|
||||
SIDEBAR_SECTIONS,
|
||||
@@ -30,6 +35,7 @@ export default function AppearanceTab() {
|
||||
settings[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
|
||||
);
|
||||
const hiddenSidebarSet = new Set(hiddenSidebarItems);
|
||||
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
|
||||
|
||||
const getSettingsLabel = (key: string, fallback: string) =>
|
||||
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
|
||||
@@ -107,6 +113,32 @@ export default function AppearanceTab() {
|
||||
{ id: "cyan", color: COLOR_THEMES.cyan, label: t("themeCyan") },
|
||||
];
|
||||
|
||||
const comboConfigModeOptions: Array<{
|
||||
id: ComboConfigMode;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "guided",
|
||||
icon: "route",
|
||||
title: getSettingsLabel("comboConfigModeGuided", "Guided"),
|
||||
description: getSettingsLabel(
|
||||
"comboConfigModeGuidedDesc",
|
||||
"Use the current step-by-step combo builder."
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "expert",
|
||||
icon: "tune",
|
||||
title: getSettingsLabel("comboConfigModeExpert", "Expert"),
|
||||
description: getSettingsLabel(
|
||||
"comboConfigModeExpertDesc",
|
||||
"Show every combo option on one page and enable direct model entry."
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const showDebug = settings.debugMode === true;
|
||||
const sidebarSections = SIDEBAR_SECTIONS.filter(
|
||||
(section) => section.visibility !== "debug" || showDebug
|
||||
@@ -223,6 +255,61 @@ export default function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("comboConfigMode", "Combo configuration mode")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"comboConfigModeDesc",
|
||||
"Choose how the combo create and edit dialog is organized."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={getSettingsLabel("comboConfigMode", "Combo configuration mode")}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||
>
|
||||
{comboConfigModeOptions.map((option) => {
|
||||
const active = comboConfigMode === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={loading}
|
||||
onClick={() => updateSetting(COMBO_CONFIG_MODE_SETTING_KEY, option.id)}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors disabled:opacity-60",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-surface/40 text-text-main hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[20px]" aria-hidden="true">
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold">{option.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 block text-xs",
|
||||
active ? "text-primary/80" : "text-text-muted"
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("PATCH /api/settings", () => {
|
||||
(getSettings as any).mockResolvedValue({
|
||||
debugMode: false,
|
||||
hiddenSidebarItems: [],
|
||||
comboConfigMode: "guided",
|
||||
});
|
||||
// Mock updateSettings to merge updates into the original
|
||||
(updateSettings as any).mockImplementation(async (updates: Record<string, unknown>) => {
|
||||
@@ -61,4 +62,15 @@ describe("PATCH /api/settings", () => {
|
||||
const calledWith = (updateSettings as any).mock.calls[0][0];
|
||||
expect(calledWith.hiddenSidebarItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates comboConfigMode via PATCH", async () => {
|
||||
const req = createPatchRequest({ comboConfigMode: "expert" });
|
||||
const res = await PATCH(req as any);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.comboConfigMode).toBe("expert");
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
const calledWith = (updateSettings as any).mock.calls[0][0];
|
||||
expect(calledWith.comboConfigMode).toBe("expert");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,55 @@ export function buildPrecisionComboModelStep({
|
||||
};
|
||||
}
|
||||
|
||||
type ComboBuilderProviderIdentity = {
|
||||
providerId?: unknown;
|
||||
alias?: unknown;
|
||||
prefix?: unknown;
|
||||
};
|
||||
|
||||
export function resolveComboBuilderProviderId(
|
||||
providerIdOrAlias: unknown,
|
||||
providers: ComboBuilderProviderIdentity[] = []
|
||||
): string | null {
|
||||
const normalizedProviderId = toTrimmedString(providerIdOrAlias);
|
||||
if (!normalizedProviderId) return null;
|
||||
|
||||
const matchedProvider = providers.find((provider) => {
|
||||
const providerId = toTrimmedString(provider.providerId);
|
||||
const alias = toTrimmedString(provider.alias);
|
||||
const prefix = toTrimmedString(provider.prefix);
|
||||
return (
|
||||
providerId === normalizedProviderId ||
|
||||
alias === normalizedProviderId ||
|
||||
prefix === normalizedProviderId
|
||||
);
|
||||
});
|
||||
|
||||
return toTrimmedString(matchedProvider?.providerId) || null;
|
||||
}
|
||||
|
||||
export function buildManualComboModelStep({
|
||||
value,
|
||||
providers = [],
|
||||
weight = 0,
|
||||
}: {
|
||||
value: unknown;
|
||||
providers?: ComboBuilderProviderIdentity[];
|
||||
weight?: number;
|
||||
}): ComboModelStep | null {
|
||||
const parsed = parseQualifiedModel(value);
|
||||
if (!parsed) return null;
|
||||
|
||||
const providerId = resolveComboBuilderProviderId(parsed.providerId, providers);
|
||||
if (!providerId) return null;
|
||||
|
||||
return buildPrecisionComboModelStep({
|
||||
providerId,
|
||||
modelId: parsed.modelId,
|
||||
weight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getExactModelStepSignature(entry: unknown): string | null {
|
||||
if (!isRecord(entry) || entry.kind === "combo-ref") return null;
|
||||
const modelValue = toTrimmedString(entry.model);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault =
|
||||
(this && this.__importDefault) ||
|
||||
function (mod) {
|
||||
return mod && mod.__esModule ? mod : { default: mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.APP_NAME = void 0;
|
||||
exports.getLegacyDotDataDir = getLegacyDotDataDir;
|
||||
exports.getDefaultDataDir = getDefaultDataDir;
|
||||
exports.resolveDataDir = resolveDataDir;
|
||||
exports.isSamePath = isSamePath;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const os_1 = __importDefault(require("os"));
|
||||
exports.APP_NAME = "omniroute";
|
||||
function fallbackHomeDir() {
|
||||
const envHome = process.env.HOME || process.env.USERPROFILE;
|
||||
if (typeof envHome === "string" && envHome.trim().length > 0) {
|
||||
return path_1.default.resolve(envHome);
|
||||
}
|
||||
return os_1.default.tmpdir();
|
||||
}
|
||||
function safeHomeDir() {
|
||||
try {
|
||||
return os_1.default.homedir();
|
||||
} catch {
|
||||
return fallbackHomeDir();
|
||||
}
|
||||
}
|
||||
function normalizeConfiguredPath(dir) {
|
||||
if (typeof dir !== "string") return null;
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) return null;
|
||||
return path_1.default.resolve(trimmed);
|
||||
}
|
||||
function getLegacyDotDataDir() {
|
||||
return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`);
|
||||
}
|
||||
function getDefaultDataDir() {
|
||||
const homeDir = safeHomeDir();
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming");
|
||||
return path_1.default.join(appData, exports.APP_NAME);
|
||||
}
|
||||
// Support XDG on Linux/macOS when explicitly configured.
|
||||
const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
|
||||
if (xdgConfigHome) {
|
||||
return path_1.default.join(xdgConfigHome, exports.APP_NAME);
|
||||
}
|
||||
return getLegacyDotDataDir();
|
||||
}
|
||||
function resolveDataDir({ isCloud = false } = {}) {
|
||||
if (isCloud) return "/tmp";
|
||||
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
|
||||
if (configured) return configured;
|
||||
return getDefaultDataDir();
|
||||
}
|
||||
function isSamePath(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const normalizedA = path_1.default.resolve(a);
|
||||
const normalizedB = path_1.default.resolve(b);
|
||||
if (process.platform === "win32") {
|
||||
return normalizedA.toLowerCase() === normalizedB.toLowerCase();
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export async function getSettings() {
|
||||
antigravitySignatureCacheMode: "enabled",
|
||||
requireLogin: true,
|
||||
hiddenSidebarItems: [],
|
||||
comboConfigMode: "guided",
|
||||
alwaysPreserveClientCache: "auto",
|
||||
idempotencyWindowMs: 5000,
|
||||
wsAuth: false,
|
||||
|
||||
9
src/shared/constants/comboConfigMode.ts
Normal file
9
src/shared/constants/comboConfigMode.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const COMBO_CONFIG_MODE_SETTING_KEY = "comboConfigMode";
|
||||
|
||||
export const COMBO_CONFIG_MODES = ["guided", "expert"] as const;
|
||||
|
||||
export type ComboConfigMode = (typeof COMBO_CONFIG_MODES)[number];
|
||||
|
||||
export function normalizeComboConfigMode(value: unknown): ComboConfigMode {
|
||||
return value === "expert" ? "expert" : "guided";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
|
||||
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import { isLocalProvider } from "@/shared/constants/providers";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
@@ -434,6 +435,7 @@ export const updateSettingsSchema = z.object({
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: settingsFallbackStrategySchema.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* at runtime (see: https://github.com/vercel/next.js/issues/12557).
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
|
||||
const fallbackStrategyValues = [
|
||||
@@ -47,6 +48,7 @@ export const updateSettingsSchema = z.object({
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(fallbackStrategyValues).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
|
||||
@@ -534,7 +534,8 @@ async function handleSingleModelChat(
|
||||
const baseRetrySettings = resolveCooldownAwareRetrySettings(
|
||||
await getCachedSettings().catch(() => ({}))
|
||||
);
|
||||
const disableCooldownAwareRetry = isCombo || runtimeOptions.emergencyFallbackTried === true;
|
||||
const disableCooldownAwareRetry =
|
||||
isCombo || forceLiveComboTest || runtimeOptions.emergencyFallbackTried === true;
|
||||
const retrySettings = disableCooldownAwareRetry
|
||||
? {
|
||||
...baseRetrySettings,
|
||||
|
||||
@@ -64,6 +64,14 @@ test.describe("Combos flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboConfigMode: "guided" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -263,6 +271,213 @@ test.describe("Combos flow", () => {
|
||||
await expect(testResultsModal).toContainText(/qa-test-model/i);
|
||||
});
|
||||
|
||||
test("expert mode shows a single-page combo form with manual model entry", async ({ page }) => {
|
||||
const state: {
|
||||
combos: ComboStub[];
|
||||
nextId: number;
|
||||
lastPayload: ComboCreatePayload | null;
|
||||
} = {
|
||||
combos: [],
|
||||
nextId: 1,
|
||||
lastPayload: null,
|
||||
};
|
||||
|
||||
await page.route("**/api/combos/metrics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ metrics: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboConfigMode: "expert" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connections: [
|
||||
{ id: "conn-codex", provider: "codex", testStatus: "active" },
|
||||
{ id: "conn-openrouter", provider: "openrouter", testStatus: "active" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/models/alias", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ aliases: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/pricing", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/builder/options", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
providerId: "codex",
|
||||
alias: "cx",
|
||||
displayName: "Codex",
|
||||
connectionCount: 1,
|
||||
models: [],
|
||||
connections: [
|
||||
{
|
||||
id: "conn-codex",
|
||||
label: "Codex Primary",
|
||||
status: "active",
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
providerId: "openrouter",
|
||||
alias: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
connectionCount: 1,
|
||||
models: [],
|
||||
connections: [
|
||||
{
|
||||
id: "conn-openrouter",
|
||||
label: "OpenRouter Primary",
|
||||
status: "active",
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
comboRefs: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: state.combos }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payloadRaw = route.request().postDataJSON();
|
||||
const payload =
|
||||
payloadRaw && typeof payloadRaw === "object" ? (payloadRaw as ComboCreatePayload) : {};
|
||||
state.lastPayload = payload;
|
||||
const comboId = `combo-${state.nextId++}`;
|
||||
const createdCombo = {
|
||||
id: comboId,
|
||||
name: payload.name || comboId,
|
||||
strategy: payload.strategy || "priority",
|
||||
models: payload.models || [],
|
||||
config: payload.config || {},
|
||||
isActive: true,
|
||||
};
|
||||
state.combos.push(createdCombo);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combo: createdCombo }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405, body: "Method not allowed in test stub" });
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/combos", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /create combo|criar combo/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const comboDialog = page.getByRole("dialog").first();
|
||||
await expect(comboDialog).toBeVisible();
|
||||
await expect(comboDialog.locator('[data-testid="combo-builder-next"]')).toHaveCount(0);
|
||||
await expect(comboDialog.locator('[data-testid="combo-builder-stage-steps"]')).toHaveCount(0);
|
||||
await expect(comboDialog.locator('[data-testid="combo-readiness-panel"]')).toBeVisible();
|
||||
await expect(comboDialog.locator('[data-testid="combo-browse-catalog"]')).toBeVisible();
|
||||
await comboDialog.locator('[data-testid="combo-browse-catalog"]').click();
|
||||
const modelCatalogDialog = page.getByRole("dialog", {
|
||||
name: /add model to combo|adicionar modelo ao combo/i,
|
||||
});
|
||||
await expect(modelCatalogDialog).toBeVisible();
|
||||
await modelCatalogDialog.getByRole("button", { name: /close/i }).click();
|
||||
await expect(comboDialog.getByText(/recommended setup|how to use this strategy/i)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await comboDialog.locator('[data-testid="combo-name-input"]').fill("expert-stack");
|
||||
await comboDialog.locator('[data-testid="combo-manual-model-input"]').fill("cx/gpt-5.5");
|
||||
await comboDialog.locator('[data-testid="combo-manual-model-add"]').click();
|
||||
await comboDialog
|
||||
.locator('[data-testid="combo-manual-model-input"]')
|
||||
.fill("openrouter/openai/gpt-5.5");
|
||||
await comboDialog.locator('[data-testid="combo-manual-model-add"]').click();
|
||||
await expect(comboDialog.locator('[data-testid="combo-readiness-panel"]')).toHaveCount(0);
|
||||
|
||||
await comboDialog
|
||||
.getByRole("button", { name: /create combo|criar combo/i })
|
||||
.last()
|
||||
.click();
|
||||
await expect(comboDialog).toBeHidden();
|
||||
|
||||
expect(state.lastPayload?.models).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
providerId: "codex",
|
||||
model: "codex/gpt-5.5",
|
||||
weight: 0,
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
providerId: "openrouter",
|
||||
model: "openrouter/openai/gpt-5.5",
|
||||
weight: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("allows dragging combo cards to persist manual order", async ({ page }) => {
|
||||
const state: {
|
||||
combos: ComboStub[];
|
||||
@@ -308,6 +523,14 @@ test.describe("Combos flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboConfigMode: "guided" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -9,6 +9,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
|
||||
const { generateSignature, invalidateBySignature, setCachedResponse } =
|
||||
await import("../../src/lib/semanticCache.ts");
|
||||
@@ -197,3 +198,33 @@ test("combo live test bypasses semantic cache and forces a fresh upstream reques
|
||||
invalidateBySignature(signature);
|
||||
}
|
||||
});
|
||||
|
||||
test("combo live test does not use cooldown-aware request retry on upstream failures", async () => {
|
||||
await seedHealthyConnection();
|
||||
await settingsDb.updateSettings({
|
||||
requestRetry: 3,
|
||||
maxRetryIntervalSec: 5,
|
||||
});
|
||||
|
||||
let fetchCalls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "upstream unavailable",
|
||||
},
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
};
|
||||
|
||||
const liveResponse = await chatRoute.POST(
|
||||
makeRequest({ "X-Internal-Test": "combo-health-check" })
|
||||
);
|
||||
const liveBody = (await liveResponse.json()) as any;
|
||||
|
||||
assert.equal(liveResponse.status, 503);
|
||||
assert.equal(fetchCalls, 1);
|
||||
assert.match(liveBody.error.message, /upstream unavailable/i);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,47 @@ test("buildPrecisionComboModelStep preserves provider/model/account triple", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("buildManualComboModelStep resolves provider aliases and uses dynamic account", () => {
|
||||
assert.deepEqual(
|
||||
builderDraft.buildManualComboModelStep({
|
||||
value: "cx/gpt-5.5",
|
||||
providers: [{ providerId: "codex", alias: "cx" }],
|
||||
}),
|
||||
{
|
||||
kind: "model",
|
||||
providerId: "codex",
|
||||
model: "codex/gpt-5.5",
|
||||
weight: 0,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
builderDraft.buildManualComboModelStep({
|
||||
value: "openrouter/openai/gpt-5.5",
|
||||
providers: [{ providerId: "openrouter", alias: "openrouter" }],
|
||||
}),
|
||||
{
|
||||
kind: "model",
|
||||
providerId: "openrouter",
|
||||
model: "openrouter/openai/gpt-5.5",
|
||||
weight: 0,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
builderDraft.resolveComboBuilderProviderId("foo", [{ providerId: "codex", alias: "cx" }]),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
builderDraft.buildManualComboModelStep({
|
||||
value: "foo/bar",
|
||||
providers: [{ providerId: "codex", alias: "cx" }],
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(builderDraft.buildManualComboModelStep({ value: "gpt-5.5" }), null);
|
||||
});
|
||||
|
||||
test("hasExactModelStepDuplicate blocks only exact provider/model/connection repeats", () => {
|
||||
const existing = [
|
||||
builderDraft.buildPrecisionComboModelStep({
|
||||
|
||||
@@ -69,6 +69,7 @@ test("getSettings exposes defaults and updateSettings persists typed values", as
|
||||
assert.equal(defaults.requestRetry, 3);
|
||||
assert.equal(defaults.maxRetryIntervalSec, 30);
|
||||
assert.equal(defaults.antigravitySignatureCacheMode, "enabled");
|
||||
assert.equal(defaults.comboConfigMode, "guided");
|
||||
assert.equal(updated.requireLogin, false);
|
||||
assert.equal(updated.cloudEnabled, true);
|
||||
assert.equal(updated.stickyRoundRobinLimit, 7);
|
||||
|
||||
@@ -38,3 +38,13 @@ test("settings schemas accept wsAuth toggle", () => {
|
||||
assert.equal(routeParsed.wsAuth, true);
|
||||
assert.equal(sharedParsed.wsAuth, false);
|
||||
});
|
||||
|
||||
test("settings schemas accept combo configuration modes", () => {
|
||||
const routeParsed = settingsRouteSchema.parse({ comboConfigMode: "expert" });
|
||||
const sharedParsed = sharedSettingsSchema.parse({ comboConfigMode: "guided" });
|
||||
|
||||
assert.equal(routeParsed.comboConfigMode, "expert");
|
||||
assert.equal(sharedParsed.comboConfigMode, "guided");
|
||||
assert.equal(settingsRouteSchema.safeParse({ comboConfigMode: "compact" }).success, false);
|
||||
assert.equal(sharedSettingsSchema.safeParse({ comboConfigMode: "compact" }).success, false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user