From e61feb13e160e711f914c5dd8d283b70bd764221 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 19 Aug 2026 20:55:39 +0800 Subject: [PATCH 01/23] feat(api): add throwIfAborted helper and completePrompt options regression tests Add a fast-fail throwIfAborted guard to the shared abort-signal utilities and regression tests for the CompletePromptOptions interface (added by #901). --- .../__tests__/complete-prompt-options.spec.ts | 29 +++++++++++++++++++ .../utils/__tests__/abort-signal.spec.ts | 29 ++++++++++++++++++- src/api/providers/utils/abort-signal.ts | 17 +++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/api/providers/__tests__/complete-prompt-options.spec.ts diff --git a/src/api/providers/__tests__/complete-prompt-options.spec.ts b/src/api/providers/__tests__/complete-prompt-options.spec.ts new file mode 100644 index 0000000000..f9925cd119 --- /dev/null +++ b/src/api/providers/__tests__/complete-prompt-options.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest" + +import type { CompletePromptOptions } from "../../index" + +describe("CompletePromptOptions", () => { + it("should allow abortSignal property", () => { + const controller = new AbortController() + const options: CompletePromptOptions = { abortSignal: controller.signal } + expect(options.abortSignal).toBe(controller.signal) + }) + + it("should allow timeoutMs property", () => { + const options: CompletePromptOptions = { timeoutMs: 5000 } + expect(options.timeoutMs).toBe(5000) + }) + + it("should allow both abortSignal and timeoutMs together", () => { + const controller = new AbortController() + const options: CompletePromptOptions = { abortSignal: controller.signal, timeoutMs: 10000 } + expect(options.abortSignal).toBe(controller.signal) + expect(options.timeoutMs).toBe(10000) + }) + + it("should allow empty options object", () => { + const options: CompletePromptOptions = {} + expect(options.abortSignal).toBeUndefined() + expect(options.timeoutMs).toBeUndefined() + }) +}) diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index ebc7edf3d3..1e2181655f 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -1,4 +1,4 @@ -import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../abort-signal" +import { mergeAbortSignalAndTimeout, mergeAbortSignals, throwIfAborted } from "../abort-signal" describe("abort-signal utilities", () => { describe("mergeAbortSignalAndTimeout", () => { @@ -99,4 +99,31 @@ describe("abort-signal utilities", () => { expect(result.aborted).toBe(true) }) }) + + describe("throwIfAborted", () => { + it("does not throw when signal is undefined", () => { + expect(() => throwIfAborted()).not.toThrow() + }) + + it("does not throw when signal is not aborted", () => { + const controller = new AbortController() + + expect(() => throwIfAborted(controller.signal)).not.toThrow() + }) + + it("throws an AbortError when signal is already aborted", () => { + const controller = new AbortController() + controller.abort() + + let caught: unknown + try { + throwIfAborted(controller.signal) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).name).toBe("AbortError") + }) + }) }) diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index 73e0356f7b..033e861b2b 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -35,3 +35,20 @@ export function mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: return AbortSignal.any([primarySignal, secondarySignal]) } + +/** + * Throw an AbortError if the given signal is already aborted. + * + * Use as a fast-fail guard at the top of request-building code paths so + * callers receive a consistent `name === "AbortError"` when the operation + * was cancelled before it started, without building or issuing the request. + */ +export function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) { + return + } + + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + throw abortError +} From e65cc08e8a6968256a8e1306317fada4691e1d7d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 21 Aug 2026 05:13:05 +0800 Subject: [PATCH 02/23] feat(api): abort signal support for openai, openai-compatible base, zai, kimi-code (round 2) Round 2 of the abort-signal series: wires request-cancellation signals through the OpenAI family of providers (addresses #404). - openai.ts: all five client.chat.completions.create sites (createMessage streaming + non-streaming, O3-family streaming + non-streaming, completePrompt) build their request config through RequestConfigBuilder; the Azure AI Inference path option and the abort signal compose in one builder (setOption("path", ...) + setAbortSignal). Every catch normalizes abort failures to the Task.ts contract shape (name === "AbortError", message ending in "aborted") via an abort-aware handleOpenAIRequestError; non-abort errors keep the existing provider-prefix wrap. - base-openai-compatible-provider.ts: the shared createMessage / createStream / completePrompt path adopts RequestConfigBuilder for signal forwarding and gains the exported abort-aware error helper handleOpenAIRequestError (reused by zai.ts); subclasses that do not override these methods inherit the wiring. - zai.ts: audit finding fixed - the GLM thinking path in createStream no longer drops requestOptions; the thinking path and the glm-5.3 completePrompt path forward a merged signal (external signal + timeoutMs via mergeAbortSignalAndTimeout). - kimi-code.ts: completePrompt no longer drops CompletePromptOptions - options are forwarded on both the initial call and the 401 OAuth retry. - Design notes: CompletePromptOptions is not ApiHandlerCreateMessageMetadata (required taskId, gap G7), so completePrompt paths use setOption("signal", mergeAbortSignalAndTimeout(...)) instead of setAbortSignal(metadata); gap G5 - mergeAbortSignalAndTimeout treats timeoutMs <= 0 as no explicit timeout. Each call builds a fresh request-local config (no class-field abort controller) with a per-entry-point throwIfAborted guard that rejects before any network I/O. - eslint-suppressions.json: one stale suppression entry pruned (kimi-code.spec.ts @typescript-eslint/no-explicit-any 1 -> 0 - the spec rewrite removed the only as-any cast); no suppression count increased. This branch is STACKED on open PR #1288: the foundation commit e61feb13e (generic RequestConfigBuilder, mergeAbortSignalAndTimeout, mergeAbortSignals, throwIfAborted) rides inside by design. --- .../base-openai-compatible-provider.spec.ts | 140 +++++++++- src/api/providers/__tests__/kimi-code.spec.ts | 114 +++++++- src/api/providers/__tests__/openai.spec.ts | 245 +++++++++++++++++- src/api/providers/__tests__/zai.spec.ts | 200 +++++++++++++- .../base-openai-compatible-provider.ts | 56 +++- src/api/providers/kimi-code.ts | 10 +- src/api/providers/openai.ts | 93 ++++++- src/api/providers/zai.ts | 36 ++- src/eslint-suppressions.json | 5 - 9 files changed, 852 insertions(+), 47 deletions(-) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index fa7c19c5ed..d2a2a40b94 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -1,7 +1,7 @@ // npx vitest run api/providers/__tests__/base-openai-compatible-provider.spec.ts import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import OpenAI, { APIUserAbortError } from "openai" import type { ModelInfo } from "@roo-code/types" @@ -14,6 +14,8 @@ const mockCreate = vi.fn() // Mock OpenAI module vi.mock("openai", () => ({ + // Named export consumed by the provider for abort-error normalization + APIUserAbortError: class extends Error {}, default: vi.fn(function () { return { chat: { @@ -49,6 +51,20 @@ class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-mo } } +/** + * Captures the rejection of an operation as an Error. The abort contract always + * throws an Error; the guard keeps strict typing without a cast. Fails the test + * if the operation resolves. + */ +async function captureError(operation: Promise): Promise { + try { + await operation + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } + throw new Error("Expected the operation to reject") +} + describe("BaseOpenAiCompatibleProvider", () => { let handler: TestOpenAiCompatibleProvider @@ -278,6 +294,128 @@ describe("BaseOpenAiCompatibleProvider", () => { }) }) + describe("abort signal wiring", () => { + it("should pass the metadata abort signal to the client request", async () => { + const controller = new AbortController() + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task", + abortSignal: controller.signal, + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { + signal: controller.signal, + }) + }) + + it("should reject before issuing any request when the abort signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + await expect(async () => { + for await (const _ of handler.createMessage("system prompt", [], { + taskId: "test-task", + abortSignal: controller.signal, + })) { + // consume + } + }).rejects.toMatchObject({ name: "AbortError", message: "This operation was aborted" }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("should normalize the SDK APIUserAbortError from the stream path into the abort contract", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + // The SDK error has name "Error" and a message ending in a period; the + // provider must rethrow the Task.ts contract shape instead. + expect(result.name).toBe("AbortError") + expect(result.message).toBe("TestProvider request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + + it("should still wrap non-abort request errors with the provider prefix", async () => { + mockCreate.mockImplementationOnce(() => { + throw new Error("boom") + }) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + expect(result.message).toBe("TestProvider completion error: boom") + }) + + it("should pass the completePrompt abort signal to the client request", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + const result = await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { + signal: controller.signal, + }) + }) + + it("should merge completePrompt timeoutMs into the request signal", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + await handler.completePrompt("test prompt", { timeoutMs: 5000 }) + + const requestOptions = mockCreate.mock.calls.at(-1)?.[1] + expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) + expect(requestOptions?.signal.aborted).toBe(false) + }) + + it("should not set a request signal for zero completePrompt timeoutMs", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), undefined) + }) + + it("should reject before any request when the completePrompt signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + await expect( + handler.completePrompt("test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "This operation was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("should normalize the SDK APIUserAbortError from completePrompt", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + + const result = await captureError(handler.completePrompt("test prompt")) + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("TestProvider request aborted") + }) + }) + describe("Tool call handling", () => { it("should yield tool_call_end events when finish_reason is tool_calls", async () => { mockCreate.mockImplementationOnce(() => diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index df909d57d4..63bbb783bd 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -18,6 +18,32 @@ vi.mock("../../../integrations/kimi-code/oauth", () => ({ vi.mock("../fetchers/modelCache", () => ({ getModels: mockGetModels })) +/** + * Spies on the inherited OpenAI client's chat.completions.create. `client` is + * protected on the OpenAiHandler base (not on the public interface), so it is + * reached through a documented `as unknown as` double assertion (AGENTS.md + * last resort; no `as any`). + */ +function completionsCreate(handler: KimiCodeHandler): ReturnType { + const client = ( + handler as unknown as { + client: { chat: { completions: Record never> } } + } + ).client + return vi.spyOn(client.chat.completions, "create") as unknown as ReturnType +} + +/** Captures the rejection of an operation; fails the test if it resolves. */ +async function captureError(operation: Promise): Promise { + try { + await operation + } catch (error) { + // Abort normalization always throws an Error; the guard keeps strict typing without casts. + return error instanceof Error ? error : new Error(String(error)) + } + throw new Error("Expected the operation to reject") +} + describe("KimiCodeHandler", () => { beforeEach(() => { clearAllMocks() @@ -117,8 +143,7 @@ describe("KimiCodeHandler", () => { it("force-refreshes and retries exactly once after a non-streaming OAuth 401", async () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 }) - const createCompletion = vi - .spyOn((handler as any).client.chat.completions, "create") + const createCompletion = completionsCreate(handler) .mockRejectedValueOnce(unauthorized) .mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] }) @@ -235,4 +260,89 @@ describe("KimiCodeHandler", () => { }) expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "max" }) }) + + it("forwards the metadata abort signal to the inherited OpenAI SDK request", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const controller = new AbortController() + const streamChunks = (async function* () { + yield { choices: [{ delta: { content: "hi" } }] } + })() + const createCompletion = completionsCreate(handler).mockResolvedValueOnce(streamChunks) + + const gen = handler.createMessage("system", [{ role: "user", content: "test" }], { + taskId: "test-task", + abortSignal: controller.signal, + }) + const first = await gen.next() + + expect(first.value).toEqual({ type: "text", text: "hi" }) + expect(createCompletion).toHaveBeenCalledWith(expect.anything(), { signal: controller.signal }) + }) + + it("rejects before any request when the createMessage abort signal is already aborted", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const controller = new AbortController() + controller.abort() + const createCompletion = completionsCreate(handler) + + const gen = handler.createMessage("system", [{ role: "user", content: "test" }], { + taskId: "test-task", + abortSignal: controller.signal, + }) + + await expect(async () => { + for await (const _ of gen) { + // consume + } + }).rejects.toMatchObject({ name: "AbortError", message: "This operation was aborted" }) + expect(createCompletion).not.toHaveBeenCalled() + }) + + it("forwards completePrompt abort options through the override on both 401 retry attempts", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 }) + const createCompletion = completionsCreate(handler) + .mockRejectedValueOnce(unauthorized) + .mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] }) + const controller = new AbortController() + + await expect(handler.completePrompt("test", { abortSignal: controller.signal })).resolves.toBe("retried") + expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce() + expect(createCompletion).toHaveBeenCalledTimes(2) + for (const call of createCompletion.mock.calls) { + expect(call[1]).toEqual({ signal: controller.signal }) + } + }) + + it("rejects before any request when the completePrompt signal is already aborted", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const controller = new AbortController() + controller.abort() + const createCompletion = completionsCreate(handler) + + await expect(handler.completePrompt("test", { abortSignal: controller.signal })).rejects.toMatchObject({ + name: "AbortError", + message: "This operation was aborted", + }) + expect(createCompletion).not.toHaveBeenCalled() + }) + + it("surfaces a normalized AbortError when the SDK aborts a streaming request", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + // The real SDK class: this spec does not mock the openai module. + const { APIUserAbortError } = await import("openai") + completionsCreate(handler).mockRejectedValueOnce(new APIUserAbortError()) + + const gen = handler.createMessage("system", [{ role: "user", content: "test" }], { taskId: "test-task" }) + const result = await captureError( + (async () => { + for await (const _ of gen) { + // consume + } + })(), + ) + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 38550533a5..67fe920818 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -3,7 +3,7 @@ import { OpenAiHandler, getOpenAiModels } from "../openai" import { ApiHandlerOptions } from "../../../shared/api" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI, { AzureOpenAI } from "openai" +import OpenAI, { AzureOpenAI, APIUserAbortError } from "openai" import { openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE, @@ -25,8 +25,10 @@ const mockCreate = vitest.fn() vitest.mock("openai", () => { const mockConstructor = vitest.fn() const mockAzureConstructor = vitest.fn() + const APIUserAbortError = class extends Error {} return { __esModule: true, + APIUserAbortError, default: mockConstructor.mockImplementation(function () { return { chat: { @@ -90,6 +92,20 @@ vitest.mock("axios", () => ({ }, })) +/** + * Captures the rejection of an operation as an Error. The abort contract always + * throws an Error; the guard keeps strict typing without a cast. Fails the test + * if the operation resolves. + */ +async function captureError(operation: Promise): Promise { + try { + await operation + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } + throw new Error("Expected the operation to reject") +} + describe("OpenAiHandler", () => { let handler: OpenAiHandler let mockOptions: ApiHandlerOptions @@ -863,6 +879,233 @@ describe("OpenAiHandler", () => { }) }) + describe("abort signal wiring", () => { + it("should pass the metadata abort signal to the streaming createMessage request", async () => { + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = handler.createMessage("system prompt", [], metadata) + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "Test response" }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: mockOptions.openAiModelId, stream: true }), + { signal: controller.signal }, + ) + }) + + it("should pass the metadata abort signal to the non-streaming createMessage request", async () => { + const nonStreamingHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled: false }) + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = nonStreamingHandler.createMessage("system prompt", [], metadata) + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "Test response" }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: mockOptions.openAiModelId }), { + signal: controller.signal, + }) + }) + + it("should pass the metadata abort signal to the o3-family streaming request", async () => { + const o3Handler = new OpenAiHandler({ ...mockOptions, openAiModelId: "o3-mini" }) + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = o3Handler.createMessage("system prompt", [], metadata) + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "Test response" }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "o3-mini", stream: true }), { + signal: controller.signal, + }) + }) + + it("should pass the metadata abort signal to the o3-family non-streaming request", async () => { + const o3Handler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "o3-mini", + openAiStreamingEnabled: false, + }) + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = o3Handler.createMessage("system prompt", [], metadata) + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "Test response" }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "o3-mini" }), { + signal: controller.signal, + }) + }) + + it("should keep the Azure AI Inference path while passing the abort signal", async () => { + const azureHandler = new OpenAiHandler({ + ...mockOptions, + openAiBaseUrl: "https://test.services.ai.azure.com", + openAiModelId: "deepseek-v3", + }) + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = azureHandler.createMessage("system prompt", [], metadata) + await gen.next() + expect(mockCreate).toHaveBeenCalledWith(expect.anything(), { + path: "/models/chat/completions", + signal: controller.signal, + }) + }) + + it("should pass the completePrompt abort signal to the request", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + const result = await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith(expect.anything(), { signal: controller.signal }) + }) + + it("should merge the completePrompt abort signal and timeout into a single request signal", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + const requestOptions = mockCreate.mock.calls.at(-1)?.[1] + expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) + expect(requestOptions?.signal.aborted).toBe(false) + }) + + it("should pass a timeout-only request signal when completePrompt timeoutMs is positive", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + await handler.completePrompt("test prompt", { timeoutMs: 5000 }) + const requestOptions = mockCreate.mock.calls.at(-1)?.[1] + expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) + }) + + it("should not pass a request signal for zero timeoutMs in completePrompt", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + expect(mockCreate).toHaveBeenCalledWith(expect.anything(), {}) + }) + + it("should reject before issuing any request when the abort signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + await expect( + (async () => { + for await (const _ of handler.createMessage("system prompt", [], metadata)) { + // consume + } + })(), + ).rejects.toMatchObject({ name: "AbortError", message: "This operation was aborted" }) + expect(mockCreate).not.toHaveBeenCalled() + + mockCreate.mockClear() + await expect( + handler.completePrompt("test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "This operation was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("should normalize the SDK APIUserAbortError from the streaming path into the abort contract", async () => { + const controller = new AbortController() + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [], metadata)) { + // consume + } + })(), + ) + // The SDK error has name "Error" and a message ending in a period; the + // provider must rethrow the Task.ts contract shape instead. + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + + it("should normalize the SDK APIUserAbortError from completePrompt without an external signal", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + const result = await captureError(handler.completePrompt("test prompt")) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + }) + + it("should normalize a fetch-level AbortError from completePrompt into the abort contract", async () => { + const fetchAbort = new Error("The operation was aborted.") + fetchAbort.name = "AbortError" + mockCreate.mockImplementationOnce(() => { + throw fetchAbort + }) + const result = await captureError(handler.completePrompt("test prompt")) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + expect(result.cause).toBe(fetchAbort) + }) + + it("should normalize the SDK APIUserAbortError from the non-streaming createMessage path", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + const noStreamHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled: false }) + const result = await captureError( + (async () => { + for await (const _ of noStreamHandler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + }) + + it("should normalize the SDK APIUserAbortError from the o3-family streaming path", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + const o3Handler = new OpenAiHandler({ ...mockOptions, openAiModelId: "o3-mini" }) + const result = await captureError( + (async () => { + for await (const _ of o3Handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + }) + + it("should normalize the SDK APIUserAbortError from the o3-family non-streaming path", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + const o3Handler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "o3-mini", + openAiStreamingEnabled: false, + }) + const result = await captureError( + (async () => { + for await (const _ of o3Handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + }) + + it("should still wrap non-abort completion errors with the provider prefix", async () => { + mockCreate.mockImplementationOnce(() => { + throw new Error("boom") + }) + const result = await captureError(handler.completePrompt("test prompt")) + expect(result.name).toBe("Error") + // The inner catch already wraps with the provider prefix and the outer + // catch wraps again (pre-existing double-wrap of openai.ts completePrompt). + expect(result.message).toBe("OpenAI completion error: OpenAI completion error: boom") + }) + }) + describe("getModel", () => { it("should return model info with sane defaults", () => { const model = handler.getModel() diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 0230b679a9..9ddbe40944 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -1,6 +1,6 @@ // npx vitest run src/api/providers/__tests__/zai.spec.ts -import OpenAI from "openai" +import OpenAI, { APIUserAbortError } from "openai" import { Anthropic } from "@anthropic-ai/sdk" import { @@ -21,12 +21,28 @@ import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai", () => { const createMock = vitest.fn() return { + // Named export consumed by the provider for abort-error normalization + APIUserAbortError: class extends Error {}, default: vitest.fn(function () { return { chat: { completions: { create: createMock } } } }), } }) +/** + * Captures the rejection of an operation as an Error. The abort contract always + * throws an Error; the guard keeps strict typing without a cast. Fails the test + * if the operation resolves. + */ +async function captureError(operation: Promise): Promise { + try { + await operation + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } + throw new Error("Expected the operation to reject") +} + describe("ZAiHandler", () => { let handler: ZAiHandler let mockCreate: any @@ -550,6 +566,159 @@ describe("ZAiHandler", () => { }) }) + describe("abort signal wiring", () => { + beforeEach(() => { + handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) + }) + + it("createMessage should pass the abort signal on the GLM thinking path", async () => { + const thinkingHandler = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + const controller = new AbortController() + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + + const gen = thinkingHandler.createMessage("system prompt", [], { + taskId: "test-task", + abortSignal: controller.signal, + }) + await gen.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "glm-4.7", thinking: { type: "enabled" } }), + { signal: controller.signal }, + ) + }) + + it("createMessage should pass the abort signal for non-thinking models via the base path", async () => { + const plainHandler = new ZAiHandler({ + apiModelId: "glm-4.6", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + const controller = new AbortController() + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + + const gen = plainHandler.createMessage("system prompt", [], { + taskId: "test-task", + abortSignal: controller.signal, + }) + await gen.next() + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "glm-4.6" }), { + signal: controller.signal, + }) + }) + + it("createMessage should reject before any request when the abort signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + await expect(async () => { + for await (const _ of handler.createMessage("system prompt", [], { + taskId: "test-task", + abortSignal: controller.signal, + })) { + // consume + } + }).rejects.toMatchObject({ name: "AbortError", message: "This operation was aborted" }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("createMessage should normalize the SDK APIUserAbortError on the thinking path", async () => { + const thinkingHandler = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + + const result = await captureError( + (async () => { + for await (const _ of thinkingHandler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Z.ai request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + + it("completePrompt should pass the abort signal through to the client", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + const result = await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith(expect.anything(), { signal: controller.signal }) + }) + + it("completePrompt should reject before any request when the signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + await expect( + handler.completePrompt("test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "This operation was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("completePrompt should normalize the SDK APIUserAbortError", async () => { + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + + const result = await captureError(handler.completePrompt("test prompt")) + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Z.ai request aborted") + }) + + it("glm-5.3 completePrompt should pass the abort signal on the thinking path", async () => { + const h53 = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + const result = await h53.completePrompt("prompt", { abortSignal: controller.signal }) + + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "glm-5.3", thinking: { type: "enabled", clear_thinking: false } }), + { signal: controller.signal }, + ) + }) + + it("glm-5.3 completePrompt should normalize the SDK APIUserAbortError", async () => { + const h53 = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + mockCreate.mockImplementationOnce(() => { + throw new APIUserAbortError() + }) + + const result = await captureError(h53.completePrompt("prompt")) + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Z.ai request aborted") + }) + }) + describe("GLM-4.7 Thinking Mode", () => { it("should cap GLM-5.1 max_tokens to 20% of context window by default", async () => { const handlerWithModel = new ZAiHandler({ @@ -568,6 +737,7 @@ describe("ZAiHandler", () => { model: "glm-5.1", max_tokens: 40_000, }), + undefined, ) }) @@ -600,6 +770,7 @@ describe("ZAiHandler", () => { model: "glm-5.1", max_tokens: 100_000, }), + undefined, ) }) @@ -622,6 +793,7 @@ describe("ZAiHandler", () => { model: "glm-4.7", thinking: { type: "enabled" }, }), + undefined, ) }) @@ -644,6 +816,7 @@ describe("ZAiHandler", () => { thinking: { type: "enabled" }, reasoning_effort: "high", }), + undefined, ) }) @@ -666,6 +839,7 @@ describe("ZAiHandler", () => { thinking: { type: "enabled" }, reasoning_effort: "max", }), + undefined, ) }) @@ -689,6 +863,7 @@ describe("ZAiHandler", () => { reasoning_effort: "max", temperature: 1, }), + undefined, ) }) @@ -711,6 +886,7 @@ describe("ZAiHandler", () => { thinking: { type: "enabled", clear_thinking: false }, reasoning_effort: "max", }), + undefined, ) }) @@ -725,13 +901,16 @@ describe("ZAiHandler", () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) await expect(handlerWithModel.completePrompt("prompt")).resolves.toBe("response") - expect(mockCreate).toHaveBeenCalledWith({ - model: "glm-5.3", - messages: [{ role: "user", content: "prompt" }], - temperature: 1, - thinking: { type: "enabled", clear_thinking: false }, - reasoning_effort: "low", - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: "glm-5.3", + messages: [{ role: "user", content: "prompt" }], + temperature: 1, + thinking: { type: "enabled", clear_thinking: false }, + reasoning_effort: "low", + }, + undefined, + ) }) it("should omit reasoning_effort for GLM-5.2 when reasoningEffort is set to disable", async () => { @@ -771,6 +950,7 @@ describe("ZAiHandler", () => { thinking: { type: "enabled" }, reasoning_effort: "high", }), + undefined, ) }) @@ -794,6 +974,7 @@ describe("ZAiHandler", () => { model: "glm-4.7", thinking: { type: "disabled" }, }), + undefined, ) }) @@ -817,6 +998,7 @@ describe("ZAiHandler", () => { model: "glm-4.7", thinking: { type: "enabled" }, }), + undefined, ) }) @@ -854,6 +1036,7 @@ describe("ZAiHandler", () => { model: "glm-5-turbo", thinking: { type: "enabled" }, }), + undefined, ) }) @@ -876,6 +1059,7 @@ describe("ZAiHandler", () => { model: "glm-5-turbo", thinking: { type: "disabled" }, }), + undefined, ) }) }) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index f4928b0b0a..344ccfa38e 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import OpenAI, { APIUserAbortError } from "openai" import type { ModelInfo } from "@roo-code/types" @@ -14,6 +14,8 @@ import { BaseProvider } from "./base-provider" import { handleOpenAIError } from "./utils/error-handler" import { calculateApiCostOpenAI } from "../../shared/cost" import { extractReasoningFromDelta } from "./utils/extract-reasoning" +import { RequestConfigBuilder } from "./config-builder/request-config-builder" +import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" type BaseOpenAiCompatibleProviderOptions = ApiHandlerOptions & { providerName: string @@ -23,6 +25,32 @@ type BaseOpenAiCompatibleProviderOptions = ApiHandlerO defaultTemperature?: number } +/** Subset of OpenAI.RequestOptions built per request for the abort-signal wiring. */ +type OpenAiRequestConfig = { + signal?: AbortSignal +} + +/** + * Handles errors from OpenAI Node SDK request sites with abort awareness. + * + * An abort failure (the external signal already aborted, the SDK's + * APIUserAbortError, or a fetch-level AbortError) is normalized to a fresh + * Error with name "AbortError" and a message ending in "aborted" (the Task.ts + * contract) instead of being wrapped as a regular completion error, which a + * plain rethrow of the SDK abort error would produce. + */ +export function handleOpenAIRequestError(error: unknown, providerName: string, abortSignal?: AbortSignal): Error { + if ( + abortSignal?.aborted || + (error instanceof Error && (error.name === "AbortError" || error instanceof APIUserAbortError)) + ) { + const aborted = new Error(`${providerName} request aborted`, { cause: error }) + aborted.name = "AbortError" + return aborted + } + return handleOpenAIError(error, providerName) +} + export abstract class BaseOpenAiCompatibleProvider extends BaseProvider implements SingleCompletionHandler @@ -67,7 +95,7 @@ export abstract class BaseOpenAiCompatibleProvider }) } - protected createStream( + protected async createStream( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, @@ -104,9 +132,9 @@ export abstract class BaseOpenAiCompatibleProvider } try { - return this.client.chat.completions.create(params, requestOptions) + return await this.client.chat.completions.create(params, requestOptions) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } } @@ -115,7 +143,12 @@ export abstract class BaseOpenAiCompatibleProvider messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const stream = await this.createStream(systemPrompt, messages, metadata) + throwIfAborted(metadata?.abortSignal) + + // Per-request abort wiring (RequestConfigBuilder adoption): subclasses inherit + // it by receiving the built config as createStream's requestOptions. + const requestConfig = new RequestConfigBuilder().setAbortSignal(metadata).build() + const stream = await this.createStream(systemPrompt, messages, metadata, requestConfig) const matcher = new TagMatcher( ["think", "thought"], @@ -213,6 +246,8 @@ export abstract class BaseOpenAiCompatibleProvider } async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + throwIfAborted(options?.abortSignal) + const { id: modelId, info: modelInfo } = this.getModel() const params: OpenAI.Chat.Completions.ChatCompletionCreateParams = { @@ -225,8 +260,15 @@ export abstract class BaseOpenAiCompatibleProvider ;(params as any).thinking = { type: "enabled" } } + // CompletePromptOptions is not ApiHandlerCreateMessageMetadata (required taskId), + // so the abort/timeout merge goes through setOption; the helper treats + // timeoutMs <= 0 as "no explicit timeout". + const requestConfig = new RequestConfigBuilder() + .setOption("signal", mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs)) + .build() + try { - const response = await this.client.chat.completions.create(params) + const response = await this.client.chat.completions.create(params, requestConfig) // Check for provider-specific error responses (e.g., MiniMax base_resp) const responseAny = response as any @@ -238,7 +280,7 @@ export abstract class BaseOpenAiCompatibleProvider return response.choices?.[0]?.message.content || "" } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, options?.abortSignal) } } diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts index 3f7136806b..a81efb488a 100644 --- a/src/api/providers/kimi-code.ts +++ b/src/api/providers/kimi-code.ts @@ -13,7 +13,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { kimiCodeOAuthManager } from "../../integrations/kimi-code/oauth" -import type { ApiHandlerCreateMessageMetadata } from "../index" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import type { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -98,14 +98,16 @@ export class KimiCodeHandler extends OpenAiHandler { } } - override async completePrompt(prompt: string): Promise { + override async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { await this.prepareRequest() try { - return await super.completePrompt(prompt) + // Forward abort/timeout options so the inherited OpenAiHandler wiring + // applies (the createMessage override inherits the same via metadata). + return await super.completePrompt(prompt, options) } catch (error) { if (getHttpStatus(error) !== 401 || !this.canRefreshOAuth()) throw error await this.prepareRequest(true) - return super.completePrompt(prompt) + return super.completePrompt(prompt, options) } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5588dd37d6..0f28a888e5 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI, { AzureOpenAI } from "openai" +import OpenAI, { AzureOpenAI, APIUserAbortError } from "openai" import axios from "axios" import { @@ -26,6 +26,35 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" import { extractReasoningFromDelta } from "./utils/extract-reasoning" +import { RequestConfigBuilder } from "./config-builder/request-config-builder" +import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" + +/** Subset of OpenAI.RequestOptions built per request for the abort-signal wiring. */ +type OpenAiRequestConfig = { + path?: string + signal?: AbortSignal +} + +/** + * Handles errors from OpenAI Node SDK request sites with abort awareness. + * + * An abort failure (the external signal already aborted, the SDK's + * APIUserAbortError, or a fetch-level AbortError) is normalized to a fresh + * Error with name "AbortError" and a message ending in "aborted" (the Task.ts + * contract) instead of being wrapped as a regular completion error, which a + * plain rethrow of the SDK abort error would produce. + */ +function handleOpenAIRequestError(error: unknown, providerName: string, abortSignal?: AbortSignal): Error { + if ( + abortSignal?.aborted || + (error instanceof Error && (error.name === "AbortError" || error instanceof APIUserAbortError)) + ) { + const aborted = new Error(`${providerName} request aborted`, { cause: error }) + aborted.name = "AbortError" + return aborted + } + return handleOpenAIError(error, providerName) +} // TODO: Rename this to OpenAICompatibleHandler. Also, I think the // `OpenAINativeHandler` can subclass from this, since it's obviously @@ -84,6 +113,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + throwIfAborted(metadata?.abortSignal) + const { info: modelInfo, reasoning } = this.getModel() const modelUrl = this.options.openAiBaseUrl ?? "" const modelId = this.options.openAiModelId ?? "" @@ -179,10 +210,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { stream = await this.client.chat.completions.create( requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + this.buildChatRequestConfig(isAzureAiInference, metadata), ) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } const matcher = new TagMatcher( @@ -245,10 +276,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { response = await this.client.chat.completions.create( requestOptions, - this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + this.buildChatRequestConfig(this._isAzureAiInference(modelUrl), metadata), ) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } const message = response.choices?.[0]?.message @@ -299,6 +330,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + throwIfAborted(options?.abortSignal) + try { const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const model = this.getModel() @@ -316,14 +349,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { response = await this.client.chat.completions.create( requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + this.buildCompletePromptRequestConfig(isAzureAiInference, options), ) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, options?.abortSignal) } return response.choices?.[0]?.message.content || "" } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // Preserve the normalized abort error (name + message contract) as-is. + throw error + } if (error instanceof Error) { const wrapped = new Error(`${this.providerName} completion error: ${error.message}`, { cause: error }) const source = error as Error & { status?: number; errorDetails?: unknown; code?: unknown } @@ -338,6 +375,40 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + /** + * Builds the per-request OpenAI SDK options for a chat completions create + * (createMessage paths): the Azure AI Inference path when applicable, plus + * the caller's abort signal, via RequestConfigBuilder adoption. + */ + private buildChatRequestConfig( + isAzureAiInference: boolean, + metadata?: ApiHandlerCreateMessageMetadata, + ): OpenAI.RequestOptions { + return ( + new RequestConfigBuilder() + .setOption("path", isAzureAiInference ? OPENAI_AZURE_AI_INFERENCE_PATH : undefined) + .setAbortSignal(metadata) + .build() ?? {} + ) + } + + /** + * Builds the per-request options for completePrompt. CompletePromptOptions + * is not ApiHandlerCreateMessageMetadata (required taskId), so the merged + * abort/timeout signal goes through setOption instead of setAbortSignal. + */ + private buildCompletePromptRequestConfig( + isAzureAiInference: boolean, + options?: CompletePromptOptions, + ): OpenAI.RequestOptions { + return ( + new RequestConfigBuilder() + .setOption("path", isAzureAiInference ? OPENAI_AZURE_AI_INFERENCE_PATH : undefined) + .setOption("signal", mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs)) + .build() ?? {} + ) + } + private async *handleO3FamilyMessage( modelId: string, systemPrompt: string, @@ -378,10 +449,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { stream = await this.client.chat.completions.create( requestOptions, - methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + this.buildChatRequestConfig(methodIsAzureAiInference, metadata), ) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } yield* this.handleStreamResponse(stream) @@ -412,10 +483,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { response = await this.client.chat.completions.create( requestOptions, - methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + this.buildChatRequestConfig(methodIsAzureAiInference, metadata), ) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } const message = response.choices?.[0]?.message diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index c53a434e38..f286d26f9b 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -14,9 +14,10 @@ import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/ap import { convertToZAiFormat } from "../transform/zai-format" import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { BaseOpenAiCompatibleProvider, handleOpenAIRequestError } from "./base-openai-compatible-provider" import { NOT_PROVIDED } from "./constants" -import { handleOpenAIError } from "./utils/error-handler" +import { RequestConfigBuilder } from "./config-builder/request-config-builder" +import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" // Custom interface for Z.ai params to support thinking mode and reasoning effort tiers. // Z.ai accepts the standard `reasoning_effort` ladder (none/minimal/low/medium/high/xhigh/max) @@ -27,6 +28,11 @@ type ZAiChatCompletionParams = Omit { constructor(options: ApiHandlerOptions) { const apiLine = options.zaiApiLine ?? "international_coding" @@ -62,8 +68,10 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { const isThinkingModel = Array.isArray(info.supportsReasoningEffort) if (isThinkingModel) { - // Create the stream with our custom thinking parameter - return this.createStreamWithThinking(systemPrompt, messages, metadata) + // Create the stream with our custom thinking parameter. + // Forward the per-request options (abort signal) so the thinking path + // keeps them instead of dropping requestOptions. + return this.createStreamWithThinking(systemPrompt, messages, metadata, requestOptions) } // For non-thinking models, use the default behavior @@ -73,10 +81,11 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { /** * Creates a stream with explicit thinking control for GLM thinking-capable models. */ - private createStreamWithThinking( + private async createStreamWithThinking( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, + requestOptions?: OpenAI.RequestOptions, ) { const { id: model, info } = this.getModel() const { reasoningEffort, useReasoning } = this.getReasoningSettings(info) @@ -114,11 +123,12 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { } try { - return this.client.chat.completions.create( + return await this.client.chat.completions.create( params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions, ) } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } } @@ -147,6 +157,8 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { return super.completePrompt(prompt, options) } + throwIfAborted(options?.abortSignal) + const { reasoningEffort } = this.getReasoningSettings(info) const params: ZAiChatCompletionParams = { model, @@ -156,13 +168,21 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { reasoning_effort: reasoningEffort, } + // CompletePromptOptions is not ApiHandlerCreateMessageMetadata (required + // taskId), so the abort/timeout merge goes through setOption; the helper + // treats timeoutMs <= 0 as "no explicit timeout". + const requestConfig = new RequestConfigBuilder() + .setOption("signal", mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs)) + .build() + try { const response = await this.client.chat.completions.create( params as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, + requestConfig, ) return response.choices?.[0]?.message.content || "" } catch (error) { - throw handleOpenAIError(error, this.providerName) + throw handleOpenAIRequestError(error, this.providerName, options?.abortSignal) } } } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f790fba436..430be7ca31 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -164,11 +164,6 @@ "count": 1 } }, - "api/providers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "api/providers/__tests__/lite-llm.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 36 From 4a3169281cc0cf49a2cbb36fc1fc40b18f65b02a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 21 Aug 2026 06:07:17 +0800 Subject: [PATCH 03/23] test(providers): export APIUserAbortError from openai mock in fireworks and sambanova specs Root cause: the abort-aware completePrompt error path inherited by fireworks and sambanova (base-openai-compatible-provider.ts) references the APIUserAbortError export of the openai SDK, which their specs' partial vi.mock("openai", ...) factories did not define, so the completePrompt error-path tests failed in the CI full suite with 'No "APIUserAbortError" export is defined on the "openai" mock'. The mocks now export APIUserAbortError using the same shape as the other series specs (base-openai-compatible-provider, zai, openai, kimi-code). --- src/api/providers/__tests__/fireworks.spec.ts | 2 ++ src/api/providers/__tests__/sambanova.spec.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index bde144591d..ab543252d9 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -23,6 +23,8 @@ vi.mock("openai", () => ({ }, } }), + // Named export consumed by the provider for abort-error normalization + APIUserAbortError: class extends Error {}, })) describe("FireworksHandler", () => { diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 2a19d5659c..18636f9c4f 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -15,6 +15,8 @@ vitest.mock("openai", () => { default: vitest.fn(function () { return { chat: { completions: { create: createMock } } } }), + // Named export consumed by the provider for abort-error normalization + APIUserAbortError: class extends Error {}, } }) From 35c95ea19e902f6b1992beda46631909ea2a3398 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 21 Aug 2026 06:37:22 +0800 Subject: [PATCH 04/23] fix(api): normalize abort errors raised during stream iteration Root cause: the creation-site catches only cover chat.completions.create; an abort that surfaces while the async iterator is being consumed (APIUserAbortError / fetch-level AbortError thrown mid-stream) leaked as the raw SDK error, which violates the Task.ts abort contract (an Error whose name is "AbortError" and whose message ends in "aborted"). The stream iteration is now wrapped and normalized through the same abort-aware handleOpenAIRequestError used at the creation sites: - base-openai-compatible-provider.ts: the createMessage for-await loop - openai.ts: the streaming createMessage for-await loop - openai.ts: the o3-family yield* this.handleStreamResponse(stream) The Z.ai thinking path inherits the base createMessage iteration, so it is covered by the base-provider fix. Non-abort iteration errors keep the existing provider-prefix wrap. Adds four regression tests (base, openai streaming, o3-family streaming, zai thinking path) with iterators that reject with APIUserAbortError after yielding the first chunk. Addresses the CodeRabbit pre-merge review comment on PR #1311. --- .../base-openai-compatible-provider.spec.ts | 23 +++++ src/api/providers/__tests__/openai.spec.ts | 46 ++++++++++ src/api/providers/__tests__/zai.spec.ts | 28 ++++++ .../base-openai-compatible-provider.ts | 86 ++++++++++--------- src/api/providers/openai.ts | 42 +++++---- 5 files changed, 170 insertions(+), 55 deletions(-) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index d2a2a40b94..a6adeb53e5 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -345,6 +345,29 @@ describe("BaseOpenAiCompatibleProvider", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("should normalize an abort error raised during stream iteration into the abort contract", async () => { + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "partial" } }] } + throw new APIUserAbortError() + })(), + ) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + // The iterator rejects after the first chunk; the provider must normalize + // it to the Task.ts contract shape (name + message ending in "aborted"). + expect(result.name).toBe("AbortError") + expect(result.message).toBe("TestProvider request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + it("should still wrap non-abort request errors with the provider prefix", async () => { mockCreate.mockImplementationOnce(() => { throw new Error("boom") diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 67fe920818..e62d3ab6a6 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1021,6 +1021,30 @@ describe("OpenAiHandler", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("should normalize an abort error raised during stream iteration into the abort contract", async () => { + const controller = new AbortController() + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "partial" } }] } + throw new APIUserAbortError() + })(), + ) + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [], metadata)) { + // consume + } + })(), + ) + + // The iterator rejects after the first chunk; the provider must normalize + // it to the Task.ts contract shape instead of leaking the raw SDK error. + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + it("should normalize the SDK APIUserAbortError from completePrompt without an external signal", async () => { mockCreate.mockImplementationOnce(() => { throw new APIUserAbortError() @@ -1074,6 +1098,28 @@ describe("OpenAiHandler", () => { expect(result.message).toBe("OpenAI request aborted") }) + it("should normalize an abort error raised during o3-family stream iteration into the abort contract", async () => { + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "partial" } }] } + throw new APIUserAbortError() + })(), + ) + const o3Handler = new OpenAiHandler({ ...mockOptions, openAiModelId: "o3-mini" }) + const result = await captureError( + (async () => { + for await (const _ of o3Handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + // handleStreamResponse consumes the iterator; a mid-stream abort must be + // normalized to the Task.ts contract shape, not leak as the raw SDK error. + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + }) + it("should normalize the SDK APIUserAbortError from the o3-family non-streaming path", async () => { mockCreate.mockImplementationOnce(() => { throw new APIUserAbortError() diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 9ddbe40944..d4a390d4c6 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -650,6 +650,34 @@ describe("ZAiHandler", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("createMessage should normalize an abort error raised during stream iteration on the thinking path", async () => { + const thinkingHandler = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "partial" } }] } + throw new APIUserAbortError() + })(), + ) + + const result = await captureError( + (async () => { + for await (const _ of thinkingHandler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + // The thinking path inherits stream iteration from the base provider; a + // mid-stream abort must normalize to the Task.ts contract shape. + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Z.ai request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + it("completePrompt should pass the abort signal through to the client", async () => { const controller = new AbortController() mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 344ccfa38e..2f37b2101b 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -162,57 +162,63 @@ export abstract class BaseOpenAiCompatibleProvider let lastUsage: OpenAI.CompletionUsage | undefined const activeToolCallIds = new Set() - for await (const chunk of stream) { - // Check for provider-specific error responses (e.g., MiniMax base_resp) - const chunkAny = chunk as any - if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) { - throw new Error( - `${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`, - ) - } + try { + for await (const chunk of stream) { + // Check for provider-specific error responses (e.g., MiniMax base_resp) + const chunkAny = chunk as any + if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) { + throw new Error( + `${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`, + ) + } - const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason + const delta = chunk.choices?.[0]?.delta + const finishReason = chunk.choices?.[0]?.finish_reason - if (delta?.content) { - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk + if (delta?.content) { + for (const processedChunk of matcher.update(delta.content)) { + yield processedChunk + } } - } - const reasoningText = extractReasoningFromDelta(delta) - if (reasoningText) { - yield { type: "reasoning", text: reasoningText } - } + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - if (toolCall.id) { - activeToolCallIds.add(toolCall.id) - } - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, + // Emit raw tool call chunks - NativeToolCallParser handles state management + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } } } - } - // Emit tool_call_end events when finish_reason is "tool_calls" - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { - for (const id of activeToolCallIds) { - yield { type: "tool_call_end", id } + // Emit tool_call_end events when finish_reason is "tool_calls" + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() } - activeToolCallIds.clear() - } - if (chunk.usage) { - lastUsage = chunk.usage + if (chunk.usage) { + lastUsage = chunk.usage + } } + } catch (error) { + // The creation-site catch does not cover errors raised by the async + // iterator itself (e.g. a mid-stream abort); normalize them the same way. + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } if (lastUsage) { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 0f28a888e5..d6b13cd039 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -228,26 +228,32 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let lastUsage const activeToolCallIds = new Set() - for await (const chunk of stream) { - const delta = chunk.choices?.[0]?.delta ?? {} - const finishReason = chunk.choices?.[0]?.finish_reason + try { + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta ?? {} + const finishReason = chunk.choices?.[0]?.finish_reason - if (delta.content) { - for (const chunk of matcher.update(delta.content)) { - yield chunk + if (delta.content) { + for (const chunk of matcher.update(delta.content)) { + yield chunk + } } - } - const reasoningText = extractReasoningFromDelta(delta) - if (reasoningText) { - yield { type: "reasoning", text: reasoningText } - } + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } - yield* this.processToolCalls(delta, finishReason, activeToolCallIds) + yield* this.processToolCalls(delta, finishReason, activeToolCallIds) - if (chunk.usage) { - lastUsage = chunk.usage + if (chunk.usage) { + lastUsage = chunk.usage + } } + } catch (error) { + // The creation-site catch does not cover errors raised by the async + // iterator itself (e.g. a mid-stream abort); normalize them the same way. + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } for (const chunk of matcher.final()) { @@ -455,7 +461,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } - yield* this.handleStreamResponse(stream) + try { + yield* this.handleStreamResponse(stream) + } catch (error) { + // The creation-site catch does not cover errors raised by the async + // iterator itself (e.g. a mid-stream abort); normalize them the same way. + throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) + } } else { const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { model: modelId, From 217f120770a23fd3316df5c499bbb0e7c2e75ce1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 21 Aug 2026 07:12:36 +0800 Subject: [PATCH 05/23] test(providers): cover the base_resp stream error path through the iteration wrapper The stream-iteration wrapper added in 35c95ea19 routes non-abort iteration errors through handleOpenAIRequestError, so a provider base_resp stream error (MiniMax-style inline error chunk) is now rethrown with the provider-prefix wrap ("TestProvider completion error: ...") instead of the raw message. Adds a focused regression test that yields a chunk carrying base_resp and pins the wrapped message. --- .../base-openai-compatible-provider.spec.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index a6adeb53e5..615a3c0327 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -368,6 +368,26 @@ describe("BaseOpenAiCompatibleProvider", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("should wrap a non-abort base_resp stream error with the provider prefix through the iteration wrapper", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { content: "partial" } }], + base_resp: { status_code: 1041, status_msg: "Invalid token" }, + }, + ]), + ) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Invalid token") + }) it("should still wrap non-abort request errors with the provider prefix", async () => { mockCreate.mockImplementationOnce(() => { throw new Error("boom") From af320da16a98037d158ad1367729e6d9c58ea1e6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 21 Aug 2026 09:00:38 +0800 Subject: [PATCH 06/23] test(providers): cover remaining branch partials in base provider and openai abort paths The codecov patch report (97.83% at 217f12077) flagged 2 partial branch lines (BRDA taken=0 on the ?? / || fallback sides of added lines): - api/providers/base-openai-compatible-provider.ts:171 branch 1 of `${...} ${chunkAny.base_resp.status_msg || "Unknown error"}` - the || "Unknown error" fallback was never exercised; added a focused test yielding a base_resp chunk with status_code set but no status_msg, asserting the wrapped "Unknown error" message. - api/providers/openai.ts:233 branch 1 of `const delta = chunk.choices?.[0]?.delta ?? {}` - the ?? {} fallback (chunk with no delta field) was never exercised; added a focused streaming test yielding a delta-less final chunk and asserting the stream completes without throwing. Full api/providers suite: 1698 passed. No provider code changed. --- .../base-openai-compatible-provider.spec.ts | 22 ++++++++++++++++++- src/api/providers/__tests__/openai.spec.ts | 16 ++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 615a3c0327..7ecb98d400 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -385,9 +385,29 @@ describe("BaseOpenAiCompatibleProvider", () => { } })(), ) + }) + + it("should fall back to an Unknown error when a base_resp stream chunk has no status_msg", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { content: "partial" } }], + base_resp: { status_code: 1041 }, + }, + ]), + ) - expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Invalid token") + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Unknown error") }) + it("should still wrap non-abort request errors with the provider prefix", async () => { mockCreate.mockImplementationOnce(() => { throw new Error("boom") diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index e62d3ab6a6..d07816b80e 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -277,6 +277,22 @@ describe("OpenAiHandler", () => { expect(textChunks[0].text).toBe("Test response") }) + it("should treat a streaming chunk without a delta as an empty delta", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "hello " }, index: 0 }] }, + // Some providers emit a final chunk with no delta field at all; the + // iteration must fall back to an empty object and not throw. + { choices: [{ finish_reason: "stop", index: 0 }] }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toEqual([{ type: "text", text: "hello " }]) + }) + it("streams reasoning chunks from delta.reasoning_content", async () => { mockCreate.mockImplementationOnce(async () => asyncStreamFrom([ From a0117fb7cd080cf49e7a54d442691bdea687995c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 21 Aug 2026 09:19:26 +0800 Subject: [PATCH 07/23] feat(api): add shared isRequestAborted and createAbortError helpers to abort-signal utils The OpenAI-family provider PRs (#1309, #1311) carry per-provider copies of the same abort-detection helper (isRequestAborted) and the same abort-error constructor (createAbortError); only the provider name in the message differs. Per the CodeRabbit maintainability finding on #1309 (extract the shared abort helpers into utils/abort-signal.ts), these are now shared in the foundation utility: - isRequestAborted(error, signal?) - true when the caller signal fired, a native AbortError / OpenAI SDK APIUserAbortError was raised, or the message is exactly "Request was aborted." (exact match; a substring match would misclassify unrelated errors that merely mention aborting) - createAbortError(providerName) - fresh error with name === "AbortError" and message "The request was aborted", satisfying the Task.ts abort contract - exported OpenAiRequestOptions type 7 new tests (isRequestAborted 4, createAbortError 3). --- .../utils/__tests__/abort-signal.spec.ts | 61 ++++++++++++++++++- src/api/providers/utils/abort-signal.ts | 41 +++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index 1e2181655f..aba72c181f 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -1,4 +1,10 @@ -import { mergeAbortSignalAndTimeout, mergeAbortSignals, throwIfAborted } from "../abort-signal" +import { + createAbortError, + isRequestAborted, + mergeAbortSignalAndTimeout, + mergeAbortSignals, + throwIfAborted, +} from "../abort-signal" describe("abort-signal utilities", () => { describe("mergeAbortSignalAndTimeout", () => { @@ -126,4 +132,57 @@ describe("abort-signal utilities", () => { expect((caught as Error).name).toBe("AbortError") }) }) + + describe("isRequestAborted", () => { + it("returns true when the caller signal is aborted", () => { + const controller = new AbortController() + controller.abort() + + expect(isRequestAborted(new Error("boom"), controller.signal)).toBe(true) + expect(isRequestAborted(undefined, controller.signal)).toBe(true) + }) + + it("returns true for a native AbortError or the OpenAI SDK APIUserAbortError", () => { + const native = new Error("This operation was aborted") + native.name = "AbortError" + expect(isRequestAborted(native)).toBe(true) + + const sdk = new Error("whatever") + sdk.name = "APIUserAbortError" + expect(isRequestAborted(sdk)).toBe(true) + }) + + it("matches the OpenAI SDK abort message exactly, not as a substring", () => { + expect(isRequestAborted(new Error("Request was aborted."))).toBe(true) + expect(isRequestAborted(new Error("Request was aborted"))).toBe(false) + expect(isRequestAborted(new Error("Request was aborted. Please retry"))).toBe(false) + }) + + it("returns false for unrelated errors, nullish errors, and live signals", () => { + expect(isRequestAborted(new Error("the abort failed"))).toBe(false) + expect(isRequestAborted(undefined)).toBe(false) + expect(isRequestAborted(null)).toBe(false) + + const controller = new AbortController() + expect(isRequestAborted(new Error("boom"), controller.signal)).toBe(false) + }) + }) + + describe("createAbortError", () => { + it("builds an error satisfying the Task.ts abort contract", () => { + const error = createAbortError("LM Studio") + + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe("AbortError") + expect(error.message).toBe("The LM Studio request was aborted") + }) + + it("interpolates the provider name", () => { + expect(createAbortError("Qwen Code").message).toBe("The Qwen Code request was aborted") + }) + + it("returns a fresh error on each call", () => { + expect(createAbortError("X")).not.toBe(createAbortError("X")) + }) + }) }) diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index 033e861b2b..26f57c3e9a 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -52,3 +52,44 @@ export function throwIfAborted(signal?: AbortSignal): void { abortError.name = "AbortError" throw abortError } + +/** + * Request options this series passes to the OpenAI SDK call. The SDK's + * `RequestOptions` declares `signal` as `AbortSignal | null | undefined`, + * which does not satisfy the builder's base constraint, so the builder is + * typed with only the options this series sets. The built config is still + * assignable to the SDK's `RequestOptions`. + */ +export type OpenAiRequestOptions = { + signal?: AbortSignal +} + +/** + * Whether a failure indicates an aborted request: the caller's signal fired, + * the SDK raised a native abort error, or the error carries the OpenAI SDK + * abort error message (exactly "Request was aborted."). The message check + * is an exact match on purpose: a substring match would misclassify + * unrelated errors that merely mention aborting. + */ +export function isRequestAborted(error: unknown, signal?: AbortSignal): boolean { + const candidate = error as { name?: string; message?: string } + return ( + Boolean(signal?.aborted) || + candidate?.name === "AbortError" || + candidate?.name === "APIUserAbortError" || + candidate?.message === "Request was aborted." + ) +} + +/** + * Fresh error satisfying the Task.ts abort contract: `name === + * "AbortError"` and a message ending in "aborted" (no trailing period). The + * OpenAI SDK's own abort error does not satisfy this contract (name "Error", + * message "Request was aborted."), so raw SDK abort errors must be + * normalized instead of rethrown. + */ +export function createAbortError(providerName: string): Error { + const abortError = new Error(`The ${providerName} request was aborted`) + abortError.name = "AbortError" + return abortError +} From 20b1228f2cd5e8debcbc58b54f27fc32a79735ec Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 01:17:03 +0800 Subject: [PATCH 08/23] test(zai): align GLM-5.3-Flash expectations with abort-signal request config --- src/api/providers/__tests__/zai.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index a251ad7d20..e3d13708e8 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -958,6 +958,7 @@ describe("ZAiHandler", () => { reasoning_effort: "max", temperature: 1, }), + undefined, ) }) @@ -1018,13 +1019,16 @@ describe("ZAiHandler", () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) await expect(handlerWithModel.completePrompt("prompt")).resolves.toBe("response") - expect(mockCreate).toHaveBeenCalledWith({ - model: "glm-5.3-flash", - messages: [{ role: "user", content: "prompt" }], - temperature: 1, - thinking: { type: "enabled", clear_thinking: false }, - reasoning_effort: "low", - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: "glm-5.3-flash", + messages: [{ role: "user", content: "prompt" }], + temperature: 1, + thinking: { type: "enabled", clear_thinking: false }, + reasoning_effort: "low", + }, + undefined, + ) }) it("should omit reasoning_effort for GLM-5.2 when reasoningEffort is set to disable", async () => { From 8ce2489d2867b540fe170f3bd1934d69cf915ac3 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 02:45:30 +0800 Subject: [PATCH 09/23] refactor(api): dedupe handleOpenAIRequestError and strengthen abort-signal specs --- .../base-openai-compatible-provider.spec.ts | 17 +++---------- src/api/providers/__tests__/kimi-code.spec.ts | 12 +-------- src/api/providers/__tests__/openai.spec.ts | 20 +++++---------- src/api/providers/__tests__/zai.spec.ts | 15 +---------- .../base-openai-compatible-provider.ts | 25 ++----------------- src/api/providers/openai.ts | 25 ++----------------- src/api/providers/utils/error-handler.ts | 23 +++++++++++++++++ src/api/providers/zai.ts | 3 ++- src/test-utils/errors.ts | 17 +++++++++++++ 9 files changed, 57 insertions(+), 100 deletions(-) create mode 100644 src/test-utils/errors.ts diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 7ecb98d400..fde5ce242c 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -8,6 +8,7 @@ import type { ModelInfo } from "@roo-code/types" import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" +import { captureError } from "../../../test-utils/errors" // Create mock functions const mockCreate = vi.fn() @@ -51,20 +52,6 @@ class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-mo } } -/** - * Captures the rejection of an operation as an Error. The abort contract always - * throws an Error; the guard keeps strict typing without a cast. Fails the test - * if the operation resolves. - */ -async function captureError(operation: Promise): Promise { - try { - await operation - } catch (error) { - return error instanceof Error ? error : new Error(String(error)) - } - throw new Error("Expected the operation to reject") -} - describe("BaseOpenAiCompatibleProvider", () => { let handler: TestOpenAiCompatibleProvider @@ -385,6 +372,8 @@ describe("BaseOpenAiCompatibleProvider", () => { } })(), ) + + expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Invalid token") }) it("should fall back to an Unknown error when a base_resp stream chunk has no status_msg", async () => { diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index 9179754695..e5f04148ca 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -2,6 +2,7 @@ import { buildApiHandler } from "../../index" import { KimiCodeHandler } from "../kimi-code" import { clearAllMocks } from "../../../test-utils/reset" +import { captureError } from "../../../test-utils/errors" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.hoisted(() => ({ @@ -37,17 +38,6 @@ function completionsCreate(handler: KimiCodeHandler): ReturnType { return vi.spyOn(client.chat.completions, "create") as unknown as ReturnType } -/** Captures the rejection of an operation; fails the test if it resolves. */ -async function captureError(operation: Promise): Promise { - try { - await operation - } catch (error) { - // Abort normalization always throws an Error; the guard keeps strict typing without casts. - return error instanceof Error ? error : new Error(String(error)) - } - throw new Error("Expected the operation to reject") -} - describe("KimiCodeHandler", () => { beforeEach(() => { clearAllMocks() diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index d07816b80e..7a38ae1f1e 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -12,6 +12,7 @@ import { import { Package } from "../../../shared/package" import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { captureError } from "../../../test-utils/errors" import axios from "axios" vitest.mock("../utils/timeout-config", () => ({ @@ -92,20 +93,6 @@ vitest.mock("axios", () => ({ }, })) -/** - * Captures the rejection of an operation as an Error. The abort contract always - * throws an Error; the guard keeps strict typing without a cast. Fails the test - * if the operation resolves. - */ -async function captureError(operation: Promise): Promise { - try { - await operation - } catch (error) { - return error instanceof Error ? error : new Error(String(error)) - } - throw new Error("Expected the operation to reject") -} - describe("OpenAiHandler", () => { let handler: OpenAiHandler let mockOptions: ApiHandlerOptions @@ -979,6 +966,11 @@ describe("OpenAiHandler", () => { const requestOptions = mockCreate.mock.calls.at(-1)?.[1] expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) expect(requestOptions?.signal.aborted).toBe(false) + // The merged signal must follow the caller's controller: a regression + // that drops options.abortSignal (keeping only the timeout signal) + // would stop aborting here and fail the test. + controller.abort() + expect(requestOptions?.signal.aborted).toBe(true) }) it("should pass a timeout-only request signal when completePrompt timeoutMs is positive", async () => { diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index e3d13708e8..53a0801a1c 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -17,6 +17,7 @@ import { import { ZAiHandler } from "../zai" import { asyncStreamFrom } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" +import { captureError } from "../../../test-utils/errors" vitest.mock("openai", () => { const createMock = vitest.fn() @@ -29,20 +30,6 @@ vitest.mock("openai", () => { } }) -/** - * Captures the rejection of an operation as an Error. The abort contract always - * throws an Error; the guard keeps strict typing without a cast. Fails the test - * if the operation resolves. - */ -async function captureError(operation: Promise): Promise { - try { - await operation - } catch (error) { - return error instanceof Error ? error : new Error(String(error)) - } - throw new Error("Expected the operation to reject") -} - describe("ZAiHandler", () => { let handler: ZAiHandler let mockCreate: any diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 2f37b2101b..698a348a54 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI, { APIUserAbortError } from "openai" +import OpenAI from "openai" import type { ModelInfo } from "@roo-code/types" @@ -11,7 +11,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" -import { handleOpenAIError } from "./utils/error-handler" +import { handleOpenAIError, handleOpenAIRequestError } from "./utils/error-handler" import { calculateApiCostOpenAI } from "../../shared/cost" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { RequestConfigBuilder } from "./config-builder/request-config-builder" @@ -30,27 +30,6 @@ type OpenAiRequestConfig = { signal?: AbortSignal } -/** - * Handles errors from OpenAI Node SDK request sites with abort awareness. - * - * An abort failure (the external signal already aborted, the SDK's - * APIUserAbortError, or a fetch-level AbortError) is normalized to a fresh - * Error with name "AbortError" and a message ending in "aborted" (the Task.ts - * contract) instead of being wrapped as a regular completion error, which a - * plain rethrow of the SDK abort error would produce. - */ -export function handleOpenAIRequestError(error: unknown, providerName: string, abortSignal?: AbortSignal): Error { - if ( - abortSignal?.aborted || - (error instanceof Error && (error.name === "AbortError" || error instanceof APIUserAbortError)) - ) { - const aborted = new Error(`${providerName} request aborted`, { cause: error }) - aborted.name = "AbortError" - return aborted - } - return handleOpenAIError(error, providerName) -} - export abstract class BaseOpenAiCompatibleProvider extends BaseProvider implements SingleCompletionHandler diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index d6b13cd039..9f6c0d7197 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI, { AzureOpenAI, APIUserAbortError } from "openai" +import OpenAI, { AzureOpenAI } from "openai" import axios from "axios" import { @@ -24,7 +24,7 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" -import { handleOpenAIError } from "./utils/error-handler" +import { handleOpenAIRequestError } from "./utils/error-handler" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { RequestConfigBuilder } from "./config-builder/request-config-builder" import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" @@ -35,27 +35,6 @@ type OpenAiRequestConfig = { signal?: AbortSignal } -/** - * Handles errors from OpenAI Node SDK request sites with abort awareness. - * - * An abort failure (the external signal already aborted, the SDK's - * APIUserAbortError, or a fetch-level AbortError) is normalized to a fresh - * Error with name "AbortError" and a message ending in "aborted" (the Task.ts - * contract) instead of being wrapped as a regular completion error, which a - * plain rethrow of the SDK abort error would produce. - */ -function handleOpenAIRequestError(error: unknown, providerName: string, abortSignal?: AbortSignal): Error { - if ( - abortSignal?.aborted || - (error instanceof Error && (error.name === "AbortError" || error instanceof APIUserAbortError)) - ) { - const aborted = new Error(`${providerName} request aborted`, { cause: error }) - aborted.name = "AbortError" - return aborted - } - return handleOpenAIError(error, providerName) -} - // TODO: Rename this to OpenAICompatibleHandler. Also, I think the // `OpenAINativeHandler` can subclass from this, since it's obviously // compatible with the OpenAI API. We can also rename it to `OpenAIHandler`. diff --git a/src/api/providers/utils/error-handler.ts b/src/api/providers/utils/error-handler.ts index 2c55b96f9c..ffa73270b4 100644 --- a/src/api/providers/utils/error-handler.ts +++ b/src/api/providers/utils/error-handler.ts @@ -9,6 +9,8 @@ * - Enables telemetry and debugging with complete error context */ +import { APIUserAbortError } from "openai" + import i18n from "../../../i18n/setup" /** @@ -112,3 +114,24 @@ export function handleProviderError( export function handleOpenAIError(error: unknown, providerName: string): Error { return handleProviderError(error, providerName, { messagePrefix: "completion" }) } + +/** + * Handles errors from OpenAI Node SDK request sites with abort awareness. + * + * An abort failure (the external signal already aborted, the SDK's + * APIUserAbortError, or a fetch-level AbortError) is normalized to a fresh + * Error with name "AbortError" and a message ending in "aborted" (the Task.ts + * contract) instead of being wrapped as a regular completion error, which a + * plain rethrow of the SDK abort error would produce. + */ +export function handleOpenAIRequestError(error: unknown, providerName: string, abortSignal?: AbortSignal): Error { + if ( + abortSignal?.aborted || + (error instanceof Error && (error.name === "AbortError" || error instanceof APIUserAbortError)) + ) { + const aborted = new Error(`${providerName} request aborted`, { cause: error }) + aborted.name = "AbortError" + return aborted + } + return handleOpenAIError(error, providerName) +} diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index 5be0ec6059..98da44d898 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -14,8 +14,9 @@ import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/ap import { convertToZAiFormat } from "../transform/zai-format" import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" -import { BaseOpenAiCompatibleProvider, handleOpenAIRequestError } from "./base-openai-compatible-provider" +import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" import { NOT_PROVIDED } from "./constants" +import { handleOpenAIRequestError } from "./utils/error-handler" import { RequestConfigBuilder } from "./config-builder/request-config-builder" import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" diff --git a/src/test-utils/errors.ts b/src/test-utils/errors.ts new file mode 100644 index 0000000000..67d897b242 --- /dev/null +++ b/src/test-utils/errors.ts @@ -0,0 +1,17 @@ +/** + * Captures the rejection of an operation as an `Error`. + * + * Provider abort normalization always throws an `Error`, but an awaited + * operation is typed `unknown`, so the guard keeps strict typing without + * casts. A non-Error rejection is re-wrapped so the original failure message + * stays visible, and a resolving operation — the contract violation the + * surrounding test exists to catch — becomes a failing assertion. + */ +export async function captureError(operation: Promise): Promise { + try { + await operation + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } + throw new Error("Expected the operation to reject") +} From cb77e0c24c80977d5e7e7fc0f88671c118e844e5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 03:21:30 +0800 Subject: [PATCH 10/23] fix(api): reject pre-aborted Kimi requests and forward per-request timeoutMs - KimiCodeHandler.createMessage/completePrompt now call throwIfAborted before prepareRequest, so pre-aborted requests skip model discovery and OAuth token work; cancellation specs assert the model-discovery and OAuth mocks received no calls. - openai and zai completePrompt request configs pass a positive options.timeoutMs as the per-request SDK timeout (a larger timeoutMs no longer expires at the client default); specs assert timeout: 5000 in the captured request options. - base provider stream iteration reads base_resp through an unknown guard instead of an as any cast (no-explicit-any 6 -> 5). - zai GLM-5.3 and base provider specs now cover the combined abortSignal + positive timeoutMs cancellation path. --- .../base-openai-compatible-provider.spec.ts | 9 +++++-- src/api/providers/__tests__/kimi-code.spec.ts | 8 ++++++ src/api/providers/__tests__/openai.spec.ts | 3 +++ src/api/providers/__tests__/zai.spec.ts | 17 +++++++++--- .../base-openai-compatible-provider.ts | 26 ++++++++++++++++--- src/api/providers/kimi-code.ts | 3 +++ src/api/providers/openai.ts | 9 ++++++- src/api/providers/zai.ts | 11 ++++++-- src/eslint-suppressions.json | 2 +- 9 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index fde5ce242c..31494eb4ef 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -425,14 +425,19 @@ describe("BaseOpenAiCompatibleProvider", () => { }) }) - it("should merge completePrompt timeoutMs into the request signal", async () => { + it("should merge the completePrompt abort signal and timeoutMs into one request signal", async () => { + const controller = new AbortController() mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) - await handler.completePrompt("test prompt", { timeoutMs: 5000 }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) const requestOptions = mockCreate.mock.calls.at(-1)?.[1] expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) expect(requestOptions?.signal.aborted).toBe(false) + // A timeout-only merged signal would still pass the assertions above; + // aborting the caller's controller proves caller cancellation survives. + controller.abort() + expect(requestOptions?.signal.aborted).toBe(true) }) it("should not set a request signal for zero completePrompt timeoutMs", async () => { diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index e5f04148ca..bbd1e1b4a9 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -290,6 +290,10 @@ describe("KimiCodeHandler", () => { } }).rejects.toMatchObject({ name: "AbortError", message: "This operation was aborted" }) expect(createCompletion).not.toHaveBeenCalled() + // Cancellation must also skip model discovery and OAuth token work. + expect(mockGetModels).not.toHaveBeenCalled() + expect(mockGetAccessToken).not.toHaveBeenCalled() + expect(mockForceRefreshAccessToken).not.toHaveBeenCalled() }) it("forwards completePrompt abort options through the override on both 401 retry attempts", async () => { @@ -319,6 +323,10 @@ describe("KimiCodeHandler", () => { message: "This operation was aborted", }) expect(createCompletion).not.toHaveBeenCalled() + // Cancellation must also skip model discovery and OAuth token work. + expect(mockGetModels).not.toHaveBeenCalled() + expect(mockGetAccessToken).not.toHaveBeenCalled() + expect(mockForceRefreshAccessToken).not.toHaveBeenCalled() }) it("surfaces a normalized AbortError when the SDK aborts a streaming request", async () => { diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 7a38ae1f1e..7eb276277d 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -971,6 +971,8 @@ describe("OpenAiHandler", () => { // would stop aborting here and fail the test. controller.abort() expect(requestOptions?.signal.aborted).toBe(true) + // A positive timeoutMs must be forwarded as the per-request SDK timeout. + expect(requestOptions?.timeout).toBe(5000) }) it("should pass a timeout-only request signal when completePrompt timeoutMs is positive", async () => { @@ -978,6 +980,7 @@ describe("OpenAiHandler", () => { await handler.completePrompt("test prompt", { timeoutMs: 5000 }) const requestOptions = mockCreate.mock.calls.at(-1)?.[1] expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) + expect(requestOptions?.timeout).toBe(5000) }) it("should not pass a request signal for zero timeoutMs in completePrompt", async () => { diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 53a0801a1c..02751434ce 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -742,7 +742,7 @@ describe("ZAiHandler", () => { expect(result.message).toBe("Z.ai request aborted") }) - it("glm-5.3 completePrompt should pass the abort signal on the thinking path", async () => { + it("glm-5.3 completePrompt should merge the abort signal and timeoutMs on the thinking path", async () => { const h53 = new ZAiHandler({ apiModelId: "glm-5.3", zaiApiKey: "test-zai-api-key", @@ -751,13 +751,22 @@ describe("ZAiHandler", () => { const controller = new AbortController() mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) - const result = await h53.completePrompt("prompt", { abortSignal: controller.signal }) + const result = await h53.completePrompt("prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) expect(result).toBe("response") - expect(mockCreate).toHaveBeenCalledWith( + const requestCall = mockCreate.mock.calls.at(-1) + expect(requestCall?.[0]).toEqual( expect.objectContaining({ model: "glm-5.3", thinking: { type: "enabled", clear_thinking: false } }), - { signal: controller.signal }, ) + const requestOptions = requestCall?.[1] + expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) + expect(requestOptions?.signal.aborted).toBe(false) + // A positive timeoutMs must be forwarded as the per-request SDK timeout. + expect(requestOptions?.timeout).toBe(5000) + // A timeout-only merged signal would still pass the assertions above; + // aborting the caller's controller proves caller cancellation survives. + controller.abort() + expect(requestOptions?.signal.aborted).toBe(true) }) it("glm-5.3 completePrompt should normalize the SDK APIUserAbortError", async () => { diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 698a348a54..bba5b33da8 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -143,11 +143,29 @@ export abstract class BaseOpenAiCompatibleProvider try { for await (const chunk of stream) { - // Check for provider-specific error responses (e.g., MiniMax base_resp) - const chunkAny = chunk as any - if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) { + // Check for provider-specific error responses (e.g., MiniMax base_resp). + // ChatCompletionChunk has no base_resp member, so read it through an + // unknown guard instead of casting the whole chunk. + const chunkUnknown: unknown = chunk + const baseResp: unknown = + typeof chunkUnknown === "object" && chunkUnknown !== null && "base_resp" in chunkUnknown + ? (chunkUnknown as { base_resp?: unknown }).base_resp + : undefined + const baseRespFields = + baseResp !== null && typeof baseResp === "object" + ? (baseResp as Record) + : undefined + const baseRespStatusCode = baseRespFields?.["status_code"] + const baseRespStatusMsg = baseRespFields?.["status_msg"] + if ( + baseRespStatusCode && + baseRespStatusCode !== 0 && + (typeof baseRespStatusCode === "number" || typeof baseRespStatusCode === "string") + ) { throw new Error( - `${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`, + `${this.providerName} API Error (${baseRespStatusCode}): ${ + typeof baseRespStatusMsg === "string" ? baseRespStatusMsg : "Unknown error" + }`, ) } diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts index a81efb488a..84d6fd885a 100644 --- a/src/api/providers/kimi-code.ts +++ b/src/api/providers/kimi-code.ts @@ -20,6 +20,7 @@ import { getModelParams } from "../transform/model-params" import { OpenAiHandler } from "./openai" import { NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" +import { throwIfAborted } from "./utils/abort-signal" const OAUTH_AUTH_METHOD: KimiCodeAuthMethod = "oauth" const API_KEY_AUTH_METHOD: KimiCodeAuthMethod = "api-key" @@ -88,6 +89,7 @@ export class KimiCodeHandler extends OpenAiHandler { messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + throwIfAborted(metadata?.abortSignal) await this.prepareRequest() try { yield* super.createMessage(systemPrompt, messages, metadata) @@ -99,6 +101,7 @@ export class KimiCodeHandler extends OpenAiHandler { } override async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + throwIfAborted(options?.abortSignal) await this.prepareRequest() try { // Forward abort/timeout options so the inherited OpenAiHandler wiring diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 9f6c0d7197..51237767f3 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -33,6 +33,8 @@ import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal type OpenAiRequestConfig = { path?: string signal?: AbortSignal + /** Per-request SDK timeout; a positive timeoutMs overrides the client default. */ + timeout?: number } // TODO: Rename this to OpenAICompatibleHandler. Also, I think the @@ -380,16 +382,21 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl /** * Builds the per-request options for completePrompt. CompletePromptOptions * is not ApiHandlerCreateMessageMetadata (required taskId), so the merged - * abort/timeout signal goes through setOption instead of setAbortSignal. + * abort/timeout signal and the per-request SDK timeout (a positive timeoutMs + * overrides the client-level default) go through setOption instead of + * setAbortSignal. */ private buildCompletePromptRequestConfig( isAzureAiInference: boolean, options?: CompletePromptOptions, ): OpenAI.RequestOptions { + const requestTimeout = + typeof options?.timeoutMs === "number" && options.timeoutMs > 0 ? options.timeoutMs : undefined return ( new RequestConfigBuilder() .setOption("path", isAzureAiInference ? OPENAI_AZURE_AI_INFERENCE_PATH : undefined) .setOption("signal", mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs)) + .setOption("timeout", requestTimeout) .build() ?? {} ) } diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index 98da44d898..c388a1a889 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -32,6 +32,8 @@ type ZAiChatCompletionParams = Omit model === "glm-5.3" || model === "glm-5.3-flash" @@ -172,10 +174,15 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { } // CompletePromptOptions is not ApiHandlerCreateMessageMetadata (required - // taskId), so the abort/timeout merge goes through setOption; the helper - // treats timeoutMs <= 0 as "no explicit timeout". + // taskId), so the abort/timeout merge and the per-request SDK timeout go + // through setOption; a positive timeoutMs overrides the client default and + // values <= 0 mean "no explicit timeout". const requestConfig = new RequestConfigBuilder() .setOption("signal", mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs)) + .setOption( + "timeout", + typeof options?.timeoutMs === "number" && options.timeoutMs > 0 ? options.timeoutMs : undefined, + ) .build() try { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 4c3955d3f3..7b31f3cd89 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -281,7 +281,7 @@ }, "api/providers/base-openai-compatible-provider.ts": { "@typescript-eslint/no-explicit-any": { - "count": 6 + "count": 5 } }, "api/providers/base-provider.ts": { From c9311dc4234e8b1c97dadf7b7d3c53a492e1c0b5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 03:59:26 +0800 Subject: [PATCH 11/23] fix(api): forward per-request timeoutMs and harden abort specs - base-openai-compatible-provider.completePrompt now forwards a positive timeoutMs as the per-request SDK timeout (RequestOptions.timeout); without it the OpenAI client falls back to the client-level default and can expire before a larger per-request timeoutMs. - base spec adds a timeout-only test (no caller signal) that exercises the timeout branch alone: the request signal aborts when timeoutMs elapses and the pending request rejects with the normalized abort error; the merged-signal test also asserts the forwarded timeout. - zai GLM-5.3 spec now keeps the mocked request pending, aborts the caller signal before awaiting, and asserts the in-flight request rejects with the normalized abort error. - kimi-code pre-abort tests use OAuth authentication so the OAuth-mock skip assertions are not vacuous (resolveAccessToken would invoke the mocks without the guards). --- .../base-openai-compatible-provider.spec.ts | 35 +++++++++++++++++++ src/api/providers/__tests__/kimi-code.spec.ts | 8 +++-- src/api/providers/__tests__/zai.spec.ts | 32 ++++++++++++----- .../base-openai-compatible-provider.ts | 9 ++++- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 31494eb4ef..532778022c 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -434,12 +434,47 @@ describe("BaseOpenAiCompatibleProvider", () => { const requestOptions = mockCreate.mock.calls.at(-1)?.[1] expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) expect(requestOptions?.signal.aborted).toBe(false) + // A positive timeoutMs must be forwarded as the per-request SDK timeout. + expect(requestOptions?.timeout).toBe(5000) // A timeout-only merged signal would still pass the assertions above; // aborting the caller's controller proves caller cancellation survives. controller.abort() expect(requestOptions?.signal.aborted).toBe(true) }) + it("should abort the request when a timeout-only completePrompt timeoutMs elapses", async () => { + // Emulate the OpenAI SDK: the pending request rejects when its signal aborts. + let capturedOptions: { signal?: AbortSignal; timeout?: number } | undefined + mockCreate.mockImplementationOnce( + async (_params: unknown, options?: { signal?: AbortSignal; timeout?: number }) => { + capturedOptions = options + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + throw new APIUserAbortError() + }, + ) + + // No caller signal: the timeout branch is exercised on its own. AbortSignal.timeout + // is backed by a native self-managed timer (not the fakeable global), so poll with + // vi.waitFor the same way abort-signal.spec.ts does. + const requestPromise = handler.completePrompt("test prompt", { timeoutMs: 50 }) + // Attach the rejection handler immediately so the timeout rejection + // is never observed as unhandled while we poll for the abort. + const resultPromise = captureError(requestPromise) + + await vi.waitFor(() => expect(capturedOptions?.signal?.aborted).toBe(true)) + expect(capturedOptions?.timeout).toBe(50) + + const result = await resultPromise + expect(result.name).toBe("AbortError") + expect(result.message).toBe("TestProvider request aborted") + }) + it("should not set a request signal for zero completePrompt timeoutMs", async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index bbd1e1b4a9..99262db641 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -274,7 +274,9 @@ describe("KimiCodeHandler", () => { }) it("rejects before any request when the createMessage abort signal is already aborted", async () => { - const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + // OAuth auth so the token assertions below are not vacuous: without the + // cancellation guard, resolveAccessToken would invoke the OAuth mocks. + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) const controller = new AbortController() controller.abort() const createCompletion = completionsCreate(handler) @@ -313,7 +315,9 @@ describe("KimiCodeHandler", () => { }) it("rejects before any request when the completePrompt signal is already aborted", async () => { - const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + // OAuth auth so the token assertions below are not vacuous: without the + // cancellation guard, resolveAccessToken would invoke the OAuth mocks. + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) const controller = new AbortController() controller.abort() const createCompletion = completionsCreate(handler) diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 02751434ce..d0edf12092 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -742,31 +742,45 @@ describe("ZAiHandler", () => { expect(result.message).toBe("Z.ai request aborted") }) - it("glm-5.3 completePrompt should merge the abort signal and timeoutMs on the thinking path", async () => { + it("glm-5.3 completePrompt should cancel the in-flight request when the caller signal aborts", async () => { const h53 = new ZAiHandler({ apiModelId: "glm-5.3", zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding", }) const controller = new AbortController() - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + // Emulate the OpenAI SDK: the pending request rejects when its signal aborts. + mockCreate.mockImplementationOnce( + async (_params: unknown, options?: { signal?: AbortSignal; timeout?: number }) => { + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + throw new APIUserAbortError() + }, + ) + + // The request stays pending while the caller's signal is still live. + const requestPromise = h53.completePrompt("prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + controller.abort() - const result = await h53.completePrompt("prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + const result = await captureError(requestPromise) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Z.ai request aborted") - expect(result).toBe("response") const requestCall = mockCreate.mock.calls.at(-1) expect(requestCall?.[0]).toEqual( expect.objectContaining({ model: "glm-5.3", thinking: { type: "enabled", clear_thinking: false } }), ) const requestOptions = requestCall?.[1] expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) - expect(requestOptions?.signal.aborted).toBe(false) // A positive timeoutMs must be forwarded as the per-request SDK timeout. expect(requestOptions?.timeout).toBe(5000) - // A timeout-only merged signal would still pass the assertions above; - // aborting the caller's controller proves caller cancellation survives. - controller.abort() - expect(requestOptions?.signal.aborted).toBe(true) + // Aborting the caller's controller propagates to the merged request signal. + expect(requestOptions?.signal?.aborted).toBe(true) }) it("glm-5.3 completePrompt should normalize the SDK APIUserAbortError", async () => { diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index bba5b33da8..289d088dae 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -28,6 +28,8 @@ type BaseOpenAiCompatibleProviderOptions = ApiHandlerO /** Subset of OpenAI.RequestOptions built per request for the abort-signal wiring. */ type OpenAiRequestConfig = { signal?: AbortSignal + /** Per-request SDK timeout (ms); overrides the client-level default when set. */ + timeout?: number } export abstract class BaseOpenAiCompatibleProvider @@ -265,9 +267,14 @@ export abstract class BaseOpenAiCompatibleProvider // CompletePromptOptions is not ApiHandlerCreateMessageMetadata (required taskId), // so the abort/timeout merge goes through setOption; the helper treats - // timeoutMs <= 0 as "no explicit timeout". + // timeoutMs <= 0 as "no explicit timeout". The per-request timeout is + // forwarded as well: without it the SDK falls back to the client-level + // default, which can still expire before a larger per-request timeoutMs. + const requestTimeout = + typeof options?.timeoutMs === "number" && options.timeoutMs > 0 ? options.timeoutMs : undefined const requestConfig = new RequestConfigBuilder() .setOption("signal", mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs)) + .setOption("timeout", requestTimeout) .build() try { From 0703c58a0d063502cd31432cc60b05fab697ab82 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 12:43:31 +0800 Subject: [PATCH 12/23] fix(ci): declare vitest as a root devDependency so the mutation gate resolves its bin --- package.json | 3 ++- pnpm-lock.yaml | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8431467918..0a914fb148 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "rimraf": "6.0.1", "tsx": "4.22.4", "turbo": "2.10.0", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "4.1.9" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,md}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 183a5e02d1..a8972fd426 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 + vitest: + specifier: 4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/cli: dependencies: @@ -5037,6 +5040,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' From 09a0c801e31f81ee247fd124b028d47d235a6327 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 14:06:26 +0800 Subject: [PATCH 13/23] test(api): strengthen focused tests for the changed-code mutation gate --- .../base-openai-compatible-provider.spec.ts | 214 ++++++++++++++++++ src/api/providers/__tests__/openai.spec.ts | 71 ++++++ src/api/providers/__tests__/zai.spec.ts | 15 ++ 3 files changed, 300 insertions(+) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 4b69fcd88d..9084ed7274 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -298,6 +298,76 @@ describe("BaseOpenAiCompatibleProvider", () => { expect(firstChunk.done).toBe(false) expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 50 }) }) + + it("should keep the last usage when a later chunk carries none", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }, + { choices: [{ delta: { content: "tail" } }] }, + ]), + ) + + const stream = handler.createMessage("system prompt", []) + const chunks = await collectStream(stream) + + // The recorded usage is only replaced by a chunk that actually carries + // usage; a later usage-less chunk must not wipe out the metrics. + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) + }) + }) + + describe("stream chunk shape edge cases", () => { + it("should surface the choices access as the failure site for a null chunk", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([null, { choices: [{ delta: { content: "after" } }] }]), + ) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + // A null chunk passes the base_resp guard (typeof null is "object" but + // the !== null check short-circuits) and only fails at the first choices + // access; pinning the message proves the guard is evaluated first. + expect(result.name).toBe("Error") + expect(result.message).toBe( + "TestProvider completion error: Cannot read properties of null (reading 'choices')", + ) + }) + + it("should continue streaming content after a primitive chunk", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom(["unexpected-chunk", { choices: [{ delta: { content: "after" } }] }]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + // A primitive chunk fails the base_resp guard's object check and its + // property reads short-circuit through optional chaining, so the stream + // must carry on with the next real chunk. + expect(chunks).toEqual([{ type: "text", text: "after" }]) + }) + + it("should skip an empty choices array without crashing the delta reads", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([{ choices: [] }, { choices: [{ delta: { content: "after" } }] }]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + // With no first choice, delta and finish_reason resolve to undefined; + // the optional chaining must keep the iteration alive for the next chunk. + expect(chunks).toEqual([{ type: "text", text: "after" }]) + }) }) describe("abort signal wiring", () => { @@ -416,6 +486,71 @@ describe("BaseOpenAiCompatibleProvider", () => { expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Unknown error") }) + it("should ignore a zero base_resp status_code because 0 is a success sentinel", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { content: "ok" } }], + base_resp: { status_code: 0 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + expect(chunks).toEqual([{ type: "text", text: "ok" }]) + }) + + it("should ignore an empty-string base_resp status_code", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { content: "ok" } }], + base_resp: { status_code: "" }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + expect(chunks).toEqual([{ type: "text", text: "ok" }]) + }) + + it("should report a string base_resp status_code with its status_msg", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + base_resp: { status_code: "1001", status_msg: "bad token" }, + }, + ]), + ) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1001): bad token") + }) + + it("should ignore a boolean base_resp status_code", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { content: "ok" } }], + base_resp: { status_code: true }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + expect(chunks).toEqual([{ type: "text", text: "ok" }]) + }) + it("should still wrap non-abort request errors with the provider prefix", async () => { mockCreate.mockImplementationOnce(() => { throw new Error("boom") @@ -645,5 +780,84 @@ describe("BaseOpenAiCompatibleProvider", () => { const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") expect(endChunks).toHaveLength(0) }) + + it("should yield a tool_call_partial with undefined name and arguments when function is absent", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { tool_calls: [{ index: 0, id: "call_fn" }] } }], + }, + ]), + ) + + const stream = handler.createMessage("system prompt", []) + const chunks = await collectStream(stream) + + // A first-chunk tool call may carry only its id; the optional chaining + // on function must yield undefined fields instead of throwing. + expect(chunks).toEqual([ + { type: "tool_call_partial", index: 0, id: "call_fn", name: undefined, arguments: undefined }, + ]) + }) + + it("should emit the tool_call_end after the trailing content, not before it", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call_seq", function: { name: "seq_tool", arguments: "{}" } }, + ], + }, + }, + ], + }, + { choices: [{ delta: { content: "between" } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]), + ) + + const stream = handler.createMessage("system prompt", []) + const chunks = await collectStream(stream) + + // The end event must only be emitted when finish_reason arrives; + // emitting it on finish-less chunks would reorder this sequence. + expect(chunks).toEqual([ + { type: "tool_call_partial", index: 0, id: "call_seq", name: "seq_tool", arguments: "{}" }, + { type: "text", text: "between" }, + { type: "tool_call_end", id: "call_seq" }, + ]) + }) + + it("should clear the tracked tool call ids after emitting tool_call_end", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call_dup", function: { name: "dup_tool", arguments: "{}" } }, + ], + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + { choices: [{ delta: { content: "more" } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]), + ) + + const stream = handler.createMessage("system prompt", []) + const chunks = await collectStream(stream) + + // Without the clear(), the second tool_calls finish would re-emit the + // end event for the already-finalized call. + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(endChunks).toEqual([{ type: "tool_call_end", id: "call_dup" }]) + }) }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 65793328a6..efef5676c1 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -280,6 +280,55 @@ describe("OpenAiHandler", () => { expect(textChunks).toEqual([{ type: "text", text: "hello " }]) }) + it("should treat a streaming chunk without choices as an empty delta", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { usage: { prompt_tokens: 1, completion_tokens: 2 } }, + { choices: [{ delta: { content: "tail" }, index: 0 }] }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + // A usage-only chunk has no choices at all; the optional chaining must + // resolve to the empty-delta fallback instead of throwing. + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toEqual([{ type: "text", text: "tail" }]) + }) + + it("should treat a streaming chunk with an empty choices array as an empty delta", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([{ choices: [] }, { choices: [{ delta: { content: "tail" }, index: 0 }] }]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + // With no first choice, delta and finish_reason resolve to undefined; + // the optional chaining must keep the iteration alive for the next chunk. + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toEqual([{ type: "text", text: "tail" }]) + }) + + it("should keep the last usage when a later chunk carries none", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { + choices: [{ delta: { content: "hi" }, index: 0 }], + usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 }, + }, + { choices: [{ delta: {}, index: 0 }] }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + // The recorded usage is only replaced by a chunk that actually carries + // usage; a later usage-less chunk must not wipe out the metrics. + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 7, outputTokens: 3 }) + }) + it("streams reasoning chunks from delta.reasoning_content", async () => { mockCreate.mockImplementationOnce(async () => asyncStreamFrom([ @@ -1076,6 +1125,28 @@ describe("OpenAiHandler", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("should wrap a non-abort stream error with the provider prefix when no metadata is passed", async () => { + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "partial" } }] } + throw new Error("mid-stream boom") + })(), + ) + + const result = await captureError( + (async () => { + for await (const _ of handler.createMessage("system prompt", [])) { + // consume + } + })(), + ) + + // Without metadata there is no abort signal to consult; the raw error + // must still be normalized through the completion-error wrapper. + expect(result.name).toBe("Error") + expect(result.message).toBe("OpenAI completion error: mid-stream boom") + }) + it("should normalize the SDK APIUserAbortError from completePrompt without an external signal", async () => { mockCreate.mockImplementationOnce(() => { throw new APIUserAbortError() diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index d0edf12092..f6d6a9eac9 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -798,6 +798,21 @@ describe("ZAiHandler", () => { expect(result.name).toBe("AbortError") expect(result.message).toBe("Z.ai request aborted") }) + + it("glm-5.3 completePrompt should omit the request timeout when timeoutMs is zero", async () => { + const h53 = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + await expect(h53.completePrompt("prompt", { timeoutMs: 0 })).resolves.toBe("response") + + // Values <= 0 mean "no explicit timeout": with no caller signal either, + // the built request config is empty and nothing is forwarded to the SDK. + expect(mockCreate.mock.calls.at(-1)?.[1]).toBeUndefined() + }) }) describe("GLM-4.7 Thinking Mode", () => { From 805f297222cd6135ff058fc5a50d017163d9dfbd Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 14:43:43 +0800 Subject: [PATCH 14/23] test(api): exclude proven-equivalent Stryker mutants from the changed-code gate --- src/api/providers/base-openai-compatible-provider.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 99d5b11ab4..0a0e1dd8d3 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -154,6 +154,7 @@ export abstract class BaseOpenAiCompatibleProvider ? (chunkUnknown as { base_resp?: unknown }).base_resp : undefined const baseRespFields = + // Stryker disable next-line ConditionalExpression,LogicalOperator: equivalent for all JSON-reachable baseResp values: null/undefined/primitive yield undefined through the optional-chained status reads and object values behave identically under every guard variant baseResp !== null && typeof baseResp === "object" ? (baseResp as Record) : undefined @@ -161,6 +162,7 @@ export abstract class BaseOpenAiCompatibleProvider const baseRespStatusMsg = baseRespFields?.["status_msg"] if ( baseRespStatusCode && + // Stryker disable next-line ConditionalExpression: a truthy baseRespStatusCode is necessarily !== 0 and the trailing typeof gate reproduces the original result for falsy values baseRespStatusCode !== 0 && (typeof baseRespStatusCode === "number" || typeof baseRespStatusCode === "string") ) { @@ -203,6 +205,7 @@ export abstract class BaseOpenAiCompatibleProvider // Emit tool_call_end events when finish_reason is "tool_calls" // This ensures tool calls are finalized even if the stream doesn't properly close + // Stryker disable next-line ConditionalExpression,EqualityOperator: with an empty activeToolCallIds the guarded loop yields nothing and clear() is a no-op, so the size guard is unobservable if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { for (const id of activeToolCallIds) { yield { type: "tool_call_end", id } From bccf293b5de1762196a818bd2d9ed2216f7d866a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 11:34:13 +0800 Subject: [PATCH 15/23] test(api): cover captureError non-Error re-wrap and resolving-contract paths --- src/test-utils/__tests__/errors.spec.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/test-utils/__tests__/errors.spec.ts diff --git a/src/test-utils/__tests__/errors.spec.ts b/src/test-utils/__tests__/errors.spec.ts new file mode 100644 index 0000000000..c666b5e977 --- /dev/null +++ b/src/test-utils/__tests__/errors.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" + +import { captureError } from "../errors" + +describe("error capture utility", () => { + it("returns the rejecting Error instance untouched", async () => { + const original = new Error("provider failed") + + expect(await captureError(Promise.reject(original))).toBe(original) + }) + + it("re-wraps a non-Error rejection so the original failure message stays visible", async () => { + const captured = await captureError(Promise.reject("plain string failure")) + + expect(captured).toBeInstanceOf(Error) + expect(captured.message).toBe("plain string failure") + }) + + it("rejects when the operation resolves, because resolving violates the contract", async () => { + await expect(captureError(Promise.resolve("value"))).rejects.toThrow("Expected the operation to reject") + }) +}) From 74e406efeb67e63abbe75051b1b5ad8a87574054 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 11:57:58 +0800 Subject: [PATCH 16/23] test(api): add abort-aware error-handler tests and assert Kimi retry timeoutMs forwarding --- src/api/providers/__tests__/kimi-code.spec.ts | 20 +++++-- .../utils/__tests__/error-handler.spec.ts | 52 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index 99262db641..9c8012a044 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -298,7 +298,7 @@ describe("KimiCodeHandler", () => { expect(mockForceRefreshAccessToken).not.toHaveBeenCalled() }) - it("forwards completePrompt abort options through the override on both 401 retry attempts", async () => { + it("forwards completePrompt abort options and timeoutMs through the override on both 401 retry attempts", async () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 }) const createCompletion = completionsCreate(handler) @@ -306,11 +306,25 @@ describe("KimiCodeHandler", () => { .mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] }) const controller = new AbortController() - await expect(handler.completePrompt("test", { abortSignal: controller.signal })).resolves.toBe("retried") + await expect( + handler.completePrompt("test", { abortSignal: controller.signal, timeoutMs: 30_000 }), + ).resolves.toBe("retried") expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce() expect(createCompletion).toHaveBeenCalledTimes(2) for (const call of createCompletion.mock.calls) { - expect(call[1]).toEqual({ signal: controller.signal }) + // A positive timeoutMs must reach the per-request config on both + // attempts: the external signal is merged in (never passed raw) and + // the SDK-level timeout is set, so a retry that dropped either would + // be caught here. + expect(call[1].timeout).toBe(30_000) + expect(call[1].signal).toBeInstanceOf(AbortSignal) + expect(call[1].signal).not.toBe(controller.signal) + expect(call[1].signal.aborted).toBe(false) + } + // The merged signals follow the external abort on both attempts. + controller.abort() + for (const call of createCompletion.mock.calls) { + expect(call[1].signal.aborted).toBe(true) } }) diff --git a/src/api/providers/utils/__tests__/error-handler.spec.ts b/src/api/providers/utils/__tests__/error-handler.spec.ts index 54971134df..c7eb440350 100644 --- a/src/api/providers/utils/__tests__/error-handler.spec.ts +++ b/src/api/providers/utils/__tests__/error-handler.spec.ts @@ -1,4 +1,6 @@ -import { handleProviderError, handleOpenAIError } from "../error-handler" +import { APIUserAbortError } from "openai" + +import { handleProviderError, handleOpenAIError, handleOpenAIRequestError } from "../error-handler" describe("handleProviderError", () => { const providerName = "TestProvider" @@ -281,3 +283,51 @@ describe("handleOpenAIError (backward compatibility)", () => { expect((result as any).status).toBe(401) }) }) + +describe("handleOpenAIRequestError (abort awareness)", () => { + it("normalizes a generic error to a fresh AbortError when the caller signal aborted", () => { + const controller = new AbortController() + controller.abort() + const original = new Error("network failure") + + const result = handleOpenAIRequestError(original, "OpenAI", controller.signal) + + expect(result).not.toBe(original) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + expect((result as Error & { cause?: unknown }).cause).toBe(original) + }) + + it("normalizes the SDK user-abort error when no signal was passed", () => { + const result = handleOpenAIRequestError(new APIUserAbortError(), "Zai") + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Zai request aborted") + }) + + it("normalizes a native fetch-level AbortError when no signal was passed", () => { + const result = handleOpenAIRequestError( + Object.assign(new Error("aborted"), { name: "AbortError" }), + "Fireworks", + ) + + expect(result.name).toBe("AbortError") + expect(result.message).toBe("Fireworks request aborted") + }) + + it("keeps the provider-prefix wrap for a non-abort failure", () => { + const original = new Error("rate limited") + + const result = handleOpenAIRequestError(original, "OpenAI", new AbortController().signal) + + expect(result).not.toBe(original) + expect(result.name).toBe("Error") + expect(result.message).toBe("OpenAI completion error: rate limited") + }) + + it("falls back to the provider-prefix wrap when no signal is available", () => { + const result = handleOpenAIRequestError(new Error("boom"), "Kimi Code") + + expect(result.message).toBe("Kimi Code completion error: boom") + }) +}) From 393b516cce0370f3996e7407bb6ee9460f195bec Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 02:37:56 +0800 Subject: [PATCH 17/23] fix(api): enforce abort-signal loop defense in openai-family streaming paths Top-of-loop break + post-loop abort check in base-openai-compatible-provider, openai, and the o3 handleStreamResponse path. Deferred-chunk kill tests prove only the break stops the buffered leak. --- .../base-openai-compatible-provider.spec.ts | 47 ++++++++++ src/api/providers/__tests__/openai.spec.ts | 91 +++++++++++++++++++ .../base-openai-compatible-provider.ts | 16 +++- src/api/providers/openai.ts | 39 +++++++- 4 files changed, 188 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 9084ed7274..c49c06654a 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -444,6 +444,53 @@ describe("BaseOpenAiCompatibleProvider", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("should stop yielding buffered chunks and reject with the abort contract when aborted mid-stream", async () => { + let releaseSecond = () => {} + const secondGate = new Promise((resolve) => { + releaseSecond = resolve + }) + // The second chunk is deferred: it only becomes available after the caller + // aborts, so the loop's next for-await pull is a suspension point only the + // top-of-loop break can decide on. Removing the break leaks "world"; removing + // the post-loop check ends the stream normally instead of rejecting. + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "hello " } }] } + await secondGate + yield { choices: [{ delta: { content: "world" } }] } + })(), + ) + + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = handler.createMessage("system prompt", [], metadata) + + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "hello " }) + + controller.abort() + releaseSecond() + + // Anything yielded after the abort is a leak: the deferred second chunk + // must not come through an aborted stream — only the top-of-loop break + // stops the loop from processing content pulled after the abort. + const leaked: string[] = [] + const result = await captureError( + (async () => { + for await (const chunk of gen) { + if (chunk.type === "text") { + leaked.push(chunk.text) + } + } + })(), + ) + + expect(leaked).toEqual([]) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("TestProvider request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + it("should wrap a non-abort base_resp stream error with the provider prefix through the iteration wrapper", async () => { mockCreate.mockImplementationOnce(() => asyncStreamFrom([ diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 0a5f82273b..6d2dafdac3 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1193,6 +1193,97 @@ describe("OpenAiHandler", () => { expect(result.message.endsWith("aborted")).toBe(true) }) + it("should stop yielding buffered chunks and reject with the abort contract when aborted mid-stream", async () => { + let releaseSecond = () => {} + const secondGate = new Promise((resolve) => { + releaseSecond = resolve + }) + // The second chunk is deferred: it only becomes available after the caller + // aborts, so the loop's next for-await pull is a suspension point only the + // top-of-loop break can decide on. Removing the break leaks "world"; removing + // the post-loop check ends the stream normally instead of rejecting. + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "hello " } }] } + await secondGate + yield { choices: [{ delta: { content: "world" } }] } + })(), + ) + + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const gen = handler.createMessage("system prompt", [], metadata) + + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "hello " }) + + controller.abort() + releaseSecond() + + // Anything yielded after the abort is a leak: the deferred second chunk + // must not come through an aborted stream — only the top-of-loop break + // stops the loop from processing content pulled after the abort. + const leaked: string[] = [] + const result = await captureError( + (async () => { + for await (const chunk of gen) { + if (chunk.type === "text") { + leaked.push(chunk.text) + } + } + })(), + ) + + expect(leaked).toEqual([]) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + + it("should stop yielding buffered chunks and reject with the abort contract when aborted mid-stream (o3 family)", async () => { + let releaseSecond = () => {} + const secondGate = new Promise((resolve) => { + releaseSecond = resolve + }) + mockCreate.mockImplementationOnce(() => + (async function* () { + yield { choices: [{ delta: { content: "hello " } }] } + await secondGate + yield { choices: [{ delta: { content: "world" } }] } + })(), + ) + + const controller = new AbortController() + const metadata = { taskId: "test-task", abortSignal: controller.signal } + const o3Handler = new OpenAiHandler({ ...mockOptions, openAiModelId: "o3-mini" }) + const gen = o3Handler.createMessage("system prompt", [], metadata) + + const first = await gen.next() + expect(first.value).toEqual({ type: "text", text: "hello " }) + + controller.abort() + releaseSecond() + + // Anything yielded after the abort is a leak: the deferred second chunk + // must not come through an aborted stream — only the top-of-loop break + // stops the loop from processing content pulled after the abort. + const leaked: string[] = [] + const result = await captureError( + (async () => { + for await (const chunk of gen) { + if (chunk.type === "text") { + leaked.push(chunk.text) + } + } + })(), + ) + + expect(leaked).toEqual([]) + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") + expect(result.message.endsWith("aborted")).toBe(true) + }) + it("should wrap a non-abort stream error with the provider prefix when no metadata is passed", async () => { mockCreate.mockImplementationOnce(() => (async function* () { diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 0a0e1dd8d3..f691a956b0 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -15,7 +15,7 @@ import { handleOpenAIError, handleOpenAIRequestError } from "./utils/error-handl import { calculateApiCostOpenAI } from "../../shared/cost" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { RequestConfigBuilder } from "./config-builder/request-config-builder" -import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" +import { createAbortError, mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" type BaseOpenAiCompatibleProviderOptions = ApiHandlerOptions & { providerName: string @@ -125,6 +125,7 @@ export abstract class BaseOpenAiCompatibleProvider metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { throwIfAborted(metadata?.abortSignal) + const signal = metadata?.abortSignal // Per-request abort wiring (RequestConfigBuilder adoption): subclasses inherit // it by receiving the built config as createStream's requestOptions. @@ -145,6 +146,12 @@ export abstract class BaseOpenAiCompatibleProvider try { for await (const chunk of stream) { + // Streaming-loop abort defense: the for-await above already pulled this + // chunk; the top-of-loop break stops *processing* (and yielding) buffered + // content that arrived after the caller's signal aborted. + if (signal?.aborted) { + break + } // Check for provider-specific error responses (e.g., MiniMax base_resp). // ChatCompletionChunk has no base_resp member, so read it through an // unknown guard instead of casting the whole chunk. @@ -223,6 +230,13 @@ export abstract class BaseOpenAiCompatibleProvider throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } + // Post-loop abort defense: a signal that aborted while the loop was running must + // surface the Task.ts abort contract instead of letting the stream end normally + // with the buffered usage / matcher.final() content below. + if (signal?.aborted) { + throw handleOpenAIRequestError(createAbortError(this.providerName), this.providerName, signal) + } + if (lastUsage) { yield this.processUsageMetrics(lastUsage, this.getModel().info) } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index a70797b402..f9ba906059 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -28,7 +28,7 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, Complete import { handleOpenAIRequestError } from "./utils/error-handler" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { RequestConfigBuilder } from "./config-builder/request-config-builder" -import { mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" +import { createAbortError, mergeAbortSignalAndTimeout, throwIfAborted } from "./utils/abort-signal" /** Subset of OpenAI.RequestOptions built per request for the abort-signal wiring. */ type OpenAiRequestConfig = { @@ -98,6 +98,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { throwIfAborted(metadata?.abortSignal) + const signal = metadata?.abortSignal const { info: modelInfo, reasoning } = this.getModel() const modelUrl = this.options.openAiBaseUrl ?? "" @@ -215,6 +216,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { for await (const chunk of stream) { + // Streaming-loop abort defense: the for-await above already pulled this + // chunk; the top-of-loop break stops *processing* (and yielding) buffered + // content that arrived after the caller's signal aborted. + if (signal?.aborted) { + break + } const delta = chunk.choices?.[0]?.delta ?? {} const finishReason = chunk.choices?.[0]?.finish_reason @@ -241,6 +248,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) } + // Post-loop abort defense: a signal that aborted while the loop was running must + // surface the Task.ts abort contract instead of letting the stream end normally + // with the buffered matcher/usage content below. + if (signal?.aborted) { + throw handleOpenAIRequestError(createAbortError(this.providerName), this.providerName, signal) + } + for (const chunk of matcher.final()) { yield chunk } @@ -419,6 +433,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ): ApiStream { const { info: modelInfo, reasoning } = this.getModel() const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + const signal = metadata?.abortSignal if (this.options.openAiStreamingEnabled ?? true) { const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) @@ -459,11 +474,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } try { - yield* this.handleStreamResponse(stream) + yield* this.handleStreamResponse(stream, signal) } catch (error) { // The creation-site catch does not cover errors raised by the async // iterator itself (e.g. a mid-stream abort); normalize them the same way. - throw handleOpenAIRequestError(error, this.providerName, metadata?.abortSignal) + throw handleOpenAIRequestError(error, this.providerName, signal) + } + + // Post-loop abort defense: a signal that aborted while the loop was running + // must surface the Task.ts abort contract instead of letting the stream end + // normally with the buffered content it may have pulled. + if (signal?.aborted) { + throw handleOpenAIRequestError(createAbortError(this.providerName), this.providerName, signal) } } else { let requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { @@ -521,10 +543,19 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - private async *handleStreamResponse(stream: AsyncIterable): ApiStream { + private async *handleStreamResponse( + stream: AsyncIterable, + signal?: AbortSignal, + ): ApiStream { const activeToolCallIds = new Set() for await (const chunk of stream) { + // Streaming-loop abort defense: the for-await above already pulled this + // chunk; the top-of-loop break stops *processing* (and yielding) buffered + // content that arrived after the caller's signal aborted. + if (signal?.aborted) { + break + } const delta = chunk.choices?.[0]?.delta const finishReason = chunk.choices?.[0]?.finish_reason From 4db5623b527bca799b2f97bad0a37fc15dba14f2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 30 Sep 2026 03:46:25 +0800 Subject: [PATCH 18/23] chore(deps): regenerate pnpm-lock.yaml after main merge The 09-29 main merge (7dc331f91) updated package.json manifests across the merged commits but the committed pnpm-lock.yaml stayed stale, so CI's frozen install fails at setup and every job (compile, unit, e2e, visual, mutation) fails within seconds of starting. pnpm install regenerates the lockfile from the merged manifests. check-types passes across all workspace packages at this head. Lockfile-only change; no executable code affected. --- pnpm-lock.yaml | 245 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 230 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9891082c58..24827f937e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 10.0.0(@types/node@22.20.1) '@stryker-mutator/vitest-runner': specifier: 10.0.0 - version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.9) '@types/node': specifier: 22.20.1 version: 22.20.1 @@ -155,13 +155,13 @@ importers: version: 4.22.4 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/vscode-e2e: devDependencies: '@copilotkit/aimock': specifier: 1.35.0 - version: 1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) '@playwright/test': specifier: 1.62.1 version: 1.62.1 @@ -212,7 +212,7 @@ importers: version: 22.20.1 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/cloud: dependencies: @@ -246,7 +246,7 @@ importers: version: 16.5.0 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/config-eslint: devDependencies: @@ -315,7 +315,7 @@ importers: version: 4.1.9(vitest@4.1.11) vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/ipc: dependencies: @@ -368,7 +368,7 @@ importers: version: 4.1.9(vitest@4.1.11) vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/types: dependencies: @@ -399,7 +399,7 @@ importers: version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) zod-to-json-schema: specifier: 3.25.2 version: 3.25.2(zod@3.25.76) @@ -417,7 +417,7 @@ importers: version: 22.20.1 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) src: dependencies: @@ -697,7 +697,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) webview-ui: dependencies: @@ -3658,6 +3658,9 @@ packages: '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/mocker@4.1.11': resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: @@ -3669,6 +3672,17 @@ packages: vite: optional: true + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: 8.1.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} @@ -3678,12 +3692,21 @@ packages: '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/snapshot@4.1.11': resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/ui@4.1.9': resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} peerDependencies: @@ -8685,6 +8708,47 @@ packages: jsdom: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: 8.1.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -9820,9 +9884,9 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@copilotkit/aimock@1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': + '@copilotkit/aimock@1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': optionalDependencies: - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@csstools/color-helpers@5.0.2': {} @@ -11367,14 +11431,14 @@ snapshots: '@stryker-mutator/util@10.0.0': {} - '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.9)': dependencies: '@stryker-mutator/api': 10.0.0 '@stryker-mutator/core': 10.0.0(@types/node@22.20.1) '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tailwindcss/node@4.3.2': dependencies: @@ -11945,7 +12009,22 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.2 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + optional: true '@vitest/expect@4.1.11': dependencies: @@ -11956,6 +12035,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 @@ -11964,6 +12052,14 @@ snapshots: optionalDependencies: vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.0 @@ -11977,6 +12073,11 @@ snapshots: '@vitest/utils': 4.1.11 pathe: 2.0.3 + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + '@vitest/snapshot@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -11984,8 +12085,17 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@4.1.11': {} + '@vitest/spy@4.1.9': {} + '@vitest/ui@4.1.9(vitest@4.1.11)': dependencies: '@vitest/utils': 4.1.9 @@ -11997,6 +12107,18 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/ui@4.1.9(vitest@4.1.9)': + dependencies: + '@vitest/utils': 4.1.9 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + optional: true + '@vitest/utils@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -17742,6 +17864,68 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + '@vitest/ui': 4.1.9(vitest@4.1.9) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.11) + '@vitest/ui': 4.1.9(vitest@4.1.9) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 @@ -17773,6 +17957,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + '@vitest/ui': 4.1.9(vitest@4.1.9) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-material-icons@0.1.1: {} From 4238bd156d06f3568a86619e9d90c55fd67baf6b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 30 Sep 2026 09:15:44 +0800 Subject: [PATCH 19/23] fix(deps): bump vitest 4.1.9 -> 4.1.11 (dependency-review advisory) The 09-29 main merge left the root manifest pinned to vitest 4.1.9, which the stale-lockfile regeneration kept. dependency-review flags it: GHSA (advisory 1193683) "Vitest: Path Traversal / Arbitrary File Read via @vitest/mocker Redirect Mock" affects >=2.1.0 <4.1.11 (moderate, CVSS 5.9); patched in >=4.1.11, which is what main already resolves. Bump the pin and regenerate the lockfile; the 4.1.9 tree drops out of the resolved snapshot (pnpm audit no longer reports vitest; the remaining advisories all pre-exist at main with identical versions). --- package.json | 2 +- pnpm-lock.yaml | 249 ++++--------------------------------------------- 2 files changed, 18 insertions(+), 233 deletions(-) diff --git a/package.json b/package.json index 9d396efa9c..afbf3d8dd7 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "tsx": "4.22.4", "turbo": "2.10.0", "typescript": "5.9.3", - "vitest": "4.1.9" + "vitest": "4.1.11" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,md}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24827f937e..b5606c23b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 10.0.0(@types/node@22.20.1) '@stryker-mutator/vitest-runner': specifier: 10.0.0 - version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.9) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) '@types/node': specifier: 22.20.1 version: 22.20.1 @@ -75,8 +75,8 @@ importers: specifier: 5.9.3 version: 5.9.3 vitest: - specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/cli: dependencies: @@ -155,13 +155,13 @@ importers: version: 4.22.4 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/vscode-e2e: devDependencies: '@copilotkit/aimock': specifier: 1.35.0 - version: 1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) '@playwright/test': specifier: 1.62.1 version: 1.62.1 @@ -212,7 +212,7 @@ importers: version: 22.20.1 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/cloud: dependencies: @@ -246,7 +246,7 @@ importers: version: 16.5.0 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/config-eslint: devDependencies: @@ -315,7 +315,7 @@ importers: version: 4.1.9(vitest@4.1.11) vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/ipc: dependencies: @@ -368,7 +368,7 @@ importers: version: 4.1.9(vitest@4.1.11) vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/types: dependencies: @@ -399,7 +399,7 @@ importers: version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) zod-to-json-schema: specifier: 3.25.2 version: 3.25.2(zod@3.25.76) @@ -417,7 +417,7 @@ importers: version: 22.20.1 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) src: dependencies: @@ -697,7 +697,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) webview-ui: dependencies: @@ -3658,9 +3658,6 @@ packages: '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.11': resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: @@ -3672,17 +3669,6 @@ packages: vite: optional: true - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} - peerDependencies: - msw: ^2.4.9 - vite: 8.1.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} @@ -3692,21 +3678,12 @@ packages: '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.11': resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/ui@4.1.9': resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} peerDependencies: @@ -8708,47 +8685,6 @@ packages: jsdom: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 - happy-dom: '*' - jsdom: '*' - vite: 8.1.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -9884,9 +9820,9 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@copilotkit/aimock@1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': + '@copilotkit/aimock@1.35.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': optionalDependencies: - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@csstools/color-helpers@5.0.2': {} @@ -11431,14 +11367,14 @@ snapshots: '@stryker-mutator/util@10.0.0': {} - '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.9)': + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@22.20.1))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': dependencies: '@stryker-mutator/api': 10.0.0 '@stryker-mutator/core': 10.0.0(@types/node@22.20.1) '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tailwindcss/node@4.3.2': dependencies: @@ -12009,22 +11945,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - - '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 - ast-v8-to-istanbul: 1.0.4 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.2 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - optional: true + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -12035,15 +11956,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/expect@4.1.9': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - chai: 6.2.2 - tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 @@ -12052,14 +11964,6 @@ snapshots: optionalDependencies: vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.0 @@ -12073,11 +11977,6 @@ snapshots: '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/runner@4.1.9': - dependencies: - '@vitest/utils': 4.1.9 - pathe: 2.0.3 - '@vitest/snapshot@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -12085,17 +11984,8 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': - dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 - magic-string: 0.30.21 - pathe: 2.0.3 - '@vitest/spy@4.1.11': {} - '@vitest/spy@4.1.9': {} - '@vitest/ui@4.1.9(vitest@4.1.11)': dependencies: '@vitest/utils': 4.1.9 @@ -12107,18 +11997,6 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/ui@4.1.9(vitest@4.1.9)': - dependencies: - '@vitest/utils': 4.1.9 - fflate: 0.8.2 - flatted: 3.4.2 - pathe: 2.0.3 - sirv: 3.0.2 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - optional: true - '@vitest/utils@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -17864,68 +17742,6 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 22.20.1 - '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) - '@vitest/ui': 4.1.9(vitest@4.1.9) - jsdom: 26.1.0 - transitivePeerDependencies: - - msw - - vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9(vitest@4.1.9))(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 22.20.1 - '@vitest/coverage-v8': 4.1.9(vitest@4.1.11) - '@vitest/ui': 4.1.9(vitest@4.1.9) - jsdom: 26.1.0 - transitivePeerDependencies: - - msw - vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 @@ -17957,37 +17773,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 22.20.1 - '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) - '@vitest/ui': 4.1.9(vitest@4.1.9) - jsdom: 26.1.0 - transitivePeerDependencies: - - msw - void-elements@3.1.0: {} vscode-material-icons@0.1.1: {} From 34a87d5fbaa059fdfc76166b940760bd5b22405d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 30 Sep 2026 09:16:30 +0800 Subject: [PATCH 20/23] test(visual): regenerate electron chat-dark sidebar reference for #1311 The 09-29 main merge pulled in webview changes that shift the chat-dark sidebar render (model row, header, and footer hint text), so the pre-merge reference now deterministically diffs by 1324 px (~1% of image pixels) in extension-host-visual. Re-baseline the reference with this branch's own CI render from the failed run's artifact (extension-host-visual-regression, run 36621716857); the actual render is the clean deterministic smoke output (verified visually: full chat-dark sidebar, Task Completed, Start New Task, input box). --- .../electron-chat-dark-sidebar.png | Bin 30321 -> 30753 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index e8497070ef3440fe4a3bbb3ef49f9a1de9d4c4d9..20caee097d887f284872e0bb5882e797a74c9ca5 100644 GIT binary patch literal 30753 zcmeFZXHb;e`z4A21w}wWKyp@zisYy*l9PadWDvrX3o2LOyy2x;l_GsC95bCKhf`00GZ zqDTb8r?{b?Y(<>9+%E@mpCw7i4A|7_54i2&~Q0;>P^TTyXTwX1I-t?kIbVN zcF6`)7AGSdBPS<^*5@D%xjQe) z<1o|g@9#f2I9TJkudb#haA$UU`tj?gPm`0ACnqOYuH$zH-@bj@b=*Bm`F?wIvw-Uw z75#UOLOmH78Gphg#n_aTl&Gj@IJbi( zr%!uqi14>ls>mM0Hi|8XD{MbS6Wh-0$@?4?hP zC&_g@yRh*5?07v0pW5U6bieN^r+)3p-(ce7y;bdB#x37vqHyR%J$7&i2*kaQc4pgR zO*`Uu52nM35&npO@LzwO>BEgUuahpQH+-m~B9ivM?JBx&V7wXJ;6f~o^JsQ$^aUN& zLoxNVma&ShqGM8#J$p`i|1axad$ri%r`D&zS})Ewi(03Wgnx}Fe0j*}?lC>$Ni~wV zU#6JJLIhmDPG`#%>k5mBk_gx<@ScVKHsNlOj?1orm!UTkSzp4G|0=H+(= z_3yEBH3~UdS%sXJ`a6?^=ettYH9K`($D+S}b%zaN(0{?SB88@oU_z7wYz_0ME1kOy?1Y>Tk3Zw zVx354!-D=e66;*;Qte(8+@vUmj17ltq?~usmr&^Me{Il2&L0o&#A@;`Eb*{yP#G%@Q>&5P*1!^N3=Y zTHlX&A9r-KmzPNTgYs>R_)nMR*68auTX|U!IE%g-4(D6Q@S2}N^7^|^w{(j<#X385 z&u3@{J+b#_&nG`-6y5lz*jsTDcf<8;l|Wb+DJ`+J^s?n!5|>Ctu|FY#f#$$q!0Fd~ zz&-w$?$<*EdS82w8Yj+5bJL3z)^ygHKZ>6pc1`#kTU)PoafHOj$9H!tALr83)8Dys z2Z=<&4~oRpH?x2ISm;R)I-OYT%W|ZeFaPp{Zew>hKR4G=%QHpH+w6t4wRM&2`go0J z+39!uhjV=Ij8Srh>svoh&3G)3`C-KrV~M|cD72AfCtYjK1OCCm!Q5Z1&d*Nw9^6w( z_L_;5YG`O^ZEMR=SuD4o*7MqIJUTl1_3PL7?@S)O4s-2aAzPi<=eyIMDTy7<)calE z_3*Z`S~=L9-d`Dx`uy2py=K4QRdFC8H$?ffApBSCq^Jy|*B6)g>5eLzf=F{)*6nEE zdJVqV@k$}_!Dmxk9&?AAmRVd!cH8MqO}|QzzMkh2{|b@Cirl&|Y@(IzFSeE|i;btFe1{I& z_~r}Fw?}trss%MhcRxLXWTL3hv9Xm|!<5-Vkne`2x{;~B44^oRGsztp}PghsBV}3iuZ7O(U!!BRll7|9{ za6Wn&YC*Ec(gS!KQ7{Ds#bCa+hPwLP>@4z4TTE}Sik8}&H?!KdH3PH!*k~p4=*q}PWb^6g41rkO=iQZ?rcHH~4D~}l@GQng4zCgs zxuBN@wkgqKo064*I5pMM%Uz*;=jRxSyw-qnT-36CJ?=)Y1MkP78DFoh zS#*jBjT{cWVavMD;cno79P#DQ0P~9c$d>IO*TZpS!qu?MRL_8pua9+%+4Xknt6E2h zwI|%HG;GjoNWR$uUr(tS@#M1&x%Akgha6=lUd9r1E!L~Fl!I;}`MidW_olT&8=BT9 zG%@3=++}$Fgq0eHgS_X#ULIZ@gQonMUvxA?v>f?sH?FQ;Ku1|pYN_x z+Yf6KcCIDIUBXf_xN3v7M~J{``d@ifrjZ47m@J2JTq3@_rt!6hAn_|&lUAsd|A;&0b_?McS`yZ;<)x`_l!Fp!2|U%r&m zHNR>1?T(Lro_cPh(W8V8s1Dcs>Ya@r-{XUS!B2X&AamJWB)P1tOrGW0#^nAd)HM8h zn)3Z<*?jd}#VkkaxxQcw>x)StDb!!1B9&~36D+D>OO{Z{J^(12?TRlMx;6tW3tAw+ zy1EE(uiAy6rXNJh+OFeE#W4JRV3YJAN{uzTZ5HioPBh8aWS^+R87rHS%(dl+Q~2r@ zr0&tmBdl_k00cd0h;TBBMkqPx)(b8!uKsseG*beKEK$0Sd=#SL@##Aj60TO1zt;mK z7&d>7Pr0ky^3bEgQl>(=I~bc44;SbXk67`k`C;kOMM{fQZuoQv3WN~{4~g?!nT%m1 z3KlU(Ons!qdYzmcSFx#m*{*8yp#Tjvu?uPs>x9O+>k&QHs4~BZe_IU4a=&}h@Bdts zWuh%DEzpqB`kje_jtq>9Rj)%v$%S23KHO%sQT|8cv5*#Ivm>fPUHA=3ZnDmL?JXTf z|JB{MTZ!^4%WAk#TfC3=YLaTcg>~`e70bw{dLx|1>okZSvZBRQCAzd43nt zYn*9`ik6+dHTHG_3uTYCiqxC7oRPWhVpJpm-!C9gUR3mmgF_*AKyUkHnzDe+Xn90L zL}g{=pCAGPS}_q;)>bRNcy&8{K>@mfrU3!&H!Gzp~4gPs~?3LDV3x#=kc{w>m!4iy8VR*N0-NCuq*w}~) zr?s^uXrA4AkuHgS?V1I0zsy@WG)}f-31fR6GP`XlI{9eDXDDk}Q;)T(~n+}w=*&t>B}2`ld=GTst(&v!TN##Q)44o*%Qai0?< z@zd{pi;1LwD*-A$xPKpj2rR(j;^OY^uG2Bx3|DMhSE68F{q=b@pVzO`y-flTkBt6) z`2G7yREzOMu3}Yl)XPz4w61K1LxVr=^XJc@AI=DHiiy=KrHDzUZ^8~iC-#r{`}gln zGEVt;p2d^H?Wa$l0!W)ih0kvv!eUQtx)HO;Pj0#e5zuDZhTc)iDYCH)bD%0S>-iQF z6GJWR@+BsQQ>VP!er&Zy$h4CR(M%wI(mgoHXE~Ucn3xEdacXL6XU7R4ZDwYsqN1X$ zt?lAcDx0BIZfB^d*a>eYBqtYm(@KK)ppq>wFTbm$hJp`zD)SxN*rICTE-Btz>CA3lBh9-49Y zvB?LCZ|k+kHs}If2%#(2@XT!Qje4%vP=wB5Z0k54JUIMAd-lZB3!joN4^{-adDIiS z^7&*>h%2P$zD<#)h+)H3M04s>KNk78i1>Jqh3+)_=_Xi7K~ia77FISk4CWocm;wS3 zSS`P6wn2FN0oOaJWRyJEGf-RuFy7wA?Yo;@@7r7Bx? zX0OZ0XxvoQ`(-Ti>ecYxD&{daw@Fv>YJWCr`{nnH3|@Zz6j4t;F0OFG&tJZfP*BK< z=T$h)_jWN02_1C`uUETm8kw7$!_pxT4UvpeA3uJa>qv-&Y=RqxpoI6j>{6a$7VrBf z32}`S>Wd?qvsJS`Nxs(8OKve<8pzc^6|$0i+}PNFzy~;Z+;9yKt)b=Kd zwoh?TPfzde-Mbtd9Isxz`uFdjR2UUJUrT2v{*4>ob{!=pB>|^E+4u19Kp@`Nlsvsy zc%Fsh^}2O~{k0KfNav(Pn)3JW-%0M=c`hMQuB*%%O(W)2o|Z<>#ihhV)MEUr*86zL zsS<+T3^~%@-ybHf?s;fZJY1`i(6QG@rcXjb(yjkHKAs}$k7t)gT8|k!shp#uqorl8 z1jAi1uYn17?pwx4tt7ajqn|&Pkt@|?cWT3>H!1!~qjGe3IE;}C4Gm?D{&+@BLqkJK zDj6=qK`Mu;LPs|mWoKtEPC7oK2;G~;4ryc{xj|j_*8LLc(NxpobVT^#NkH%+^=3) zSGUF*!`G#M>((u2k<&XgG(NjaFSD>$8C@2fgpMn2h^255;%I4UUA}UKI%sE7Krvr^ zeW=LK2kxDWOE2-!Ye{GjWo53`*7>hn;1Lq|tf9KYydB5=ruCwN^!5UxI6du+AVyrI z&*#qw-$`O(;+pE}l>jP12mel`bmzk@7|Xdl-Q5F%f|L^Y@(T(I($dnVPv;J5zpG@6 zl5>Au3E`?G=e6jEPb8og>fc;*esexjq1)Zo*0$)`VxL%VU{@^@$5OvKWmGh@iq=X~ zmQUbYg*l19x|v0Bxdb-@; z=P=Y28~yy8oNuPzaOrD2cH?=>iB6@d>SkTAv9V#SE!iU%aod225$~GcvXy%7fWbp@ zylY3BaUF9ZWL%xeqL^$B+3<;@-DOiAib9bIZr~?>GBcbhfqS78HbC zd)=M-G%SETRI*a&Xh5gZDLXt|$vLBhCnp|-kol0l)7Lx{<{VJb;PMd7{pm~G zZ{ZAiW@W{Gs@O-c7B9RvXJ2C~CE8`^=(r8>S>m=iH8*EsYr77WN;sL?dVOHM?%ZcE z?-i6QmZ;UyiYP)AO-;86@BKqqC&$Mwu=jWb1imo#Lmh)4cb)K_Z&AOE*xK46<B9M&w=LY^Stjz*D|u^4J; zrQFJ7RitCp{McDLFE4LKRu-3TWeFrsZEbSt&M4>j_-s^bE9CvR47pf7e*R^r%99hX zEJ0CyYDE8`{LAw4~Z9acPS zRf2;~DcRd_6ZtBi*|#`~e=@iCGHkjU!uh(~P9(e-f|N;w%Om>;wHxn%X)9)vlEo*Cz&mqVZrb30^od`@bcSq2!b$zn=e`K ztWjed;L%_G-+rwGexhHUpGQ}@+5)(yrKJ_Zi&4*oL|EdL5{y}LM+pqZhPt(F5=4N%bR{Jg7+*|oPAZP&nv>GI8K zR9{ad8!IdH_(i%mXJ=>7<_!%E0sOwqR)corHcByy$3!F}E&XBo3l!SGz-t*QE%V!} z6M7+yQv&_IWy_s}l2TG^Bp*vlO957*F6Cc*95jlywxC;-nQE-*>FL7c)Stqr&3GuN z{aWo4HQ8M#gq!t-g*qM(1t&_rx3IO(3ic8^k0Ea*s%(Rj0vzOmKt^WfTza?|1yq^l zNXA9!EZ{^1-M7ADBD1UH$^w+rl)HQA0M4xL-KH8C7=RuM@Np4csQ>(VfOo2=RDFFt z5Vn2GyW`{IJ82|4S@&63B(wUUD^^#Fco+x*n;^Gr$ za&wjC%RqSFrJ>;@#JO?vrnZ(A$&KfQdNmFX4waVYzZLESb;r!y=zR3<%5^o?foBAw z64wJo-8Lp~jeWAYX$X9dKo}cL4wLi(zyQ5@^G4Kj@6)GGBA$DBeYsv@bue_mLq7Z} z5A&Xp1-uS^4p{I)t449WaHtT0i;?k<6`vf_XJDB> zeZm8Npi;oQ-LLQO&!0cxp^7xANJ-T-H3bZQV+nl_Jpl?Q1{n38`57xr>8d1uSS5$Q zQ@=6F^h){5moEp3O_j>h1)vdWrS;TtkcM=a+{DAPva!hwl}u91>M?^Blbf4s&6fak z+y1_-rKM`_02K;GM5LC&^$aUZ%k(b9q(G;$z0hP{pG59}g>Qo!&{x2$QeS7RaSjE{ z47^K{H)kt=J=-Bt=SamYHcrmW^wRTd?4&TSY5OENJ8R=CrgoNK4_z9eSTVT2o0>`& zEt{cy9~K?>DTq47tm3+yzC|xDuYL=pJPW0OU8+_-{;gZ9V^#aWqyfzS)6lRG*T@jr zj7G=eQ!iJR1A~#Isi&uSU5ktJq8vMTOikqH-o&jIgw2Qkymsp!VAbg`r)3;On_}x56O>!w$|70n%+z_$((*aKR@4R_)<%~HqX#UarI}#zg237_ ztU=e#xnq+&E7D_wn1NkuI1!Z0^z>AOBDPUjpi2?*3MSLCx18z6P9}JOC}iFpFX^!jB$BiZuc#2@|8=f#-lG z3DFrP%>*bB8X3&)nVBY_*X&sQZ44n>boBZpISrI300iax-z$D;A)Wap}?}>hsj!s~@}OxwJ|?h*c(b%}*>4e;+%15_fibEF~pXhC~9+ZQ>1r$q|4k zlnO4cbpT4#Ul>VlbeI%R8zyv^q-$&>@yRn0RV4o*xe=g944BaQ8{@kt8loi)pJ=Hi5a#H4r+&LpSUUCeRh=EBu;UzF4gKFRp9PqI+uPf} zXB~;39ZYLy3d%wQ?xQS+M4?jh>5egk+eh)|T{?-OprjldCs`8MjMo?|(B}{8wm{C! z&4F~4%0~P)Xd^=TEhP>CE_OM#8wtYc5B9=k0zW^(%cJ*ck`4Cn`%|a1UdJlC;KmXq z-OR$J=$$CYFJp=HB+-{>vNzK|$pXkqV2iA9!4V9b7Eny@ncTs98w50lsw#0uC$Fgu zzrT~A2N@C;Pm|py$mBAX2JjyM;b>@4^_O&!ln694%paaf_LqJazzkr2LV`MK&+i-) z9_j5xRJHfg3tjph*=W>b#*-xau3_Dvomm)_nLlhbxV-BnrhlKr#aZ=|%`;Fv($eb3 zqdNawL41{;Ggny9{M!4X9!5XS3zFDN47fH7>gLshq@P;(PF9M3^#(i?3M^5oN={0M zq2Re6mQ|2lg1UdR{@ki8AXiF$s~wA3e|(zb`Y<7AGhO5cy>7;ICG{VZu6y1sKFsN= zm`iq!zk)i>f<&U+8|}|wx%SqXBe@c9F`xQeF-Ql8gfh#fG~(F%RG50jI|Y^j!`Y!&<>rFk zDM=P0$Dp^JE_!qiy;Jo6S;P3^c(A0b0;{ZGZ{+LF&Gj#r(HSB=)>lL;)ZYrQ`(rfu zeQK&4>E zk+ya;;wtJNragJ!_wGr9^@^m|YL$1bThQ_$^)9?=t{i-Mr zMy4xS^mum}xGTvZwy=J8n`8O_y54t=m4^2YUNLkDp6Aa1fxh z9o7ISA3S)#!NKe=ozV;Y7qy_njARP&4Sak~HnwLt|BeJp zVOP0ywmrQjf4>Ve-PqcqfJqtZjOZ{)nlkX2fTaBV{8s5pZ1rJqkj?n!O(N_@$#X}9 zrpu@Zk4iw9iD=WU8rsW|&f43zZ~rr*3x!HDMzsLFL`X^L30y`@3@Jj|$jC@i@*4eL z^QU3c*9d6Z?Gu&n_b=~4SpePi1#5In$_?FRwky_4;H?Ur&moV~MKn zL!B05?)cwJOBPCV1UGJ|K#xWWv9JVo&G+372|xf@{6#hcpk$@Tu7#&-kf*2T@$vDt zTQzW~O-+&^jj$@vQecLXOF-TT_*PLN$igxQ_>3j$0XO&OFpF6MhOj4ebpGX>($leD zzsgXyBhr5U_|c5PRF#zUuU?6{BrG+It;3K=m=M6I`(^Vhg+G5rpr&Cuzk2nm znudlW71`ap=75_`wt=F_&B+Z{xRz+V3uA`ZOH;7(Re&0td2;VkP~AIY2* z2*XcZs!VM(l2KC9$X2@%zyKVPu<$Xzz~+^*+%VtTLeZvA%Uc(q^LQU%M{Oqups8wy zSy|vhf&YVUtH$~gof8k+1fr7%UbY%5)EMBU#;VHHSgmbrVp@!iisERfdJh4V8gqZG zx$IPxs*;U&^CqCMDz8HaclQcJTI%Zpqmq{kBuXB)-GxTtPuEjZQ!ib)mMG>uVxPKR zfQye`ioW2X;IbqmZ)B0UC4{v ze>$zBAhy;f>grUPUCEAdX@rH25;WOqc+9#1K#U}GINvzE>*z%l{e%u6qgX5VZ|CF( z%Gqj3wVElhvFJKqUnL8Kde$RY@#VL)P7;KuSRGTxq$wd6e1!*{-B0+p7G9BrL9W*u zz_<@}x)#ZLHL9h*zCs;~#hCk5)-$s%Lo>4N3! zXmkmhioJd)f7JH3`yIV{ZvKkZtJdq;)*ROeZtnk`d{v|;JG{xIRO$UPC*o*dJvxbP z|NH$Eto30wQ=8K^^jNvOUGBnJyfV#9@j^QGyVHWx@AXMt-ev#lL(R`yx(ChqMau2} z_)!)b>+O0yC#vx0nkn1QdBPsIKRC9lv4rl>uk-nLEvnvYzE~!iYz;R}f^Lflxo=^o zrhIZDKoBwo!!%;VJKq+>j+-vu=EQDTmvSBSr%FlKo!a?Wo%47iaoK^s=2K5B2$N=*FnpHE%h7gUFvkA+;);G=i`5#3Kkb8c8PpNrS^|9I?O zI&;%oHQ`s5!+1{BHUa61=f1%c|MP>4V1L5r1dTr$89S~yLPVV2d#>Pnrsy4Y_B;N| z!uLzwO%lDI6b|~zKlf?C9ebSg7Dc8rQlak%3NdU(K9Uj_uRX8%LSOtkonQI>Y+w*;mF2h=27mg zy~BIrkBX3Kyz@aiSq}axh9Q-W$dk0<*IaK%qLVmmv#n)L52RNno||kHX%tu+w}!8}4ax zP_Xn~ZsEuZY!Bltq0nA-Hu0;-{?%ep+SEa<72#2|Bmeb;~KRh zPSNdzqJf1Hm6zoyBGNS6lk-{K1JV65vqTd#ALYzr7rv10BD-GKujcg8lYGG5o~WI# ziuJLxI(yC{@ppomeU6{=Z&gB!ChiwjF-iu;CLRsQBz+uhEg}g0=a$%Ek&X%_$kX2%h{9mv zca4U-DyBjXyl(4jXWcqDUn;ob)zft>d;j(VPo0Na`3qB#l0E6`^X8#Kug7b*UkP^# zDe^2Td^?{YN}CBx%nZ~z;Cp-?g@O z5V2WEq<5Oq!jGplMhgjc-*)%+Qpv9tMKTWFQn4k9AqL!$STH2C@i2h1rgHhVYt4i%B?G2 z4$F(!>z$MkM#n)qT+!%64HN1^rkX9UeG&Y#zjLaO&&4>5jBSg`1opG4X&%-4N4!o@ zl-Kq#ZTNiK-;OWWc|YNpiY%M+hk3Sna0TPY=J$+YgR`|p-9Giv+rqC2i-!A|osY)_ zrEwkzsZ;R@?9`ugn!m8jCtV)*UER0)6Q2`*zTxvrnZY8*La2-lwV9Dl8P;)dTyl)& zDvi(YKz3Xom!tPrBOBbnu0C9Eud(|~-nP7J@qSwbEzKz6>k<7Zi>UD(nV)UzIKj-zy{e;Fjx28s`hgE_3p0-o(v|o@}B3QlL3O`o2 zPW&k>S9)7kgSfIF$r`;>iNL`^%VemW;(NTn8Xf&#v26U0@2R}!Z~aJbOa=eXD*NdT59!BY9p#<*1&fTz$HSiRC;Mxq%$8VD>*aIKT-& zbln8!C(!>+9v(-xMw)kab`-6ZJb@$vOF|fj!!sr#cTdlW)COCE8v*Z8%gL;$wl+C- zQdhYbB-8IwLM2~oCnWTA|G3R4$;8YolhF%`Uw^-vkUgkj>5`!q*4AUC)(I`f9+U5` zU%g!a@eUhkA%K_v{P_di6^N>BTy^ZRZQyRCy?gi7ix)-UdV(<}TTRIK>;!~zplBwH zxxIG>G-hY(M8rXD6x(WMOz}C&ZZT%?|3twnn>*0E?)v5pGrjTFObdMF=N4n2WhZ>k zYe44aF;@XS)5eCA-ncn}{zkwQu)6*cAYa1$f=&k}N~_^t&vTu{TWh45Dv#HyUK<&; zn`{I5qLd``9H#<2r36$0Kkfb@BBgggyxefBW|oWDI6P5E;Crd2Mu=$m^9S~Vl8`VW zDXHc)3jb*O4H+5Pz1TMJS+q}Yg4Rq;MwW$Mu7jn&0RBiWo$`mGqNm_hRg#lyYi_n1 z!`SezuT`ygwzO2h;OMcp;(oC44&f&tkJ6j-V7)`Vnhu4D=_><4i}rk z)U-ZaJioKp=N|zhUDbN6FayIkpcdhNGBPrDENj3+D&~Ep_VT6mSS1qJ2zy~hMn-CJ zb@W%vu_jQ6q+EL7xZGPAe$Cb4Y#%A@1ZF&wH*dy3$D#E(FjPGM?4uyT z(vO91K1wSrEUf#_1wZu3!Gd3GEuZzs4m?s<+#|nIK~os0fkbp(9t1XaCFT4iOEP^J zqM*^}aI1|A7U05^01k$kTRuB8BeFm0=pO;xH(Jkg6&PK3j^Ga;+`tJ*b@NkL zm=7!lOj|hg;JvT|YYOCI8Q4z1_t1%a#0RS)ezqBYL6x<&IaZa8+30zBoq)OpPgn z`-*}$xMqKR83rho?C;{F1tB*B8o>I&p;fYwU|S28YS*d>V9Z$vamIa)SAj`}?QmLb z8|Q4IuZG2o;nJ@Kk3kTPNAqGvz)dorv%`5%dq73Tz-|jVve3^Yd#)W$g>dEM=7RBJ zZ+&8Yb@jquP&^C{b|)cfdL{vZ1Bka8yMGOknbv$5FmFJ0INz^34{n?Szi8i(?Ra$s zX!X3jz%UC1oNxU517#tLwtB!KKmT-glgOcB)T9_h2T&c!^u0E-QhY4d6{y9$+y-)9 zf|&=R3&;bImaJ_RD|9+9RO05YuJdWybLm`cgfti)z-j=33iwz-G0*@h9O4Cr<*;d! z&O~?cBCo7iA+=6h?xld3tiTZ2opE7cF>3q>B4II@OE4HuD0Q^nJ1wOqO&qHDewZH`X$od zFgVx5Bnq}yy{Q1D56ZmcD=u&f*<~R3TSB9R3 zBdKCRZTlA5GC65568PQ1!UEQ>K(7WwlcnY5MTw}Enf`Kds8rxY|7)}fM0jG@<8UfL zB1+0*$gr}Kl9~B==w(2fH?77%*ul05F)@9wtkj|-bh10y7|_rc-R)P^F0%n6fSBW4 zJCp(7tLVQM6-9P}+TL%$OAiji?$1DbbtRu3)^h`+cBV&&_#!(?tR&JiG(=8Fs0^Gh z1x2g~#Qe;r8*udnlm-)r1)i9w4$zDepgS5Eq$*}v+Srt?FF|b?HnkfWutXPl zLqf0Bou7fl3YLDX%5{HffbIT$GYBdtMKEAQ3yCYXofVbx7cZkIh=`2Nb2RhSD+@=o zM~%7fkdYB1M%T(fN&w~ZXdIW`+1Yv3J5|?lTMSRS5be9wa&NDX?@N!F+I^B8w%#nI zl-5?+qnoBub$R{~pva(z>A|M6^Xywn1~uw*Nst>-3dDABO3Z-YPT%*`IqPNJKJxXG6g+I4;yIR={QPBhKikeie{F8k9^cr&*Z*9=S@H4hb@`q- znww|q71n{#o~^KY>YqzxXP)wX9r3}^Qa%(H)+w%CRe5Y|?CZmV<9z5JVRbjhJkV!gKK=-TQYAJ>Bi!8E0zwBllHY`GxPRaYk_BgsMzkNJ;Uj_l0ohS8Nr=~O@;7+;DYC-Fj-X<7txow9cA0jzwo>qzMIh4X{Fzu- zdW%tLozYbv2xUua>x_Fhp}{;2x2(K99lrrR^_RvQ5Sqe8_u@`7prsXP78#9II-3+% zl$P#IoS*U28$&a^XdgN{iHNkJ5_4kF<+%9xqd{$3=yU?5m5>T=?r!>FOb!=`RS+Ai^&{(g5w39=R58lR8=A+vyYoq$GOR`v+WxHb1z6;;(m z++b+BOBM6Ulu)36e1j7Qi^CLUrB=gR?T@?Ib;=|7zCr`@g&YBG{5F({#jKP;P}tdH z02c^%`~<@e_=9MnYJsmrugIto-1V~#hu{ibsOZ@He~K(ra&X)+6Uo32;o@h~g$<(T zC);2_-P_ta`WHmIwY@!7@I0%}dZP9eJ_SS{;8Q{F0-(X2&~cIVt4K6h`=N$_O)04} zObAd(!Q-k^O-l=lQk$`h&&U~A0(aNQh6}_ix^T_QrD_5!GR4pkAlJcj2OR@n{F~x( zvf&2CsG8|8f2>HfbAceP?}3lLzJ86Kt!+9n0_bGCYgh7#sc^4e=He=Zm{=R30cM;u zM7Ip(d1N2Pach4l8N!GL@?U%HBy_fSRrxv{R6praS*_x;fb^?ZwSM4rw%slpCepzfIz_sLU8M14F~2n08sE z<)`-;f;PeeEo6?WMlZC%N}Dku^kTAWybqkWTBJr$p?dI5>TaR`T?UZ?(%!6&Lz9jW z?9el_v!OJiLJuC8gO`m!>|hcKC={!e5p4635_5R`^OJdTsN>ZZ136HPhdnu*kbw6=3W3@bp9h z2o^pSUEx_Rue@lU0s?MedjzKtQ+Y z9{VPr?YO)ru5P81IT#oK`+uuq2J24imj?`y3zPMJdj*<+VyGXReA3Eyp_2T)`UAWF z%L7#lOUp&OOW`70&4ITlt@yOTtPFBIfCkShGnjRtyMyUx7{K=P=Z&A4<$C-2VA*Ab z8Bl-!{ur=0CdUT74m@M{I5^F1ZJ1i`DhY;HKMm?(WAzcufWfgZKQ%F7$rpk1Im<0$ z?qxhNyoM?&L^XH%klH1Mh3jCc-Cv(L2R|=Zwniwyv*8LJhoq&8waeEo8$_em$AIRV z2Mq>N84em1gG$k@xQu46FS_Xhg4%LBOy1D?K?b`hW3a5rURy2rOBfiJp(z}>{=c%> z3=}0?TwHikX)CKdX(m=KF4vViGS6rT2`7he9ih*{oWkM{Zg6uzTrktj!h(QTE-oVC ziR%_%K=Fe~zl+hYpmH@-YtjtDrewcGbbs_&Rq!WD{;=utJ(oz?j0^GaVpRCo5CAA& z0i04yM4sqzP(?u7p-l%D>^TyxCk)m*%fgzu<>d>i089*E)9>rN1qTJwb(JI-dcZ$n zdwg}A{ho-y; z7b9Ux$^^_+buc7CSr>M`oyws((uZ!TcHf58Rfj{UP|7dX8YT#Oe^^#im?;3x128T* zv$gHNTyQ48(B&;SPB z5`up)WGE#H7;^c-2@Npai-9#tuW$m21Vky|R6rNg)6?!EdUkep;WVNP(l|7GVpRiU zBxU&(V52A~7%#P^Tz#aerdHy8?1H-FA~axLDY~b)>~sad=so74DuXiqZ8(lV%C4E( zV+N=47Gkq&ZvDv427ULgsD~4HJ3$aU4Lk+s;mDmI;3Sia<@7#U%+e^(G1vcC`wDsl z+OwScI&mr^uE1(;Wu=f7rhHY(7Ckzu1KxJPY=Bix=f%(6r-JV-6zQm{Mqymx6d7Er z9dj@P0qBGjM3$D~Q}8q!Jp2+JEjW&?cyn>0PW-g&`*#>0=3(Qv=Q^s~HW@@Y$%UL2 zV0DDoe4H`m4-gBbWyfI`Q~S_uA|h^+_PAI*j~4rG#V%n5*WXrr!U`29@~l{d_UB|)`GlIc+UX~8rLqt?O~ zQ1_sO*^ZR76!q2ActSt>bt8CSaqms8#9S%-?6_p1|izSqaxv%xwC3`J8@ zQ?j6ApyCBfCb~;f6V@=j$)XT1!KNn#PS~-AfSZfm+)ylm79g-*p%(Y?f@+A~;I1 zchn=y%E4i6W|qn*n?87^XEe zgqO0F8VscvCKko4?eC_dV{&i|{hD)BO~4PeoCG0%tTNGPS?4Gk;>#yK zbpBYM=>I1#XC)l_D!eDLX)OQyhTZ=^X5at%-Tzwm|B8YCih=)IV?e%J7yA`rI`3b) z)w?SnV#OmL1S^l|GG7^Gm-qN)$&~M`(}6ucKz!2bIHtl%Mn&Z%+J+IR)O2?p6VND3ji5voj~;fZ zCp5pmdig{=WFWIRxLx(kW?CpITan8k^y{05plKewx5Vj|Z#v;-E+}F1U<+@K!&P2Ldk@v9_Rh!Rl@sJ4d`N-pq1~OY)wDL10B@>JP z%piGUrf)KpP)?Rp|Hu?+dNd&e*|y5rkmui#DNftfs>koi2)3OQf6=RM?|bvoaGr(B zT*{YRK9*Xps=idlAJxc$_i2u7W!EcGanOIuAn~>{(%ZgEG=QVtmgJ~T{byC`oAp2c z5H$4u)w~?#;VI335H#ckiGsgT&Xp>yWh2evEZ4hz-KlUlY6}u>o!1s-t_#OX=5ye0 zk>VZh&(^N%cpMt!&xag;a35zFbr|I=9*{g~fd6sFJxfFiopJX|U*FAwn;bh!t8hy< z`>%yf^NnE`dzB7GlAd~Ul~yO{(JtQevdFVgj*u|@<>QjGd3=*Y< zo9j;{2p`Zu#(5szm~U)F8}}0)Dc$y^0r!hl&Hg|Apc3L(9x zfZnk7gB&xy8hdH)+QBC{6~1vsil4%D8YWbSw$6GyvXsub(oYqm7gw3DoZ=6CZ&l>z zZHZT23L>mL`iE%iY^h7-gulRJ0a!wBCfz89a?WJ2Rs>ngBqR67S2>JWy6TP3K*76K1y4HFq;G0J4sfy~dF`}(~FDVRS}e{XKy zh8Pwvt_zoK5_fyGEOXeUfKzUoTK%JbsNdw@_J`OHWxI(cM&HqgS;xFf9CtOe^oO*c z`7*NXZ(2NV`=U~-)_m8l@c`{c_0Q=RP5VKJVxThm_x5ufUuG6%5f+`j;d7+xT#q&N zd|I9G+Yo9xoHc)GLc#|hze~JeKn{Io9R4*cWMg#9+*PDnrf?EpTbWIH?_P`Gs<5bN zSGJ>Gyau|(zISLdruL}OM{qp9HN?2(m&7Zb=Mi+}AFj%C8e(K`IdrSB9 z{ri~1)A}p;bK_ry%O~3TA5ZY>+hRS9Fksm2zt`s5pSM!yDAO%e&Zv zzbUVN>q$Q4BUo4XOI17LyZ-3CFJnu;t;ZFmM6LGb!R4^?g^0!WH*?gcGwZd+tg1wH z6@;3*a}z^pH5E@QR#Q;Z)gRKOS7^1AuLf^VUE!*tJreCzr#~N8c+)km?YN<#iu3-< zgJAEv#FK>6CR%E4@AFk8XYM$i!TtA58nzo;Ix3pDK4h2n8)}=q;*M#9+Sbhn-uh~H z{OYu5Y^)G?PnYJeoW??U^??S_BR*crDZ;CZ1uu8{=ZD&(x#$1&@QeB)_lpmk*!~!% zzGuL3_>J&AA-gphs+zt8S#laCUDPm&HC$)r^r9j zHSgQ99;cLj4Ipu_7uz(_@0OmX+ivqq!$~-r?~~KFMGu&_AhcJG0)H#_i|Y+BW6w5a zbt!lFewqBId>~jav9xEJnQ|t(%e7YU(ec3zsq-`c^%)XBx@O0eYrgKT11YYJ>+a@z zt3}O|`r4+?62)0gP*0?@em`E@Ua~v*V{2>98g=@|v(~Y=LPI$7%BZSoEbUPCGs2m3 zyueBek}#X+!HwMNwGStSukYN;GMd~bV$5s&y*R9N#kX2vyXTMg!I%Hm z+?hvH{l5J^+G*2HyHX*sN!p~0A(WvA8KMl8Oqq&IAtYrJQRcCXkt87`Q>IihPf3(y z3SWc_8KU#{{hf2xdd^yBJ^w!bs#W&>4EKHA*L_{@_v>EB7LzV+_=jboVvZrPtgw69 zx}B$bH_O7_9HGa?+f2{M$>lGE6h_G{{QAn!J?Y`|M%(t!3hfYoLx%nd^3QMS$FCfC z`K?J=HAR5!$2~8Fcj8osZ6{xA)0rm|#mUCneAe3zQysJ-hdXU|sYEJCj~J5JML3dAM9DeWV`k&Ro0cUz#dc>A=D%0V zFMgiWa9a=>RHn>K^=cVcDt{B~>Z@QQ>h8%{vTOZH3aRopEcA3Gm2=J9H;Iv}+Q}g4 zRd2?`O`t!wQ8{&MiKqf)9|QiXW0p)-{Qv#YGNIvL{%Qaq z4vi{bBPdVAe~aI!NXew7P&Iau_rH7*=5SG$CT}MtVZ$ppQcnI5)L_ZT?N^m>lW4BN z^3t7oqa<mE``aN=f^^a;mpHB}$+>Y|z2MNB-E!%#MeRKV&zBvid=Q1jGW* zPNtKJ_?1XC3n`AogoMV3^jEL+GR`v4-+3ojf85d6p||`dn@!&O_3Mu%Z&<VMUUb=_S8o~%xf&b?j@JMn(APiN?#vVBAf3=UvWq4+<}7WZ zuCJ>*G_%OZ#|HyDih*!U)eq6v-9!og;>A4*WV=Fq=K99++}u_ZVIoafMrLfz!}~DE zA3n;dsE4BxmQ1;$2qC~zoeY9P?1Kk%N8o*UXIACNq11{?@{zWRp)XFP5P721b~qkY zHp|~>$?AtL#DUKpaPvDAb5Pr15E*iEFZ=fMr`-c}KZ=T$mKGWv(6AU8{rvf}sVGD( zts5Q4!NEa1x4fdFBJ>c)NfL1yWM6M@Zg=wy$5I!dlaV?B5R)My1Fg6f(I64Ogz@eVLF>$R4q{8cog<+)ZMr zdh`VKd{6*PX5RKWz{`mqy*vf{2N-UZv_X_%AyiGcZlb}9);x4 zX_uvMl!gx!fs5fLdrVUDB$==27C6u zWfUIqJ`p_d)$TWhP#b4g!7*`@aQLTCg9q3I6nMfyMQa8eN5wu1>t=SimiAmJpiey(JU}L} z!MiguBIPkltrmtUug?Ln2^eZHY~la%uQo0Gve|hQ9pAXdXju4bAFHLk5r%=K)~*oa zE!qN|_lt)*)zaj%*Ux$!jU8QB@e-av+eau{rzAkJ=*5ec?rw+zHeJ~gdV*Nv_&tDw z!~jiWMVkhiVnNCSXyXvGrCrAj9w`sXHgDOy84ju06Mm-0Ru?XD)WD5=>eM~x+@urM z41+V;EV_DnuOMb}km3lbfjmJgQI^qsxARVTPQp~gB_w!={Z7R{hKKb9HaKNn5@yAq z+sAl<{C4s;eB1rec>!^AG|IxtDilStx`F5}2-h5bppMTTo17#1N6UO?lvxo5TAGNd(=AxM$Oh2ln6PQ0-eQ)(a_nuKs zbEy2f`aHq=4JQhDzW{{|cQMSJ=wV6bs5c{2_F=57CWj@aoquaVJdtPm>Dce0HMV-LVBP?Ca8v!s^j4iw&MTSiH6v7 zBuoV@m9B~TfB?qiezhcS{oV2%&>msD0kncPW}2+h0mlhC(@L{T3LiT=BX}7tHRK<7 zE>9F{v+Ph3Ii3m2Rae~hZM>>abTWd*kJ8IMzn{S#D{lEUI^`*eyGEE)<2c%#n3#z0 zI5cE2Wshl(xc>0r`3#m1hkcE5B;y)on|*eP4)gCo1AlBT`-(_DLwU z6}dsLIX|Jc4ezgaQt0R4t$bz0a9(&}g;~{a;VD~)vzIY{oq@dzlits?*1TY?vfu`k)*m2;o zwvEjJ)o@TfI^G7!YeyR8Sg~48#IP*I4h;<m>a;PuNttkiFf*qf8{ zd(ks3`@<7*kaA~u(38>r6BY<7({3DhA1J4ej*oM2az>|!qPsB;%mBnnl%Fq8z};V; z_b!}FqsX^3>u#5%X{LY4vpA)|{ebD_n&BU~8B5;tg3)LhK|7!*aHGJ-l$Dt2;d6$A z+ZLqFv9a}y-H^TY_Hr@ZfrJGvRHz1tv?2(xfSOd`y!|ijfktT&KPn|fRBVHNp8QH$ zhPXF)0ID;OGR_)DL>{N&(=6|V8!Yojol9mN_ga}r>-brVOG`^LnJK`z{qfT$Ggb*1 z83ceWOU!rM@xz~!9IO$^sEJAkMI0|+9(`aE7$Jf5_)eVH9~!r&j@ZMC+wwgzyL z+5b41fNbS7RV5rax?hW)xD4DfW^&sW?m#oJF_e@xC~5aOxY^p?A1E4UAm~EJn)&=W zsE2I^-lhiVI8~XD38t>DJ3r36e?+`S3g<-z>1N+xc?}r^fgX}6!FmWmKbhr*@{ayq z7JG>|(S=!E(YwVc70S0PJsDwPVKK3M0MYI!hH0zaxFHif0Er0R8~8xk&z{lio3hSB z3iW=#k>Brf(_3fPpI-wgA1W)s{lmx0e}gTxx)1n-+%8*+!!x5C`>R)jlv5$E$9>2v zIhXe28rXsf^gtb1BjUGzb!%s*RjvWX;|Xo^bFT^tb||%?844<|h3tK$_Y)go1pxIe-};yXjG8uAut4{0QnkJ zGXNDKsoMGabsy&J^73!yqMMnS`=!2hb>SUvWMEJU|B6J5MnS@Cna@bWLqHPJ)74d* zXM5|`Ee(y$)~5H~K{qBQE9?35>k(4*xa*B327-qIlwB|ikB>ipXGS*%ZU_=xd=thQ zUYAW0&qXd41tOGBk8Lg&oUiEB3V&Q>5*r^+@P4B3q3!0jc1D{%I-IaELO| zmQ>^2=2g)bu+}DM0`nAff(O`;+}y3^%y$$wZQ9go?QCaxXm%qTodbMDzQBQ4#&Cs%1t=K)3CW4^#0Skn%H4X|=w2 za|PO3gi~2gE_QZAk&R0d8tu4gw&pJ_E$A5UK+5;1<--ReYcz5==o(66j7(;$b!C=R@cNroU%zrFkiX~G4A8M5KZAZ7_u^-YE0<0< zWVN1$m}gPH^Ym#7IW{3-Ll-qrQTr^}7ZDD?rRRS==1Vr?e6h&oOnAx+_4yT$!$_Mj$d5WjwTT&*lY*C^c35 zGu4Hv!$7J=?1DE)aA&o!dWk?O9rkv-mk^3c9zYN8*|P_#Qwvt$Z;x;Uk6RT7^V7(` zWQ7iwG!NU@+b2DIh|z&zzL`KT+x&ZW7TN4n$*`|Q*Q`GB3fsP};%n*Eed}`0LY<+` zH{hN~IhG?=bR4snppZ;eTk13s78DZ77d&uLNjvi8pV(Ca^NmYC+`X)=sh5@n0(lII zFGDFlIcZ$nCE9wWfJS*PO?<4sqgt3$4TY{+THWF&CK@H8w$h4{$)=I8`eOGZ*yK4w ze$UU-_d^I71(cJz(942`#uT| zGt4E|)F?<+chFsP7PhJshRt8+p9;DZYTb}iOIA29qZ58RctD+R`^Ycv^HG=-mI{w? zg21lAwKD>eUwAvGwtjAFqftJ*c|%{cKlbJAI}hTgWP#MI8U{OTX~|=%>q-F-T zbKjR#{T&fN>T?Q`QULgRdU`l&_HAPfOaW^4u|nEfE@j_)3_xpVX-M_3qdRo(ouc+f z?D2m6a`Mn*Q3B_*w6wIkPuQRmyJ%|Q{O{k_U0o5NwOPEs{!nlD4wpL=G)N~|l)Rg6 zKnRwZIR=#)a&%UbrBILx!AF5b>4t{~;_?5@R|yJtYX-1IN1h4O224FP3fOAEzua8t z^B)bVoq643XMFyAT`bSDvjQwi)qPXXvL>e80GT;h_?75Cl6-asA%reJ@LY*Fn92s5}94ZiJxDVX=kGT4A)47ir= zX)voiK04~wq-@aFMo8X_pjCDXt=L>FfqS?t@>f-mAt+BPYM+~c@S^&Az-rn9-ui|cZ zH^n+pM0ii~T$+`#b|mHQ9~YyInX8`bv{|s0);OBnjb3-t+x(-q>1Ql3etv#|7iteE zH_=gcr?wh!h(TJFVQXr6&b1@y1lf0XwD;NKHA|Co5*I!@@jZz+x~pHxE@+Hy)JD!^ zTI`nBsEBI#tVbDJT8lWK+@B74fA_Ni65WgKfs(dh2_buEczrUpF3;AFK=W6V%xoKV zZu`_fr{Spl=6)5O4%tSVjnc_s!^L>-l1Rigv2}UM$qPN-x_9x|r- zMMm=oHY8I8*utI%s`J%*aeo?_^EWjpdB;ZC7NBxLHN1UK@W4i9jt>%^RgRu`_Uu-n@bFk=r@wK^#DdSg-Hi3q$B%)6ry7p*X5V$0(djI`1AeAR0x+yF z?K0bD%-zJY2V;k)-IzI~ghfQ?YN|6M`qh(8v-DtL0|5}bN9{a3x<{P@Q>4HIDPNzc!rRuapT#Y# z1ui;PV`R$r=UM%by?>r?jj4wR0BY3IqN2}0M;$u`2O2lX5m)9Qu8Y?kZfU^-M!7M5 z?2%q3;4x}HUAG}$Fg5kI=t5~qjI{USzOaeLqVP+J)IcN$Uqm)IMV;p0e(U&k)LE1y zCMmf;qE;o`u-1O_i)Wo&3S?HlfD|xa5t{aGQbiovp8RE^$t{yNz0^U9^ij^wwC$i0 zczkP5npOyEAzn9cgsOivcUrsnQBhHZ3?*$@pqw1G(hp&qlc2eaNC2uuaA#an(#9u) z2f`-o_E$L?inDKc_54WI1t*WSXS*v%x#0ZOZ@pw4(ZJMT`c0NoY^(&Uq6xZ+!=u*G zx@D`ZevZ-!SY25CK;%kqXM72Ln1K6R+m>;uTbmX9oNH2?{iSQ$N`R07Wd|Zr(F~mo zVG(WP=mV@dzni;ve)V*=hRV!sxR`DuFkM$b4E-rM%&^+R

M_5xFB}Z2C?O)s7t9Lxu&mpQ6wI=YtVl@eV~uD27N`vAkT~_{6kpzk6DG&OhgX zB)+e_*B~Sd*KQQ?n5w2j2R~bE{)5IJNVAYeN=d~I3wsAew8x30U*fbLBoS^eYwat7a7qN!~1@)jX z157(oG@q*bJhZCh|GsMBC8qqy*8Y7I$IJ=skJjQF+%Kc@t2C4H;pXp-KcDpGmiDST z^=L{WV5iq>c`Q9U{MM19`h_f8Wtf1D-@gS2ky`%Zva+Kb%vu^6S(97!_N7Z+^1GB+ zW%4vDYl99`u~}s0J)ZrUOIca}%oT1eER53BQq?jp@MF`o>$<&t=<%(+VRw7%u9sqk zZ56*N&MxT^BWpDzCO~oxk;q{V-K_KS(iHc)q~;jBj-*5NdCdNo4*Ku06L@s`>7Yvy zU2$8@_v3Bn3=IWG;{P;0N&dhVdZ5{5$YGtg;k|#uEr~_(|3qH(DoZMvXs*k&Et=lf zK}_?RuYC5Y|NLbly-|@ODYb#|6rJJ+VUp~ZUG(R6)LwH5Vv*SYx-~^okurubY5OUj zY|t-XD2|f8V7(>BhL-|&7`X#ekRP>Lvn2X5dYdB}iL+lke_r6D+>N8JQn5qj3~3kF zAirhTI)Ja!zkPwDd4|o3JuAV5-C;(yBKmIh+9f5wBmLKI#V4`-k1wCk5YypRP0@Fr zOx!LKJz!RqRxs(?CZ45}K@Ak3dL4f>CEJ{5i;_#XfUCxf;7_GZ)P>D0y62_ZKXnD( zBwegMqd!}@i5Aq-;Mw(tZKB`6+SH@t4}%Y#La2k(>n_LF3XdNa_$OAF?I`7=zMRXJ zW0JbWpg_LZ5p7T%W6AzgwJwPNk6O}5{2L2Z_j8pmk9+^R!X0_vGEpHmxXMH&O~+gQ z_V8lkw+`8rxA!s$}$fn4Sjigx$!i-di(&o{~m zQBK|GxQsZOrI#aK#&3!X`&Ia@8<{hM|C8+m1Xdhf!NP5?>%cKNGP2I9c4=i59t_m4 zPw*&&qmKJ5D{F4iQ|YWJ({4t74vP_5^WPp`Gv~DXhK`qGQl9E`73B%W?VnX+Jm9Jk zS~2HD8z|CDUw;X`1;#syGzx2wF{^;+YNHP-?KU>MIXD_x^sug1c{fo+XMqsh+VOzi zH}u4rMSZjG{(S%NGZ#|qyocUD{q?&b>yXP;5rGHm6Wc;R1fF6iC`4YV+8d=dbZ$!U zKeu5dz4P7>{1iyIx~hi%cvXCvn0J%CBfSE+9o!#~70bHQdso7WhH|*!A^qIwu)tTx zzU_>2r!6+uO?`d*G;Lw9^?T)f^4R{(zZ+YBnC+nRUI(Z5Oy&A5ujf8pfGx$Gm5(#z zB+piIs1~TE;>d%0T3baVBp6<{M5QRC`wY0DzU;*qiFU^tV-&jKz_zrVSMyghy}uo- zkI0#Fxn;E6BDtH>wM|#1tN2yRTeZb47j%2Ovy4Z-FzxWVRb#oK;B3ZOiMJ7;K{=F zQ9k{CH;WRWlHS5Olw*cow|ADzOOwEyDk!)*e&krH_1?Z)Bc)AsW6M!3H#>9xAZ?$b;~OM)Ufpqev(G?1X_@ zSM(Zubd7ffqMA^@n;jCmwTQCS%F4=E(IWhG>_N&XxEJS2N8gsGphp6O!8G=nAvN34 zTJdc(5gu_a0hhS6N0aP}_zVO0dAqeye(zmgx;t(FOCgcw3AKNIe*UO2K;$U4Mby5W z-x4{fU`>)~ro1n!_6fXFIg+msJ$XpD%YKfHU7i$1^(Z1=7={a=F54{Yj9x>e0$ z>~*nIQ)=s2 zM(U7M-IEi?_N{gp4P;+=7t%Y&(4Ta;S1b78CEI^yhx%h#u=_VIa^Q|q1K*V4F?>{0 zvM@7vu_&R6yg|X_T(reGlb5KpF+5Qh*YKgfmha?5%|F6E@cUxxfzb^o!)nv@@`2yW`CGE?p5W0>CF|u^t*<(@%VDOu(DN|0eX!xu z>F;`b=W>j*|8(eQ8YswqetLpD*feZuN&mIy;KPvBFVR_iObVfKh16zsu>$F5I=URq zH)}2FJwt2FGtSmc{zgM_*|XeuNfqFeX#cZUL|l9p zCjV1^kKwh;(~G^YrWGL@X6BR~aVK*V?<}V^s6sY?k~cp zy8_QhO#P6vbk!;yaXrbXJxXV3c^M}iusJltcHv=ePEZI%ivixP;oZHX=wiWIY~tJ) z&|{aAnHf=kEria-9O*ZW5{nZRYTwStju=Y#@Wf4BW0g?T^?aJ=_&G5A+Q;mr^oUh` zKMdPHj`x;gFZM`s{Y}k!c&n7`RxY&gS;JzZU|ooiSW<)VE!F@+e@2a+#L*?ot+a~$ z5vo2_XeyItu}vs``4XgC)K5qZ$FE=ijpFnBQ|GwWW*R(YTUNa$Z}f`AH5%pohT(Yq z!$IlK*l-Sn$Z;~=fq&yR8P%&YBN)-B{Do+xbvnbnho&G7eK|?ShPZ6DZH>o434!zW zN&y_U(xC8yC2Z#64xS^KaTK>?N&9Q0u#T;#f4 zO!$fFbW|0K3CPs=)CyEzX%v*3|5H@#b5L;y>vevim^o0;H~(>;@d5h_yodR1-^p+l zWRx#2St-u_bQ_=C5+hXc;EHfKU>KhciF)37_~+hVNvp>Jk0}stISI@s*j4LoKm1Sc zh|>@bMp!7kwOnYFqyJF#?+?iecT|mza^enFdM3RJtCsCU5jv;*<^J>m|BD74OjAHs zwEo%VRzq&iqnVvCubBl7^8|u#eQ2TR6 zE;g}gqJ`iwH15n%qZ;1uqCNd`Nl~KhfvCj4nJMd=zN;C}VzXB>J=?SEfx2=y?+d}O z`k@2GdUPEZ+lJS?ZO7(6Uveroyiz+J>5?DKw}ivp4hHtG#d}PBs*q6UsPpKT0Jw^N z<7AX1uh4MN@v-i9N*k;4W2c;YP8J|0pC24X0;fr$~BFWz> zTMDP`t!z7!$0hebT}|iVNiWgpNqDP7e_5s53eYH_ybt8Gl&?o{B{f3^0|WNHQ!nxE zwy?QlQ(i(q9#U*rKD`pqFVx`q+;(Z~+23DV=6>@raWHUqlI51Wn)Nig&O$l^WDA~t zFq%F0w@W&5 zDW8Q4owmH0k@Yj(Cld8uSg@JX@qZ`6yl|zdYJUWrWwK-}JD$z+STB$cdCJ$=SKgy`h60&CUUvLCkO2X>A1)WWsOC%?W0V)f8BYxUW0IdLo zEBs^c^K6l4jwP`NF-UE!ANmCZ!;7NfaF*|D7BTuE7AjY(#Ex8sv1H312d}J}iNENN zXC1!{ntOqBrV`=v-NAFO4(Vdop?9_HfDbj0Gi0NZHr5Hz2M{d5+dSxWq{b1=Lno)a zqIa$07b|yekiT$ssIG8>dV)wvc)HV)4A3mX^1*< zRRdj+?eW=(cq}a49-VB;sktWHC&(wVC8#dSM-q9$4zRyVJU-=nTjWuin^a+WVi1N# z##z|!@|*WS=!C^-qI8e`6SGdjn047$fXkYMj(;a4beuk0{!1@D_U{&hiML^U?{wY7 QnZN5)l{8OeE1J;$7o$D2mjD0& literal 30321 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 From 9ece5d95f2849044456a589008b4a030a2ec9897 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 30 Sep 2026 09:53:27 +0800 Subject: [PATCH 21/23] test(visual): restore original chat-dark sidebar reference The previous reference regeneration committed a render captured under the stale vitest 4.1.9 pin, whose toHaveScreenshot mask handling left the dynamic token counter visible (30753-byte actual vs the 30321-byte masked render). With vitest 4.1.11 (the pin restored in this branch) the masked actual reproduces the original reference byte-for-byte, so the #1680-era reference is correct again and the re-baseline is reverted. --- .../electron-chat-dark-sidebar.png | Bin 30753 -> 30321 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index 20caee097d887f284872e0bb5882e797a74c9ca5..e8497070ef3440fe4a3bbb3ef49f9a1de9d4c4d9 100644 GIT binary patch literal 30321 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 literal 30753 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 QnZN5)l{8OeE1J;$7o$D2mjD0& From ac0c847f4f5b42c4cf133b0d91fbeaaf176da7dd Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 30 Sep 2026 18:49:55 +0800 Subject: [PATCH 22/23] test(openai): make the timeout-only signal test observe the abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout-only completePrompt signal test resolved the mocked request immediately and only asserted the signal type and the forwarded SDK timeout, so any live (non-aborted) signal would have passed. Keep the request parked until its signal aborts, then assert the abort fired, the timeout was forwarded, and the normalized abort error contract (name AbortError, message "OpenAI request aborted") — covering the merged signal's real timeout behaviour instead of just its presence. CodeRabbit (R1, dbid 4143687450): verified against the current code — mergeAbortSignalAndTimeout(undefined, timeoutMs) returns a native AbortSignal.timeout signal that does fire, and completePrompt's catch re-throws the handleOpenAIRequestError-normalized AbortError as-is. --- src/api/providers/__tests__/openai.spec.ts | 33 ++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index f08ab88984..03cc87ca45 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1113,11 +1113,34 @@ describe("OpenAiHandler", () => { }) it("should pass a timeout-only request signal when completePrompt timeoutMs is positive", async () => { - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) - await handler.completePrompt("test prompt", { timeoutMs: 5000 }) - const requestOptions = mockCreate.mock.calls.at(-1)?.[1] - expect(requestOptions?.signal).toBeInstanceOf(AbortSignal) - expect(requestOptions?.timeout).toBe(5000) + // The request must actually observe the merged signal firing: resolving the mock + // immediately only proves a signal was passed, not that a live (non-aborted) + // one aborts on timeout. Keep the request parked until its signal aborts, then + // assert the abort plus the retained timeout and error contract. + let capturedOptions: { signal?: AbortSignal; timeout?: number } | undefined + mockCreate.mockImplementationOnce( + async (_params: unknown, options?: { signal?: AbortSignal; timeout?: number }) => { + capturedOptions = options + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + throw new APIUserAbortError() + }, + ) + + const requestPromise = handler.completePrompt("test prompt", { timeoutMs: 50 }) + const resultPromise = captureError(requestPromise) + + await vi.waitFor(() => expect(capturedOptions?.signal?.aborted).toBe(true)) + expect(capturedOptions?.timeout).toBe(50) + + const result = await resultPromise + expect(result.name).toBe("AbortError") + expect(result.message).toBe("OpenAI request aborted") }) it("should not pass a request signal for zero timeoutMs in completePrompt", async () => { From c44241b5bdae7edac4a3d7e72b3d0ff44c016a7e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 30 Sep 2026 19:52:49 +0800 Subject: [PATCH 23/23] test(kimi-code): keep the SDK create signature in the spy helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type the protected-client projection against the real OpenAI SDK client type and return the inferred vi.spyOn result without the second type-erasing assertion, so the spy keeps the SDK method's own signature and mock values / call-site typing are checked against it. Mock values now use full SDK response shapes (ChatCompletion with refusal, Stream of ChatCompletionChunk), and the per-request option assertions guard the optional second argument explicitly. CodeRabbit (full review, dbid 4144205886): verified against openai 5.23.2 — the last create overload resolves to APIPromise | ChatCompletion>, ChatCompletion requires id/choices/created/model/object, Choice requires finish_reason/index/logprobs/message with refusal added as required on ChatCompletionMessage, and Stream's constructor takes (() => AsyncIterator, AbortController). tsc clean, 23/23 pass. --- src/api/providers/__tests__/kimi-code.spec.ts | 76 ++++++++++++++----- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index 9c8012a044..2a55ae17ff 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -4,6 +4,8 @@ import { KimiCodeHandler } from "../kimi-code" import { clearAllMocks } from "../../../test-utils/reset" import { captureError } from "../../../test-utils/errors" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import OpenAI from "openai" +import { Stream } from "openai/streaming" const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.hoisted(() => ({ mockGetAccessToken: vi.fn(), @@ -26,16 +28,14 @@ vi.mock("../fetchers/modelCache", () => ({ /** * Spies on the inherited OpenAI client's chat.completions.create. `client` is * protected on the OpenAiHandler base (not on the public interface), so it is - * reached through a documented `as unknown as` double assertion (AGENTS.md - * last resort; no `as any`). + * reached through a documented `as unknown as` projection onto the real SDK + * client type (AGENTS.md last resort; no `as any`). The spy keeps the SDK + * method's own signature, so the mock values below use full SDK response + * shapes (ChatCompletion / Stream of ChatCompletionChunk). */ -function completionsCreate(handler: KimiCodeHandler): ReturnType { - const client = ( - handler as unknown as { - client: { chat: { completions: Record never> } } - } - ).client - return vi.spyOn(client.chat.completions, "create") as unknown as ReturnType +function completionsCreate(handler: KimiCodeHandler) { + const client = (handler as unknown as { client: OpenAI }).client + return vi.spyOn(client.chat.completions, "create") } describe("KimiCodeHandler", () => { @@ -139,7 +139,20 @@ describe("KimiCodeHandler", () => { const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 }) const createCompletion = completionsCreate(handler) .mockRejectedValueOnce(unauthorized) - .mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] }) + .mockResolvedValueOnce({ + id: "cmpl-1", + object: "chat.completion", + created: 0, + model: "kimi-for-coding", + choices: [ + { + index: 0, + message: { role: "assistant", content: "retried", refusal: null }, + logprobs: null, + finish_reason: "stop", + }, + ], + }) await expect(handler.completePrompt("test")).resolves.toBe("retried") expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce() @@ -258,9 +271,19 @@ describe("KimiCodeHandler", () => { it("forwards the metadata abort signal to the inherited OpenAI SDK request", async () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) const controller = new AbortController() - const streamChunks = (async function* () { - yield { choices: [{ delta: { content: "hi" } }] } - })() + const streamChunks = new Stream( + () => + (async function* (): AsyncGenerator { + yield { + id: "cmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "kimi-for-coding", + choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }], + } + })()[Symbol.asyncIterator](), + new AbortController(), + ) const createCompletion = completionsCreate(handler).mockResolvedValueOnce(streamChunks) const gen = handler.createMessage("system", [{ role: "user", content: "test" }], { @@ -303,7 +326,20 @@ describe("KimiCodeHandler", () => { const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 }) const createCompletion = completionsCreate(handler) .mockRejectedValueOnce(unauthorized) - .mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] }) + .mockResolvedValueOnce({ + id: "cmpl-1", + object: "chat.completion", + created: 0, + model: "kimi-for-coding", + choices: [ + { + index: 0, + message: { role: "assistant", content: "retried", refusal: null }, + logprobs: null, + finish_reason: "stop", + }, + ], + }) const controller = new AbortController() await expect( @@ -316,15 +352,17 @@ describe("KimiCodeHandler", () => { // attempts: the external signal is merged in (never passed raw) and // the SDK-level timeout is set, so a retry that dropped either would // be caught here. - expect(call[1].timeout).toBe(30_000) - expect(call[1].signal).toBeInstanceOf(AbortSignal) - expect(call[1].signal).not.toBe(controller.signal) - expect(call[1].signal.aborted).toBe(false) + const options = call[1] + expect(options).toBeDefined() + expect(options?.timeout).toBe(30_000) + expect(options?.signal).toBeInstanceOf(AbortSignal) + expect(options?.signal).not.toBe(controller.signal) + expect(options?.signal?.aborted).toBe(false) } // The merged signals follow the external abort on both attempts. controller.abort() for (const call of createCompletion.mock.calls) { - expect(call[1].signal.aborted).toBe(true) + expect(call[1]?.signal?.aborted).toBe(true) } })