@lota-sdk/core 0.4.28 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "files": [
5
5
  "src",
6
6
  "infrastructure/schema"
@@ -32,7 +32,7 @@
32
32
  "@ai-sdk/provider": "^3.0.9",
33
33
  "@chat-adapter/slack": "^4.26.0",
34
34
  "@chat-adapter/state-ioredis": "^4.26.0",
35
- "@lota-sdk/shared": "0.4.28",
35
+ "@lota-sdk/shared": "0.4.29",
36
36
  "@mendable/firecrawl-js": "^4.20.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.170",
@@ -38,8 +38,8 @@ class AiGatewayStreamAttemptTag extends Context.Service<
38
38
 
39
39
  const EXPECTED_GATEWAY_KEY_PREFIX = 'sk-bf-'
40
40
  const AI_GATEWAY_VIRTUAL_KEY_HEADER = 'x-bf-vk'
41
- const AI_GATEWAY_TIMEOUT_MS = 180_000
42
- const AI_GATEWAY_STREAM_IDLE_TIMEOUT_MS = 30_000
41
+ const AI_GATEWAY_TIMEOUT_MS = 360_000
42
+ const AI_GATEWAY_STREAM_IDLE_TIMEOUT_MS = 180_000
43
43
  const AI_GATEWAY_MAX_RETRIES = 4
44
44
  const AI_GATEWAY_MAX_RETRY_DELAY_MS = 15_000
45
45
  const OPENAI_RESPONSES_PROVIDER_ID = 'openai.responses'
@@ -748,13 +748,60 @@ function isOpenRouterModel(modelId: string): boolean {
748
748
  return modelId.trim().toLowerCase().startsWith('openrouter/')
749
749
  }
750
750
 
751
+ function mergeAbortSignals(signals: Array<AbortSignal | undefined>): { signal?: AbortSignal; cleanup: () => void } {
752
+ const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal))
753
+ if (activeSignals.length === 0) return { cleanup: () => undefined }
754
+ if (activeSignals.length === 1) return { signal: activeSignals[0], cleanup: () => undefined }
755
+
756
+ const controller = new AbortController()
757
+ const listeners: Array<() => void> = []
758
+ const abortFrom = (signal: AbortSignal) => {
759
+ if (!controller.signal.aborted) controller.abort(signal.reason)
760
+ }
761
+
762
+ for (const signal of activeSignals) {
763
+ if (signal.aborted) {
764
+ abortFrom(signal)
765
+ continue
766
+ }
767
+
768
+ const listener = () => abortFrom(signal)
769
+ signal.addEventListener('abort', listener, { once: true })
770
+ listeners.push(() => signal.removeEventListener('abort', listener))
771
+ }
772
+
773
+ return {
774
+ signal: controller.signal,
775
+ cleanup: () => {
776
+ for (const cleanup of listeners) cleanup()
777
+ },
778
+ }
779
+ }
780
+
781
+ async function callAbortableProvider<T>(
782
+ params: AiGatewayCallOptions,
783
+ effectSignal: AbortSignal,
784
+ evaluate: (params: AiGatewayCallOptions) => PromiseLike<T>,
785
+ ): Promise<T> {
786
+ const { signal, cleanup } = mergeAbortSignals([effectSignal, params.abortSignal])
787
+ try {
788
+ return await evaluate(signal ? { ...params, abortSignal: signal } : params)
789
+ } finally {
790
+ cleanup()
791
+ }
792
+ }
793
+
751
794
  function attemptAiGatewayGenerate(
752
795
  source: string,
753
- evaluate: () => PromiseLike<AiGatewayGenerateResult>,
796
+ params: AiGatewayCallOptions,
797
+ evaluate: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayGenerateResult>,
754
798
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayGenerateResult>, AiGenerationError> {
755
799
  return withAiGatewayResilience(
756
800
  source,
757
- Effect.tryPromise({ try: evaluate, catch: (cause) => classifyAiGatewayError(source, cause) }),
801
+ Effect.tryPromise({
802
+ try: (signal) => callAbortableProvider(params, signal, evaluate),
803
+ catch: (cause) => classifyAiGatewayError(source, cause),
804
+ }),
758
805
  ).pipe(
759
806
  Effect.map((result) => ({ source, result })),
760
807
  Effect.withSpan('AiGateway.generateAttempt'),
@@ -764,11 +811,15 @@ function attemptAiGatewayGenerate(
764
811
 
765
812
  function attemptAiGatewayStream(
766
813
  source: string,
767
- evaluate: () => PromiseLike<AiGatewayStreamResult>,
814
+ params: AiGatewayCallOptions,
815
+ evaluate: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayStreamResult>,
768
816
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayStreamResult>, AiGenerationError> {
769
817
  return withAiGatewayResilience(
770
818
  source,
771
- Effect.tryPromise({ try: evaluate, catch: (cause) => classifyAiGatewayError(source, cause) }),
819
+ Effect.tryPromise({
820
+ try: (signal) => callAbortableProvider(params, signal, evaluate),
821
+ catch: (cause) => classifyAiGatewayError(source, cause),
822
+ }),
772
823
  ).pipe(
773
824
  Effect.map((result) => ({ source, result })),
774
825
  Effect.withSpan('AiGateway.streamAttempt'),
@@ -778,10 +829,11 @@ function attemptAiGatewayStream(
778
829
 
779
830
  function executeGenerateAttemptPlan(
780
831
  modelId: string,
781
- doGenerate: () => PromiseLike<AiGatewayGenerateResult>,
832
+ params: AiGatewayCallOptions,
833
+ doGenerate: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayGenerateResult>,
782
834
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayGenerateResult>, AiGenerationError> {
783
835
  const primary = Layer.succeed(AiGatewayGenerateAttemptTag, {
784
- execute: attemptAiGatewayGenerate('ai-gateway.generate', doGenerate),
836
+ execute: attemptAiGatewayGenerate('ai-gateway.generate', params, doGenerate),
785
837
  })
786
838
  const effect = Effect.gen(function* () {
787
839
  const attempt = yield* AiGatewayGenerateAttemptTag
@@ -797,10 +849,11 @@ function executeGenerateAttemptPlan(
797
849
 
798
850
  function executeStreamAttemptPlan(
799
851
  modelId: string,
800
- doStream: () => PromiseLike<AiGatewayStreamResult>,
852
+ params: AiGatewayCallOptions,
853
+ doStream: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayStreamResult>,
801
854
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayStreamResult>, AiGenerationError> {
802
855
  const primary = Layer.succeed(AiGatewayStreamAttemptTag, {
803
- execute: attemptAiGatewayStream('ai-gateway.stream', doStream),
856
+ execute: attemptAiGatewayStream('ai-gateway.stream', params, doStream),
804
857
  })
805
858
  const effect = Effect.gen(function* () {
806
859
  const attempt = yield* AiGatewayStreamAttemptTag
@@ -1046,7 +1099,7 @@ function createAiGatewayLanguageModelMiddleware(
1046
1099
  const model = resolveProviderModel(resolvedDeps.gateway.provider, modelId, providerId)
1047
1100
  return resolvedDeps.runPromise(
1048
1101
  withAiGatewayConcurrency(
1049
- executeGenerateAttemptPlan(modelId, () => model.doGenerate(params)).pipe(
1102
+ executeGenerateAttemptPlan(modelId, params, (attemptParams) => model.doGenerate(attemptParams)).pipe(
1050
1103
  Effect.map(({ result }) => ({
1051
1104
  ...result,
1052
1105
  content: injectAiGatewayChatReasoningContent(
@@ -1063,7 +1116,7 @@ function createAiGatewayLanguageModelMiddleware(
1063
1116
  const model = resolveProviderModel(resolvedDeps.gateway.provider, modelId, providerId)
1064
1117
  return resolvedDeps.runPromise(
1065
1118
  withAiGatewayStreamConcurrency(
1066
- executeStreamAttemptPlan(modelId, () => model.doStream(params)).pipe(
1119
+ executeStreamAttemptPlan(modelId, params, (attemptParams) => model.doStream(attemptParams)).pipe(
1067
1120
  Effect.map((attempt) => ({
1068
1121
  ...attempt,
1069
1122
  result: isReasoningEnabled(params)
@@ -66,11 +66,13 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
66
66
  queueName?: string
67
67
  workerName?: string
68
68
  concurrency?: number
69
+ attempts?: number
69
70
  lockDuration?: number
70
71
  }): { enqueue: (job: TJob) => Promise<void>; startWorker: (options?: { registerSignals?: boolean }) => WorkerHandle } {
71
72
  const queueName = params.queueName ?? DEFAULT_DOCUMENT_PROCESSOR_QUEUE
72
73
  const workerName = params.workerName ?? DEFAULT_WORKER_NAME
73
74
  const concurrency = params.concurrency ?? 10
75
+ const attempts = params.attempts ?? 3
74
76
  const lockDuration = params.lockDuration ?? 300_000
75
77
  const queueRuntime = createQueueFactory<TJob>({
76
78
  name: queueName,
@@ -80,7 +82,7 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
80
82
  lockDuration,
81
83
  logger: params.logger,
82
84
  connectionProvider: params.getConnectionForBullMQ,
83
- defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
85
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts, backoff: { type: 'exponential', delay: 1000 } },
84
86
  processorPath: params.getWorkerPath(),
85
87
  queueJobService: params.queueJobService,
86
88
  })
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from 'node:timers/promises'
1
2
  import { fileURLToPath } from 'node:url'
2
3
 
3
4
  import type { Job, Worker } from 'bullmq'
@@ -30,6 +31,32 @@ export interface WorkerHandle {
30
31
  shutdown: () => Promise<void>
31
32
  }
32
33
 
34
+ async function closeWorkerWithTimeout(
35
+ worker: Worker,
36
+ name: string,
37
+ logger: typeof chatLogger,
38
+ timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS,
39
+ ): Promise<void> {
40
+ const gracefulClose = worker.close(false)
41
+ const gracefulOutcome = gracefulClose.then(
42
+ () => ({ status: 'closed' as const }),
43
+ (error: unknown) => ({ status: 'failed' as const, error }),
44
+ )
45
+
46
+ const outcome = await Promise.race([gracefulOutcome, delay(timeoutMs).then(() => ({ status: 'timed_out' as const }))])
47
+
48
+ if (outcome.status === 'closed') return
49
+
50
+ if (outcome.status === 'timed_out') {
51
+ logger.warn`${name} worker did not close within ${timeoutMs}ms; force-closing`
52
+ } else {
53
+ logger.warn`${name} worker graceful close failed; force-closing: ${outcome.error}`
54
+ }
55
+
56
+ await worker.close(true)
57
+ if (outcome.status === 'failed') throw outcome.error
58
+ }
59
+
33
60
  interface TracedWorkerJobLike {
34
61
  id?: unknown
35
62
  name: string
@@ -87,7 +114,7 @@ export const createWorkerShutdown = (worker: Worker, name: string, logger: typeo
87
114
  return Effect.runPromise(
88
115
  Effect.asVoid(
89
116
  Effect.tryPromise({
90
- try: () => worker.close(true),
117
+ try: () => closeWorkerWithTimeout(worker, name, logger),
91
118
  catch: (cause) => new QueueWorkerError({ phase: 'close', cause }),
92
119
  }),
93
120
  ),