autofallback.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  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 type {
  20. EmbeddingProvider,
  21. ProviderEmbedOptions,
  22. ProviderEmbedding,
  23. ProviderHealth,
  24. ProviderKind,
  25. } from "./provider.js";
  26. import { CircuitOpenError } from "./openai.js";
  27. import { commercialApiHold } from "../model-policy.js";
  28. export type AutoFallbackProviderConfig = {
  29. primary: EmbeddingProvider;
  30. fallback: EmbeddingProvider;
  31. /**
  32. * Number of consecutive non-CircuitOpenError failures before we suppress
  33. * primary calls and route directly to fallback. Default: 3.
  34. */
  35. failureStreakThreshold?: number;
  36. /**
  37. * Time in ms to keep routing through fallback after the breaker opens.
  38. * Default: 5 minutes (matches `OpenAIEmbeddingsProvider`'s circuit duration).
  39. */
  40. cooldownMs?: number;
  41. /**
  42. * Optional WARN sink. Defaults to writing to `process.stderr` once per
  43. * routing transition (closed→open and open→closed).
  44. */
  45. warn?: (msg: string) => void;
  46. /** Custom clock for tests */
  47. now?: () => number;
  48. };
  49. const DEFAULT_FAILURE_STREAK = 3;
  50. const DEFAULT_COOLDOWN_MS = 5 * 60_000;
  51. function defaultWarn(msg: string): void {
  52. process.stderr.write(`${msg}\n`);
  53. }
  54. export type FallbackState = "primary" | "fallback";
  55. export class AutoFallbackEmbeddingProvider implements EmbeddingProvider {
  56. readonly kind: ProviderKind;
  57. readonly primary: EmbeddingProvider;
  58. readonly fallback: EmbeddingProvider;
  59. private readonly failureStreakThreshold: number;
  60. private readonly cooldownMs: number;
  61. private readonly warn: (msg: string) => void;
  62. private readonly now: () => number;
  63. private failureStreak = 0;
  64. private fallbackUntil: number | null = null;
  65. private lastTransitionState: FallbackState = "primary";
  66. constructor(config: AutoFallbackProviderConfig) {
  67. throw commercialApiHold(
  68. "automatic provider fallback is disabled; commercial API failures must remain HOLD",
  69. );
  70. if (!config.primary) throw new Error("AutoFallbackEmbeddingProvider: primary is required");
  71. if (!config.fallback) throw new Error("AutoFallbackEmbeddingProvider: fallback is required");
  72. if (config.primary === config.fallback) {
  73. throw new Error("AutoFallbackEmbeddingProvider: primary and fallback must differ");
  74. }
  75. this.primary = config.primary;
  76. this.fallback = config.fallback;
  77. // Inherit the primary's kind for callers introspecting `provider.kind`.
  78. this.kind = config.primary.kind;
  79. this.failureStreakThreshold = config.failureStreakThreshold ?? DEFAULT_FAILURE_STREAK;
  80. this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
  81. this.warn = config.warn ?? defaultWarn;
  82. this.now = config.now ?? Date.now;
  83. }
  84. /**
  85. * Stable model id reported by the primary. The model-id guard runs against
  86. * the primary's id because that's what callers actually want when the
  87. * remote endpoint is online; a secondary commercial provider must report a
  88. * compatible id.
  89. */
  90. getModelId(): string {
  91. return this.primary.getModelId();
  92. }
  93. getDimensions(): number | undefined {
  94. return this.primary.getDimensions() ?? this.fallback.getDimensions();
  95. }
  96. /**
  97. * Combined last-error from primary + fallback. Either, neither, or both legs
  98. * may have a tracked error after `embed()`/`embedBatch()` runs:
  99. * - Both clean → undefined
  100. * - Primary failed, fallback rescued → returns primary error (most useful)
  101. * - Both failed → returns "primary: <msg> | fallback: <msg>"
  102. * - Only primary skipped (cooldown), fallback also failed → returns fallback error
  103. */
  104. getLastError(): string | undefined {
  105. const primaryErr = this.primary.getLastError?.();
  106. const fallbackErr = this.fallback.getLastError?.();
  107. if (primaryErr && fallbackErr) {
  108. return `primary: ${primaryErr} | fallback: ${fallbackErr}`;
  109. }
  110. return primaryErr ?? fallbackErr;
  111. }
  112. /** Current routing state (mostly for tests + observability) */
  113. getRoutingState(): FallbackState {
  114. if (this.fallbackUntil !== null && this.now() < this.fallbackUntil) {
  115. return "fallback";
  116. }
  117. return "primary";
  118. }
  119. /** Reset failure-streak + cooldown (mostly for tests / admin) */
  120. reset(): void {
  121. this.failureStreak = 0;
  122. this.fallbackUntil = null;
  123. this.transition("primary");
  124. }
  125. async healthcheck(signal?: AbortSignal): Promise<ProviderHealth> {
  126. // Primary first; if degraded, check fallback so callers can still tell
  127. // whether they have *any* working backend.
  128. const primaryHealth = await this.primary.healthcheck(signal);
  129. if (primaryHealth.ok) return primaryHealth;
  130. const fallbackHealth = await this.fallback.healthcheck(signal);
  131. return {
  132. ok: fallbackHealth.ok,
  133. model: this.primary.getModelId(),
  134. dimensions: primaryHealth.dimensions ?? fallbackHealth.dimensions,
  135. detail:
  136. `primary: ${primaryHealth.detail ?? "fail"} | fallback: ${fallbackHealth.detail ?? (fallbackHealth.ok ? "ok" : "fail")}`,
  137. };
  138. }
  139. async embed(
  140. text: string,
  141. options: ProviderEmbedOptions = {},
  142. ): Promise<ProviderEmbedding | null> {
  143. return this.run(
  144. (p, opts) => p.embed(text, opts),
  145. options,
  146. );
  147. }
  148. async embedBatch(
  149. texts: string[],
  150. options: ProviderEmbedOptions = {},
  151. ): Promise<(ProviderEmbedding | null)[]> {
  152. if (texts.length === 0) return [];
  153. return this.run(
  154. (p, opts) => p.embedBatch(texts, opts),
  155. options,
  156. () => texts.map(() => null),
  157. );
  158. }
  159. async dispose(): Promise<void> {
  160. await Promise.allSettled([this.primary.dispose(), this.fallback.dispose()]);
  161. }
  162. // ────────────────────── Internals ──────────────────────
  163. /**
  164. * Generic dispatcher: try primary if not in cooldown, fall back on
  165. * `CircuitOpenError`, count other errors against the failure streak.
  166. * `op` is invoked with whichever provider is selected.
  167. */
  168. private async run<T>(
  169. op: (provider: EmbeddingProvider, opts: ProviderEmbedOptions) => Promise<T>,
  170. options: ProviderEmbedOptions,
  171. onTotalFail?: () => T,
  172. ): Promise<T> {
  173. const inCooldown =
  174. this.fallbackUntil !== null && this.now() < this.fallbackUntil;
  175. if (inCooldown) {
  176. // Skip primary entirely
  177. this.transition("fallback");
  178. try {
  179. return await op(this.fallback, options);
  180. } catch (err) {
  181. if (onTotalFail) return onTotalFail();
  182. throw err;
  183. }
  184. }
  185. // Try primary first
  186. try {
  187. const result = await op(this.primary, options);
  188. // Success — clear streak and ensure routing reads "primary"
  189. this.failureStreak = 0;
  190. this.fallbackUntil = null;
  191. this.transition("primary");
  192. return result;
  193. } catch (err) {
  194. if (err instanceof CircuitOpenError) {
  195. // Primary circuit is open — open our own cooldown matching its
  196. // expected duration so subsequent calls skip the primary.
  197. this.openCooldown(`primary CircuitOpenError`);
  198. } else {
  199. this.failureStreak++;
  200. if (this.failureStreak >= this.failureStreakThreshold) {
  201. this.openCooldown(
  202. `primary failure streak ${this.failureStreak} ≥ ${this.failureStreakThreshold}`,
  203. );
  204. }
  205. }
  206. // Try fallback for THIS call regardless
  207. try {
  208. this.transition("fallback");
  209. return await op(this.fallback, options);
  210. } catch (fbErr) {
  211. if (onTotalFail) return onTotalFail();
  212. // Both providers failed — surface the fallback error (the primary
  213. // failure already informed the breaker).
  214. throw fbErr;
  215. }
  216. }
  217. }
  218. private openCooldown(reason: string): void {
  219. if (this.fallbackUntil === null || this.now() >= this.fallbackUntil) {
  220. this.fallbackUntil = this.now() + this.cooldownMs;
  221. this.warn(
  222. `[AutoFallbackEmbeddingProvider] WARN — falling back to "${this.fallback.kind}" provider for ${Math.round(this.cooldownMs / 1000)}s (reason: ${reason})`,
  223. );
  224. }
  225. }
  226. private transition(to: FallbackState): void {
  227. if (this.lastTransitionState === to) return;
  228. this.lastTransitionState = to;
  229. if (to === "primary") {
  230. this.warn(
  231. `[AutoFallbackEmbeddingProvider] WARN — primary "${this.primary.kind}" recovered, routing restored`,
  232. );
  233. }
  234. // The "fallback" transition WARN is already emitted by openCooldown
  235. // (with a richer message). No second WARN here.
  236. }
  237. }