openai.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  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 a GPU worker (e.g. ai.mm.mk →
  9. * qmd-embed-worker on `models` LXC, RTX 4090) instead of running
  10. * node-llama-cpp locally.
  11. *
  12. * Features:
  13. * - Batches input in groups of ≤64 (configurable via QMD_EMBED_BATCH_SIZE)
  14. * - Retries 429 / 503 with exponential backoff (1s, 4s, 16s)
  15. * - 4xx (non-429) → no retry, count as failure
  16. * - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min,
  17. * callers can use this to fall back to a local provider
  18. * - Per-call timeout via AbortSignal (default QMD_EMBED_TIMEOUT_MS=30000)
  19. * - Healthcheck via `GET /health` if available, else a probe embed call
  20. */
  21. import os from "node:os";
  22. // ─────────────────────────── Configuration ───────────────────────────────────
  23. /**
  24. * Default batch size — most OpenAI-compatible embedding endpoints accept up to
  25. * 2048 inputs per call but for memory and latency we cap at 64.
  26. */
  27. export const DEFAULT_BATCH_SIZE = 64;
  28. /**
  29. * Default in-flight concurrency cap for `embedBatch`. The qmd-embed-worker
  30. * exposes a 4-way semaphore (`MAX_CONCURRENT_REQUESTS=4`) and idles at
  31. * queue-depth 1.0 under sequential clients (i-fkpnar9i baseline). Defaulting
  32. * to 4 matches the worker's advertised concurrency without overshooting the
  33. * GPU. Override per-deploy via `QMD_EMBED_CONCURRENCY`. Setting to 1 reverts
  34. * to the legacy sequential dispatch.
  35. */
  36. export const DEFAULT_CONCURRENCY = 4;
  37. /**
  38. * Default per-request timeout (30 s). embeddinggemma-300M on RTX 4090 takes
  39. * <500ms per batch of 64 in practice; 30s is a safe upper bound.
  40. */
  41. export const DEFAULT_TIMEOUT_MS = 30_000;
  42. /**
  43. * Retry backoff schedule (ms) for 429/503 responses. 3 attempts total
  44. * (initial + 2 retries) — aligns with issue spec "1s/4s/16s".
  45. */
  46. export const RETRY_BACKOFFS_MS = [1_000, 4_000, 16_000];
  47. /**
  48. * Circuit breaker — flips OPEN when error rate exceeds threshold within
  49. * window. While OPEN, every call fails fast so the caller can fall back.
  50. */
  51. export const CIRCUIT_WINDOW_MS = 60_000;
  52. export const CIRCUIT_OPEN_DURATION_MS = 5 * 60_000;
  53. export const CIRCUIT_FAILURE_RATE_THRESHOLD = 0.5;
  54. export const CIRCUIT_MIN_SAMPLES = 4;
  55. // ─────────────────────────── Helpers ─────────────────────────────────────────
  56. function defaultSleep(ms) {
  57. return new Promise((resolve) => setTimeout(resolve, ms));
  58. }
  59. /**
  60. * Build the advisory `X-AI-Caller` attribution header value (Oivo ai.mm.mk
  61. * "Observability-Driven Fleet Self-Improvement" rollout, Thread 1). Format
  62. * mirrors Oivo's `cli/src/shared/aiCallerHeader.ts` EXACTLY:
  63. * site=<file:line | tool file>; mc=<machine>; sid=<session-8char>;
  64. * comp=<component>; via=<logical-label>
  65. *
  66. * Advisory metadata ONLY — never an auth/authorization input. `mc`/`sid` come
  67. * from the Oivo fleet env when qmd runs as a delegated embedder; both degrade
  68. * to `-`/short-hostname safely when qmd runs standalone. Values are sanitized
  69. * (`;` + control chars stripped, capped at 120) so a pathological env value can
  70. * never break the header grammar.
  71. */
  72. function aiCallerHeaderValue(via) {
  73. const clean = (raw, fallback) => {
  74. if (raw == null)
  75. return fallback;
  76. let out = "";
  77. for (const ch of String(raw)) {
  78. const code = ch.charCodeAt(0);
  79. out += code < 32 || code === 127 || ch === ";" ? " " : ch;
  80. }
  81. const collapsed = out.replace(/\s+/g, " ").trim();
  82. return (collapsed || fallback).slice(0, 120);
  83. };
  84. let mc = (process.env.OIVO_MACHINE_NAME ?? "").trim();
  85. if (!mc) {
  86. try {
  87. mc = os.hostname().split(".")[0] || "unknown";
  88. }
  89. catch {
  90. mc = "unknown";
  91. }
  92. }
  93. const sid = (process.env.OIVO_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || "-").slice(0, 8);
  94. return `site=src/embedding/openai.ts; mc=${clean(mc, "unknown")}; sid=${clean(sid, "-")}; comp=qmd; via=${clean(via, "-")}`;
  95. }
  96. /**
  97. * Build the merged AbortSignal for a single HTTP attempt: combines an
  98. * external `userSignal` (from caller / withLLMSession) with a per-attempt
  99. * timeout signal. Returns the merged signal AND the timeout id so the
  100. * caller can `clearTimeout` after the attempt completes (avoids leaks).
  101. */
  102. function buildAttemptSignal(userSignal, timeoutMs) {
  103. const ctrl = new AbortController();
  104. const timeoutId = setTimeout(() => {
  105. ctrl.abort(new Error(`Request timed out after ${timeoutMs}ms`));
  106. }, timeoutMs);
  107. // Don't keep process alive just for this timer
  108. if (typeof timeoutId === "object" && timeoutId !== null && "unref" in timeoutId) {
  109. timeoutId.unref();
  110. }
  111. const onUserAbort = () => ctrl.abort(userSignal?.reason);
  112. if (userSignal) {
  113. if (userSignal.aborted) {
  114. ctrl.abort(userSignal.reason);
  115. }
  116. else {
  117. userSignal.addEventListener("abort", onUserAbort, { once: true });
  118. }
  119. }
  120. const cleanup = () => {
  121. clearTimeout(timeoutId);
  122. if (userSignal)
  123. userSignal.removeEventListener("abort", onUserAbort);
  124. };
  125. return { signal: ctrl.signal, cleanup };
  126. }
  127. /**
  128. * Determine whether an HTTP status is retryable. 429 (Too Many Requests)
  129. * and 503 (Service Unavailable) are retried; 4xx (other than 429) are not.
  130. */
  131. export function isRetryableStatus(status) {
  132. return status === 429 || status === 503;
  133. }
  134. /**
  135. * Chunk an array into pieces of ≤ size each. `size` MUST be ≥ 1.
  136. */
  137. export function chunkArray(items, size) {
  138. if (size < 1)
  139. throw new Error(`chunkArray: size must be ≥ 1, got ${size}`);
  140. if (items.length <= size)
  141. return items.length === 0 ? [] : [items];
  142. const out = [];
  143. for (let i = 0; i < items.length; i += size) {
  144. out.push(items.slice(i, i + size));
  145. }
  146. return out;
  147. }
  148. // ─────────────────────────── Circuit Breaker ─────────────────────────────────
  149. /**
  150. * Sliding-window circuit breaker. Tracks the last N samples (min 4) over a
  151. * 60-second window; flips OPEN when failure rate exceeds 50%, then auto-
  152. * resets to HALF-OPEN after 5 minutes — at which point the next probe
  153. * decides whether to close (success) or re-open (failure).
  154. */
  155. export class CircuitBreaker {
  156. samples = [];
  157. state = "closed";
  158. openedAt = null;
  159. windowMs;
  160. openDurationMs;
  161. threshold;
  162. minSamples;
  163. now;
  164. constructor(opts = {}) {
  165. this.windowMs = opts.windowMs ?? CIRCUIT_WINDOW_MS;
  166. this.openDurationMs = opts.openDurationMs ?? CIRCUIT_OPEN_DURATION_MS;
  167. this.threshold = opts.threshold ?? CIRCUIT_FAILURE_RATE_THRESHOLD;
  168. this.minSamples = opts.minSamples ?? CIRCUIT_MIN_SAMPLES;
  169. this.now = opts.now ?? Date.now;
  170. }
  171. getState() {
  172. this.tickAutoReset();
  173. return this.state;
  174. }
  175. /**
  176. * Returns true when calls should be short-circuited (skip HTTP, fall back).
  177. * Side-effects: may transition OPEN → HALF-OPEN if the open window expired.
  178. */
  179. shouldFailFast() {
  180. return this.getState() === "open";
  181. }
  182. /** Record a successful call. */
  183. recordSuccess() {
  184. // Honor the time-based OPEN→HALF-OPEN transition before deciding what
  185. // to do with this sample. Without this, a success that lands AFTER the
  186. // open window expired would still see state==="open" and never close
  187. // the breaker (a probe call could only flip it via getState()).
  188. this.tickAutoReset();
  189. this.pushSample(true);
  190. if (this.state === "half-open") {
  191. this.state = "closed";
  192. this.openedAt = null;
  193. }
  194. }
  195. /** Record a failed call. May trigger OPEN. */
  196. recordFailure() {
  197. // Same reasoning as recordSuccess — apply lazy auto-reset before
  198. // classifying the sample.
  199. this.tickAutoReset();
  200. this.pushSample(false);
  201. if (this.state === "half-open") {
  202. // Probe failed — re-open
  203. this.state = "open";
  204. this.openedAt = this.now();
  205. return;
  206. }
  207. if (this.state === "closed")
  208. this.evaluate();
  209. }
  210. /** Force-reset the breaker (used by tests / admin) */
  211. reset() {
  212. this.samples = [];
  213. this.state = "closed";
  214. this.openedAt = null;
  215. }
  216. pushSample(ok) {
  217. const ts = this.now();
  218. this.samples.push({ ts, ok });
  219. // Drop samples outside the window
  220. const cutoff = ts - this.windowMs;
  221. while (this.samples.length > 0 && this.samples[0].ts < cutoff) {
  222. this.samples.shift();
  223. }
  224. }
  225. evaluate() {
  226. if (this.samples.length < this.minSamples)
  227. return;
  228. const failures = this.samples.filter((s) => !s.ok).length;
  229. const rate = failures / this.samples.length;
  230. if (rate > this.threshold) {
  231. this.state = "open";
  232. this.openedAt = this.now();
  233. }
  234. }
  235. tickAutoReset() {
  236. if (this.state === "open" && this.openedAt !== null) {
  237. if (this.now() - this.openedAt >= this.openDurationMs) {
  238. this.state = "half-open";
  239. }
  240. }
  241. }
  242. }
  243. // ─────────────────────────── Errors ──────────────────────────────────────────
  244. /**
  245. * Raised when the circuit breaker is OPEN and a call is short-circuited.
  246. * Callers (e.g. fallback wrapper) can catch this to switch to local provider.
  247. */
  248. export class CircuitOpenError extends Error {
  249. constructor(message = "OpenAIEmbeddingsProvider circuit is OPEN") {
  250. super(message);
  251. this.name = "CircuitOpenError";
  252. }
  253. }
  254. /**
  255. * Persistent (non-retryable) HTTP error from upstream. Includes status code.
  256. */
  257. export class HttpError extends Error {
  258. status;
  259. bodyPreview;
  260. constructor(status, bodyPreview) {
  261. super(`HTTP ${status}: ${bodyPreview.slice(0, 200)}`);
  262. this.name = "HttpError";
  263. this.status = status;
  264. this.bodyPreview = bodyPreview.slice(0, 1024);
  265. }
  266. }
  267. // ─────────────────────────── Provider ────────────────────────────────────────
  268. export class OpenAIEmbeddingsProvider {
  269. kind = "openai";
  270. endpoint;
  271. apiKey;
  272. modelId;
  273. upstreamModel;
  274. batchSize;
  275. concurrency;
  276. timeoutMs;
  277. fetchImpl;
  278. retryBackoffsMs;
  279. sleep;
  280. now;
  281. dimensions = undefined;
  282. lastError = undefined;
  283. breaker;
  284. constructor(config) {
  285. if (!config.endpoint) {
  286. throw new Error("OpenAIEmbeddingsProvider: endpoint is required");
  287. }
  288. this.endpoint = config.endpoint.replace(/\/+$/, "");
  289. this.apiKey = config.apiKey;
  290. this.modelId = config.modelId ?? "embeddinggemma";
  291. this.upstreamModel = config.upstreamModel ?? this.modelId;
  292. this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
  293. this.concurrency = config.concurrency ?? DEFAULT_CONCURRENCY;
  294. this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  295. this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
  296. this.retryBackoffsMs = config.retryBackoffsMs ?? RETRY_BACKOFFS_MS;
  297. this.sleep = config.sleep ?? defaultSleep;
  298. this.now = config.now ?? Date.now;
  299. this.breaker = new CircuitBreaker({ now: this.now });
  300. if (!this.fetchImpl) {
  301. throw new Error("OpenAIEmbeddingsProvider: global fetch is unavailable. " +
  302. "Provide a `fetchImpl` config option (Node ≥18 ships fetch by default).");
  303. }
  304. if (this.batchSize < 1) {
  305. throw new Error(`OpenAIEmbeddingsProvider: batchSize must be ≥ 1, got ${this.batchSize}`);
  306. }
  307. if (this.concurrency < 1) {
  308. throw new Error(`OpenAIEmbeddingsProvider: concurrency must be ≥ 1, got ${this.concurrency}`);
  309. }
  310. }
  311. getModelId() {
  312. return this.modelId;
  313. }
  314. getDimensions() {
  315. return this.dimensions;
  316. }
  317. /**
  318. * Most recent per-chunk failure message (HTTP status + body preview, malformed
  319. * JSON, timeout, abort reason). Returns `undefined` after a successful call
  320. * or before the first call. See `EmbeddingProvider.getLastError`.
  321. */
  322. getLastError() {
  323. return this.lastError;
  324. }
  325. /** Endpoint URL configured at construction time — used by callers when
  326. * building error messages for failed first-chunk probes. */
  327. getEndpoint() {
  328. return this.endpoint;
  329. }
  330. async healthcheck(signal) {
  331. // Try GET /health first (worker exposes it). Fall back to probe embed.
  332. try {
  333. const { signal: attemptSig, cleanup } = buildAttemptSignal(signal, this.timeoutMs);
  334. try {
  335. const resp = await this.fetchImpl(`${this.endpoint}/health`, {
  336. method: "GET",
  337. headers: this.buildHeaders(),
  338. signal: attemptSig,
  339. });
  340. if (resp.ok) {
  341. return {
  342. ok: true,
  343. model: this.modelId,
  344. dimensions: this.dimensions,
  345. detail: `GET /health → ${resp.status}`,
  346. };
  347. }
  348. return {
  349. ok: false,
  350. model: this.modelId,
  351. detail: `GET /health → HTTP ${resp.status}`,
  352. };
  353. }
  354. finally {
  355. cleanup();
  356. }
  357. }
  358. catch (err) {
  359. // Endpoint may not implement /health — try a single embed probe instead.
  360. try {
  361. const probe = await this.embed("healthcheck", { signal });
  362. if (probe) {
  363. return {
  364. ok: true,
  365. model: this.modelId,
  366. dimensions: probe.embedding.length,
  367. detail: "embed probe ok",
  368. };
  369. }
  370. return {
  371. ok: false,
  372. model: this.modelId,
  373. detail: "embed probe returned null",
  374. };
  375. }
  376. catch (probeErr) {
  377. return {
  378. ok: false,
  379. model: this.modelId,
  380. detail: (err instanceof Error ? err.message : String(err)) +
  381. " | probe: " +
  382. (probeErr instanceof Error ? probeErr.message : String(probeErr)),
  383. };
  384. }
  385. }
  386. }
  387. async embed(text, options = {}) {
  388. const batch = await this.embedBatch([text], options);
  389. return batch[0] ?? null;
  390. }
  391. async embedBatch(texts, options = {}) {
  392. if (texts.length === 0)
  393. return [];
  394. if (this.breaker.shouldFailFast()) {
  395. throw new CircuitOpenError();
  396. }
  397. const chunks = chunkArray(texts, this.batchSize);
  398. const results = new Array(texts.length).fill(null);
  399. // Pre-compute the input-array starting position for each chunk so each
  400. // worker can write its slice of `results` independently — input order is
  401. // preserved end-to-end without a final re-sort step.
  402. const chunkStarts = new Array(chunks.length);
  403. {
  404. let cursor = 0;
  405. for (let i = 0; i < chunks.length; i++) {
  406. chunkStarts[i] = cursor;
  407. cursor += chunks[i].length;
  408. }
  409. }
  410. // Shared state across the worker pool. Each transition is final-write,
  411. // so plain JS scalars are safe — no atomics or locks needed since
  412. // workers only contend on these via cooperative-scheduled awaits.
  413. let nextChunkIdx = 0;
  414. let anySucceeded = false;
  415. let aborted = false;
  416. let circuitTrippedDuringRun = null;
  417. // Workers run as parallel async tasks pulling chunks off `nextChunkIdx`
  418. // until the queue is drained or one of the early-exit flags is set.
  419. // Concurrency is capped at min(this.concurrency, chunks.length) so we
  420. // don't spin up idle workers for tiny inputs.
  421. const workerCount = Math.min(this.concurrency, chunks.length);
  422. const dispatchOne = async () => {
  423. while (true) {
  424. if (aborted || circuitTrippedDuringRun)
  425. return;
  426. const idx = nextChunkIdx++;
  427. if (idx >= chunks.length)
  428. return;
  429. const chunk = chunks[idx];
  430. const start = chunkStarts[idx];
  431. // Honor abort/breaker BEFORE issuing the request so we don't waste
  432. // network for a dispatch we know will be discarded.
  433. if (options.signal?.aborted) {
  434. aborted = true;
  435. this.lastError = `aborted by caller${options.signal.reason ? `: ${String(options.signal.reason)}` : ""}`;
  436. return;
  437. }
  438. if (this.breaker.shouldFailFast()) {
  439. // Capture the breaker-open intent so we throw it AFTER all
  440. // currently in-flight workers settle, instead of leaking
  441. // half-completed results. The thrown error is a fresh instance
  442. // (matching legacy behavior).
  443. circuitTrippedDuringRun = new CircuitOpenError();
  444. return;
  445. }
  446. try {
  447. const embeddings = await this.requestWithRetry(chunk, options);
  448. for (let i = 0; i < chunk.length; i++) {
  449. const embedding = embeddings[i];
  450. if (embedding) {
  451. results[start + i] = {
  452. embedding,
  453. model: this.modelId,
  454. };
  455. anySucceeded = true;
  456. // Record dimensions on first success. Concurrent workers may
  457. // race on this assignment, but they all observe the same
  458. // length so the race is benign.
  459. if (this.dimensions === undefined) {
  460. this.dimensions = embedding.length;
  461. }
  462. }
  463. }
  464. this.breaker.recordSuccess();
  465. }
  466. catch (err) {
  467. this.breaker.recordFailure();
  468. if (err instanceof CircuitOpenError) {
  469. circuitTrippedDuringRun = err;
  470. return;
  471. }
  472. // Last-write-wins on lastError matches the legacy semantics — under
  473. // concurrency multiple workers may fail in the same call, but the
  474. // lastError just needs to surface "the most recent cause."
  475. this.lastError = this.formatErrorContext(err);
  476. if (process.env.QMD_EMBED_DEBUG) {
  477. process.stderr.write(`OpenAIEmbeddingsProvider: chunk failed (${err instanceof Error ? err.message : String(err)})\n`);
  478. }
  479. }
  480. }
  481. };
  482. await Promise.all(Array.from({ length: workerCount }, () => dispatchOne()));
  483. // If a worker observed `shouldFailFast()` mid-run, surface the error
  484. // after all in-flight workers have settled.
  485. if (circuitTrippedDuringRun)
  486. throw circuitTrippedDuringRun;
  487. // Clear lastError on a fully-successful sweep (every input got an embedding).
  488. if (anySucceeded && results.every((r) => r !== null)) {
  489. this.lastError = undefined;
  490. }
  491. return results;
  492. }
  493. async dispose() {
  494. // Nothing to release — fetch handles its own connection pooling.
  495. // Reset the breaker so a re-instantiation starts fresh.
  496. this.breaker.reset();
  497. }
  498. // ────────────────────── Internals ──────────────────────
  499. /**
  500. * Format a request-failure context string for `lastError`. Includes endpoint
  501. * + HTTP status + body preview when the error was an `HttpError`, otherwise
  502. * falls back to the message of the underlying error (or the value itself
  503. * when not an Error). Kept short — body preview is already capped at 1024
  504. * chars by `HttpError`, but we trim further here for the dimension-probe
  505. * thrown error which surfaces directly to users.
  506. */
  507. formatErrorContext(err) {
  508. if (err instanceof HttpError) {
  509. const preview = err.bodyPreview.replace(/\s+/g, " ").trim().slice(0, 240);
  510. return `endpoint=${this.endpoint}/v1/embeddings status=${err.status}${preview ? ` body="${preview}"` : ""}`;
  511. }
  512. if (err instanceof Error) {
  513. return `endpoint=${this.endpoint}/v1/embeddings error="${err.message}"`;
  514. }
  515. return `endpoint=${this.endpoint}/v1/embeddings error="${String(err)}"`;
  516. }
  517. buildHeaders() {
  518. const headers = {
  519. "Content-Type": "application/json",
  520. "Accept": "application/json",
  521. // Advisory caller attribution for the ai.mm.mk gateway (NULL-safe, never
  522. // auth). Single chokepoint for both /health and /v1/embeddings.
  523. "X-AI-Caller": aiCallerHeaderValue("embeddings"),
  524. };
  525. if (this.apiKey) {
  526. headers["Authorization"] = `Bearer ${this.apiKey}`;
  527. }
  528. return headers;
  529. }
  530. /**
  531. * Single HTTP request with retry on 429/503. Returns embeddings indexed
  532. * the same as `texts`. Throws on non-retryable failure or all attempts
  533. * exhausted.
  534. */
  535. async requestWithRetry(texts, options) {
  536. let lastErr = null;
  537. const maxAttempts = this.retryBackoffsMs.length + 1;
  538. for (let attempt = 0; attempt < maxAttempts; attempt++) {
  539. // Honor user abort BEFORE issuing the call (avoids wasted network)
  540. if (options.signal?.aborted) {
  541. throw new Error("aborted by caller");
  542. }
  543. try {
  544. return await this.requestOnce(texts, options);
  545. }
  546. catch (err) {
  547. lastErr = err;
  548. const retryable = err instanceof HttpError ? isRetryableStatus(err.status) : false;
  549. if (!retryable)
  550. throw err;
  551. if (attempt < this.retryBackoffsMs.length) {
  552. await this.sleep(this.retryBackoffsMs[attempt]);
  553. }
  554. }
  555. }
  556. // Exhausted retries → throw the last error so caller marks the chunk null
  557. throw lastErr ?? new Error("requestWithRetry exhausted");
  558. }
  559. /**
  560. * Issue one HTTP attempt to `POST /v1/embeddings`. Does NOT retry.
  561. */
  562. async requestOnce(texts, options) {
  563. const { signal: attemptSig, cleanup } = buildAttemptSignal(options.signal, this.timeoutMs);
  564. try {
  565. const body = JSON.stringify({
  566. model: options.model ?? this.upstreamModel,
  567. input: texts,
  568. });
  569. const resp = await this.fetchImpl(`${this.endpoint}/v1/embeddings`, {
  570. method: "POST",
  571. headers: this.buildHeaders(),
  572. body,
  573. signal: attemptSig,
  574. });
  575. if (!resp.ok) {
  576. const text = await resp.text().catch(() => "");
  577. throw new HttpError(resp.status, text);
  578. }
  579. let parsed;
  580. try {
  581. parsed = (await resp.json());
  582. }
  583. catch (err) {
  584. throw new Error(`OpenAIEmbeddingsProvider: malformed JSON from ${this.endpoint}/v1/embeddings: ${err instanceof Error ? err.message : String(err)}`);
  585. }
  586. if (!parsed || !Array.isArray(parsed.data)) {
  587. throw new Error(`OpenAIEmbeddingsProvider: response missing "data" array (got ${typeof parsed})`);
  588. }
  589. // Sort by index to match input order (in case server returns out-of-order).
  590. const out = new Array(texts.length);
  591. for (const item of parsed.data) {
  592. if (typeof item.index !== "number" ||
  593. item.index < 0 ||
  594. item.index >= texts.length) {
  595. throw new Error(`OpenAIEmbeddingsProvider: data item index out of range (${item.index}, expected 0..${texts.length - 1})`);
  596. }
  597. if (!Array.isArray(item.embedding)) {
  598. throw new Error(`OpenAIEmbeddingsProvider: data[${item.index}].embedding is not an array`);
  599. }
  600. out[item.index] = item.embedding;
  601. }
  602. // Sanity check — every slot must be filled
  603. for (let i = 0; i < texts.length; i++) {
  604. if (!out[i]) {
  605. throw new Error(`OpenAIEmbeddingsProvider: response missing embedding for index ${i}`);
  606. }
  607. }
  608. return out;
  609. }
  610. finally {
  611. cleanup();
  612. }
  613. }
  614. }