autofallback.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * autofallback.ts - AutoFallbackEmbeddingProvider.
  3. *
  4. * Historical internal helper for two commercial API providers. It is not
  5. * exported by the package or selected by the factory.
  6. *
  7. * Behavior summary:
  8. * - Primary call succeeds → return; record success.
  9. * - Primary throws CircuitOpenError → fall back, log WARN once per transition.
  10. * - Primary throws any other error → fall back for THIS call only;
  11. * count toward the failure-streak threshold.
  12. * - When failure streak crosses threshold (default 3) → set our own
  13. * "open until" timestamp; until expiry, route directly to fallback
  14. * (skip primary entirely).
  15. * - On expiry, retry primary opportunistically.
  16. * - getModelId / getDimensions / dispose are delegated to whichever
  17. * provider is currently active (or to the primary if both are usable).
  18. */
  19. import { CircuitOpenError } from "./openai.js";
  20. import { commercialApiHold } from "../model-policy.js";
  21. const DEFAULT_FAILURE_STREAK = 3;
  22. const DEFAULT_COOLDOWN_MS = 5 * 60_000;
  23. function defaultWarn(msg) {
  24. process.stderr.write(`${msg}\n`);
  25. }
  26. export class AutoFallbackEmbeddingProvider {
  27. kind;
  28. primary;
  29. fallback;
  30. failureStreakThreshold;
  31. cooldownMs;
  32. warn;
  33. now;
  34. failureStreak = 0;
  35. fallbackUntil = null;
  36. lastTransitionState = "primary";
  37. constructor(config) {
  38. throw commercialApiHold("automatic provider fallback is disabled; commercial API failures must remain HOLD");
  39. if (!config.primary)
  40. throw new Error("AutoFallbackEmbeddingProvider: primary is required");
  41. if (!config.fallback)
  42. throw new Error("AutoFallbackEmbeddingProvider: fallback is required");
  43. if (config.primary === config.fallback) {
  44. throw new Error("AutoFallbackEmbeddingProvider: primary and fallback must differ");
  45. }
  46. this.primary = config.primary;
  47. this.fallback = config.fallback;
  48. // Inherit the primary's kind for callers introspecting `provider.kind`.
  49. this.kind = config.primary.kind;
  50. this.failureStreakThreshold = config.failureStreakThreshold ?? DEFAULT_FAILURE_STREAK;
  51. this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
  52. this.warn = config.warn ?? defaultWarn;
  53. this.now = config.now ?? Date.now;
  54. }
  55. /**
  56. * Stable model id reported by the primary. The model-id guard runs against
  57. * the primary's id because that's what callers actually want when the
  58. * remote endpoint is online; a secondary commercial provider must report a
  59. * compatible id.
  60. */
  61. getModelId() {
  62. return this.primary.getModelId();
  63. }
  64. getDimensions() {
  65. return this.primary.getDimensions() ?? this.fallback.getDimensions();
  66. }
  67. /**
  68. * Combined last-error from primary + fallback. Either, neither, or both legs
  69. * may have a tracked error after `embed()`/`embedBatch()` runs:
  70. * - Both clean → undefined
  71. * - Primary failed, fallback rescued → returns primary error (most useful)
  72. * - Both failed → returns "primary: <msg> | fallback: <msg>"
  73. * - Only primary skipped (cooldown), fallback also failed → returns fallback error
  74. */
  75. getLastError() {
  76. const primaryErr = this.primary.getLastError?.();
  77. const fallbackErr = this.fallback.getLastError?.();
  78. if (primaryErr && fallbackErr) {
  79. return `primary: ${primaryErr} | fallback: ${fallbackErr}`;
  80. }
  81. return primaryErr ?? fallbackErr;
  82. }
  83. /** Current routing state (mostly for tests + observability) */
  84. getRoutingState() {
  85. if (this.fallbackUntil !== null && this.now() < this.fallbackUntil) {
  86. return "fallback";
  87. }
  88. return "primary";
  89. }
  90. /** Reset failure-streak + cooldown (mostly for tests / admin) */
  91. reset() {
  92. this.failureStreak = 0;
  93. this.fallbackUntil = null;
  94. this.transition("primary");
  95. }
  96. async healthcheck(signal) {
  97. // Primary first; if degraded, check fallback so callers can still tell
  98. // whether they have *any* working backend.
  99. const primaryHealth = await this.primary.healthcheck(signal);
  100. if (primaryHealth.ok)
  101. return primaryHealth;
  102. const fallbackHealth = await this.fallback.healthcheck(signal);
  103. return {
  104. ok: fallbackHealth.ok,
  105. model: this.primary.getModelId(),
  106. dimensions: primaryHealth.dimensions ?? fallbackHealth.dimensions,
  107. detail: `primary: ${primaryHealth.detail ?? "fail"} | fallback: ${fallbackHealth.detail ?? (fallbackHealth.ok ? "ok" : "fail")}`,
  108. };
  109. }
  110. async embed(text, options = {}) {
  111. return this.run((p, opts) => p.embed(text, opts), options);
  112. }
  113. async embedBatch(texts, options = {}) {
  114. if (texts.length === 0)
  115. return [];
  116. return this.run((p, opts) => p.embedBatch(texts, opts), options, () => texts.map(() => null));
  117. }
  118. async dispose() {
  119. await Promise.allSettled([this.primary.dispose(), this.fallback.dispose()]);
  120. }
  121. // ────────────────────── Internals ──────────────────────
  122. /**
  123. * Generic dispatcher: try primary if not in cooldown, fall back on
  124. * `CircuitOpenError`, count other errors against the failure streak.
  125. * `op` is invoked with whichever provider is selected.
  126. */
  127. async run(op, options, onTotalFail) {
  128. const inCooldown = this.fallbackUntil !== null && this.now() < this.fallbackUntil;
  129. if (inCooldown) {
  130. // Skip primary entirely
  131. this.transition("fallback");
  132. try {
  133. return await op(this.fallback, options);
  134. }
  135. catch (err) {
  136. if (onTotalFail)
  137. return onTotalFail();
  138. throw err;
  139. }
  140. }
  141. // Try primary first
  142. try {
  143. const result = await op(this.primary, options);
  144. // Success — clear streak and ensure routing reads "primary"
  145. this.failureStreak = 0;
  146. this.fallbackUntil = null;
  147. this.transition("primary");
  148. return result;
  149. }
  150. catch (err) {
  151. if (err instanceof CircuitOpenError) {
  152. // Primary circuit is open — open our own cooldown matching its
  153. // expected duration so subsequent calls skip the primary.
  154. this.openCooldown(`primary CircuitOpenError`);
  155. }
  156. else {
  157. this.failureStreak++;
  158. if (this.failureStreak >= this.failureStreakThreshold) {
  159. this.openCooldown(`primary failure streak ${this.failureStreak} ≥ ${this.failureStreakThreshold}`);
  160. }
  161. }
  162. // Try fallback for THIS call regardless
  163. try {
  164. this.transition("fallback");
  165. return await op(this.fallback, options);
  166. }
  167. catch (fbErr) {
  168. if (onTotalFail)
  169. return onTotalFail();
  170. // Both providers failed — surface the fallback error (the primary
  171. // failure already informed the breaker).
  172. throw fbErr;
  173. }
  174. }
  175. }
  176. openCooldown(reason) {
  177. if (this.fallbackUntil === null || this.now() >= this.fallbackUntil) {
  178. this.fallbackUntil = this.now() + this.cooldownMs;
  179. this.warn(`[AutoFallbackEmbeddingProvider] WARN — falling back to "${this.fallback.kind}" provider for ${Math.round(this.cooldownMs / 1000)}s (reason: ${reason})`);
  180. }
  181. }
  182. transition(to) {
  183. if (this.lastTransitionState === to)
  184. return;
  185. this.lastTransitionState = to;
  186. if (to === "primary") {
  187. this.warn(`[AutoFallbackEmbeddingProvider] WARN — primary "${this.primary.kind}" recovered, routing restored`);
  188. }
  189. // The "fallback" transition WARN is already emitted by openCooldown
  190. // (with a richer message). No second WARN here.
  191. }
  192. }