openai.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045
  1. /**
  2. * openai.ts - OpenAI-compatible HTTP embedding provider
  3. *
  4. * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  5. * shape: request `{model, input: string|string[]}`, response
  6. * `{data: [{embedding: number[], index: number}, ...]}`.
  7. *
  8. * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  9. *
  10. * Features:
  11. * - Batches input in groups of ≤64 (configurable via QMD_EMBED_BATCH_SIZE)
  12. * - Retries 429 / 503 with exponential backoff (1s, 4s, 16s)
  13. * - 429 gets its own, larger retry budget and honours `Retry-After`
  14. * (header or `"Retry after 29s"` body text) — a shared gateway bucket
  15. * refilling in 30s must not exhaust a 1s/4s/16s schedule (i-yghj098h)
  16. * - Bulk lane (`embedBatch` with >1 input): a 429 pauses the WHOLE worker
  17. * pool for the advertised cooldown and halves in-flight concurrency
  18. * (AIMD), so reindex traffic yields the bucket to interactive callers
  19. * instead of contending head-on with them
  20. * - 4xx (non-429) → no retry, count as failure
  21. * - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min;
  22. * callers receive failures and no model fallback is selected
  23. * - Per-call timeout via AbortSignal (default QMD_EMBED_TIMEOUT_MS=30000)
  24. * - Healthcheck via `GET /health` if available, else a probe embed call
  25. */
  26. import os from "node:os";
  27. import type {
  28. EmbeddingProvider,
  29. ProviderEmbedOptions,
  30. ProviderEmbedding,
  31. ProviderHealth,
  32. ProviderKind,
  33. } from "./provider.js";
  34. // ─────────────────────────── Configuration ───────────────────────────────────
  35. /**
  36. * Default batch size — most OpenAI-compatible embedding endpoints accept up to
  37. * 2048 inputs per call but for memory and latency we cap at 64.
  38. */
  39. export const DEFAULT_BATCH_SIZE = 64;
  40. /**
  41. * Default in-flight concurrency cap for `embedBatch`. The qmd-embed-worker
  42. * exposes a 4-way semaphore (`MAX_CONCURRENT_REQUESTS=4`) and idles at
  43. * queue-depth 1.0 under sequential clients (i-fkpnar9i baseline). Defaulting
  44. * to 4 matches the worker's advertised concurrency without overshooting the
  45. * GPU. Override per-deploy via `QMD_EMBED_CONCURRENCY`. Setting to 1 reverts
  46. * to the legacy sequential dispatch.
  47. */
  48. export const DEFAULT_CONCURRENCY = 4;
  49. /**
  50. * Default per-request timeout (30 s). embeddinggemma-300M on RTX 4090 takes
  51. * <500ms per batch of 64 in practice; 30s is a safe upper bound.
  52. */
  53. export const DEFAULT_TIMEOUT_MS = 30_000;
  54. /**
  55. * Retry backoff schedule (ms) for 429/503 responses. 3 attempts total
  56. * (initial + 2 retries) — aligns with issue spec "1s/4s/16s".
  57. */
  58. export const RETRY_BACKOFFS_MS: readonly number[] = [1_000, 4_000, 16_000];
  59. /**
  60. * Rate-limit (429) retry budget, separate from `RETRY_BACKOFFS_MS`.
  61. *
  62. * A 429 is backpressure, not a fault: the shared ai.mm.mk gateway answers
  63. * `{"detail":"Rate limit exceeded (tokens). Retry after 29s."}` with observed
  64. * retry-after values of 7s/29s/30s, while the generic schedule waits at most
  65. * 1+4+16 = 21s in total. Bulk reindex runs therefore burned all three attempts
  66. * inside one bucket refill and reported the chunk as failed (i-yghj098h).
  67. * 429s get their own attempt count and always wait at least as long as the
  68. * server asked for, capped by `RATE_LIMIT_MAX_BACKOFF_MS`.
  69. */
  70. export const DEFAULT_RATE_LIMIT_RETRIES = 5;
  71. /** Upper bound on a single rate-limit wait, so a bogus Retry-After can't hang a run. */
  72. export const RATE_LIMIT_MAX_BACKOFF_MS = 60_000;
  73. /** Fallback wait when a 429 carries no parseable Retry-After (doubles per attempt). */
  74. export const RATE_LIMIT_BASE_BACKOFF_MS = 5_000;
  75. /**
  76. * Consecutive successful bulk requests before the lane additively recovers one
  77. * unit of concurrency after a 429-triggered halving (AIMD).
  78. */
  79. export const LANE_RECOVERY_STREAK = 8;
  80. /**
  81. * Circuit breaker — flips OPEN when error rate exceeds threshold within
  82. * window. While OPEN, every call fails fast so the caller can fall back.
  83. */
  84. export const CIRCUIT_WINDOW_MS = 60_000;
  85. export const CIRCUIT_OPEN_DURATION_MS = 5 * 60_000;
  86. export const CIRCUIT_FAILURE_RATE_THRESHOLD = 0.5;
  87. export const CIRCUIT_MIN_SAMPLES = 4;
  88. // ─────────────────────────── Types ───────────────────────────────────────────
  89. export type OpenAIProviderConfig = {
  90. /** Endpoint base URL — e.g. "https://ai.mm.mk" (no trailing slash) */
  91. endpoint: string;
  92. /** Optional bearer token sent as `Authorization: Bearer ...` */
  93. apiKey?: string;
  94. /**
  95. * Stable model identifier to report up via `getModelId()`.
  96. * Defaults to "embeddinggemma" to match qmd's existing DB rows.
  97. */
  98. modelId?: string;
  99. /**
  100. * Upstream model name sent in the HTTP request body. Often differs from
  101. * `modelId` (e.g. modelId="embeddinggemma" but upstream model="embeddinggemma:300m").
  102. */
  103. upstreamModel?: string;
  104. /** Batch size cap (default DEFAULT_BATCH_SIZE = 64) */
  105. batchSize?: number;
  106. /**
  107. * Max in-flight HTTP requests during a single `embedBatch` call. Default
  108. * `DEFAULT_CONCURRENCY=4` matches the worker semaphore. Set to 1 to force
  109. * legacy sequential dispatch (useful for benchmarks / regression bisect).
  110. */
  111. concurrency?: number;
  112. /** Per-request timeout in ms (default DEFAULT_TIMEOUT_MS = 30_000) */
  113. timeoutMs?: number;
  114. /** Custom fetch (for testing). Defaults to global `fetch`. */
  115. fetchImpl?: typeof fetch;
  116. /** Custom retry schedule (for testing). Defaults to RETRY_BACKOFFS_MS. */
  117. retryBackoffsMs?: readonly number[];
  118. /**
  119. * Extra retry attempts granted to 429 responses on top of the generic
  120. * schedule (default DEFAULT_RATE_LIMIT_RETRIES = 5, env
  121. * `QMD_EMBED_RATE_LIMIT_RETRIES`). Rate limits are backpressure, not faults.
  122. */
  123. rateLimitRetries?: number;
  124. /** Custom sleep impl (for testing). Defaults to setTimeout. */
  125. sleep?: (ms: number) => Promise<void>;
  126. /** Custom clock (for testing). Defaults to Date.now. */
  127. now?: () => number;
  128. };
  129. export type OpenAIEmbeddingsResponse = {
  130. object?: string;
  131. model?: string;
  132. data: Array<{
  133. object?: string;
  134. index: number;
  135. embedding: number[];
  136. }>;
  137. usage?: {
  138. prompt_tokens?: number;
  139. total_tokens?: number;
  140. };
  141. };
  142. /**
  143. * Circuit breaker state — exported for tests
  144. */
  145. export type CircuitState = "closed" | "open" | "half-open";
  146. /**
  147. * Traffic lane for a single `embedBatch` call. `bulk` = reindex traffic
  148. * (multi-input batch), which self-throttles via `BulkLaneGate` and is
  149. * attributed separately in `X-AI-Caller`. `interactive` = query-time embeds,
  150. * which are never made to wait behind a bulk cooldown.
  151. */
  152. export type EmbedLane = "interactive" | "bulk";
  153. // ─────────────────────────── Helpers ─────────────────────────────────────────
  154. function defaultSleep(ms: number): Promise<void> {
  155. return new Promise((resolve) => setTimeout(resolve, ms));
  156. }
  157. /** Parse a non-negative integer env value; `undefined` when unset/invalid. */
  158. function parseNonNegativeInt(raw: string | undefined): number | undefined {
  159. if (raw == null || raw.trim() === "") return undefined;
  160. const n = Number.parseInt(raw, 10);
  161. return Number.isFinite(n) && n >= 0 ? n : undefined;
  162. }
  163. /**
  164. * Build the advisory `X-AI-Caller` attribution header value (Oivo ai.mm.mk
  165. * "Observability-Driven Fleet Self-Improvement" rollout, Thread 1). Format
  166. * mirrors Oivo's `cli/src/shared/aiCallerHeader.ts` EXACTLY:
  167. * site=<file:line | tool file>; mc=<machine>; sid=<session-8char>;
  168. * comp=<component>; via=<logical-label>
  169. *
  170. * Advisory metadata ONLY — never an auth/authorization input. `mc`/`sid` come
  171. * from the Oivo fleet env when qmd runs as a delegated embedder; both degrade
  172. * to `-`/short-hostname safely when qmd runs standalone. Values are sanitized
  173. * (`;` + control chars stripped, capped at 120) so a pathological env value can
  174. * never break the header grammar.
  175. */
  176. function aiCallerHeaderValue(via: string): string {
  177. const clean = (raw: string | undefined, fallback: string): string => {
  178. if (raw == null) return fallback;
  179. let out = "";
  180. for (const ch of String(raw)) {
  181. const code = ch.charCodeAt(0);
  182. out += code < 32 || code === 127 || ch === ";" ? " " : ch;
  183. }
  184. const collapsed = out.replace(/\s+/g, " ").trim();
  185. return (collapsed || fallback).slice(0, 120);
  186. };
  187. let mc = (process.env.OIVO_MACHINE_NAME ?? "").trim();
  188. if (!mc) {
  189. try {
  190. mc = os.hostname().split(".")[0] || "unknown";
  191. } catch {
  192. mc = "unknown";
  193. }
  194. }
  195. const sid = (process.env.OIVO_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || "-").slice(0, 8);
  196. return `site=src/embedding/openai.ts; mc=${clean(mc, "unknown")}; sid=${clean(sid, "-")}; comp=qmd; via=${clean(via, "-")}`;
  197. }
  198. /**
  199. * Build the merged AbortSignal for a single HTTP attempt: combines an
  200. * external `userSignal` (from caller / withLLMSession) with a per-attempt
  201. * timeout signal. Returns the merged signal AND the timeout id so the
  202. * caller can `clearTimeout` after the attempt completes (avoids leaks).
  203. */
  204. function buildAttemptSignal(
  205. userSignal: AbortSignal | undefined,
  206. timeoutMs: number,
  207. ): { signal: AbortSignal; cleanup: () => void } {
  208. const ctrl = new AbortController();
  209. const timeoutId = setTimeout(() => {
  210. ctrl.abort(new Error(`Request timed out after ${timeoutMs}ms`));
  211. }, timeoutMs);
  212. // Don't keep process alive just for this timer
  213. if (typeof timeoutId === "object" && timeoutId !== null && "unref" in timeoutId) {
  214. (timeoutId as { unref: () => void }).unref();
  215. }
  216. const onUserAbort = () => ctrl.abort(userSignal?.reason);
  217. if (userSignal) {
  218. if (userSignal.aborted) {
  219. ctrl.abort(userSignal.reason);
  220. } else {
  221. userSignal.addEventListener("abort", onUserAbort, { once: true });
  222. }
  223. }
  224. const cleanup = () => {
  225. clearTimeout(timeoutId);
  226. if (userSignal) userSignal.removeEventListener("abort", onUserAbort);
  227. };
  228. return { signal: ctrl.signal, cleanup };
  229. }
  230. /**
  231. * Determine whether an HTTP status is retryable. 429 (Too Many Requests)
  232. * and 503 (Service Unavailable) are retried; 4xx (other than 429) are not.
  233. */
  234. export function isRetryableStatus(status: number): boolean {
  235. return status === 429 || status === 503;
  236. }
  237. /**
  238. * Extract the server-advertised cooldown from a rate-limited response.
  239. *
  240. * Two sources, in priority order:
  241. * 1. the standard `Retry-After` header — delta-seconds or an HTTP-date;
  242. * 2. the ai.mm.mk body text, which states the cooldown in prose only:
  243. * `{"detail":"Rate limit exceeded (tokens). Retry after 29s.", ...}`.
  244. *
  245. * Returns `undefined` when neither source yields a sane positive duration, so
  246. * the caller falls back to its own schedule. Values are clamped to
  247. * `RATE_LIMIT_MAX_BACKOFF_MS`.
  248. */
  249. export function parseRetryAfterMs(
  250. headerValue: string | null | undefined,
  251. bodyPreview?: string,
  252. now: () => number = Date.now,
  253. ): number | undefined {
  254. const clamp = (ms: number): number | undefined =>
  255. Number.isFinite(ms) && ms > 0 ? Math.min(ms, RATE_LIMIT_MAX_BACKOFF_MS) : undefined;
  256. const header = headerValue?.trim();
  257. if (header) {
  258. if (/^\d+(\.\d+)?$/.test(header)) {
  259. const fromSeconds = clamp(Number(header) * 1000);
  260. if (fromSeconds !== undefined) return fromSeconds;
  261. }
  262. const asDate = Date.parse(header);
  263. if (!Number.isNaN(asDate)) {
  264. const fromDate = clamp(asDate - now());
  265. if (fromDate !== undefined) return fromDate;
  266. }
  267. }
  268. if (bodyPreview) {
  269. const m = /retry\s+after\s+(\d+(?:\.\d+)?)\s*(ms|s|seconds?)?/i.exec(bodyPreview);
  270. if (m) {
  271. const value = Number(m[1]);
  272. const unit = (m[2] ?? "s").toLowerCase();
  273. return clamp(unit === "ms" ? value : value * 1000);
  274. }
  275. }
  276. return undefined;
  277. }
  278. /**
  279. * Chunk an array into pieces of ≤ size each. `size` MUST be ≥ 1.
  280. */
  281. export function chunkArray<T>(items: T[], size: number): T[][] {
  282. if (size < 1) throw new Error(`chunkArray: size must be ≥ 1, got ${size}`);
  283. if (items.length <= size) return items.length === 0 ? [] : [items];
  284. const out: T[][] = [];
  285. for (let i = 0; i < items.length; i += size) {
  286. out.push(items.slice(i, i + size));
  287. }
  288. return out;
  289. }
  290. // ─────────────────────────── Circuit Breaker ─────────────────────────────────
  291. /**
  292. * Sliding-window circuit breaker. Tracks the last N samples (min 4) over a
  293. * 60-second window; flips OPEN when failure rate exceeds 50%, then auto-
  294. * resets to HALF-OPEN after 5 minutes — at which point the next probe
  295. * decides whether to close (success) or re-open (failure).
  296. */
  297. export class CircuitBreaker {
  298. private samples: { ts: number; ok: boolean }[] = [];
  299. private state: CircuitState = "closed";
  300. private openedAt: number | null = null;
  301. private readonly windowMs: number;
  302. private readonly openDurationMs: number;
  303. private readonly threshold: number;
  304. private readonly minSamples: number;
  305. private readonly now: () => number;
  306. constructor(opts: {
  307. windowMs?: number;
  308. openDurationMs?: number;
  309. threshold?: number;
  310. minSamples?: number;
  311. now?: () => number;
  312. } = {}) {
  313. this.windowMs = opts.windowMs ?? CIRCUIT_WINDOW_MS;
  314. this.openDurationMs = opts.openDurationMs ?? CIRCUIT_OPEN_DURATION_MS;
  315. this.threshold = opts.threshold ?? CIRCUIT_FAILURE_RATE_THRESHOLD;
  316. this.minSamples = opts.minSamples ?? CIRCUIT_MIN_SAMPLES;
  317. this.now = opts.now ?? Date.now;
  318. }
  319. getState(): CircuitState {
  320. this.tickAutoReset();
  321. return this.state;
  322. }
  323. /**
  324. * Returns true when calls should be short-circuited (skip HTTP, fall back).
  325. * Side-effects: may transition OPEN → HALF-OPEN if the open window expired.
  326. */
  327. shouldFailFast(): boolean {
  328. return this.getState() === "open";
  329. }
  330. /** Record a successful call. */
  331. recordSuccess(): void {
  332. // Honor the time-based OPEN→HALF-OPEN transition before deciding what
  333. // to do with this sample. Without this, a success that lands AFTER the
  334. // open window expired would still see state==="open" and never close
  335. // the breaker (a probe call could only flip it via getState()).
  336. this.tickAutoReset();
  337. this.pushSample(true);
  338. if (this.state === "half-open") {
  339. this.state = "closed";
  340. this.openedAt = null;
  341. }
  342. }
  343. /** Record a failed call. May trigger OPEN. */
  344. recordFailure(): void {
  345. // Same reasoning as recordSuccess — apply lazy auto-reset before
  346. // classifying the sample.
  347. this.tickAutoReset();
  348. this.pushSample(false);
  349. if (this.state === "half-open") {
  350. // Probe failed — re-open
  351. this.state = "open";
  352. this.openedAt = this.now();
  353. return;
  354. }
  355. if (this.state === "closed") this.evaluate();
  356. }
  357. /** Force-reset the breaker (used by tests / admin) */
  358. reset(): void {
  359. this.samples = [];
  360. this.state = "closed";
  361. this.openedAt = null;
  362. }
  363. private pushSample(ok: boolean): void {
  364. const ts = this.now();
  365. this.samples.push({ ts, ok });
  366. // Drop samples outside the window
  367. const cutoff = ts - this.windowMs;
  368. while (this.samples.length > 0 && this.samples[0]!.ts < cutoff) {
  369. this.samples.shift();
  370. }
  371. }
  372. private evaluate(): void {
  373. if (this.samples.length < this.minSamples) return;
  374. const failures = this.samples.filter((s) => !s.ok).length;
  375. const rate = failures / this.samples.length;
  376. if (rate > this.threshold) {
  377. this.state = "open";
  378. this.openedAt = this.now();
  379. }
  380. }
  381. private tickAutoReset(): void {
  382. if (this.state === "open" && this.openedAt !== null) {
  383. if (this.now() - this.openedAt >= this.openDurationMs) {
  384. this.state = "half-open";
  385. }
  386. }
  387. }
  388. }
  389. // ─────────────────────────── Errors ──────────────────────────────────────────
  390. /**
  391. * Raised when the circuit breaker is OPEN and a call is short-circuited.
  392. */
  393. export class CircuitOpenError extends Error {
  394. constructor(message = "OpenAIEmbeddingsProvider circuit is OPEN") {
  395. super(message);
  396. this.name = "CircuitOpenError";
  397. }
  398. }
  399. /**
  400. * Persistent (non-retryable) HTTP error from upstream. Includes status code.
  401. */
  402. export class HttpError extends Error {
  403. readonly status: number;
  404. readonly bodyPreview: string;
  405. /** Server-advertised cooldown for 429s, when the response stated one. */
  406. readonly retryAfterMs?: number;
  407. constructor(status: number, bodyPreview: string, retryAfterMs?: number) {
  408. super(`HTTP ${status}: ${bodyPreview.slice(0, 200)}`);
  409. this.name = "HttpError";
  410. this.status = status;
  411. this.bodyPreview = bodyPreview.slice(0, 1024);
  412. this.retryAfterMs = retryAfterMs;
  413. }
  414. }
  415. // ─────────────────────────── Bulk lane ───────────────────────────────────────
  416. /**
  417. * Client-side bulk lane for reindex traffic (i-yghj098h).
  418. *
  419. * qmd's bulk embedding shares the interactive ai.mm.mk token bucket. Without a
  420. * server-side per-caller budget, the only way bulk traffic can stop contending
  421. * head-on with interactive callers is to police itself:
  422. *
  423. * - a 429 on ANY worker pauses the ENTIRE pool for the advertised cooldown
  424. * (one shared promise — concurrent 429s coalesce instead of stacking N
  425. * cooldowns), so the bucket refills for interactive callers rather than
  426. * being re-drained by the remaining workers;
  427. * - the in-flight cap halves on each cooldown (floor 1) and recovers one unit
  428. * per `LANE_RECOVERY_STREAK` successes — classic AIMD, so a run settles at
  429. * whatever share the bucket actually has spare.
  430. *
  431. * Deliberately clock-free: cooldowns are modelled as a promise produced by the
  432. * injected `sleep`, so tests drive them with a fake sleep and no fake clock.
  433. */
  434. export class BulkLaneGate {
  435. private readonly maxPermits: number;
  436. private readonly sleep: (ms: number) => Promise<void>;
  437. private permits: number;
  438. private inFlight = 0;
  439. private okStreak = 0;
  440. private cooldown: Promise<void> | null = null;
  441. private waiters: (() => void)[] = [];
  442. constructor(maxPermits: number, sleep: (ms: number) => Promise<void>) {
  443. this.maxPermits = Math.max(1, maxPermits);
  444. this.permits = this.maxPermits;
  445. this.sleep = sleep;
  446. }
  447. /** Current in-flight cap — exported state for tests/diagnostics. */
  448. get permitCount(): number {
  449. return this.permits;
  450. }
  451. /** True while the lane is serving a rate-limit cooldown. */
  452. get isCoolingDown(): boolean {
  453. return this.cooldown !== null;
  454. }
  455. /** Take a slot, waiting out any cooldown and respecting the current cap. */
  456. async acquire(): Promise<void> {
  457. while (this.cooldown !== null || this.inFlight >= this.permits) {
  458. if (this.cooldown !== null) {
  459. await this.cooldown;
  460. continue;
  461. }
  462. await new Promise<void>((resolve) => this.waiters.push(resolve));
  463. }
  464. this.inFlight++;
  465. }
  466. /** Return a slot. Always call from a `finally`. */
  467. release(): void {
  468. if (this.inFlight > 0) this.inFlight--;
  469. this.wakeOne();
  470. }
  471. /**
  472. * Enter (or join) a cooldown of `waitMs` and halve the in-flight cap.
  473. * Returns the shared cooldown promise — the caller awaits it INSTEAD of
  474. * sleeping itself, so a burst of 429s costs one cooldown, not one each.
  475. */
  476. penalize(waitMs: number): Promise<void> {
  477. if (this.cooldown !== null) return this.cooldown;
  478. this.okStreak = 0;
  479. this.permits = Math.max(1, Math.floor(this.permits / 2));
  480. const cooldown = this.sleep(waitMs).then(() => {
  481. if (this.cooldown === cooldown) this.cooldown = null;
  482. this.wakeAll();
  483. });
  484. this.cooldown = cooldown;
  485. return cooldown;
  486. }
  487. /** Record a successful bulk request; recovers one permit per success streak. */
  488. noteSuccess(): void {
  489. if (this.permits >= this.maxPermits) return;
  490. if (++this.okStreak >= LANE_RECOVERY_STREAK) {
  491. this.okStreak = 0;
  492. this.permits++;
  493. this.wakeOne();
  494. }
  495. }
  496. private wakeOne(): void {
  497. this.waiters.shift()?.();
  498. }
  499. private wakeAll(): void {
  500. const pending = this.waiters;
  501. this.waiters = [];
  502. for (const resolve of pending) resolve();
  503. }
  504. }
  505. // ─────────────────────────── Provider ────────────────────────────────────────
  506. export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
  507. readonly kind: ProviderKind = "openai";
  508. private readonly endpoint: string;
  509. private readonly apiKey?: string;
  510. private readonly modelId: string;
  511. private readonly upstreamModel: string;
  512. private readonly batchSize: number;
  513. private readonly concurrency: number;
  514. private readonly timeoutMs: number;
  515. private readonly fetchImpl: typeof fetch;
  516. private readonly retryBackoffsMs: readonly number[];
  517. private readonly rateLimitRetries: number;
  518. private readonly sleep: (ms: number) => Promise<void>;
  519. private readonly now: () => number;
  520. private dimensions: number | undefined = undefined;
  521. private lastError: string | undefined = undefined;
  522. readonly breaker: CircuitBreaker;
  523. /** Shared bulk-traffic lane — see `BulkLaneGate`. */
  524. readonly lane: BulkLaneGate;
  525. constructor(config: OpenAIProviderConfig) {
  526. if (!config.endpoint) {
  527. throw new Error("OpenAIEmbeddingsProvider: endpoint is required");
  528. }
  529. this.endpoint = config.endpoint.replace(/\/+$/, "");
  530. this.apiKey = config.apiKey;
  531. this.modelId = config.modelId ?? "embeddinggemma";
  532. this.upstreamModel = config.upstreamModel ?? this.modelId;
  533. this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
  534. this.concurrency = config.concurrency ?? DEFAULT_CONCURRENCY;
  535. this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  536. this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
  537. this.retryBackoffsMs = config.retryBackoffsMs ?? RETRY_BACKOFFS_MS;
  538. this.rateLimitRetries =
  539. config.rateLimitRetries ??
  540. parseNonNegativeInt(process.env.QMD_EMBED_RATE_LIMIT_RETRIES) ??
  541. DEFAULT_RATE_LIMIT_RETRIES;
  542. this.sleep = config.sleep ?? defaultSleep;
  543. this.now = config.now ?? Date.now;
  544. this.breaker = new CircuitBreaker({ now: this.now });
  545. this.lane = new BulkLaneGate(this.concurrency, this.sleep);
  546. if (!this.fetchImpl) {
  547. throw new Error(
  548. "OpenAIEmbeddingsProvider: global fetch is unavailable. " +
  549. "Provide a `fetchImpl` config option (Node ≥18 ships fetch by default).",
  550. );
  551. }
  552. if (this.batchSize < 1) {
  553. throw new Error(`OpenAIEmbeddingsProvider: batchSize must be ≥ 1, got ${this.batchSize}`);
  554. }
  555. if (this.concurrency < 1) {
  556. throw new Error(`OpenAIEmbeddingsProvider: concurrency must be ≥ 1, got ${this.concurrency}`);
  557. }
  558. }
  559. getModelId(): string {
  560. return this.modelId;
  561. }
  562. getDimensions(): number | undefined {
  563. return this.dimensions;
  564. }
  565. /**
  566. * Most recent per-chunk failure message (HTTP status + body preview, malformed
  567. * JSON, timeout, abort reason). Returns `undefined` after a successful call
  568. * or before the first call. See `EmbeddingProvider.getLastError`.
  569. */
  570. getLastError(): string | undefined {
  571. return this.lastError;
  572. }
  573. /** Endpoint URL configured at construction time — used by callers when
  574. * building error messages for failed first-chunk probes. */
  575. getEndpoint(): string {
  576. return this.endpoint;
  577. }
  578. async healthcheck(signal?: AbortSignal): Promise<ProviderHealth> {
  579. // Try GET /health first (worker exposes it). Fall back to probe embed.
  580. try {
  581. const { signal: attemptSig, cleanup } = buildAttemptSignal(signal, this.timeoutMs);
  582. try {
  583. const resp = await this.fetchImpl(`${this.endpoint}/health`, {
  584. method: "GET",
  585. headers: this.buildHeaders(),
  586. signal: attemptSig,
  587. });
  588. if (resp.ok) {
  589. return {
  590. ok: true,
  591. model: this.modelId,
  592. dimensions: this.dimensions,
  593. detail: `GET /health → ${resp.status}`,
  594. };
  595. }
  596. return {
  597. ok: false,
  598. model: this.modelId,
  599. detail: `GET /health → HTTP ${resp.status}`,
  600. };
  601. } finally {
  602. cleanup();
  603. }
  604. } catch (err) {
  605. // Endpoint may not implement /health — try a single embed probe instead.
  606. try {
  607. const probe = await this.embed("healthcheck", { signal });
  608. if (probe) {
  609. return {
  610. ok: true,
  611. model: this.modelId,
  612. dimensions: probe.embedding.length,
  613. detail: "embed probe ok",
  614. };
  615. }
  616. return {
  617. ok: false,
  618. model: this.modelId,
  619. detail: "embed probe returned null",
  620. };
  621. } catch (probeErr) {
  622. return {
  623. ok: false,
  624. model: this.modelId,
  625. detail:
  626. (err instanceof Error ? err.message : String(err)) +
  627. " | probe: " +
  628. (probeErr instanceof Error ? probeErr.message : String(probeErr)),
  629. };
  630. }
  631. }
  632. }
  633. async embed(
  634. text: string,
  635. options: ProviderEmbedOptions = {},
  636. ): Promise<ProviderEmbedding | null> {
  637. const batch = await this.embedBatch([text], options);
  638. return batch[0] ?? null;
  639. }
  640. async embedBatch(
  641. texts: string[],
  642. options: ProviderEmbedOptions = {},
  643. ): Promise<(ProviderEmbedding | null)[]> {
  644. if (texts.length === 0) return [];
  645. if (this.breaker.shouldFailFast()) {
  646. throw new CircuitOpenError();
  647. }
  648. // Bulk = anything that isn't a single query-time embed. Reindex runs go
  649. // through the self-throttling lane and are attributed as `embeddings-bulk`;
  650. // `embed()` (search path) stays interactive and is never parked behind a
  651. // bulk cooldown (i-yghj098h).
  652. const lane: EmbedLane = texts.length > 1 ? "bulk" : "interactive";
  653. const chunks = chunkArray(texts, this.batchSize);
  654. const results: (ProviderEmbedding | null)[] = new Array(texts.length).fill(null);
  655. // Pre-compute the input-array starting position for each chunk so each
  656. // worker can write its slice of `results` independently — input order is
  657. // preserved end-to-end without a final re-sort step.
  658. const chunkStarts: number[] = new Array(chunks.length);
  659. {
  660. let cursor = 0;
  661. for (let i = 0; i < chunks.length; i++) {
  662. chunkStarts[i] = cursor;
  663. cursor += chunks[i]!.length;
  664. }
  665. }
  666. // Shared state across the worker pool. Each transition is final-write,
  667. // so plain JS scalars are safe — no atomics or locks needed since
  668. // workers only contend on these via cooperative-scheduled awaits.
  669. let nextChunkIdx = 0;
  670. let anySucceeded = false;
  671. let aborted = false;
  672. let circuitTrippedDuringRun: CircuitOpenError | null = null;
  673. // Workers run as parallel async tasks pulling chunks off `nextChunkIdx`
  674. // until the queue is drained or one of the early-exit flags is set.
  675. // Concurrency is capped at min(this.concurrency, chunks.length) so we
  676. // don't spin up idle workers for tiny inputs.
  677. const workerCount = Math.min(this.concurrency, chunks.length);
  678. const dispatchOne = async (): Promise<void> => {
  679. while (true) {
  680. if (aborted || circuitTrippedDuringRun) return;
  681. const idx = nextChunkIdx++;
  682. if (idx >= chunks.length) return;
  683. const chunk = chunks[idx]!;
  684. const start = chunkStarts[idx]!;
  685. // Honor abort/breaker BEFORE issuing the request so we don't waste
  686. // network for a dispatch we know will be discarded.
  687. if (options.signal?.aborted) {
  688. aborted = true;
  689. this.lastError = `aborted by caller${options.signal.reason ? `: ${String(options.signal.reason)}` : ""}`;
  690. return;
  691. }
  692. if (this.breaker.shouldFailFast()) {
  693. // Capture the breaker-open intent so we throw it AFTER all
  694. // currently in-flight workers settle, instead of leaking
  695. // half-completed results. The thrown error is a fresh instance
  696. // (matching legacy behavior).
  697. circuitTrippedDuringRun = new CircuitOpenError();
  698. return;
  699. }
  700. try {
  701. if (lane === "bulk") await this.lane.acquire();
  702. let embeddings: number[][];
  703. try {
  704. embeddings = await this.requestWithRetry(chunk, options, lane);
  705. } finally {
  706. if (lane === "bulk") this.lane.release();
  707. }
  708. if (lane === "bulk") this.lane.noteSuccess();
  709. for (let i = 0; i < chunk.length; i++) {
  710. const embedding = embeddings[i];
  711. if (embedding) {
  712. results[start + i] = {
  713. embedding,
  714. model: this.modelId,
  715. };
  716. anySucceeded = true;
  717. // Record dimensions on first success. Concurrent workers may
  718. // race on this assignment, but they all observe the same
  719. // length so the race is benign.
  720. if (this.dimensions === undefined) {
  721. this.dimensions = embedding.length;
  722. }
  723. }
  724. }
  725. this.breaker.recordSuccess();
  726. } catch (err) {
  727. // A rate limit is backpressure, not a fault. Counting 429s toward the
  728. // breaker turns "the bucket is empty for 30s" into a 5-minute hard
  729. // OPEN, which is the outage the retry budget above exists to avoid.
  730. // The lane cooldown is already the correct control response.
  731. const rateLimited = err instanceof HttpError && err.status === 429;
  732. if (!rateLimited) this.breaker.recordFailure();
  733. if (err instanceof CircuitOpenError) {
  734. circuitTrippedDuringRun = err;
  735. return;
  736. }
  737. // Last-write-wins on lastError matches the legacy semantics — under
  738. // concurrency multiple workers may fail in the same call, but the
  739. // lastError just needs to surface "the most recent cause."
  740. this.lastError = this.formatErrorContext(err);
  741. if (process.env.QMD_EMBED_DEBUG) {
  742. process.stderr.write(
  743. `OpenAIEmbeddingsProvider: chunk failed (${err instanceof Error ? err.message : String(err)})\n`,
  744. );
  745. }
  746. }
  747. }
  748. };
  749. await Promise.all(Array.from({ length: workerCount }, () => dispatchOne()));
  750. // If a worker observed `shouldFailFast()` mid-run, surface the error
  751. // after all in-flight workers have settled.
  752. if (circuitTrippedDuringRun) throw circuitTrippedDuringRun;
  753. // Clear lastError on a fully-successful sweep (every input got an embedding).
  754. if (anySucceeded && results.every((r) => r !== null)) {
  755. this.lastError = undefined;
  756. }
  757. return results;
  758. }
  759. async dispose(): Promise<void> {
  760. // Nothing to release — fetch handles its own connection pooling.
  761. // Reset the breaker so a re-instantiation starts fresh.
  762. this.breaker.reset();
  763. }
  764. // ────────────────────── Internals ──────────────────────
  765. /**
  766. * Format a request-failure context string for `lastError`. Includes endpoint
  767. * + HTTP status + body preview when the error was an `HttpError`, otherwise
  768. * falls back to the message of the underlying error (or the value itself
  769. * when not an Error). Kept short — body preview is already capped at 1024
  770. * chars by `HttpError`, but we trim further here for the dimension-probe
  771. * thrown error which surfaces directly to users.
  772. */
  773. private formatErrorContext(err: unknown): string {
  774. if (err instanceof HttpError) {
  775. const preview = err.bodyPreview.replace(/\s+/g, " ").trim().slice(0, 240);
  776. return `endpoint=${this.endpoint}/v1/embeddings status=${err.status}${preview ? ` body="${preview}"` : ""}`;
  777. }
  778. if (err instanceof Error) {
  779. return `endpoint=${this.endpoint}/v1/embeddings error="${err.message}"`;
  780. }
  781. return `endpoint=${this.endpoint}/v1/embeddings error="${String(err)}"`;
  782. }
  783. private buildHeaders(lane: EmbedLane = "interactive"): Record<string, string> {
  784. const headers: Record<string, string> = {
  785. "Content-Type": "application/json",
  786. "Accept": "application/json",
  787. // Advisory caller attribution for the ai.mm.mk gateway (NULL-safe, never
  788. // auth). Single chokepoint for both /health and /v1/embeddings.
  789. // `via` distinguishes bulk reindex traffic from query-time embeds so the
  790. // gateway can budget the two separately (i-yghj098h part 1b) — the
  791. // gateway-side per-caller bucket keys off this label.
  792. "X-AI-Caller": aiCallerHeaderValue(
  793. lane === "bulk" ? "embeddings-bulk" : "embeddings",
  794. ),
  795. };
  796. if (this.apiKey) {
  797. headers["Authorization"] = `Bearer ${this.apiKey}`;
  798. }
  799. return headers;
  800. }
  801. /**
  802. * Single HTTP request with retry on 429/503. Returns embeddings indexed
  803. * the same as `texts`. Throws on non-retryable failure or all attempts
  804. * exhausted.
  805. */
  806. private async requestWithRetry(
  807. texts: string[],
  808. options: ProviderEmbedOptions,
  809. lane: EmbedLane = "interactive",
  810. ): Promise<number[][]> {
  811. let lastErr: unknown = null;
  812. // 429s draw on their own budget: a token bucket that refills in 30s must
  813. // not be able to exhaust a schedule whose total wait is 21s (i-yghj098h).
  814. let backoffAttempt = 0;
  815. let rateLimitAttempt = 0;
  816. for (;;) {
  817. // Honor user abort BEFORE issuing the call (avoids wasted network)
  818. if (options.signal?.aborted) {
  819. throw new Error("aborted by caller");
  820. }
  821. try {
  822. return await this.requestOnce(texts, options, lane);
  823. } catch (err) {
  824. lastErr = err;
  825. const status = err instanceof HttpError ? err.status : 0;
  826. if (!isRetryableStatus(status)) throw err;
  827. if (status === 429) {
  828. if (rateLimitAttempt >= this.rateLimitRetries) throw err;
  829. const waitMs = this.rateLimitWaitMs(err as HttpError, rateLimitAttempt);
  830. rateLimitAttempt++;
  831. if (lane === "bulk") {
  832. // Pause the whole pool, not just this worker: the other workers
  833. // hammering the same empty bucket is exactly what turns one 429
  834. // into a cascade, and what starves interactive callers.
  835. await this.lane.penalize(waitMs);
  836. } else {
  837. await this.sleep(waitMs);
  838. }
  839. continue;
  840. }
  841. if (backoffAttempt >= this.retryBackoffsMs.length) break;
  842. await this.sleep(this.retryBackoffsMs[backoffAttempt]!);
  843. backoffAttempt++;
  844. }
  845. }
  846. // Exhausted retries → throw the last error so caller marks the chunk null
  847. throw lastErr ?? new Error("requestWithRetry exhausted");
  848. }
  849. /**
  850. * How long to wait after a 429. The server's own `Retry-After` wins when it
  851. * gave one; otherwise fall back to the configured schedule (so injected test
  852. * schedules stay authoritative) and then to an exponential 5s/10s/20s… ramp.
  853. * Always clamped to `RATE_LIMIT_MAX_BACKOFF_MS`.
  854. */
  855. private rateLimitWaitMs(err: HttpError, rateLimitAttempt: number): number {
  856. const scheduled =
  857. this.retryBackoffsMs[
  858. Math.min(rateLimitAttempt, this.retryBackoffsMs.length - 1)
  859. ];
  860. const fallback =
  861. scheduled ?? RATE_LIMIT_BASE_BACKOFF_MS * Math.pow(2, rateLimitAttempt);
  862. return Math.min(
  863. Math.max(err.retryAfterMs ?? 0, fallback),
  864. RATE_LIMIT_MAX_BACKOFF_MS,
  865. );
  866. }
  867. /**
  868. * Issue one HTTP attempt to `POST /v1/embeddings`. Does NOT retry.
  869. */
  870. private async requestOnce(
  871. texts: string[],
  872. options: ProviderEmbedOptions,
  873. lane: EmbedLane = "interactive",
  874. ): Promise<number[][]> {
  875. const { signal: attemptSig, cleanup } = buildAttemptSignal(options.signal, this.timeoutMs);
  876. try {
  877. const body = JSON.stringify({
  878. model: options.model ?? this.upstreamModel,
  879. input: texts,
  880. });
  881. const resp = await this.fetchImpl(`${this.endpoint}/v1/embeddings`, {
  882. method: "POST",
  883. headers: this.buildHeaders(lane),
  884. body,
  885. signal: attemptSig,
  886. });
  887. if (!resp.ok) {
  888. const text = await resp.text().catch(() => "");
  889. // ai.mm.mk states the cooldown in the JSON body, not always in a
  890. // Retry-After header — read both (i-yghj098h).
  891. const retryAfterMs =
  892. resp.status === 429
  893. ? parseRetryAfterMs(
  894. resp.headers?.get?.("retry-after"),
  895. text,
  896. this.now,
  897. )
  898. : undefined;
  899. throw new HttpError(resp.status, text, retryAfterMs);
  900. }
  901. let parsed: OpenAIEmbeddingsResponse;
  902. try {
  903. parsed = (await resp.json()) as OpenAIEmbeddingsResponse;
  904. } catch (err) {
  905. throw new Error(
  906. `OpenAIEmbeddingsProvider: malformed JSON from ${this.endpoint}/v1/embeddings: ${err instanceof Error ? err.message : String(err)}`,
  907. );
  908. }
  909. if (!parsed || !Array.isArray(parsed.data)) {
  910. throw new Error(
  911. `OpenAIEmbeddingsProvider: response missing "data" array (got ${typeof parsed})`,
  912. );
  913. }
  914. // Sort by index to match input order (in case server returns out-of-order).
  915. const out: number[][] = new Array(texts.length);
  916. for (const item of parsed.data) {
  917. if (
  918. typeof item.index !== "number" ||
  919. item.index < 0 ||
  920. item.index >= texts.length
  921. ) {
  922. throw new Error(
  923. `OpenAIEmbeddingsProvider: data item index out of range (${item.index}, expected 0..${texts.length - 1})`,
  924. );
  925. }
  926. if (!Array.isArray(item.embedding)) {
  927. throw new Error(
  928. `OpenAIEmbeddingsProvider: data[${item.index}].embedding is not an array`,
  929. );
  930. }
  931. out[item.index] = item.embedding;
  932. }
  933. // Sanity check — every slot must be filled
  934. for (let i = 0; i < texts.length; i++) {
  935. if (!out[i]) {
  936. throw new Error(
  937. `OpenAIEmbeddingsProvider: response missing embedding for index ${i}`,
  938. );
  939. }
  940. }
  941. return out;
  942. } finally {
  943. cleanup();
  944. }
  945. }
  946. }