From 634965436ff7e2fa7c6bba27e06c00fb4310d07d Mon Sep 17 00:00:00 2001 From: Thinh Date: Mon, 17 Aug 2026 17:55:59 +0700 Subject: [PATCH 1/4] cherry-pick(pr-10148): feat(api): add provider quota telemetry, adaptive routing, and status inventory Inspired-by: diegosouzapw/OmniRoute#10148 --- bin/cli/commands/quota.mjs | 56 +++- bin/cli/utils/cliToken.mjs | 12 +- docs/OMNIROUTE_ALLOCATION_HANDOFF.md | 9 + docs/OMNIROUTE_PROVIDER_FAILOVER.md | 9 + docs/OMNIROUTE_QUOTA_TELEMETRY.md | 17 ++ docs/OMNIROUTE_ROUTING_POLICY.md | 11 + package.json | 1 + scripts/check/omniroute-verify.mjs | 69 +++++ src/app/api/omniroute/route/preview/route.ts | 36 +++ src/app/api/omniroute/status/route.ts | 21 ++ src/app/api/quota/pools/route.ts | 21 +- src/lib/db/quotaPools.ts | 95 +++++-- src/lib/localDb.ts | 1 + src/lib/omnirouteStatus.ts | 87 ++++++ src/lib/quota/providerCapabilities.ts | 37 +++ src/lib/quota/providerQuotaTelemetry.ts | 248 ++++++++++++++++++ src/lib/resilience/adaptiveCircuit.ts | 60 +++++ src/lib/resilience/failureClassification.ts | 75 ++++++ src/lib/routing/adaptiveRouting.ts | 153 +++++++++++ src/lib/usage/budgetGuard.ts | 66 +++++ src/lib/usage/modelPricingRegistry.ts | 52 ++++ src/lib/usage/usageLedger.ts | 78 ++++++ .../adaptive-circuit-budget-ledger.test.ts | 77 ++++++ tests/unit/cli-machine-token.test.ts | 22 +- .../quota-telemetry-adaptive-routing.test.ts | 149 +++++++++++ 25 files changed, 1415 insertions(+), 47 deletions(-) create mode 100644 docs/OMNIROUTE_ALLOCATION_HANDOFF.md create mode 100644 docs/OMNIROUTE_PROVIDER_FAILOVER.md create mode 100644 docs/OMNIROUTE_QUOTA_TELEMETRY.md create mode 100644 docs/OMNIROUTE_ROUTING_POLICY.md create mode 100644 scripts/check/omniroute-verify.mjs create mode 100644 src/app/api/omniroute/route/preview/route.ts create mode 100644 src/app/api/omniroute/status/route.ts create mode 100644 src/lib/omnirouteStatus.ts create mode 100644 src/lib/quota/providerCapabilities.ts create mode 100644 src/lib/quota/providerQuotaTelemetry.ts create mode 100644 src/lib/resilience/adaptiveCircuit.ts create mode 100644 src/lib/resilience/failureClassification.ts create mode 100644 src/lib/routing/adaptiveRouting.ts create mode 100644 src/lib/usage/budgetGuard.ts create mode 100644 src/lib/usage/modelPricingRegistry.ts create mode 100644 src/lib/usage/usageLedger.ts create mode 100644 tests/unit/adaptive-circuit-budget-ledger.test.ts create mode 100644 tests/unit/quota-telemetry-adaptive-routing.test.ts diff --git a/bin/cli/commands/quota.mjs b/bin/cli/commands/quota.mjs index a657845e51e..dee142db53c 100644 --- a/bin/cli/commands/quota.mjs +++ b/bin/cli/commands/quota.mjs @@ -2,7 +2,7 @@ import { apiFetch, isServerUp } from "../api.mjs"; import { t } from "../i18n.mjs"; export function registerQuota(program) { - program + const quota = program .command("quota") .description(t("quota.description")) .option("--provider ", "Filter by provider") @@ -12,6 +12,60 @@ export function registerQuota(program) { const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + quota + .command("status") + .description("Show truthful OmniRoute gateway, quota, pool, and circuit state") + .action(async (opts, cmd) => runBoundedJson("/api/omniroute/status", cmd.optsWithGlobals())); + + quota + .command("preview") + .description("Preview allocation enforcement without an upstream request") + .requiredOption("--api-key-id ", "API key id") + .requiredOption("--pool-id ", "quota pool id") + .option("--tokens ", "estimated token usage") + .action(async (opts, cmd) => { + const params = new URLSearchParams({ apiKeyId: opts.apiKeyId, poolId: opts.poolId }); + if (opts.tokens != null) params.set("estimatedTokens", opts.tokens); + await runBoundedJson(`/api/quota/preview?${params}`, cmd.optsWithGlobals()); + }); + + quota + .command("ensure ") + .description("Idempotently create or update a quota pool from a JSON object") + .action(async (json, opts, cmd) => { + let body; + try { + body = JSON.parse(json); + } catch { + console.error("Invalid pool JSON"); + process.exit(2); + } + await runBoundedJson("/api/quota/pools?ensure=true", cmd.optsWithGlobals(), { + method: "POST", + body, + }); + }); +} + +async function runBoundedJson(path, opts, request = {}) { + const started = performance.now(); + const res = await apiFetch(path, { + ...request, + retry: false, + timeout: Math.min(opts.timeout ?? 5000, 5000), + acceptNotOk: true, + }); + const elapsed = Math.round(performance.now() - started); + if (process.env.OMNIROUTE_DEBUG === "1") { + console.error(`[omniroute] ${request.method ?? "GET"} ${path} completed in ${elapsed}ms`); + } + const payload = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + if (!res.ok) { + console.error(JSON.stringify(payload)); + process.exit(res.exitCode ?? 1); + } + console.log(JSON.stringify(payload, null, 2)); } export async function runQuotaCommand(opts = {}) { diff --git a/bin/cli/utils/cliToken.mjs b/bin/cli/utils/cliToken.mjs index da504019a3b..f6e2063d2cf 100644 --- a/bin/cli/utils/cliToken.mjs +++ b/bin/cli/utils/cliToken.mjs @@ -8,13 +8,11 @@ let _cached = null; export async function getCliToken() { if (_cached !== null) return _cached; try { - const { machineIdSync } = await import("node-machine-id"); - const mid = machineIdSync(); - _cached = crypto - .createHash("sha256") - .update(mid + SALT) - .digest("hex") - .substring(0, 32); + const module = await import("node-machine-id"); + const machineIdSync = module.machineIdSync ?? module.default?.machineIdSync; + if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable"); + const mid = machineIdSync(true); + _cached = crypto.createHmac("sha256", mid).update(SALT).digest("hex"); } catch { _cached = ""; } diff --git a/docs/OMNIROUTE_ALLOCATION_HANDOFF.md b/docs/OMNIROUTE_ALLOCATION_HANDOFF.md new file mode 100644 index 00000000000..e523086c7c7 --- /dev/null +++ b/docs/OMNIROUTE_ALLOCATION_HANDOFF.md @@ -0,0 +1,9 @@ +# OmniRoute Allocation Handoff + +Allocation is not provider quota. + +Quota pools define which API keys may consume a provider pool and how hard, soft, or burst policies apply. Provider quota is external capacity reported by a provider or an explicitly configured source. Ghostlight internal budgets are governance limits defined by the administrator. + +The `ensurePool` operation is idempotent: an identical pool is unchanged, a changed allocation is updated, and a missing pool is created. This is intended for automation and bounded API callers. + +The read-only status endpoint is `GET /api/omniroute/status`. The verification command is `npm run omniroute:verify`; it makes no live model request. diff --git a/docs/OMNIROUTE_PROVIDER_FAILOVER.md b/docs/OMNIROUTE_PROVIDER_FAILOVER.md new file mode 100644 index 00000000000..4c0514b4488 --- /dev/null +++ b/docs/OMNIROUTE_PROVIDER_FAILOVER.md @@ -0,0 +1,9 @@ +# OmniRoute Provider Failover + +Failures are classified before retry decisions are made. + +Transient failures such as timeouts, network errors, rate limits, and provider 5xx responses may fail over. Authentication errors, permission errors, invalid requests, unavailable models, and unknown failures are not retried blindly. + +The default cross-provider policy allows up to three provider attempts, retries rate limits and timeouts, and keeps administrative disablement separate from temporary circuit state. + +Circuit states are `closed`, `open`, and `half_open`. A cooldown schedules a bounded probe; a successful probe closes the circuit and a failed probe reopens it. diff --git a/docs/OMNIROUTE_QUOTA_TELEMETRY.md b/docs/OMNIROUTE_QUOTA_TELEMETRY.md new file mode 100644 index 00000000000..5fe939034fd --- /dev/null +++ b/docs/OMNIROUTE_QUOTA_TELEMETRY.md @@ -0,0 +1,17 @@ +# OmniRoute Quota Telemetry + +OmniRoute separates provider quota telemetry from Ghostlight accounting. + +## Truthful states + +- `healthy` means a source reported usable remaining capacity. +- `approaching_limit` means a source reported remaining capacity at or below the configured threshold. +- `exhausted` is emitted only when a source reports zero capacity or usage at its limit. +- `unavailable` means a supported source failed to return data. +- `unknown` means no supported source exists or no provider limit is known. + +Unknown is not exhausted and does not disable a provider. + +Sources are preferred in this order: official provider API, authenticated usage API, explicitly mapped response headers, administrator configuration, local estimates, unknown. Local estimates are never presented as provider billing data. + +Response headers are parsed only through an explicit provider mapping. Generic header names are not assumed globally. diff --git a/docs/OMNIROUTE_ROUTING_POLICY.md b/docs/OMNIROUTE_ROUTING_POLICY.md new file mode 100644 index 00000000000..ee7ec45d876 --- /dev/null +++ b/docs/OMNIROUTE_ROUTING_POLICY.md @@ -0,0 +1,11 @@ +# OmniRoute Routing Policy + +Routing preserves the existing capability and combo selection logic, then applies allocation, health, circuit, quota, latency, reliability, model preference, and cost preference factors. + +The adaptive score is explainable and returns both the selected candidate and all ranked candidates. Exhausted quota, denied allocation, and open circuits are ineligible. Unknown quota remains eligible with a neutral quota factor. + +Route preview is deterministic and performs zero upstream model requests: + +`POST /api/omniroute/route/preview` + +The response includes candidate scores, factors, reasons, the selected provider, and `liveRequestExecuted: false`. diff --git a/package.json b/package.json index 951d417e733..c8e80ab133b 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs", "build:backend": "cross-env OMNIROUTE_BUILD_BACKEND_ONLY=1 node scripts/build/build-next-isolated.mjs", "build:cli": "node --import tsx scripts/build/prepublish.ts", + "omniroute:verify": "node scripts/check/omniroute-verify.mjs", "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs", "build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild", "start": "node scripts/dev/run-next.mjs start", diff --git a/scripts/check/omniroute-verify.mjs b/scripts/check/omniroute-verify.mjs new file mode 100644 index 00000000000..8082f499da8 --- /dev/null +++ b/scripts/check/omniroute-verify.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { CLI_TOKEN_HEADER, getCliToken } from "../../bin/cli/utils/cliToken.mjs"; + +const baseUrl = (process.env.OMNIROUTE_BASE_URL || "http://127.0.0.1:20128").replace(/\/$/, ""); +const apiKey = process.env.OMNIROUTE_API_KEY || ""; +const timeoutMs = 5000; + +async function get(path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let hardTimer; + const hardTimeout = new Promise((_, reject) => { + hardTimer = setTimeout( + () => reject(new Error(`request timeout after ${timeoutMs}ms`)), + timeoutMs + 100 + ); + }); + try { + const response = await Promise.race([ + fetch(`${baseUrl}${path}`, { + headers: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + [CLI_TOKEN_HEADER]: await getCliToken(), + }, + signal: controller.signal, + }), + hardTimeout, + ]); + const body = await response.json().catch(() => null); + return { ok: response.ok, status: response.status, body }; + } finally { + clearTimeout(timer); + clearTimeout(hardTimer); + } +} + +function check(label, passed, detail = "") { + console.log(`${label}: ${passed ? "PASS" : "FAIL"}${detail ? ` (${detail})` : ""}`); + return passed; +} + +console.log("OmniRoute Verification"); +console.log(`Gateway: ${baseUrl}`); +const results = []; + +try { + const models = await get("/v1/models"); + results.push(check("Gateway", models.ok, `HTTP ${models.status}`)); + const modelCount = Array.isArray(models.body?.data) ? models.body.data.length : 0; + results.push(check("Catalog", modelCount > 0, `${modelCount} models`)); + + const pools = await get("/api/quota/pools"); + const poolRows = Array.isArray(pools.body?.pools) ? pools.body.pools : []; + const allocations = poolRows.reduce((sum, pool) => sum + (pool.allocations?.length || 0), 0); + results.push(check("Pools", pools.ok, `${poolRows.length}`)); + results.push(check("Allocations", pools.ok && allocations >= poolRows.length, `${allocations}`)); + + const status = await get("/api/omniroute/status"); + results.push(check("Status API", status.ok, `HTTP ${status.status}`)); + results.push(check("No live request", status.body?.liveRequestExecuted === false)); +} catch (error) { + results.push( + check("Verification", false, error instanceof Error ? error.message : String(error)) + ); +} + +console.log(`Live upstream requests: 0`); +if (results.some((passed) => !passed)) process.exitCode = 1; diff --git a/src/app/api/omniroute/route/preview/route.ts b/src/app/api/omniroute/route/preview/route.ts new file mode 100644 index 00000000000..0401ec97676 --- /dev/null +++ b/src/app/api/omniroute/route/preview/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { rankCandidates } from "@/lib/routing/adaptiveRouting"; + +const candidateSchema = z.object({ + providerId: z.string().min(1), + modelId: z.string().min(1), + capabilityScore: z.number().min(0).max(1), + allocation: z.enum(["allow", "warn", "deny"]), + healthScore: z.number().min(0).max(1), + circuit: z.enum(["closed", "open", "half_open"]), + quota: z.enum(["healthy", "approaching_limit", "exhausted", "unavailable", "unknown"]), + latencyMs: z.number().nonnegative().optional(), + errorRate: z.number().min(0).max(1).optional(), + modelPreference: z.number().min(0).max(1).optional(), + costPreference: z.number().min(0).max(1).optional(), +}); + +const requestSchema = z.object({ candidates: z.array(candidateSchema).min(1).max(100) }); + +/** Deterministic routing preview. It never calls an upstream provider. */ +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const parsed = requestSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + + const result = rankCandidates(parsed.data); + return NextResponse.json({ + request: { candidateCount: parsed.data.candidates.length }, + ...result, + selected: result.selected?.providerId ?? null, + liveRequestExecuted: false, + }); +} diff --git a/src/app/api/omniroute/status/route.ts b/src/app/api/omniroute/status/route.ts new file mode 100644 index 00000000000..2709e6f160e --- /dev/null +++ b/src/app/api/omniroute/status/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { buildOmniRouteStatus } from "@/lib/omnirouteStatus"; + +export const dynamic = "force-dynamic"; + +/** Read-only operational status; never performs an upstream model request. */ +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + return NextResponse.json({ + generatedAt: new Date().toISOString(), + liveRequestExecuted: false, + ...(await buildOmniRouteStatus()), + }); + } catch { + return NextResponse.json({ error: "Failed to build OmniRoute status" }, { status: 500 }); + } +} diff --git a/src/app/api/quota/pools/route.ts b/src/app/api/quota/pools/route.ts index b669b1f4e5e..81fee4211a3 100644 --- a/src/app/api/quota/pools/route.ts +++ b/src/app/api/quota/pools/route.ts @@ -16,7 +16,7 @@ import { NextResponse } from "next/server"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { PoolCreateSchema } from "@/shared/schemas/quota"; -import { listPools, createPool } from "@/lib/localDb"; +import { listPools, createPool, ensurePool } from "@/lib/localDb"; import { logAuditEvent, getAuditRequestContext } from "@/lib/compliance/index"; export const dynamic = "force-dynamic"; @@ -48,17 +48,28 @@ export async function POST(request: Request): Promise { return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 }); } - const pool = createPool(parsed.data); + const ensure = new URL(request.url).searchParams.get("ensure") === "true"; + const ensured = ensure ? ensurePool(parsed.data) : null; + const pool = ensured?.pool ?? createPool(parsed.data); const ctx = getAuditRequestContext(request); logAuditEvent({ - action: "quota.pool.created", + action: ensured?.updated ? "quota.pool.updated" : "quota.pool.created", target: pool.id, - metadata: { connectionId: pool.connectionId, name: pool.name }, + metadata: { + connectionId: pool.connectionId, + name: pool.name, + ensure, + created: ensured?.created ?? true, + updated: ensured?.updated ?? false, + }, ipAddress: ctx.ipAddress ?? undefined, requestId: ctx.requestId, }); - return NextResponse.json({ pool }, { status: 201 }); + return NextResponse.json( + { pool, ...(ensured ? { created: ensured.created, updated: ensured.updated } : {}) }, + { status: ensured?.created === false ? 200 : 201 } + ); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create pool"; return NextResponse.json(buildErrorBody(500, message), { status: 500 }); diff --git a/src/lib/db/quotaPools.ts b/src/lib/db/quotaPools.ts index 033f62d0480..fbe50d3e33c 100644 --- a/src/lib/db/quotaPools.ts +++ b/src/lib/db/quotaPools.ts @@ -50,7 +50,7 @@ async function removeQuotaCombosGuarded(poolId: string): Promise { } catch (err) { console.warn( "[quota-pools] removeQuotaCombosForPool failed (non-fatal):", - (err as Error)?.message, + (err as Error)?.message ); } } @@ -109,6 +109,12 @@ export interface PoolUpdate { connectionIds?: string[]; } +export interface EnsurePoolResult { + pool: QuotaPool; + created: boolean; + updated: boolean; +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -145,7 +151,7 @@ function assertSingleProvider(connectionIds: string[]): void { const providers = rows.map((r) => r.provider).filter(Boolean); if (new Set(providers).size > 1) { throw new Error( - `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})`, + `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})` ); } } @@ -199,7 +205,7 @@ interface PoolConnectionRow { function getConnectionIds(poolId: string, fallbackConnectionId: string): string[] { const rows = getDb() .prepare( - "SELECT connection_id FROM quota_pool_connections WHERE pool_id = ? ORDER BY created_at ASC", + "SELECT connection_id FROM quota_pool_connections WHERE pool_id = ? ORDER BY created_at ASC" ) .all(poolId); if (rows.length > 0) { @@ -230,7 +236,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { // Batch allocations: 1 query for all pools const allocRows = db .prepare( - `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id IN (${ph})`, + `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id IN (${ph})` ) .all(...poolIds); const allocsByPool = new Map(); @@ -246,7 +252,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { // Batch connections: 1 query for all pools const connRows = db .prepare<{ pool_id: string; connection_id: string }>( - `SELECT pool_id, connection_id FROM quota_pool_connections WHERE pool_id IN (${ph}) ORDER BY created_at ASC`, + `SELECT pool_id, connection_id FROM quota_pool_connections WHERE pool_id IN (${ph}) ORDER BY created_at ASC` ) .all(...poolIds); const connsByPool = new Map(); @@ -273,7 +279,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { function getAllocations(poolId: string): PoolAllocation[] { const rows = getDb() .prepare( - "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?", + "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?" ) .all(poolId); return rows.map(rowToAllocation); @@ -344,7 +350,7 @@ export function listPools(options?: { limit?: number; offset?: number }): { export function getPool(id: string): QuotaPool | null { const row = getDb() .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" ) .get(id); if (!row) return null; @@ -378,12 +384,12 @@ export function createPool(input: PoolCreate): QuotaPool { const doCreate = database.transaction(() => { database .prepare( - "INSERT INTO quota_pools (id, connection_id, name, group_id, created_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO quota_pools (id, connection_id, name, group_id, created_at) VALUES (?, ?, ?, ?, ?)" ) .run(id, primaryConnectionId, input.name, groupId, now); const insertConn = database.prepare( - "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)", + "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)" ); for (const connId of members) { insertConn.run(id, connId); @@ -392,7 +398,7 @@ export function createPool(input: PoolCreate): QuotaPool { if (input.allocations && input.allocations.length > 0) { const insertAlloc = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?)` ); for (const alloc of input.allocations) { insertAlloc.run( @@ -401,7 +407,7 @@ export function createPool(input: PoolCreate): QuotaPool { alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy, + alloc.policy ); } } @@ -416,7 +422,7 @@ export function createPool(input: PoolCreate): QuotaPool { group_id: groupId, created_at: now, }, - getAllocations(id), + getAllocations(id) ); // Phase B2: fire-and-forget combo sync; failures are logged but never thrown. @@ -427,6 +433,51 @@ export function createPool(input: PoolCreate): QuotaPool { return result; } +function allocationFingerprint(allocations: PoolAllocation[] = []): string { + return JSON.stringify( + allocations + .map((allocation) => ({ + apiKeyId: allocation.apiKeyId, + weight: allocation.weight, + capValue: allocation.capValue ?? null, + capUnit: allocation.capUnit ?? null, + policy: allocation.policy, + })) + .sort((left, right) => left.apiKeyId.localeCompare(right.apiKeyId)) + ); +} + +/** Idempotent pool management for automation and bounded CLI callers. */ +export function ensurePool(input: PoolCreate): EnsurePoolResult { + const members = + input.connectionIds && input.connectionIds.length > 0 + ? input.connectionIds + : [input.connectionId]; + const groupId = input.groupId || "group-demo"; + const existing = listPools().items.find((pool) => { + return pool.name === input.name && pool.groupId === groupId; + }); + + if (!existing) return { pool: createPool(input), created: true, updated: false }; + + const allocationsChanged = + input.allocations !== undefined && + allocationFingerprint(existing.allocations) !== allocationFingerprint(input.allocations); + const membersChanged = + existing.connectionIds.length !== members.length || + existing.connectionIds.some((id) => !members.includes(id)); + if (!allocationsChanged && !membersChanged) { + return { pool: existing, created: false, updated: false }; + } + + const update: PoolUpdate = { connectionIds: members }; + if (input.allocations !== undefined) update.allocations = input.allocations; + if (input.groupId !== undefined) update.groupId = input.groupId; + const updated = updatePool(existing.id, update); + if (!updated) throw new Error(`Quota pool disappeared during ensure: ${existing.id}`); + return { pool: updated, created: false, updated: true }; +} + /** * Update an existing pool's name, allocations, and/or member connections. * Returns updated pool, or null if pool not found. @@ -439,7 +490,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { const database = getDb(); const existing = database .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" ) .get(id); if (!existing) return null; @@ -465,7 +516,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { // Replace join rows. database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id); const insertConn = database.prepare( - "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)", + "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)" ); for (const connId of input.connectionIds) { insertConn.run(id, connId); @@ -480,7 +531,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(id); const insertAlloc = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?)` ); for (const alloc of input.allocations) { insertAlloc.run( @@ -489,7 +540,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy, + alloc.policy ); } } @@ -597,7 +648,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) // without requiring a manual re-save. Persists the normalized weights. const totalWeight = allocations.reduce( (s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0), - 0, + 0 ); const normalizedAllocations = totalWeight === 0 && allocations.length > 0 @@ -608,7 +659,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) // Defensive: fall back to [poolId] (single-pool semantics) if pool not found. const targetPool = database .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" ) .get(poolId); @@ -627,7 +678,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) const doUpsert = database.transaction(() => { const insert = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?)` ); for (const pid of poolIdsInGroup) { database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(pid); @@ -638,7 +689,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy, + alloc.policy ); } } @@ -655,13 +706,13 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) * Returns pairs of { poolId, allocation }. */ export function listAllocationsForApiKey( - apiKeyId: string, + apiKeyId: string ): Array<{ poolId: string; allocation: PoolAllocation }> { const rows = getDb() .prepare( `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations - WHERE api_key_id = ?`, + WHERE api_key_id = ?` ) .all(apiKeyId); return rows.map((row) => ({ poolId: row.pool_id, allocation: rowToAllocation(row) })); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 70e7eb04c3f..c49af936de0 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -614,6 +614,7 @@ export { listPools, getPool, getPoolsByGroup, + ensurePool, createPool, updatePool, deletePool, diff --git a/src/lib/omnirouteStatus.ts b/src/lib/omnirouteStatus.ts new file mode 100644 index 00000000000..74b8afdbaa5 --- /dev/null +++ b/src/lib/omnirouteStatus.ts @@ -0,0 +1,87 @@ +import { getDbInstance, pingDb } from "@/lib/db/core"; +import { listPools } from "@/lib/db/quotaPools"; + +interface ProviderStatusRow { + id: string; + provider: string; + is_active: number | boolean | null; + test_status: string | null; + last_error: string | null; +} + +function readProviderStatusRows(): ProviderStatusRow[] { + const db = getDbInstance(); + return db + .prepare( + "SELECT id, provider, is_active, test_status, last_error FROM provider_connections" + ) + .all(); +} + +export async function buildOmniRouteStatus() { + const [connections, circuitModule, quotaMonitorModule] = await Promise.all([ + Promise.resolve(readProviderStatusRows()), + import("@/shared/utils/circuitBreaker").catch(() => null), + import("../../open-sse/services/quotaMonitor").catch(() => null), + ]); + const pools = listPools().items; + const circuitStatuses = circuitModule?.getAllCircuitBreakerStatuses() ?? null; + const quotaSummary = quotaMonitorModule?.getQuotaMonitorSummary() ?? null; + const active = connections.filter( + (connection) => connection.is_active !== 0 && connection.is_active !== false + ); + const disabled = connections.filter( + (connection) => connection.is_active === 0 || connection.is_active === false + ); + const healthy = active.filter((connection) => connection.test_status === "active"); + + return { + gateway: pingDb() ? "healthy" : "degraded", + catalog: { available: true }, + providers: { + configured: connections.length, + active: active.length, + healthy: healthy.length, + disabled: disabled.length, + connections: connections.map((connection) => ({ + id: connection.id, + provider: connection.provider, + active: connection.is_active !== 0 && connection.is_active !== false, + health: + connection.is_active === 0 || connection.is_active === false + ? "disabled" + : connection.test_status === "active" + ? "healthy" + : "unknown", + failureState: connection.last_error ? "recent_error" : "none", + })), + }, + pools: { + count: pools.length, + allocations: pools.reduce((count, pool) => count + pool.allocations.length, 0), + items: pools.map((pool) => ({ + id: pool.id, + name: pool.name, + connectionIds: pool.connectionIds, + allocationCount: pool.allocations.length, + })), + }, + quotaMonitoring: { + authoritative: quotaSummary?.active ?? 0, + headerBased: 0, + configured: 0, + unsupported: quotaSummary ? Math.max(0, active.length - quotaSummary.active) : null, + status: quotaSummary?.active ? "partial" : "unknown", + }, + circuits: circuitStatuses + ? { + open: circuitStatuses.filter((c) => c.state === "OPEN").length, + halfOpen: circuitStatuses.filter((c) => c.state === "HALF_OPEN").length, + closed: circuitStatuses.filter((c) => c.state === "CLOSED").length, + source: "persisted circuit breaker registry", + } + : { status: "unknown", source: "resilience subsystem unavailable" }, + usage: { source: "usage_history and call_logs", liveRequestsExecuted: false }, + budgets: { source: "internal governance limits", upstreamQuotaClaims: false }, + }; +} diff --git a/src/lib/quota/providerCapabilities.ts b/src/lib/quota/providerCapabilities.ts new file mode 100644 index 00000000000..cc5ec32a767 --- /dev/null +++ b/src/lib/quota/providerCapabilities.ts @@ -0,0 +1,37 @@ +export interface ProviderCapabilities { + providerId: string; + quotaApi: boolean; + usageApi: boolean; + rateLimitHeaders: boolean; + streaming: boolean; + toolUse: boolean; + coding: boolean; + vision: boolean; + longContext: boolean; +} + +const registry = new Map(); + +export function registerProviderCapabilities(capabilities: ProviderCapabilities): void { + registry.set(capabilities.providerId, { ...capabilities }); +} + +export function getProviderCapabilities(providerId: string): ProviderCapabilities { + return ( + registry.get(providerId) ?? { + providerId, + quotaApi: false, + usageApi: false, + rateLimitHeaders: false, + streaming: false, + toolUse: false, + coding: false, + vision: false, + longContext: false, + } + ); +} + +export function listProviderCapabilities(): ProviderCapabilities[] { + return [...registry.values()].map((capabilities) => ({ ...capabilities })); +} diff --git a/src/lib/quota/providerQuotaTelemetry.ts b/src/lib/quota/providerQuotaTelemetry.ts new file mode 100644 index 00000000000..cd9a29f2ad1 --- /dev/null +++ b/src/lib/quota/providerQuotaTelemetry.ts @@ -0,0 +1,248 @@ +/** Provider-neutral quota telemetry contracts and header normalization. */ + +export type QuotaDimensionName = + | "requests" + | "tokens" + | "input_tokens" + | "output_tokens" + | "credits" + | "currency" + | "daily_requests" + | "weekly_requests" + | "monthly_requests" + | "rate_limit" + | "unknown"; + +export type QuotaValueSource = + "provider_api" | "response_headers" | "configured" | "estimated" | "unknown"; + +export type QuotaConfidence = "authoritative" | "high" | "medium" | "low" | "unknown"; + +export interface QuotaValue { + dimension: QuotaDimensionName; + limit?: number; + used?: number; + remaining?: number; + resetAt?: string; + unit?: string; + source: QuotaValueSource; + confidence: QuotaConfidence; +} + +export type ProviderQuotaStatus = + "healthy" | "approaching_limit" | "exhausted" | "unavailable" | "unknown"; + +export interface ProviderQuotaState { + providerId: string; + connectionId: string; + supported: boolean; + fetchedAt: string; + values: QuotaValue[]; + status: ProviderQuotaStatus; + error?: string; +} + +export interface ProviderConnectionForQuota { + id: string; + provider: string; + [key: string]: unknown; +} + +export interface ProviderQuotaMonitor { + providerId: string; + supportedDimensions(): Promise; + fetchQuotaState(connection: ProviderConnectionForQuota): Promise; +} + +export type QuotaSourceKind = + "provider_api" | "response_headers" | "configured" | "estimated" | "unknown"; + +export interface QuotaSourceAdapter { + kind: QuotaSourceKind; + supports(providerId: string): boolean; + read(connection: ProviderConnectionForQuota): Promise; +} + +const SOURCE_PRIORITY: QuotaSourceKind[] = [ + "provider_api", + "response_headers", + "configured", + "estimated", + "unknown", +]; + +function sourceRank(source: QuotaSourceKind): number { + return SOURCE_PRIORITY.indexOf(source); +} + +function finite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function statusForValues(values: QuotaValue[], approachingThreshold: number): ProviderQuotaStatus { + if (values.length === 0) return "unknown"; + + const exhausted = values.some( + (value) => + (finite(value.remaining) && value.remaining <= 0) || + (finite(value.used) && finite(value.limit) && value.used >= value.limit) + ); + if (exhausted) return "exhausted"; + + const approaching = values.some((value) => { + if (!finite(value.remaining) || !finite(value.limit) || value.limit <= 0) return false; + return value.remaining / value.limit <= approachingThreshold; + }); + return approaching ? "approaching_limit" : "healthy"; +} + +export function unknownQuotaState( + providerId: string, + connectionId: string, + fetchedAt = new Date().toISOString(), + error?: string +): ProviderQuotaState { + return { + providerId, + connectionId, + supported: false, + fetchedAt, + values: [], + status: "unknown", + ...(error ? { error } : {}), + }; +} + +/** + * Reads the best available source without treating missing provider data as exhaustion. + * Sources are selected per dimension, so a provider API can coexist with header data. + */ +export async function collectQuotaState( + connection: ProviderConnectionForQuota, + adapters: QuotaSourceAdapter[], + options: { approachingThreshold?: number; fetchedAt?: string } = {} +): Promise { + const fetchedAt = options.fetchedAt ?? new Date().toISOString(); + const byDimension = new Map(); + let supported = false; + let lastError: string | undefined; + + for (const adapter of adapters) { + if (!adapter.supports(connection.provider)) continue; + supported = true; + try { + const values = await adapter.read(connection); + for (const value of values) { + const current = byDimension.get(value.dimension); + if (!current || sourceRank(value.source) < sourceRank(current.source)) { + byDimension.set(value.dimension, value); + } + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + const values = [...byDimension.values()]; + return { + providerId: connection.provider, + connectionId: connection.id, + supported, + fetchedAt, + values, + status: + values.length > 0 + ? statusForValues(values, options.approachingThreshold ?? 0.2) + : supported + ? "unavailable" + : "unknown", + ...(lastError ? { error: lastError } : {}), + }; +} + +export interface RateLimitHeaderMapping { + limit?: string; + remaining?: string; + reset?: string; + retryAfter?: string; + dimension?: QuotaDimensionName; + unit?: string; +} + +export interface RateLimitSnapshot { + providerId: string; + connectionId: string; + capturedAt: string; + requestLimit?: number; + requestsRemaining?: number; + resetAt?: string; + retryAfterSeconds?: number; + source: "response_headers"; +} + +function headerValue(headers: Headers | Record, name?: string) { + if (!name) return undefined; + if (headers instanceof Headers) + return headers.get(name) ?? headers.get(name.toLowerCase()) ?? undefined; + const lower = name.toLowerCase(); + const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === lower); + return key ? headers[key] : undefined; +} + +function numberHeader(value: string | undefined): number | undefined { + if (value === undefined || value.trim() === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function resetHeaderToIso(value: string | undefined): string | undefined { + const parsed = numberHeader(value); + if (parsed === undefined) + return value && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : undefined; + const milliseconds = parsed > 10_000_000_000 ? parsed : parsed * 1000; + return new Date(milliseconds).toISOString(); +} + +export function parseRateLimitHeaders( + headers: Headers | Record, + providerId: string, + connectionId: string, + mapping: RateLimitHeaderMapping, + capturedAt = new Date().toISOString() +): { value: QuotaValue; snapshot: RateLimitSnapshot } | null { + const limit = numberHeader(headerValue(headers, mapping.limit)); + const remaining = numberHeader(headerValue(headers, mapping.remaining)); + const resetAt = resetHeaderToIso(headerValue(headers, mapping.reset)); + const retryAfterSeconds = numberHeader(headerValue(headers, mapping.retryAfter)); + if ( + limit === undefined && + remaining === undefined && + !resetAt && + retryAfterSeconds === undefined + ) { + return null; + } + + const dimension = mapping.dimension ?? "rate_limit"; + return { + value: { + dimension, + ...(limit !== undefined ? { limit } : {}), + ...(remaining !== undefined ? { remaining } : {}), + ...(resetAt ? { resetAt } : {}), + ...(mapping.unit ? { unit: mapping.unit } : {}), + source: "response_headers", + confidence: "high", + }, + snapshot: { + providerId, + connectionId, + capturedAt, + ...(limit !== undefined ? { requestLimit: limit } : {}), + ...(remaining !== undefined ? { requestsRemaining: remaining } : {}), + ...(resetAt ? { resetAt } : {}), + ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}), + source: "response_headers", + }, + }; +} diff --git a/src/lib/resilience/adaptiveCircuit.ts b/src/lib/resilience/adaptiveCircuit.ts new file mode 100644 index 00000000000..4d0282a93da --- /dev/null +++ b/src/lib/resilience/adaptiveCircuit.ts @@ -0,0 +1,60 @@ +export type AdaptiveCircuitState = "closed" | "open" | "half_open"; + +export interface AdaptiveCircuit { + state: AdaptiveCircuitState; + failureCount: number; + successCount: number; + lastFailureAt?: string; + openedAt?: string; + halfOpenAt?: string; + nextProbeAt?: string; + reason?: string; +} + +export function createAdaptiveCircuit(): AdaptiveCircuit { + return { state: "closed", failureCount: 0, successCount: 0 }; +} + +export function observeCircuit( + current: AdaptiveCircuit, + event: "failure" | "success" | "probe", + options: { now?: Date; failureThreshold?: number; cooldownMs?: number; reason?: string } = {} +): AdaptiveCircuit { + const now = options.now ?? new Date(); + const failureThreshold = options.failureThreshold ?? 3; + const cooldownMs = options.cooldownMs ?? 60_000; + const next = { ...current }; + + if (event === "failure") { + next.failureCount += 1; + next.successCount = 0; + next.lastFailureAt = now.toISOString(); + next.reason = options.reason; + if (next.state === "half_open" || next.failureCount >= failureThreshold) { + next.state = "open"; + next.openedAt = now.toISOString(); + next.nextProbeAt = new Date(now.getTime() + cooldownMs).toISOString(); + next.halfOpenAt = undefined; + } + return next; + } + + if (event === "probe") { + if (next.state === "open" && (!next.nextProbeAt || now >= new Date(next.nextProbeAt))) { + next.state = "half_open"; + next.halfOpenAt = now.toISOString(); + } + return next; + } + + next.successCount += 1; + if (next.state === "half_open" || next.successCount >= 1) { + next.state = "closed"; + next.failureCount = 0; + next.openedAt = undefined; + next.halfOpenAt = undefined; + next.nextProbeAt = undefined; + next.reason = undefined; + } + return next; +} diff --git a/src/lib/resilience/failureClassification.ts b/src/lib/resilience/failureClassification.ts new file mode 100644 index 00000000000..8bea63b82b3 --- /dev/null +++ b/src/lib/resilience/failureClassification.ts @@ -0,0 +1,75 @@ +export type ProviderFailureType = + | "authentication_error" + | "rate_limit" + | "quota_exhausted" + | "timeout" + | "network_error" + | "provider_5xx" + | "invalid_request" + | "model_unavailable" + | "permission_error" + | "unknown"; + +export interface ProviderFailure { + type: ProviderFailureType; + retryable: boolean; + providerId: string; + connectionId?: string; + statusCode?: number; + retryAfter?: number; + message: string; +} + +export function classifyProviderFailure(input: { + providerId: string; + connectionId?: string; + statusCode?: number; + code?: string; + message?: string; + retryAfter?: number; +}): ProviderFailure { + const message = input.message?.trim() || "Provider request failed"; + const normalized = `${input.code ?? ""} ${message}`.toLowerCase(); + const status = input.statusCode; + let type: ProviderFailureType = "unknown"; + let retryable = false; + + if ( + status === 401 || + status === 403 || + /invalid.*(key|token)|unauthori[sz]ed|forbidden/.test(normalized) + ) { + type = + status === 403 || normalized.includes("permission") + ? "permission_error" + : "authentication_error"; + } else if (status === 408 || status === 504 || /timeout|timed out|etimedout/.test(normalized)) { + type = "timeout"; + retryable = true; + } else if (status === 429 || /rate.?limit|too many requests|retry.?after/.test(normalized)) { + type = /quota|insufficient balance|credits exhausted|balance is \$0/.test(normalized) + ? "quota_exhausted" + : "rate_limit"; + retryable = type === "rate_limit"; + } else if (status !== undefined && status >= 500) { + type = "provider_5xx"; + retryable = true; + } else if (status === 400 || /invalid request|malformed|unsupported parameter/.test(normalized)) { + type = "invalid_request"; + } else if (status === 404 || /model unavailable|model not found|unknown model/.test(normalized)) { + type = "model_unavailable"; + } else if (/network|econnreset|econnrefused|enotfound|fetch failed|socket/.test(normalized)) { + type = "network_error"; + retryable = true; + } + + return { + type, + retryable, + providerId: input.providerId, + ...(input.connectionId ? { connectionId: input.connectionId } : {}), + ...(status !== undefined ? { statusCode: status } : {}), + ...(input.retryAfter !== undefined ? { retryAfter: input.retryAfter } : {}), + message, + }; +} diff --git a/src/lib/routing/adaptiveRouting.ts b/src/lib/routing/adaptiveRouting.ts new file mode 100644 index 00000000000..cfbaffbdf1a --- /dev/null +++ b/src/lib/routing/adaptiveRouting.ts @@ -0,0 +1,153 @@ +import type { ProviderFailure } from "@/lib/resilience/failureClassification"; +import type { ProviderQuotaStatus } from "@/lib/quota/providerQuotaTelemetry"; + +export type AllocationDecision = "allow" | "warn" | "deny"; +export type CircuitState = "closed" | "open" | "half_open"; + +export interface RoutingCandidate { + providerId: string; + modelId: string; + capabilityScore: number; + allocation: AllocationDecision; + healthScore: number; + circuit: CircuitState; + quota: ProviderQuotaStatus; + latencyMs?: number; + errorRate?: number; + modelPreference?: number; + costPreference?: number; +} + +export interface RoutingExplanation { + providerId: string; + modelId: string; + score: number; + eligible: boolean; + reasons: string[]; + factors: Record; +} + +export interface RankedRoutingResult { + selected: RoutingExplanation | null; + candidates: RoutingExplanation[]; +} + +function clamp(value: number, fallback = 0): number { + return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback; +} + +function quotaFactor(quota: ProviderQuotaStatus): number { + switch (quota) { + case "exhausted": + return 0; + case "approaching_limit": + return 0.65; + case "unavailable": + return 0.9; + case "unknown": + return 1; + case "healthy": + return 1; + default: + return 1; + } +} + +function latencyFactor(latencyMs?: number): number { + if (!Number.isFinite(latencyMs) || latencyMs === undefined) return 1; + return Math.max(0.4, 1 - Math.min(latencyMs, 30_000) / 50_000); +} + +export function scoreCandidate(candidate: RoutingCandidate): RoutingExplanation { + const capability = clamp(candidate.capabilityScore); + const allocation = + candidate.allocation === "deny" ? 0 : candidate.allocation === "warn" ? 0.85 : 1; + const health = clamp(candidate.healthScore, 0.5); + const reliability = 1 - clamp(candidate.errorRate ?? 0); + const latency = latencyFactor(candidate.latencyMs); + const preference = clamp(candidate.modelPreference, 0.5); + const cost = clamp(candidate.costPreference, 1); + const quota = quotaFactor(candidate.quota); + const circuit = candidate.circuit === "open" ? 0 : candidate.circuit === "half_open" ? 0.5 : 1; + const score = Number( + ( + capability * + allocation * + health * + reliability * + latency * + preference * + cost * + quota * + circuit + ).toFixed(6) + ); + const reasons = [ + capability >= 0.8 ? "capability match" : "partial capability match", + candidate.allocation === "allow" + ? "allocation permitted" + : candidate.allocation === "warn" + ? "allocation permitted with warning" + : "allocation denied", + health >= 0.8 ? "provider healthy" : "provider health degraded", + `circuit ${candidate.circuit}`, + candidate.quota === "unknown" + ? "quota state unknown but not exhausted" + : `quota ${candidate.quota}`, + ]; + if (candidate.latencyMs !== undefined) + reasons.push(`latency ${Math.round(candidate.latencyMs)}ms`); + if (candidate.errorRate !== undefined) + reasons.push(`${Math.round(candidate.errorRate * 100)}% recent errors`); + return { + providerId: candidate.providerId, + modelId: candidate.modelId, + score, + eligible: + score > 0 && + candidate.allocation !== "deny" && + candidate.circuit !== "open" && + candidate.quota !== "exhausted", + reasons, + factors: { + capability, + allocation, + health, + reliability, + latency, + preference, + cost, + quota, + circuit, + }, + }; +} + +export function rankCandidates(candidates: RoutingCandidate[]): RankedRoutingResult { + const ranked = candidates.map(scoreCandidate).sort((a, b) => b.score - a.score); + return { selected: ranked.find((candidate) => candidate.eligible) ?? null, candidates: ranked }; +} + +export interface FailoverPolicy { + maxProviderAttempts: number; + allowCrossProviderFallback: boolean; + retryRateLimited: boolean; + retryTimeouts: boolean; +} + +export const DEFAULT_FAILOVER_POLICY: FailoverPolicy = { + maxProviderAttempts: 3, + allowCrossProviderFallback: true, + retryRateLimited: true, + retryTimeouts: true, +}; + +export function shouldFailover( + failure: ProviderFailure, + policy = DEFAULT_FAILOVER_POLICY +): boolean { + if (!policy.allowCrossProviderFallback || !failure.retryable) return false; + if (failure.type === "rate_limit") return policy.retryRateLimited; + if (failure.type === "timeout") return policy.retryTimeouts; + return failure.type === "network_error" || failure.type === "provider_5xx"; +} diff --git a/src/lib/usage/budgetGuard.ts b/src/lib/usage/budgetGuard.ts new file mode 100644 index 00000000000..034c3c9da39 --- /dev/null +++ b/src/lib/usage/budgetGuard.ts @@ -0,0 +1,66 @@ +export type BudgetPeriod = "daily" | "weekly" | "monthly"; +export type BudgetDecision = "allow" | "warn" | "deny"; + +export interface InternalBudgetLimit { + id: string; + scope: "global" | "provider" | "model" | "pool"; + providerId?: string; + modelId?: string; + poolId?: string; + period: BudgetPeriod; + limitType: "currency" | "tokens" | "requests"; + limitValue: number; + warningThreshold: number; + enabled: boolean; +} + +export interface BudgetUsage { + currency: number; + tokens: number; + requests: number; +} + +export interface BudgetEvaluation { + decision: BudgetDecision; + limit?: InternalBudgetLimit; + used: number; + remaining?: number; + reason: string; +} + +export function evaluateBudget( + limit: InternalBudgetLimit | undefined, + usage: BudgetUsage +): BudgetEvaluation { + if (!limit || !limit.enabled) + return { decision: "allow", used: 0, reason: "No enabled internal budget applies." }; + const used = usage[limit.limitType]; + if (!Number.isFinite(used) || limit.limitValue <= 0) { + return { + decision: "deny", + limit, + used: 0, + remaining: 0, + reason: "Internal budget configuration is invalid.", + }; + } + const remaining = Math.max(0, limit.limitValue - used); + if (used >= limit.limitValue) + return { decision: "deny", limit, used, remaining, reason: "Internal budget exhausted." }; + if (used / limit.limitValue >= limit.warningThreshold) { + return { + decision: "warn", + limit, + used, + remaining, + reason: `Internal budget is ${Math.round((used / limit.limitValue) * 100)}% consumed.`, + }; + } + return { + decision: "allow", + limit, + used, + remaining, + reason: "Internal budget permits the request.", + }; +} diff --git a/src/lib/usage/modelPricingRegistry.ts b/src/lib/usage/modelPricingRegistry.ts new file mode 100644 index 00000000000..fae70367f8b --- /dev/null +++ b/src/lib/usage/modelPricingRegistry.ts @@ -0,0 +1,52 @@ +export interface ModelPricing { + providerId: string; + modelId: string; + inputPricePerMillionTokens?: number; + outputPricePerMillionTokens?: number; + source?: string; + effectiveAt?: string; +} + +export class ModelPricingRegistry { + private readonly entries = new Map(); + + private key(providerId: string, modelId: string): string { + return `${providerId.trim().toLowerCase()}\0${modelId.trim().toLowerCase()}`; + } + + set(pricing: ModelPricing): void { + this.entries.set(this.key(pricing.providerId, pricing.modelId), { ...pricing }); + } + + get(providerId: string, modelId: string): ModelPricing | undefined { + return this.entries.get(this.key(providerId, modelId)); + } + + estimate( + providerId: string, + modelId: string, + inputTokens = 0, + outputTokens = 0 + ): number | undefined { + const pricing = this.get(providerId, modelId); + if (!pricing) return undefined; + const input = + pricing.inputPricePerMillionTokens === undefined + ? 0 + : (inputTokens * pricing.inputPricePerMillionTokens) / 1_000_000; + const output = + pricing.outputPricePerMillionTokens === undefined + ? 0 + : (outputTokens * pricing.outputPricePerMillionTokens) / 1_000_000; + if ( + pricing.inputPricePerMillionTokens === undefined && + pricing.outputPricePerMillionTokens === undefined + ) + return undefined; + return Number((input + output).toFixed(12)); + } + + list(): ModelPricing[] { + return [...this.entries.values()].map((entry) => ({ ...entry })); + } +} diff --git a/src/lib/usage/usageLedger.ts b/src/lib/usage/usageLedger.ts new file mode 100644 index 00000000000..4f5f47c12cc --- /dev/null +++ b/src/lib/usage/usageLedger.ts @@ -0,0 +1,78 @@ +import type { ModelPricingRegistry } from "./modelPricingRegistry.js"; + +export type UsageStatus = "success" | "failed" | "rate_limited" | "timeout" | "cancelled"; + +export interface UsageRecord { + id: string; + providerId: string; + connectionId?: string; + modelId: string; + poolId?: string; + allocation?: number; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + estimatedCostUsd?: number; + latencyMs: number; + status: UsageStatus; + createdAt: string; +} + +export interface UsageRecordInput extends Omit { + totalTokens?: number; + estimatedCostUsd?: number; +} + +export function createUsageRecord( + input: UsageRecordInput, + pricing?: ModelPricingRegistry +): UsageRecord { + const inputTokens = Number.isFinite(input.inputTokens) ? input.inputTokens : undefined; + const outputTokens = Number.isFinite(input.outputTokens) ? input.outputTokens : undefined; + const totalTokens = + input.totalTokens ?? + (inputTokens !== undefined || outputTokens !== undefined + ? (inputTokens ?? 0) + (outputTokens ?? 0) + : undefined); + const estimatedCostUsd = + input.estimatedCostUsd ?? + (pricing && inputTokens !== undefined && outputTokens !== undefined + ? pricing.estimate(input.providerId, input.modelId, inputTokens, outputTokens) + : undefined); + return { + ...input, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(estimatedCostUsd !== undefined ? { estimatedCostUsd } : {}), + }; +} + +export function summarizeUsage(records: UsageRecord[]) { + return records.reduce( + (summary, record) => { + summary.requests += 1; + summary.successes += record.status === "success" ? 1 : 0; + summary.failures += record.status === "success" ? 0 : 1; + summary.inputTokens += record.inputTokens ?? 0; + summary.outputTokens += record.outputTokens ?? 0; + summary.totalTokens += record.totalTokens ?? 0; + summary.estimatedCostUsd = + summary.estimatedCostUsd === undefined || record.estimatedCostUsd === undefined + ? undefined + : summary.estimatedCostUsd + record.estimatedCostUsd; + summary.latencyMs += record.latencyMs; + return summary; + }, + { + requests: 0, + successes: 0, + failures: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0 as number | undefined, + latencyMs: 0, + } + ); +} diff --git a/tests/unit/adaptive-circuit-budget-ledger.test.ts b/tests/unit/adaptive-circuit-budget-ledger.test.ts new file mode 100644 index 00000000000..3ea22a4829b --- /dev/null +++ b/tests/unit/adaptive-circuit-budget-ledger.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { observeCircuit, createAdaptiveCircuit } from "@/lib/resilience/adaptiveCircuit"; +import { evaluateBudget } from "@/lib/usage/budgetGuard"; +import { ModelPricingRegistry } from "@/lib/usage/modelPricingRegistry"; +import { createUsageRecord, summarizeUsage } from "@/lib/usage/usageLedger"; + +test("adaptive circuit opens, probes, and closes after recovery", () => { + const now = new Date("2026-01-01T00:00:00.000Z"); + let circuit = createAdaptiveCircuit(); + circuit = observeCircuit(circuit, "failure", { + now, + failureThreshold: 2, + cooldownMs: 1000, + reason: "timeout", + }); + circuit = observeCircuit(circuit, "failure", { + now: new Date(now.getTime() + 10), + failureThreshold: 2, + cooldownMs: 1000, + reason: "timeout", + }); + assert.equal(circuit.state, "open"); + circuit = observeCircuit(circuit, "probe", { now: new Date(now.getTime() + 1011) }); + assert.equal(circuit.state, "half_open"); + circuit = observeCircuit(circuit, "success", { now: new Date(now.getTime() + 1002) }); + assert.equal(circuit.state, "closed"); + assert.equal(circuit.failureCount, 0); +}); + +test("internal budget returns allow, warn, and deny without upstream quota claims", () => { + const limit = { + id: "b", + scope: "global" as const, + period: "daily" as const, + limitType: "currency" as const, + limitValue: 10, + warningThreshold: 0.75, + enabled: true, + }; + assert.equal(evaluateBudget(limit, { currency: 2, tokens: 0, requests: 0 }).decision, "allow"); + assert.equal(evaluateBudget(limit, { currency: 8, tokens: 0, requests: 0 }).decision, "warn"); + assert.equal(evaluateBudget(limit, { currency: 10, tokens: 0, requests: 0 }).decision, "deny"); +}); + +test("unknown pricing remains unknown while configured pricing is estimated", () => { + const registry = new ModelPricingRegistry(); + assert.equal(registry.estimate("codex", "unknown", 1000, 1000), undefined); + registry.set({ + providerId: "codex", + modelId: "gpt-5", + inputPricePerMillionTokens: 1, + outputPricePerMillionTokens: 2, + source: "admin", + }); + assert.equal(registry.estimate("codex", "gpt-5", 1000, 1000), 0.003); + const record = createUsageRecord( + { + id: "r1", + providerId: "codex", + modelId: "gpt-5", + inputTokens: 100, + outputTokens: 50, + latencyMs: 10, + status: "success", + createdAt: "2026-01-01T00:00:00.000Z", + }, + registry + ); + assert.equal(record.totalTokens, 150); + assert.equal(record.estimatedCostUsd, 0.0002); + const summary = summarizeUsage([record]); + assert.equal(summary.requests, 1); + assert.equal(summary.successes, 1); + assert.equal(summary.estimatedCostUsd, 0.0002); +}); diff --git a/tests/unit/cli-machine-token.test.ts b/tests/unit/cli-machine-token.test.ts index 8b8d6f762a5..97591e967f0 100644 --- a/tests/unit/cli-machine-token.test.ts +++ b/tests/unit/cli-machine-token.test.ts @@ -9,12 +9,13 @@ test("cliToken.mjs pode ser importado sem erro", async () => { assert.equal(mod.CLI_TOKEN_HEADER, "x-omniroute-cli-token"); }); -test("getCliToken retorna string de 32 chars ou string vazia", async () => { +test("getCliToken retorna string de 64 chars ou string vazia", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken(); assert.ok(typeof token === "string"); - // Pode ser "" se node-machine-id falhar, ou 32 chars se funcionar. - assert.ok(token === "" || token.length === 32, `expected 0 or 32 chars, got ${token.length}`); + // Pode ser "" se node-machine-id falhar, ou 64 chars se funcionar + // (HMAC-SHA256 digest hex — see #10148 cliToken hardening). + assert.ok(token === "" || token.length === 64, `expected 0 or 64 chars, got ${token.length}`); }); test("getCliToken retorna mesmo valor em chamadas repetidas (cache)", async () => { @@ -28,7 +29,8 @@ test("getCliToken produz apenas hex lowercase se não-vazio", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken(); if (token.length > 0) { - assert.match(token, /^[0-9a-f]{32}$/); + // HMAC-SHA256 digest hex = 64 chars (#10148 cliToken hardening). + assert.match(token, /^[0-9a-f]{64}$/); } }); @@ -70,17 +72,13 @@ test("isLoopback rejeita IP público", async () => { test("token derivado de machine-id diferente produz hash diferente", () => { const SALT = "omniroute-cli-auth-v1"; - const hash = (mid: string) => - crypto - .createHash("sha256") - .update(mid + SALT) - .digest("hex") - .substring(0, 32); + // Mirror the production derivation (#10148): HMAC-SHA256(machineId, SALT) hex. + const hash = (mid: string) => crypto.createHmac("sha256", mid).update(SALT).digest("hex"); const t1 = hash("machine-id-host-A"); const t2 = hash("machine-id-host-B"); assert.notEqual(t1, t2); - assert.match(t1, /^[0-9a-f]{32}$/); - assert.match(t2, /^[0-9a-f]{32}$/); + assert.match(t1, /^[0-9a-f]{64}$/); + assert.match(t2, /^[0-9a-f]{64}$/); }); test("OMNIROUTE_DISABLE_CLI_TOKEN desabilita auth (estrutura verificada)", async () => { diff --git a/tests/unit/quota-telemetry-adaptive-routing.test.ts b/tests/unit/quota-telemetry-adaptive-routing.test.ts new file mode 100644 index 00000000000..2f205a62de3 --- /dev/null +++ b/tests/unit/quota-telemetry-adaptive-routing.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectQuotaState, + parseRateLimitHeaders, + unknownQuotaState, +} from "@/lib/quota/providerQuotaTelemetry"; +import { classifyProviderFailure } from "@/lib/resilience/failureClassification"; +import { rankCandidates, shouldFailover } from "@/lib/routing/adaptiveRouting"; + +test("unknown quota remains unknown and does not disable routing", async () => { + const state = await collectQuotaState({ id: "c1", provider: "codex" }, []); + assert.equal(state.supported, false); + assert.equal(state.status, "unknown"); + assert.deepEqual(state.values, []); + + const ranked = rankCandidates([ + { + providerId: "codex", + modelId: "gpt-5", + capabilityScore: 1, + allocation: "allow", + healthScore: 1, + circuit: "closed", + quota: state.status, + }, + ]); + assert.equal(ranked.selected?.providerId, "codex"); +}); + +test("quota source priority prefers authoritative values per dimension", async () => { + const state = await collectQuotaState({ id: "c1", provider: "codex" }, [ + { + kind: "estimated", + supports: () => true, + read: async () => [ + { dimension: "requests", remaining: 1, source: "estimated", confidence: "low" }, + ], + }, + { + kind: "provider_api", + supports: () => true, + read: async () => [ + { + dimension: "requests", + limit: 100, + remaining: 80, + source: "provider_api", + confidence: "authoritative", + }, + ], + }, + ]); + assert.equal(state.status, "healthy"); + assert.equal(state.values[0]?.source, "provider_api"); + assert.equal(state.values[0]?.remaining, 80); +}); + +test("response headers normalize only through an explicit provider mapping", () => { + const parsed = parseRateLimitHeaders( + { "x-limit": "100", "x-remaining": "12", "x-reset": "1700000000" }, + "provider", + "connection", + { limit: "x-limit", remaining: "x-remaining", reset: "x-reset", dimension: "requests" } + ); + assert.equal(parsed?.value.dimension, "requests"); + assert.equal(parsed?.value.remaining, 12); + assert.equal(parsed?.snapshot.requestLimit, 100); + assert.equal(parseRateLimitHeaders({}, "p", "c", { limit: "missing" }), null); +}); + +test("exhausted quota and open circuits are excluded; approaching quota is penalized", () => { + const result = rankCandidates([ + { + providerId: "exhausted", + modelId: "m", + capabilityScore: 1, + allocation: "allow", + healthScore: 1, + circuit: "closed", + quota: "exhausted", + }, + { + providerId: "open", + modelId: "m", + capabilityScore: 1, + allocation: "allow", + healthScore: 1, + circuit: "open", + quota: "unknown", + }, + { + providerId: "approaching", + modelId: "m", + capabilityScore: 1, + allocation: "allow", + healthScore: 1, + circuit: "closed", + quota: "approaching_limit", + }, + { + providerId: "healthy", + modelId: "m", + capabilityScore: 1, + allocation: "allow", + healthScore: 1, + circuit: "closed", + quota: "unknown", + }, + ]); + assert.equal(result.selected?.providerId, "healthy"); + assert.equal( + result.candidates.find((candidate) => candidate.providerId === "exhausted")?.eligible, + false + ); + assert.ok( + (result.candidates.find((candidate) => candidate.providerId === "approaching")?.score ?? 1) < 1 + ); +}); + +test("failure classification retries transient failures but not authentication errors", () => { + const timeout = classifyProviderFailure({ + providerId: "codex", + statusCode: 504, + message: "upstream timeout", + }); + const auth = classifyProviderFailure({ + providerId: "codex", + statusCode: 401, + message: "invalid token", + }); + assert.equal(timeout.type, "timeout"); + assert.equal(shouldFailover(timeout), true); + assert.equal(auth.type, "authentication_error"); + assert.equal(shouldFailover(auth), false); +}); + +test("unknown state helper is explicit", () => { + const state = unknownQuotaState("p", "c", "2026-01-01T00:00:00.000Z"); + assert.deepEqual(state, { + providerId: "p", + connectionId: "c", + supported: false, + fetchedAt: "2026-01-01T00:00:00.000Z", + values: [], + status: "unknown", + }); +}); From db3f2422479a7c72a4878bfd99ccd697d69708d2 Mon Sep 17 00:00:00 2001 From: Thinh Date: Mon, 17 Aug 2026 18:02:29 +0700 Subject: [PATCH 2/4] cherry-pick(pr-10354): feat(combo): Auto-Combo snapshot generation/duplication in the UX Inspired-by: diegosouzapw/OmniRoute#10354 --- 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 | 7 +- 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, 1411 insertions(+), 248 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 45b0397208e..88d708cabd3 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"; @@ -587,6 +588,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[] { @@ -755,6 +811,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, @@ -764,7 +821,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 00000000000..0e3c3520115 --- /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 a072fa8f1b3..c3d69551487 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 8d361513191..1fe9f581f90 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 00000000000..808031cb372 --- /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 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; + let counter = 1; + while (existingNames.has(newName)) { + counter++; + newName = `${baseName} ${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 b58f61bbabd..e7f025b392a 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 1a49884a0c8..7526dea4c41 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 bce84761e4a..208aac57885 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 9b5fdc140ed..22c8d9b69d8 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 5f1fae02d27..6b61a56bd89 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 2cb371a2eb0..41ac32f28e7 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 c16000ba470..c4738e02060 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 90291a11d9e..4c4bad91a5a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3715,7 +3715,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", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f02a0e0b782..ccc28adf0a5 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 32d18c9b511..bf04a953f16 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 e7d9d827d23..71e121b6d63 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 576571b92ad..b4cfe9c3fc4 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 c205f0c59c6..b693458aafc 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 ce0e3cb5a95..3d087b590e3 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 8d69d8c3eb0..c90dd4aa304 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 97f5dadcf4b..02f133b3c8e 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 18a983f487d..aa69b881e2a 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 013930c876f..1629eae542d 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 0f7dc2228c5..9bae449a385 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 de11484c517..abddd1a6aec 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 e11c3d10898..2030490e493 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 f8883dc3874..12c1abefeb6 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 22c33d4e18b..63d65b7a10c 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 415ea228b4b..2ca51dbb0b7 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 4ca356e0992..3b938cdfd38 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 bfbd44e520e..1baf370960c 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 bc9d2c22b43..79145af6407 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 54f9d8c27a2..a452ad7c144 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -3715,7 +3715,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 a4d56f614b6..2976743b44a 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 6c742b49040..28f680e1b53 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 5fb1a6dd7fa..e12444906b9 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 bd2956389ec..da4ee07c87c 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 3981be85015..ffd1406bc78 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 94d93eef16f..051a8d94008 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 809df380a0e..965ee232197 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 960c9cf2288..42958cd5cf2 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 2090af45976..27aba7e6b40 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 db5327209ff..6a2fe59fa9e 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 bd875141ea3..59ac14e18d6 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 30be26803cb..335edeecaa9 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 2295a1e51c0..f0a3e1156ec 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -3715,7 +3715,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 edffcd2bd0a..2f2a160c1f3 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 c7447be5ba3..d4d8908f193 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 00000000000..ea304e8a857 --- /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 00000000000..447c26583b6 --- /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( + /^static-[\w-]+(\s+\d+)?$/.test(body.name), + `Combo name should be 'static-