diff --git a/src/api/providers/__tests__/request-config-builder.spec.ts b/src/api/providers/__tests__/request-config-builder.spec.ts new file mode 100644 index 0000000000..977b09df6b --- /dev/null +++ b/src/api/providers/__tests__/request-config-builder.spec.ts @@ -0,0 +1,508 @@ +import { describe, expect, test, vi } from "vitest" + +import { makeCreateMessageMetadata } from "../../../test-utils/api" +import { RequestConfigBuilder } from "../config-builder/request-config-builder" + +describe("RequestConfigBuilder", () => { + describe("constructor", () => { + test("should initialize with empty options by default", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + }) + + test("should initialize with provided defaultOptions", () => { + const defaults = { modelId: "test-model" } + const builder = new RequestConfigBuilder(defaults) + const result = builder.build() + expect(result).toEqual({ modelId: "test-model" }) + }) + + test("should create a shallow copy of defaultOptions", () => { + const defaults = { modelId: "test-model" } + const builder = new RequestConfigBuilder(defaults) + defaults.modelId = "modified-model" + const result = builder.build() + expect(result?.modelId).toBe("test-model") + }) + + test("should ignore undefined values from defaultOptions", () => { + const builder = new RequestConfigBuilder({ modelId: undefined }) + + expect(builder.build()).toBeUndefined() + }) + + test("should keep falsy-but-defined values", () => { + const builder = new RequestConfigBuilder({ count: 0, enabled: false, label: "" }) + + expect(builder.build()).toEqual({ count: 0, enabled: false, label: "" }) + }) + + test("should not alias the caller's default headers", () => { + const defaults = { headers: { A: "1" } } + const builder = new RequestConfigBuilder(defaults) + + defaults.headers.A = "2" + + expect(builder.getOption("headers")).toEqual({ A: "1" }) + }) + }) + + describe("setAbortSignal", () => { + test("should set signal when metadata contains abortSignal", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const builder = new RequestConfigBuilder() + const result = builder.setAbortSignal(metadata) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { signal?: AbortSignal } + expect(config?.signal).toBe(controller.signal) + }) + + test("should do nothing when metadata is undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setAbortSignal(undefined) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBeUndefined() + }) + + test("should do nothing when metadata.abortSignal is undefined", () => { + const metadata = makeCreateMessageMetadata() + + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setAbortSignal(metadata) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBeUndefined() + }) + + test("should replace existing signal if metadata contains abortSignal", () => { + const controller1 = new AbortController() + const controller2 = new AbortController() + + const builder = new RequestConfigBuilder({ signal: controller1.signal }) + builder.setAbortSignal(makeCreateMessageMetadata({ abortSignal: controller2.signal })) + + const config = builder.build() as { signal?: AbortSignal } + expect(config?.signal).toBe(controller2.signal) + }) + + test("should support chaining with other methods", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const builder = new RequestConfigBuilder() + const result = builder.setAbortSignal(metadata).setOption("customKey", "customValue") + + expect(result).toBe(builder) + const config = builder.build() as { signal?: AbortSignal; customKey?: string } + expect(config?.signal).toBe(controller.signal) + expect(config?.customKey).toBe("customValue") + }) + }) + + describe("addHeaders", () => { + test("should merge headers when provided", () => { + const builder = new RequestConfigBuilder() + const result = builder.addHeaders({ "X-Custom": "value1" }) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Custom": "value1" }) + }) + + test("should do nothing when headers are undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + const result = builder.addHeaders() + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config.headers).toBeUndefined() + }) + + test("should do nothing when headers object is empty", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + const result = builder.addHeaders({}) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config.headers).toBeUndefined() + }) + + test("should override existing header values", () => { + const builder = new RequestConfigBuilder({ headers: { "X-Existing": "old" } }) + builder.addHeaders({ "X-Existing": "new" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers?.["X-Existing"]).toBe("new") + }) + + test("should merge with existing headers without overwriting unrelated keys", () => { + const builder = new RequestConfigBuilder({ headers: { "X-Existing": "value" } }) + builder.addHeaders({ "X-New": "newValue" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Existing": "value", "X-New": "newValue" }) + }) + + test("should create headers object if none exists", () => { + const builder = new RequestConfigBuilder() + builder.addHeaders({ "X-Custom": "value" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Custom": "value" }) + }) + + test("should support chaining with other methods", () => { + const builder = new RequestConfigBuilder() + builder.addHeaders({ "X-First": "1" }).addHeaders({ "X-Second": "2" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-First": "1", "X-Second": "2" }) + }) + }) + + describe("setOption", () => { + test("should set option when value is defined", () => { + const builder = new RequestConfigBuilder() + const result = builder.setOption("modelId", "test-model") + + expect(result).toBe(builder) // chainable + const config = builder.build() as { modelId?: string } + expect(config?.modelId).toBe("test-model") + }) + + test("should do nothing when value is undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setOption("initial", undefined as unknown as string) + + const config = builder.build() as { initial?: string } + // When setOption receives undefined, it should NOT modify the existing value + expect(config.initial).toBe("value") + }) + + test("should replace existing option value", () => { + const builder = new RequestConfigBuilder({ modelId: "old-model" }) + builder.setOption("modelId", "new-model") + + const config = builder.build() as { modelId?: string } + expect(config?.modelId).toBe("new-model") + }) + + test("should support different value types", () => { + const builder = new RequestConfigBuilder() + + builder.setOption("stringKey", "stringValue") + builder.setOption("numberKey", 42) + builder.setOption("booleanKey", true) + builder.setOption("objectKey", { nested: true }) + + const config = builder.build() as { + stringKey?: string + numberKey?: number + booleanKey?: boolean + objectKey?: { nested: boolean } + } + expect(config.stringKey).toBe("stringValue") + expect(config.numberKey).toBe(42) + expect(config.booleanKey).toBe(true) + expect(config.objectKey).toEqual({ nested: true }) + }) + + test("should keep falsy-but-defined values", () => { + const builder = new RequestConfigBuilder() + builder.setOption("count", 0).setOption("enabled", false).setOption("label", "") + + const config = builder.build() as { count?: number; enabled?: boolean; label?: string } + expect(config.count).toBe(0) + expect(config.enabled).toBe(false) + expect(config.label).toBe("") + }) + + test("should support chaining", () => { + const builder = new RequestConfigBuilder() + const result = builder.setOption("key1", "value1").setOption("key2", "value2") + + expect(result).toBe(builder) + const config = builder.build() as { key1?: string; key2?: string } + expect(config.key1).toBe("value1") + expect(config.key2).toBe("value2") + }) + }) + + describe("getOption", () => { + test("should return existing option value", () => { + const builder = new RequestConfigBuilder({ modelId: "test-model" }) + expect(builder.getOption("modelId")).toBe("test-model") + }) + + test("should return undefined for non-existent key", () => { + const builder = new RequestConfigBuilder() + expect(builder.getOption("nonExistent")).toBeUndefined() + }) + }) + + describe("build", () => { + test("should return shallow copy of options", () => { + const builder = new RequestConfigBuilder({ key: "value" }) + const result1 = builder.build() + const result2 = builder.build() + + expect(result1).toEqual(result2) + expect(result1).not.toBe(result2) // different references + }) + + test("should return undefined when options are empty", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + }) + + test("modifying build result should not affect internal state", () => { + const builder = new RequestConfigBuilder({ key: "value" }) + const result = builder.build() as { key: string } + + result.key = "modified" + expect(builder.getOption("key")).toBe("value") + }) + + test("mutating returned headers should not affect internal state", () => { + const builder = new RequestConfigBuilder({ headers: { Authorization: "Bearer x" } }) + const config = builder.build() as { headers?: Record } + + config.headers!.Authorization = "TAMPERED" + + expect(builder.getOption("headers")).toEqual({ Authorization: "Bearer x" }) + }) + + test("should return all set options", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const builder = new RequestConfigBuilder() + builder.setAbortSignal(metadata).addHeaders({ "X-Custom": "value" }).setOption("modelId", "test-model") + + const config = builder.build() as { + signal?: AbortSignal + headers?: Record + modelId?: string + } + expect(config.signal).toBe(controller.signal) + expect(config.headers).toEqual({ "X-Custom": "value" }) + expect(config.modelId).toBe("test-model") + }) + }) + + describe("static fromMetadata", () => { + test("should return undefined when both metadata and extraOptions are undefined", () => { + const result = RequestConfigBuilder.fromMetadata() + expect(result).toBeUndefined() + }) + + test("should set signal from metadata.abortSignal", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const result = RequestConfigBuilder.fromMetadata(metadata) as { signal?: AbortSignal } + expect(result.signal).toBe(controller.signal) + }) + + test("should merge extraOptions with metadata signal", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const extraOptions = { modelId: "test-model", customKey: "customValue" } + + const result = RequestConfigBuilder.fromMetadata(metadata, extraOptions) as { + signal?: AbortSignal + modelId?: string + customKey?: string + } + expect(result.signal).toBe(controller.signal) + expect(result.modelId).toBe("test-model") + expect(result.customKey).toBe("customValue") + }) + + test("should return only extraOptions when metadata is undefined", () => { + const extraOptions = { modelId: "test-model" } + const result = RequestConfigBuilder.fromMetadata(undefined, extraOptions) as { modelId?: string } + expect(result.modelId).toBe("test-model") + }) + + test("should treat undefined extraOptions values as absent", () => { + const result = RequestConfigBuilder.fromMetadata(undefined, { signal: undefined }) + + expect(result).toBeUndefined() + }) + + test("should not set signal when metadata.abortSignal is undefined", () => { + const metadata = makeCreateMessageMetadata() + const extraOptions = { modelId: "test-model" } + + const result = RequestConfigBuilder.fromMetadata(metadata, extraOptions) as { + signal?: AbortSignal + modelId?: string + } + expect(result.signal).toBeUndefined() + expect(result.modelId).toBe("test-model") + }) + }) + + describe("addMergedSignal", () => { + test("should add internal controller signal when metadata and timeout are absent", () => { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + const result = builder.addMergedSignal(internalController) + + expect(result).toBe(builder) + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBe(internalController.signal) + }) + + test("should merge internal controller signal with metadata abort signal", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).not.toBe(internalController.signal) + expect(config.signal).not.toBe(externalController.signal) + + externalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + + test("should abort merged signal when internal controller is aborted", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(false) + + internalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + + test("should abort merged signal after timeout elapses without manual cleanup", async () => { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal(internalController, undefined, 50) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).not.toBe(internalController.signal) + expect(config.signal?.aborted).toBe(false) + + await vi.waitFor(() => expect(config.signal?.aborted).toBe(true)) + }) + + test("should immediately abort when metadata signal is already aborted", () => { + const internalController = new AbortController() + const externalController = new AbortController() + externalController.abort() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(true) + }) + + test("should propagate abort from internal controller when all three sources are merged", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + 10_000, + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(false) + + internalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + }) + + describe("integration tests", () => { + test("should support full chain of operations", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + type TestOptions = { + modelId?: string + signal?: AbortSignal + headers?: Record + maxTokens?: number + } + + const builder = new RequestConfigBuilder({ modelId: "default-model" }) + builder.setAbortSignal(metadata) + builder.addHeaders({ "X-API-Key": "secret" }) + builder.setOption("maxTokens", 2000) + + const config = builder.build() as TestOptions + expect(config.modelId).toBe("default-model") + expect(config.signal).toBe(controller.signal) + expect(config.headers).toEqual({ "X-API-Key": "secret" }) + expect(config.maxTokens).toBe(2000) + }) + + test("should handle empty builder through full lifecycle", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + expect(builder.getOption("anyKey")).toBeUndefined() + }) + + test("should work with custom default options type", () => { + type CustomOptions = { apiUrl: string; timeout: number; retryCount?: number } + + const defaults: Partial = { + apiUrl: "https://api.example.com", + timeout: 30000, + } + + const builder = new RequestConfigBuilder(defaults) + builder.setOption("retryCount", 3) + + const config = builder.build() as CustomOptions + expect(config.apiUrl).toBe("https://api.example.com") + expect(config.timeout).toBe(30000) + expect(config.retryCount).toBe(3) + }) + + test("should accept interface-based options without an index signature", () => { + interface SdkOptions { + modelId?: string + signal?: AbortSignal + headers?: Record + maxTokens?: number + } + + const builder = new RequestConfigBuilder({ modelId: "default-model" }) + builder.setOption("maxTokens", 2000) + + const config = builder.build() as SdkOptions + expect(config.modelId).toBe("default-model") + expect(config.maxTokens).toBe(2000) + }) + }) +}) diff --git a/src/api/providers/config-builder/request-config-builder.ts b/src/api/providers/config-builder/request-config-builder.ts new file mode 100644 index 0000000000..2201d735bc --- /dev/null +++ b/src/api/providers/config-builder/request-config-builder.ts @@ -0,0 +1,166 @@ +import type { ApiHandlerCreateMessageMetadata } from "../../index" +import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../utils/abort-signal" + +/** + * A generic, SDK-agnostic request configuration builder. + * + * Provides a fluent API for building request configurations with: + * - Chainable method calls + * - Generic type support (TOptions) + * - Abort signal handling + * - Header merging + * - Static factory methods + */ +type RequestConfigOptionsBase = object & { + headers?: Record + signal?: AbortSignal +} + +type RequestConfigOptions = RequestConfigOptionsBase & Record + +export class RequestConfigBuilder { + protected options: Partial + + constructor(defaultOptions?: Partial) { + if (!defaultOptions) { + this.options = {} + return + } + + const defined = Object.fromEntries( + Object.entries(defaultOptions).filter(([, value]) => value !== undefined), + ) as Partial + + // Own the headers object so later mutations of the caller's defaults do not leak in. + if (defined.headers) { + defined.headers = { ...defined.headers } + } + + this.options = defined + } + + /** + * Set the abort signal from metadata, replacing any previously configured + * signal (including one created by addMergedSignal). Use addMergedSignal to + * combine signals instead of overwriting them. + * + * @param metadata - Optional metadata containing an abortSignal + * @returns this for chainable calls + */ + setAbortSignal(metadata?: ApiHandlerCreateMessageMetadata): this { + if (!metadata?.abortSignal) { + return this + } + + this.options = { ...this.options, signal: metadata.abortSignal } + return this + } + + /** + * Add or merge custom headers. + * + * @param headers - Key-value pairs of header names and values + * @returns this for chainable calls + */ + addHeaders(headers?: Record): this { + if (!headers || Object.keys(headers).length === 0) { + return this + } + + const existingHeaders = this.options.headers ?? {} + this.options = { ...this.options, headers: { ...existingHeaders, ...headers } } + return this + } + + /** + * Merge an internal controller signal with an external metadata signal and optional timeout. + * + * Use this for providers that already maintain their own AbortController but also need + * to honor the request-level abort signal from metadata and/or a timeout. The timeout is + * created via the native AbortSignal.timeout() API, which self-manages its timer — no + * manual cleanup is required. + * + * @param internalController - Provider-owned AbortController for the current request + * @param metadata - Optional metadata containing an external abortSignal + * @param timeoutMs - Optional positive timeout in milliseconds; <= 0 disables timeout + * @returns this for chainable calls + */ + addMergedSignal( + internalController: AbortController, + metadata?: ApiHandlerCreateMessageMetadata, + timeoutMs?: number, + ): this { + const merged = mergeAbortSignalAndTimeout(metadata?.abortSignal, timeoutMs) + const signal = mergeAbortSignals(internalController.signal, merged) + + this.options = { ...this.options, signal } + return this + } + + /** + * Set a single option by key (type-safe). + * + * @param key - Option key + * @param value - Option value + * @returns this for chainable calls + */ + setOption(key: K, value: TOptions[K]): this { + if (value === undefined) { + return this + } + + this.options = { ...this.options, [key]: value } + return this + } + + /** + * Get an option by key. + * + * @param key - Option key + * @returns The option value or undefined if not set + */ + getOption(key: K): TOptions[K] | undefined { + return this.options[key] + } + + /** + * Build the final configuration object. + * + * Copies the top-level options and the nested headers object, so mutating the + * result does not change builder state. The abort signal is a live object and + * is shared by reference on purpose. Other nested option values are not cloned. + * Returns undefined if no options have been set. + * + * @returns A partial built configuration (only the options that were set) or + * undefined if empty + */ + build(): Partial | undefined { + const keys = Object.keys(this.options as object) + if (keys.length === 0) { + return undefined + } + + const result = { ...this.options } + if (result.headers) { + result.headers = { ...result.headers } + } + + return result + } + + /** + * Factory method to quickly create and configure a builder from metadata. + * + * @param metadata - Optional metadata containing an abortSignal + * @param extraOptions - Additional options to merge + * @returns The built configuration or undefined if empty + */ + static fromMetadata( + metadata?: ApiHandlerCreateMessageMetadata, + extraOptions?: Partial, + ): Partial | undefined { + const builder = new RequestConfigBuilder(extraOptions) + builder.setAbortSignal(metadata) + return builder.build() + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 37fea799a5..8ba1eae382 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -1,3 +1,4 @@ +export { RequestConfigBuilder } from "./config-builder/request-config-builder" export { AnthropicVertexHandler } from "./anthropic-vertex" export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts new file mode 100644 index 0000000000..ebc7edf3d3 --- /dev/null +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -0,0 +1,102 @@ +import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../abort-signal" + +describe("abort-signal utilities", () => { + describe("mergeAbortSignalAndTimeout", () => { + it("returns undefined when no signal or positive timeout is provided", () => { + expect(mergeAbortSignalAndTimeout(undefined, 0)).toBeUndefined() + expect(mergeAbortSignalAndTimeout(undefined, -1)).toBeUndefined() + expect(mergeAbortSignalAndTimeout(undefined, NaN)).toBeUndefined() + expect(mergeAbortSignalAndTimeout()).toBeUndefined() + }) + + it("forwards external signal directly when timeout is disabled", () => { + const controller = new AbortController() + + expect(mergeAbortSignalAndTimeout(controller.signal, -1)).toBe(controller.signal) + expect(mergeAbortSignalAndTimeout(controller.signal, NaN)).toBe(controller.signal) + expect(mergeAbortSignalAndTimeout(controller.signal)).toBe(controller.signal) + }) + + it("creates a self-managed timeout signal when only positive timeout is provided", async () => { + const result = mergeAbortSignalAndTimeout(undefined, 50) + + expect(result).toBeInstanceOf(AbortSignal) + expect(result?.aborted).toBe(false) + + await vi.waitFor(() => expect(result?.aborted).toBe(true)) + }) + + it("merges external signal and timeout signal", () => { + const controller = new AbortController() + + const result = mergeAbortSignalAndTimeout(controller.signal, 100) + + expect(result).toBeInstanceOf(AbortSignal) + expect(result).not.toBe(controller.signal) + expect(result?.aborted).toBe(false) + + controller.abort() + + expect(result?.aborted).toBe(true) + }) + + it("aborts via timeout alone when the external signal stays active", async () => { + const controller = new AbortController() + + const result = mergeAbortSignalAndTimeout(controller.signal, 50) + + expect(result).not.toBe(controller.signal) + expect(result?.aborted).toBe(false) + + await vi.waitFor(() => expect(result?.aborted).toBe(true)) + }) + }) + + describe("mergeAbortSignals", () => { + it("returns primary signal directly when secondary signal is absent", () => { + const controller = new AbortController() + + const result = mergeAbortSignals(controller.signal) + + expect(result).toBe(controller.signal) + }) + + it("returns a merged signal when secondary signal is present", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result).not.toBe(primaryController.signal) + expect(result).not.toBe(secondaryController.signal) + expect(result.aborted).toBe(false) + + secondaryController.abort() + + expect(result.aborted).toBe(true) + }) + + it("aborts merged signal when primary signal is aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result.aborted).toBe(false) + + primaryController.abort() + + expect(result.aborted).toBe(true) + }) + + it("returns an aborted signal when primary is already aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + primaryController.abort() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result.aborted).toBe(true) + }) + }) +}) diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts new file mode 100644 index 0000000000..73e0356f7b --- /dev/null +++ b/src/api/providers/utils/abort-signal.ts @@ -0,0 +1,37 @@ +/** + * Merge an optional external abort signal with an optional timeout. + * + * Timeout values <= 0 are treated as disabled. The timeout is created via the + * native AbortSignal.timeout() API, which self-manages its timer — callers do + * not need to (and cannot) clear it manually. + */ +export function mergeAbortSignalAndTimeout(externalSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined { + const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0 + + if (!hasTimeout) { + return externalSignal + } + + const timeoutSignal = AbortSignal.timeout(timeoutMs) + + if (!externalSignal) { + return timeoutSignal + } + + return mergeAbortSignals(externalSignal, timeoutSignal) +} + +/** + * Merge two abort signals using the standard AbortSignal.any() API. + * + * Returns the primary signal directly when no secondary signal is provided to + * avoid creating unnecessary controllers/listeners for the common single-signal + * path. + */ +export function mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: AbortSignal): AbortSignal { + if (!secondarySignal) { + return primarySignal + } + + return AbortSignal.any([primarySignal, secondarySignal]) +} diff --git a/src/test-utils/api.ts b/src/test-utils/api.ts index 1939fbb7e8..d3381119e7 100644 --- a/src/test-utils/api.ts +++ b/src/test-utils/api.ts @@ -1,5 +1,6 @@ import { expect, vi, type Mock } from "vitest" +import type { ApiHandlerCreateMessageMetadata } from "../api" import type { ApiHandlerOptions } from "../shared/api" export function makeApiHandlerOptions(overrides: Partial = {}): ApiHandlerOptions { @@ -10,6 +11,19 @@ export function makeApiHandlerOptions(overrides: Partial = {} } } +/** + * Build request-message metadata for provider tests. Defaults a taskId so tests + * only pass the fields they care about (for example an abortSignal). + */ +export function makeCreateMessageMetadata( + overrides: Partial = {}, +): ApiHandlerCreateMessageMetadata { + return { + taskId: "test-task", + ...overrides, + } +} + export function mockOpenAiResponsesClient(create: Mock) { return { __esModule: true,