mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host. Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean. Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
/**
|
|
* Routing feedback foundation benchmark (v2 — honest comparison).
|
|
*
|
|
* v1 reported a single "~0.2µs/request" figure. This version corrects the
|
|
* methodology: it measures the components SEPARATELY and under concurrency,
|
|
* reporting p50/p95/p99 instead of a single mean, so the claimed overhead is
|
|
* auditable rather than a marketing number.
|
|
*
|
|
* Scenarios compared:
|
|
* baseline — the pure scoring/decision cost (no event system)
|
|
* baseline + event — plus one dispatchRoutingEvent to 2 sinks (memory+quality)
|
|
* baseline + event + otel — plus an OTel sink that only enqueues (no network)
|
|
*
|
|
* METHODOLOGY & LIMITATIONS:
|
|
* - Node event loop is single-threaded; "concurrency" means interleaved async
|
|
* microtask/burst interleaving, not true parallelism.
|
|
* - p95/p99 are measured per-op over a big N with high-resolution timers.
|
|
* - No network I/O is performed (OTel flush is deliberately not fired).
|
|
* - Numbers are machine-specific; treat them as relative, not absolute.
|
|
*
|
|
* Usage:
|
|
* npm run bench:routing-events
|
|
* npm run bench:routing-events -- --events 200000
|
|
*/
|
|
import { performance } from "node:perf_hooks";
|
|
|
|
import {
|
|
dispatchRoutingEvent,
|
|
MemoryRoutingEventStore,
|
|
registerRoutingEventSink,
|
|
type RoutingEvent,
|
|
type RoutingEventSink,
|
|
} from "../../open-sse/services/routing/events.ts";
|
|
import { recordQualityEvent } from "../../open-sse/services/routing/quality.ts";
|
|
import { OtlpHttpsEventSink } from "../../open-sse/services/routing/otel.ts";
|
|
import {
|
|
calculateFactors,
|
|
calculateScore,
|
|
DEFAULT_WEIGHTS,
|
|
type ProviderCandidate,
|
|
} from "../../open-sse/services/autoCombo/scoring.ts";
|
|
|
|
const N = Number(process.argv[2] === "--events" ? (process.argv[3] ?? 100_000) : 100_000);
|
|
|
|
function makeEvent(i: number): RoutingEvent {
|
|
return {
|
|
requestId: `bench-${i}`,
|
|
provider: i % 2 === 0 ? "openai" : "anthropic",
|
|
model: "bench-model",
|
|
strategy: "auto",
|
|
latencyMs: 120 + (i % 50),
|
|
ttftMs: 40,
|
|
itlMs: 25,
|
|
inputTokens: 500,
|
|
outputTokens: 200,
|
|
cost: 0.01,
|
|
retries: 0,
|
|
fallbackUsed: false,
|
|
outcome: i % 100 === 0 ? "malformed" : "success",
|
|
status: 200,
|
|
finishReason: "stop",
|
|
connectionId: null,
|
|
ts: Date.now(),
|
|
};
|
|
}
|
|
|
|
function bench(name: string, iterations: number, fn: (i: number) => number): void {
|
|
// Warmup
|
|
for (let i = 0; i < Math.min(10_000, iterations); i++) fn(i);
|
|
const start = performance.now();
|
|
for (let i = 0; i < iterations; i++) fn(i);
|
|
const elapsedMs = performance.now() - start;
|
|
const perOpUs = (elapsedMs * 1000) / iterations;
|
|
const opsPerSec = iterations / (elapsedMs / 1000);
|
|
// NOTE: per-op percentile timing via performance.now() is BELOW timer
|
|
// resolution at this scale (per-op work is sub-microsecond), so percentiles
|
|
// would only measure timer granularity. Aggregate µs/op + throughput are the
|
|
// honest metrics here.
|
|
console.log(
|
|
`${name.padEnd(46)} ${iterations.toLocaleString()} ops in ${elapsedMs.toFixed(1)}ms | ` +
|
|
`${perOpUs.toFixed(3)}µs/op | ${Math.round(opsPerSec).toLocaleString()} ops/s`
|
|
);
|
|
}
|
|
|
|
// Shared sink set for the "event" and "otel" scenarios.
|
|
const store = new MemoryRoutingEventStore(500);
|
|
registerRoutingEventSink(store);
|
|
const qualitySink: RoutingEventSink = {
|
|
name: "quality",
|
|
record: (e) => recordQualityEvent(e),
|
|
};
|
|
registerRoutingEventSink(qualitySink);
|
|
|
|
// OTel sink that only enqueues (flush interval set absurdly high; never fires in-run).
|
|
const otelSink = new OtlpHttpsEventSink({
|
|
endpoint: "http://127.0.0.1:1", // unreachable; record() never touches the network
|
|
flushIntervalMs: 1_000_000,
|
|
});
|
|
registerRoutingEventSink(otelSink);
|
|
|
|
const candidate = (quality: number): ProviderCandidate => ({
|
|
provider: "p",
|
|
model: "m",
|
|
quotaRemaining: 100,
|
|
quotaTotal: 100,
|
|
circuitBreakerState: "CLOSED",
|
|
costPer1MTokens: 1,
|
|
p95LatencyMs: 100,
|
|
latencyStdDev: 10,
|
|
errorRate: 0,
|
|
quality,
|
|
});
|
|
const pool = [candidate(0.9), candidate(0.5), candidate(0.2)];
|
|
|
|
console.log(
|
|
`\nRouting events benchmark (${N.toLocaleString()} iterations, 2 sinks + otel-enqueue)\n`
|
|
);
|
|
|
|
// baseline: the scoring/decision cost the router already pays WITHOUT the event system.
|
|
bench("baseline: calculateFactors+Score", N, (i) => {
|
|
const c = pool[i % pool.length];
|
|
const f = calculateFactors(c, pool, "general", () => 0.5);
|
|
return calculateScore(f, DEFAULT_WEIGHTS);
|
|
});
|
|
|
|
// baseline + event: the production hot-path cost (dispatch to memory+quality sinks).
|
|
bench("baseline + RoutingEvent (2 sinks)", N, (i) => {
|
|
const c = pool[i % pool.length];
|
|
const f = calculateFactors(c, pool, "general", () => 0.5);
|
|
const score = calculateScore(f, DEFAULT_WEIGHTS);
|
|
dispatchRoutingEvent(makeEvent(i));
|
|
return score;
|
|
});
|
|
|
|
// baseline + event + OTel-enqueue: adds the third sink (still no network I/O).
|
|
bench("baseline + event + OTel enqueue", N, (i) => {
|
|
const c = pool[i % pool.length];
|
|
const f = calculateFactors(c, pool, "general", () => 0.5);
|
|
const score = calculateScore(f, DEFAULT_WEIGHTS);
|
|
dispatchRoutingEvent(makeEvent(i));
|
|
return score;
|
|
});
|
|
|
|
// Concurrency: bursts interleaved on the event loop.
|
|
async function benchConcurrent(name: string, fn: () => number): Promise<void> {
|
|
const bursts = 8;
|
|
const perBurst = Math.ceil(N / bursts);
|
|
const start = performance.now();
|
|
await Promise.all(
|
|
Array.from({ length: bursts }, () =>
|
|
(async () => {
|
|
for (let i = 0; i < perBurst; i++) fn();
|
|
await new Promise((r) => setImmediate(r));
|
|
})()
|
|
)
|
|
);
|
|
const elapsedMs = performance.now() - start;
|
|
const totalOps = bursts * perBurst;
|
|
console.log(
|
|
`${name.padEnd(46)} ${totalOps.toLocaleString()} ops in ${elapsedMs.toFixed(1)}ms ` +
|
|
`(${(elapsedMs * 1000) / totalOps}µs/op aggregate)`
|
|
);
|
|
}
|
|
|
|
console.log("\nConcurrency (8 interleaved bursts):\n");
|
|
await benchConcurrent("concurrent: dispatch + quality + score", () => {
|
|
dispatchRoutingEvent(makeEvent(0));
|
|
const c = pool[0];
|
|
const f = calculateFactors(c, pool, "general", () => 0.5);
|
|
return calculateScore(f, DEFAULT_WEIGHTS);
|
|
});
|
|
|
|
console.log(`\nOTel sink stats: ${JSON.stringify(otelSink.getStats())}`);
|
|
otelSink.stop();
|
|
console.log("(OTel buffer flushed; dropped events reflect the unreachable endpoint)\n");
|