Files
freeway/packages/core/src/util.ts
T
Aryan SinghandClaude Opus 5 cb985dcf9c Freeway: self-hosted OpenAI-compatible gateway for free-tier providers
One endpoint in front of every free LLM tier, with automatic failover and no
provider key ever leaving the box.

Providers are data, not code: adding one is a JSON file plus an env var, never
a TypeScript change. Ships 12 providers pre-written; `verify-provider --apply`
derives the live model list from your own key.

- core: registry, bucketed quota meter (rolling + UTC-calendar windows),
  key-pool store, 6-step model router with diagnostic rejection reasons
- gateway: node:http, failover that commits to a stream only after 200 headers,
  SSE passthrough with usage sniffing, virtual keys with admin/inference roles
- context engine: sqlite sessions and a 5-rung refit ladder, so a 128k thread
  keeps working when it fails over to an 8k model
- cache: exact (sha256, safe-by-default) + opt-in semantic (flat Float32Array)
- guard: inbound limits, quota forecasting, limit calibration from upstream
  headers, key health, secret/PII/injection scanning
- dashboard: one self-contained HTML file, live SSE request log, token usage
- sdk: zero-dep typed client, drop-in for the OpenAI SDK

Zero runtime dependencies. 326 tests, 27-check smoke suite against a scripted
mock upstream so routing is testable without spending quota.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:41:49 -05:00

106 lines
3.4 KiB
TypeScript

/** Small shared helpers. Nothing here knows about providers or HTTP. */
import { createHash } from 'node:crypto';
/**
* The only sanctioned representation of a secret outside the gateway process.
* Everything that touches a key — logs, `/api/*`, the dashboard, error strings —
* routes through here, so there is exactly one place to audit.
*/
export function mask(secret: string): string {
if (!secret) return '';
// Below this length, showing head and tail would reveal most of the value.
if (secret.length <= 6) return '…';
if (secret.length <= 12) return `${secret.slice(0, 2)}…${secret.slice(-2)}`;
return `${secret.slice(0, 4)}…${secret.slice(-4)}`;
}
/**
* Stable, non-reversible identifier for a secret.
*
* `mask()` is lossy on purpose — `fw-app-key` and `fw-other-key` both render as
* `fw…ey`. That is fine for a dashboard and catastrophic as an authorization
* principal, where a collision means two keys share each other's sessions and
* rate-limit budget. Anything that identifies a caller uses this instead.
*/
export function fingerprint(secret: string): string {
return createHash('sha256').update(secret).digest('hex').slice(0, 16);
}
export interface Interpolation {
value: string;
missing: string[];
}
const ENV_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
/**
* Resolve `${ENV_VAR}` references. This is what keeps a provider that embeds an
* account id in its URL — Cloudflare — pure data instead of a code change.
*/
export function interpolate(input: string, env: Record<string, string | undefined>): Interpolation {
const missing: string[] = [];
const value = input.replace(ENV_PATTERN, (_match, name: string) => {
const found = env[name];
if (found === undefined || found === '') {
if (!missing.includes(name)) missing.push(name);
return '';
}
return found;
});
return { value, missing };
}
export function envRefs(input: string): string[] {
const out: string[] = [];
for (const m of input.matchAll(ENV_PATTERN)) {
const name = m[1];
if (name !== undefined && !out.includes(name)) out.push(name);
}
return out;
}
/** A single env var may hold several comma-separated keys — that is the pool. */
export function splitKeys(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
export function joinUrl(base: string, path: string): string {
if (!path) return base;
const b = base.endsWith('/') ? base.slice(0, -1) : base;
const p = path.startsWith('/') ? path : `/${path}`;
return `${b}${p}`;
}
export function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n));
}
/** Exponentially weighted moving average. Seeds on first sample. */
export function ewma(prev: number | null, sample: number, alpha = 0.2): number {
if (prev === null || !Number.isFinite(prev)) return sample;
return prev + alpha * (sample - prev);
}
export interface Logger {
info(msg: string, ...rest: unknown[]): void;
warn(msg: string, ...rest: unknown[]): void;
error(msg: string, ...rest: unknown[]): void;
}
export const consoleLogger: Logger = {
info: (m, ...r) => console.log(`[freeway] ${m}`, ...r),
warn: (m, ...r) => console.warn(`[freeway] ${m}`, ...r),
error: (m, ...r) => console.error(`[freeway] ${m}`, ...r),
};
export const silentLogger: Logger = {
info: () => {},
warn: () => {},
error: () => {},
};