mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
Introduce runtime-configurable payload mutation/filter rules with file reload support and a settings API so upstream request bodies can be customized per model and protocol without restarts. Expand search support with Google PSE, Linkup, SearchAPI, and SearXNG, including validation, routing, analytics costing, MCP schema updates, and search-type-aware provider selection. Update Pollinations to support anonymous access, endpoint failover, and the latest public model lineup. Add OmniRoute response metadata headers/SSE comments, per-connection model exclusion rules, combo tag-based routing, buffered spend writes, and scheduled daily/weekly/monthly budget resets. Update model catalog and dashboard UIs to surface source labels and hide models excluded by all active connections.
209 lines
5.9 KiB
TypeScript
209 lines
5.9 KiB
TypeScript
import { batchSaveCostEntries } from "@/lib/db/domainState";
|
|
|
|
export interface BufferedCostEntry {
|
|
apiKeyId: string;
|
|
cost: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface SpendBatchWriterOptions {
|
|
flushIntervalMs?: number;
|
|
maxBufferSize?: number;
|
|
persistEntries?: (entries: BufferedCostEntry[]) => Promise<void> | void;
|
|
logger?: Pick<Console, "log" | "error">;
|
|
}
|
|
|
|
type FlushResult = {
|
|
flushedEntries: number;
|
|
uniqueKeys: number;
|
|
requeued: boolean;
|
|
};
|
|
|
|
const DEFAULT_FLUSH_INTERVAL_MS = 60_000;
|
|
const DEFAULT_MAX_BUFFER_SIZE = 1_000;
|
|
|
|
function getFlushIntervalMs() {
|
|
const parsed = Number.parseInt(process.env.OMNIROUTE_SPEND_FLUSH_INTERVAL_MS || "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FLUSH_INTERVAL_MS;
|
|
}
|
|
|
|
function getMaxBufferSize() {
|
|
const parsed = Number.parseInt(process.env.OMNIROUTE_SPEND_MAX_BUFFER_SIZE || "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_BUFFER_SIZE;
|
|
}
|
|
|
|
function normalizeEntry(entry: BufferedCostEntry): BufferedCostEntry | null {
|
|
if (!entry?.apiKeyId || !Number.isFinite(entry.cost) || entry.cost <= 0) return null;
|
|
return {
|
|
apiKeyId: entry.apiKeyId,
|
|
cost: entry.cost,
|
|
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
|
|
};
|
|
}
|
|
|
|
export class SpendBatchWriter {
|
|
private buffer: BufferedCostEntry[] = [];
|
|
private inFlightEntries: BufferedCostEntry[] = [];
|
|
private discardedApiKeyIds = new Set<string>();
|
|
private timer: NodeJS.Timeout | null = null;
|
|
private started = false;
|
|
private flushPromise: Promise<FlushResult> | null = null;
|
|
private persistEntries: (entries: BufferedCostEntry[]) => Promise<void> | void;
|
|
private logger: Pick<Console, "log" | "error">;
|
|
private flushIntervalMs: number;
|
|
private maxBufferSize: number;
|
|
|
|
constructor(options: SpendBatchWriterOptions = {}) {
|
|
this.persistEntries = options.persistEntries || batchSaveCostEntries;
|
|
this.logger = options.logger || console;
|
|
this.flushIntervalMs = options.flushIntervalMs ?? getFlushIntervalMs();
|
|
this.maxBufferSize = options.maxBufferSize ?? getMaxBufferSize();
|
|
}
|
|
|
|
start() {
|
|
if (this.started) return;
|
|
this.started = true;
|
|
this.timer = setInterval(() => {
|
|
void this.flush();
|
|
}, this.flushIntervalMs);
|
|
this.timer.unref?.();
|
|
}
|
|
|
|
increment(apiKeyId: string, cost: number, timestamp = Date.now()) {
|
|
const entry = normalizeEntry({ apiKeyId, cost, timestamp });
|
|
if (!entry) return;
|
|
|
|
this.start();
|
|
this.discardedApiKeyIds.delete(entry.apiKeyId);
|
|
this.buffer.push(entry);
|
|
|
|
if (this.buffer.length >= this.maxBufferSize) {
|
|
void this.flush();
|
|
}
|
|
}
|
|
|
|
getBufferedEntries(
|
|
apiKeyId: string,
|
|
sinceTimestamp = 0,
|
|
untilTimestamp = Number.POSITIVE_INFINITY
|
|
) {
|
|
const matchesWindow = (entry: BufferedCostEntry) =>
|
|
entry.apiKeyId === apiKeyId &&
|
|
entry.timestamp >= sinceTimestamp &&
|
|
entry.timestamp < untilTimestamp;
|
|
|
|
return [...this.inFlightEntries, ...this.buffer].filter(matchesWindow);
|
|
}
|
|
|
|
getPendingCostTotal(
|
|
apiKeyId: string,
|
|
sinceTimestamp = 0,
|
|
untilTimestamp = Number.POSITIVE_INFINITY
|
|
) {
|
|
return this.getBufferedEntries(apiKeyId, sinceTimestamp, untilTimestamp).reduce(
|
|
(sum, entry) => sum + entry.cost,
|
|
0
|
|
);
|
|
}
|
|
|
|
discardEntries(apiKeyId: string) {
|
|
this.discardedApiKeyIds.add(apiKeyId);
|
|
this.buffer = this.buffer.filter((entry) => entry.apiKeyId !== apiKeyId);
|
|
this.inFlightEntries = this.inFlightEntries.filter((entry) => entry.apiKeyId !== apiKeyId);
|
|
}
|
|
|
|
async flush(): Promise<FlushResult> {
|
|
if (this.flushPromise) {
|
|
return this.flushPromise;
|
|
}
|
|
|
|
if (this.buffer.length === 0) {
|
|
return { flushedEntries: 0, uniqueKeys: 0, requeued: false };
|
|
}
|
|
|
|
const entriesToFlush = [...this.buffer];
|
|
this.buffer = [];
|
|
this.inFlightEntries = entriesToFlush;
|
|
|
|
this.flushPromise = (async () => {
|
|
const entriesToPersist = entriesToFlush.filter(
|
|
(entry) => !this.discardedApiKeyIds.has(entry.apiKeyId)
|
|
);
|
|
const uniqueKeys = new Set(entriesToPersist.map((entry) => entry.apiKeyId)).size;
|
|
|
|
try {
|
|
if (entriesToPersist.length > 0) {
|
|
await this.persistEntries(entriesToPersist);
|
|
}
|
|
this.logger.log(
|
|
`[SpendWriter] Flushed ${entriesToPersist.length} cost entr${
|
|
entriesToPersist.length === 1 ? "y" : "ies"
|
|
} across ${uniqueKeys} key(s)`
|
|
);
|
|
return {
|
|
flushedEntries: entriesToPersist.length,
|
|
uniqueKeys,
|
|
requeued: false,
|
|
};
|
|
} catch (error) {
|
|
this.buffer = [...entriesToPersist, ...this.buffer];
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.logger.error(`[SpendWriter] Flush error: ${message}`);
|
|
return {
|
|
flushedEntries: 0,
|
|
uniqueKeys,
|
|
requeued: true,
|
|
};
|
|
} finally {
|
|
this.inFlightEntries = [];
|
|
this.flushPromise = null;
|
|
}
|
|
})();
|
|
|
|
return this.flushPromise;
|
|
}
|
|
|
|
async stop() {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
this.started = false;
|
|
return this.flush();
|
|
}
|
|
|
|
resetForTests() {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
this.started = false;
|
|
this.buffer = [];
|
|
this.inFlightEntries = [];
|
|
this.discardedApiKeyIds.clear();
|
|
this.flushPromise = null;
|
|
}
|
|
}
|
|
|
|
export const spendBatchWriter = new SpendBatchWriter();
|
|
|
|
export function startSpendBatchWriter() {
|
|
spendBatchWriter.start();
|
|
}
|
|
|
|
export async function flushSpendBatchWriter() {
|
|
return spendBatchWriter.flush();
|
|
}
|
|
|
|
export async function stopSpendBatchWriter() {
|
|
return spendBatchWriter.stop();
|
|
}
|
|
|
|
export function resetSpendBatchWriterForTests() {
|
|
spendBatchWriter.resetForTests();
|
|
}
|
|
|
|
export function discardSpendBatchEntries(apiKeyId: string) {
|
|
spendBatchWriter.discardEntries(apiKeyId);
|
|
}
|