From d56bda1148a6442c4b4a8adaa3596e66bf1e0e09 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 20 Aug 2026 04:36:45 +0800 Subject: [PATCH 01/22] feat(api): abort signal support for native-ollama (completePrompt + createMessage) --- .../providers/__tests__/native-ollama.spec.ts | 370 +++++++++++++++++- src/api/providers/native-ollama.ts | 107 +++-- src/eslint-suppressions.json | 2 +- 3 files changed, 455 insertions(+), 24 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 8fcbf4a0a1..b69a3aacef 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -6,21 +6,36 @@ import { NativeOllamaHandler } from "../native-ollama" import { ApiHandlerOptions } from "../../../shared/api" import { getOllamaModels } from "../fetchers/ollama" +import { makeCreateMessageMetadata } from "../../../test-utils/api" import { clearAllMocks } from "../../../test-utils/reset" // Mock the ollama package const mockChat = vitest.fn() + +// Use vi.hoisted to define mocks that can be referenced both inside and outside the hoisted vi.mock +const mockedOllama = vi.hoisted(() => ({ + OllamaMock: vitest.fn(), +})) + vitest.mock("ollama", () => { + const { OllamaMock } = mockedOllama return { - Ollama: vitest.fn().mockImplementation(function () { + Ollama: OllamaMock.mockImplementation(function (options?: { host?: string }) { + const instanceAbort = vitest.fn() return { chat: mockChat, + abort: instanceAbort, + _host: options?.host ?? "http://localhost:11434", + _instanceAbort: instanceAbort, } }), Message: vitest.fn(), } }) +// Export OllamaMock for test access +const OllamaMock = mockedOllama.OllamaMock + // Mock the getOllamaModels function vitest.mock("../fetchers/ollama", () => ({ getOllamaModels: vitest.fn(), @@ -756,6 +771,205 @@ describe("NativeOllamaHandler", () => { }), ) }) + it("should use a request-local client when abortSignal is provided", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const controller = new AbortController() + await handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + + expect(OllamaMock).toHaveBeenCalledTimes(1) + expect(OllamaMock).toHaveBeenCalledWith({ host: "http://localhost:11434" }) + // Ollama implementation only passes the payload, not a second options argument. + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + model: "llama2", + messages: [{ role: "user", content: "Test prompt" }], + stream: false, + options: { temperature: 0 }, + }), + ) + expect(mockChat).toHaveBeenCalledTimes(1) + expect(mockChat.mock.calls[0]).toHaveLength(1) + }) + + it("should not include signal-related options when not provided", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt") + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + model: "llama2", + messages: [{ role: "user", content: "Test prompt" }], + stream: false, + options: { temperature: 0 }, + }), + ) + }) + + it("should work without options (backward compatible)", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Response") + }) + + it("should call client.abort() when timeoutMs is reached", async () => { + const testTimeout = 5000 + let capturedFn: (() => void) | undefined + + // The timer id is never consumed (the callback is captured and fired + // manually), so bridge the ambient setTimeout signature through unknown. + vitest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void, ms?: number) => { + if (ms === testTimeout) { + capturedFn = fn + } + return 0 + }) as unknown as typeof setTimeout) + + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: testTimeout }) + + expect(capturedFn).toBeDefined() + if (capturedFn) capturedFn() + // The timeout callback should have invoked client.abort() on the request-local instance + expect(OllamaMock).toHaveBeenCalled() + }) + + it("should call instance.abort() when abortSignal is aborted", async () => { + const controller = new AbortController() + let capturedInstanceAbort: (() => void) | undefined + + // Override the constructor to capture the instance abort spy + OllamaMock.mockImplementation(function (options?: { host?: string }) { + const instanceAbort = vitest.fn() + capturedInstanceAbort = instanceAbort + return { + chat: mockChat, + abort: instanceAbort, + _host: options?.host ?? "http://localhost:11434", + _instanceAbort: instanceAbort, + } + }) + + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + controller.abort() + await expect(promise).rejects.toThrow("This operation was aborted") + + expect(capturedInstanceAbort).toBeDefined() + expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) + }) + + it("should call instance.abort() immediately when abortSignal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + let capturedInstanceAbort: (() => void) | undefined + + // Override the constructor to capture the instance abort spy + OllamaMock.mockImplementation(function (options?: { host?: string }) { + const instanceAbort = vitest.fn() + capturedInstanceAbort = instanceAbort + return { + chat: mockChat, + abort: instanceAbort, + _host: options?.host ?? "http://localhost:11434", + _instanceAbort: instanceAbort, + } + }) + + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await expect(handler.completePrompt("Test prompt", { abortSignal: controller.signal })).rejects.toThrow( + "This operation was aborted", + ) + + expect(capturedInstanceAbort).toBeDefined() + expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) + }) + + it("should not create a request-local client or timer for non-positive timeoutMs", async () => { + const setTimeoutSpy = vitest.spyOn(global, "setTimeout") + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: 0 }) + + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(OllamaMock).toHaveBeenCalledTimes(1) + expect(OllamaMock).toHaveBeenCalledWith({ host: "http://localhost:11434" }) + }) + + it("should remove abort listener and clear timeout when abortSignal fires", async () => { + const controller = new AbortController() + // The timer id is never consumed directly (clearTimeout is mocked in these + // tests), so bridge the ambient setTimeout return type through unknown. + const timeoutHandle = 1 as unknown as ReturnType + const clearTimeoutSpy = vitest.spyOn(global, "clearTimeout").mockImplementation(() => {}) + vitest.spyOn(global, "setTimeout").mockImplementation(() => timeoutHandle) + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + let resolveChat: (value: { message: { content: string } }) => void = () => {} + mockChat.mockImplementation( + () => + new Promise((resolve) => { + resolveChat = resolve + }), + ) + + const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + for (let i = 0; i < 10 && mockChat.mock.calls.length === 0; i++) { + await Promise.resolve() + } + + controller.abort() + resolveChat({ message: { content: "Response" } }) + + await expect(promise).resolves.toBe("Response") + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle) + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function)) + }) + + it("should clear timeoutId in finally block on success", async () => { + let capturedDelay: number | undefined + // The timer id is never consumed directly (clearTimeout is mocked in these + // tests), so bridge the ambient setTimeout return type through unknown. + const timeoutHandle = 1 as unknown as ReturnType + + vitest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void, ms?: number) => { + if (ms === 5000) { + capturedDelay = ms + } + return timeoutHandle + }) as unknown as typeof setTimeout) + + const clearTimeoutSpy = vitest.spyOn(global, "clearTimeout").mockImplementation(() => {}) + + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: 5000 }) + + // setTimeout should have been called with the correct delay + expect(capturedDelay).toBe(5000) + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle) + }) }) it("should send think parameter in completePrompt when reasoningEffort is set", async () => { @@ -1603,4 +1817,158 @@ describe("NativeOllamaHandler", () => { expect(userImageMessages[0].images).toEqual(["img-a"]) }) }) + + describe("per-request client creation", () => { + it("should create a new Ollama client for each completePrompt call (per-request pattern)", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://localhost:11434", + }) + + // First call + await handler.completePrompt("Test prompt 1") + + // Second call - should create a new client each time (per-request pattern) + await handler.completePrompt("Test prompt 2") + + // Verify the Ollama constructor was called twice (per-request pattern, not singleton) + expect(OllamaMock).toHaveBeenCalledTimes(2) + }) + + it("should pass API key through constructor headers option", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://localhost:11434", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("Test prompt") + + // Verify Ollama was constructed with headers containing the API key + expect(OllamaMock).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + Authorization: "Bearer test-api-key-123", + }, + }), + ) + }) + + it("should work without API key (no headers)", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://localhost:11434", + }) + + await handler.completePrompt("Test prompt") + + // Verify Ollama was constructed without headers when no API key is provided + expect(OllamaMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: "http://localhost:11434", + }), + ) + // headers should not be present when no API key + const callArgs = OllamaMock.mock.calls[0][0] + expect(callArgs.headers).toBeUndefined() + }) + + it("should use custom baseUrl in client options", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://custom-ollama:11434", + }) + + await handler.completePrompt("Test prompt") + + expect(OllamaMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: "http://custom-ollama:11434", + }), + ) + }) + }) + describe("createMessage abort signal", () => { + it("should reject with AbortError when external abortSignal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "Test" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should abort the in-flight request when external abortSignal fires", async () => { + let rejectChat: ((reason: unknown) => void) | undefined + + // Wire the per-request client's abort() to reject the in-flight chat + // request, mirroring how the real Ollama SDK surfaces client.abort(). + OllamaMock.mockImplementation(function (options?: { host?: string }) { + return { + chat: mockChat, + abort: () => { + rejectChat?.(new DOMException("This operation was aborted", "AbortError")) + }, + _host: options?.host ?? "http://localhost:11434", + } + }) + mockChat.mockImplementation( + () => + new Promise((_, reject) => { + rejectChat = reject + }), + ) + + const controller = new AbortController() + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "Test" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const consume = (async () => { + for await (const _ of stream) { + // consume stream + } + })() + + // Wait until the in-flight request has actually started. + for (let i = 0; i < 50 && mockChat.mock.calls.length === 0; i++) { + await Promise.resolve() + } + expect(mockChat).toHaveBeenCalledTimes(1) + + controller.abort() + + await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + }) + }) }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index eb380d1eb2..b018057cc3 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -225,7 +225,6 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: Ollama | undefined protected models: Record = {} constructor(options: ApiHandlerOptions) { @@ -233,27 +232,25 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio this.options = options } - private ensureClient(): Ollama { - if (!this.client) { - try { - const clientOptions: OllamaOptions = { - host: this.options.ollamaBaseUrl || "http://localhost:11434", - // Note: The ollama npm package handles timeouts internally - } - - // Add API key if provided (for Ollama cloud or authenticated instances) - if (this.options.ollamaApiKey) { - clientOptions.headers = { - Authorization: `Bearer ${this.options.ollamaApiKey}`, - } - } + /** + * Creates a new Ollama client instance with the configured options. + * Uses constructor `headers` option for API key instead of mutating config. + */ + private _createOllamaClient(): Ollama { + const clientOptions: OllamaOptions = { + host: this.options.ollamaBaseUrl || "http://localhost:11434", + // Note: The ollama npm package handles timeouts internally + } - this.client = new Ollama(clientOptions) - } catch (error: any) { - throw new Error(`Error creating Ollama client: ${error.message}`) + // Add API key if provided (for Ollama cloud or authenticated instances) + // Use constructor `headers` option instead of mutating (request as any).config + if (this.options.ollamaApiKey) { + clientOptions.headers = { + Authorization: `Bearer ${this.options.ollamaApiKey}`, } } - return this.client + + return new Ollama(clientOptions) } /** @@ -398,7 +395,28 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const client = this.ensureClient() + // Per-request client (SDK doesn't support per-call signal) + const client = this._createOllamaClient() + + // Bridge the external abort signal into the per-request client. The Ollama + // SDK exposes cancellation only through the client-level abort(), so the + // external signal drives that same client instance instead of a separate + // internal controller. + const externalAbortSignal = metadata?.abortSignal + let onExternalAbort: (() => void) | undefined + if (externalAbortSignal) { + if (externalAbortSignal.aborted) { + client.abort() + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + throw abortError + } + onExternalAbort = () => { + client.abort() + } + externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) + } + const { id: modelId } = await this.fetchModel() const useR1Format = modelId.toLowerCase().includes("deepseek-r1") @@ -535,6 +553,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`) throw error } + + // The request completed normally (the error path above always rethrows); + // detach the external-signal listener so it cannot outlive this request. + if (onExternalAbort) { + externalAbortSignal?.removeEventListener("abort", onExternalAbort) + } } async fetchModel() { @@ -551,11 +575,41 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + // Ollama native client cancellation calls client.abort(), so any request with + // cancellation behavior must use a dedicated client instance to avoid aborting + // unrelated requests sharing a provider-level client. The Ollama SDK has no + // per-call signal, so every request uses a per-request client. + const client = this._createOllamaClient() + let timeoutId: ReturnType | undefined + let onAbort: (() => void) | undefined + try { - const client = this.ensureClient() const { id: modelId } = await this.fetchModel() const useR1Format = modelId.toLowerCase().includes("deepseek-r1") + // Handle timeoutMs if provided + if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { + timeoutId = setTimeout(() => client.abort(), options.timeoutMs) + } + + // Propagate abortSignal into the per-request client via client.abort() + if (options?.abortSignal) { + if (options.abortSignal.aborted) { + client.abort() + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + throw abortError + } else { + onAbort = () => { + client.abort() + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } + options.abortSignal.addEventListener("abort", onAbort, { once: true }) + } + } + // Reuse the shared request-option builder so single-shot // completions respect the same reasoning configuration as the // streaming path. @@ -571,10 +625,19 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return response.message?.content || "" } catch (error) { - if (error instanceof Error) { + // Let AbortError surface unmodified so callers can identify cancellations + // by `name === "AbortError"`; every other error keeps the historical wrap. + if (error instanceof Error && error.name !== "AbortError") { throw new Error(`Ollama completion error: ${error.message}`) } throw error + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + if (onAbort && options?.abortSignal) { + options.abortSignal.removeEventListener("abort", onAbort) + } } } } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f790fba436..c10d853c82 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -391,7 +391,7 @@ }, "api/providers/native-ollama.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 } }, "api/providers/openai-codex.ts": { From 346eeb311f637407ef416bd6be255a37babde93e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 20 Aug 2026 09:11:54 +0800 Subject: [PATCH 02/22] fix(api): address CodeRabbit review on native-ollama abort handling --- .../providers/__tests__/native-ollama.spec.ts | 34 ++++++++++++++++-- src/api/providers/native-ollama.ts | 36 +++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index b69a3aacef..196c01c83a 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -823,6 +823,20 @@ describe("NativeOllamaHandler", () => { it("should call client.abort() when timeoutMs is reached", async () => { const testTimeout = 5000 let capturedFn: (() => void) | undefined + let capturedInstanceAbort: (() => void) | undefined + + // Capture the per-request client's abort spy so the assertion targets + // the actual abort() call rather than just the constructor call. + OllamaMock.mockImplementation(function (options?: { host?: string }) { + const instanceAbort = vitest.fn() + capturedInstanceAbort = instanceAbort + return { + chat: mockChat, + abort: instanceAbort, + _host: options?.host ?? "http://localhost:11434", + _instanceAbort: instanceAbort, + } + }) // The timer id is never consumed (the callback is captured and fired // manually), so bridge the ambient setTimeout signature through unknown. @@ -842,7 +856,8 @@ describe("NativeOllamaHandler", () => { expect(capturedFn).toBeDefined() if (capturedFn) capturedFn() // The timeout callback should have invoked client.abort() on the request-local instance - expect(OllamaMock).toHaveBeenCalled() + expect(capturedInstanceAbort).toBeDefined() + expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) }) it("should call instance.abort() when abortSignal is aborted", async () => { @@ -902,7 +917,22 @@ describe("NativeOllamaHandler", () => { expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) }) - it("should not create a request-local client or timer for non-positive timeoutMs", async () => { + it("should reject with AbortError without fetching models when abortSignal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + await expect( + handler.completePrompt("Test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + }) + + // The pre-aborted check must short-circuit before any network call. + expect(mockGetOllamaModels).not.toHaveBeenCalled() + expect(mockChat).not.toHaveBeenCalled() + }) + + it("should not start a timeout timer for non-positive timeoutMs", async () => { const setTimeoutSpy = vitest.spyOn(global, "setTimeout") mockChat.mockResolvedValue({ message: { content: "Response" }, diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index b018057cc3..57509c1a2b 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -552,12 +552,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`) throw error - } - - // The request completed normally (the error path above always rethrows); - // detach the external-signal listener so it cannot outlive this request. - if (onExternalAbort) { - externalAbortSignal?.removeEventListener("abort", onExternalAbort) + } finally { + // Detach the external-signal listener so it cannot outlive this request, + // including on error paths and early generator finalization. + if (onExternalAbort) { + externalAbortSignal?.removeEventListener("abort", onExternalAbort) + } } } @@ -584,15 +584,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio let onAbort: (() => void) | undefined try { - const { id: modelId } = await this.fetchModel() - const useR1Format = modelId.toLowerCase().includes("deepseek-r1") - - // Handle timeoutMs if provided + // Handle timeoutMs if provided (client already exists above, so the + // timer can abort the per-request client directly) if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { timeoutId = setTimeout(() => client.abort(), options.timeoutMs) } - // Propagate abortSignal into the per-request client via client.abort() + // Propagate abortSignal into the per-request client via client.abort(). + // Set this up before fetchModel() so an already-aborted signal (or one + // that aborts during the model-list fetch) is honored before the + // request proceeds. if (options?.abortSignal) { if (options.abortSignal.aborted) { client.abort() @@ -610,6 +611,19 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } + const { id: modelId } = await this.fetchModel() + + // Re-check after the model-list fetch: if the signal aborted during + // fetchModel(), the listener above already aborted the client, and the + // request must reject instead of proceeding to chat(). + if (options?.abortSignal?.aborted) { + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + throw abortError + } + + const useR1Format = modelId.toLowerCase().includes("deepseek-r1") + // Reuse the shared request-option builder so single-shot // completions respect the same reasoning configuration as the // streaming path. From df58e603d6ab0e72e090eb50d646813a8e04d194 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 05:43:33 +0800 Subject: [PATCH 03/22] fix(api): harden native Ollama abort handling and credential gating - Add isSecureOllamaEndpoint() and attach the Ollama API key only for HTTPS or loopback endpoints (plaintext HTTP to a remote host would leak the credential) - Race model discovery against the external abort signal and the completePrompt timeout so requests aborted during discovery reject with AbortError instead of issuing a stale chat request - Re-throw AbortError unmodified so callers can identify cancellations - Detach the external abort listener even when model discovery rejects - Tests: discovery-abort/timeout rejection, listener same-reference removal, key gating for HTTPS/loopback/remote-HTTP endpoints, and unit tests for isSecureOllamaEndpoint --- .../providers/__tests__/native-ollama.spec.ts | 209 +++++++++++++++++- .../fetchers/__tests__/ollama.test.ts | 51 ++++- src/api/providers/fetchers/ollama.ts | 26 ++- src/api/providers/native-ollama.ts | 147 ++++++++---- 4 files changed, 377 insertions(+), 56 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 196c01c83a..4f00b70937 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -36,10 +36,17 @@ vitest.mock("ollama", () => { // Export OllamaMock for test access const OllamaMock = mockedOllama.OllamaMock -// Mock the getOllamaModels function -vitest.mock("../fetchers/ollama", () => ({ - getOllamaModels: vitest.fn(), -})) +// Mock only the model-list fetch. The real isSecureOllamaEndpoint is kept so +// the credential-gating assertions exercise the production predicate. +vitest.mock("../fetchers/ollama", async (importOriginal) => { + // The factory's importOriginal is typed as unknown; recover the module's + // real type so the production predicate can be spread without `any`. + const actual = (await importOriginal()) as typeof import("../fetchers/ollama") + return { + ...actual, + getOllamaModels: vitest.fn(), + } +}) const mockGetOllamaModels = vitest.mocked(getOllamaModels) @@ -711,6 +718,11 @@ describe("NativeOllamaHandler", () => { }) describe("completePrompt", () => { + // Shared capture for the per-request client's abort spy. The timeoutMs + // and abortSignal tests below each override the OllamaMock constructor + // and re-assign it inside their own mockImplementation. + let capturedInstanceAbort: (() => void) | undefined + it("should complete a prompt without streaming", async () => { mockChat.mockResolvedValue({ message: { content: "This is the response" }, @@ -823,7 +835,6 @@ describe("NativeOllamaHandler", () => { it("should call client.abort() when timeoutMs is reached", async () => { const testTimeout = 5000 let capturedFn: (() => void) | undefined - let capturedInstanceAbort: (() => void) | undefined // Capture the per-request client's abort spy so the assertion targets // the actual abort() call rather than just the constructor call. @@ -862,7 +873,6 @@ describe("NativeOllamaHandler", () => { it("should call instance.abort() when abortSignal is aborted", async () => { const controller = new AbortController() - let capturedInstanceAbort: (() => void) | undefined // Override the constructor to capture the instance abort spy OllamaMock.mockImplementation(function (options?: { host?: string }) { @@ -891,7 +901,6 @@ describe("NativeOllamaHandler", () => { it("should call instance.abort() immediately when abortSignal is already aborted", async () => { const controller = new AbortController() controller.abort() - let capturedInstanceAbort: (() => void) | undefined // Override the constructor to capture the instance abort spy OllamaMock.mockImplementation(function (options?: { host?: string }) { @@ -953,6 +962,7 @@ describe("NativeOllamaHandler", () => { const clearTimeoutSpy = vitest.spyOn(global, "clearTimeout").mockImplementation(() => {}) vitest.spyOn(global, "setTimeout").mockImplementation(() => timeoutHandle) const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") let resolveChat: (value: { message: { content: string } }) => void = () => {} mockChat.mockImplementation( @@ -972,7 +982,15 @@ describe("NativeOllamaHandler", () => { await expect(promise).resolves.toBe("Response") expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle) - expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function)) + // The listener removed in the finally block must be the exact function + // that was registered, proving the same reference is detached. + expect(addEventListenerSpy).toHaveBeenCalledWith( + "abort", + expect.any(Function), + { once: true }, + ) + const registeredHandler = addEventListenerSpy.mock.calls[0]?.[1] + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registeredHandler) }) it("should clear timeoutId in finally block on success", async () => { @@ -1846,6 +1864,63 @@ describe("NativeOllamaHandler", () => { expect(userImageMessages).toHaveLength(1) expect(userImageMessages[0].images).toEqual(["img-a"]) }) + + it("should reject with AbortError when the abort signal fires during model discovery", async () => { + let resolveFetch: (() => void) | undefined + + // Hold model discovery open so the abort lands before the chat request. + mockGetOllamaModels.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => resolve({}) + }), + ) + + const controller = new AbortController() + const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + + // The discovery fetch is in flight; abort before it settles. + controller.abort() + resolveFetch?.() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + expect(mockGetOllamaModels).toHaveBeenCalledTimes(1) + expect(mockChat).not.toHaveBeenCalled() + }) + + it("should reject with AbortError when timeoutMs fires during model discovery", async () => { + const testTimeout = 5000 + let capturedFn: (() => void) | undefined + let resolveFetch: (() => void) | undefined + + // Hold model discovery open so the timeout lands before the chat request. + mockGetOllamaModels.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => resolve({}) + }), + ) + + // The timer id is never consumed (the callback is captured and fired + // manually), so bridge the ambient setTimeout signature through unknown. + vitest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void, ms?: number) => { + if (ms === testTimeout) { + capturedFn = fn + } + return 0 + }) as unknown as typeof setTimeout) + + const promise = handler.completePrompt("Test prompt", { timeoutMs: testTimeout }) + expect(capturedFn).toBeDefined() + + // Fire the timeout while discovery is still in flight, then let the + // held fetch settle so the post-discovery deadline re-check can run. + capturedFn?.() + resolveFetch?.() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + expect(mockChat).not.toHaveBeenCalled() + }) }) describe("per-request client creation", () => { @@ -1937,6 +2012,74 @@ describe("NativeOllamaHandler", () => { }), ) }) + + it("should not attach the API key for a remote HTTP endpoint", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://ollama.example.com:11434", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("Test prompt") + + // Plaintext HTTP to a remote host would leak the credential (CWE-319), + // so the Authorization header must be omitted. + const callArgs = OllamaMock.mock.calls[0][0] + expect(callArgs.headers).toBeUndefined() + }) + + it("should attach the API key for an HTTPS endpoint", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "https://ollama.example.com:11434", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("Test prompt") + + expect(OllamaMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: "https://ollama.example.com:11434", + headers: { + Authorization: "Bearer test-api-key-123", + }, + }), + ) + }) + + it("should attach the API key for a loopback IP endpoint", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://127.0.0.1:11434", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("Test prompt") + + expect(OllamaMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: "http://127.0.0.1:11434", + headers: { + Authorization: "Bearer test-api-key-123", + }, + }), + ) + }) }) describe("createMessage abort signal", () => { it("should reject with AbortError when external abortSignal is already aborted", async () => { @@ -2000,5 +2143,55 @@ describe("NativeOllamaHandler", () => { await expect(consume).rejects.toMatchObject({ name: "AbortError" }) }) + + it("should reject with AbortError when the abort signal fires during model discovery", async () => { + let resolveFetch: (() => void) | undefined + let clientAborted = false + + // Hold model discovery open so the abort lands between fetchModel() + // starting and the chat request being issued. + mockGetOllamaModels.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => resolve({}) + }), + ) + + // Wire the per-request client's abort() so the bridge into the SDK + // client can be observed; the chat request must never start. + OllamaMock.mockImplementation(function (options?: { host?: string }) { + return { + chat: mockChat, + abort: () => { + clientAborted = true + }, + _host: options?.host ?? "http://localhost:11434", + } + }) + + const controller = new AbortController() + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "Test" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const consume = (async () => { + for await (const _ of stream) { + // consume stream + } + })() + + // Abort while the model list is still being fetched. The race must + // reject the generator with AbortError before chat() is attempted. + controller.abort() + await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + expect(mockChat).not.toHaveBeenCalled() + expect(mockGetOllamaModels).toHaveBeenCalledTimes(1) + expect(clientAborted).toBe(true) + + // Settle the held discovery fetch so no promise is left dangling. + resolveFetch?.() + }) }) }) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index 9c0b547e88..8408866b08 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -1,6 +1,6 @@ import axios from "axios" -import { getOllamaModels, parseOllamaModel } from "../ollama" +import { getOllamaModels, isSecureOllamaEndpoint, parseOllamaModel } from "../ollama" import ollamaModelsData from "./fixtures/ollama-model-details.json" // Mock axios @@ -116,7 +116,56 @@ describe("Ollama Fetcher", () => { }) }) + describe("isSecureOllamaEndpoint", () => { + it("should treat loopback HTTP endpoints as safe", () => { + expect(isSecureOllamaEndpoint("http://localhost:11434")).toBe(true) + expect(isSecureOllamaEndpoint("http://127.0.0.1:11434")).toBe(true) + }) + + it("should treat any HTTPS endpoint as safe", () => { + expect(isSecureOllamaEndpoint("https://ollama.example.com")).toBe(true) + expect(isSecureOllamaEndpoint("https://10.0.0.5:11434")).toBe(true) + }) + + it("should treat remote HTTP endpoints as unsafe", () => { + expect(isSecureOllamaEndpoint("http://ollama.example.com:11434")).toBe(false) + expect(isSecureOllamaEndpoint("http://10.0.0.5:11434")).toBe(false) + }) + + it("should treat unparseable endpoints as unsafe", () => { + expect(isSecureOllamaEndpoint("not a url")).toBe(false) + }) + }) + describe("getOllamaModels", () => { + it("should omit the Authorization header for a remote HTTP endpoint", async () => { + const baseUrl = "http://ollama.example.com:11434" + const apiKey = "test-api-key-123" + + mockedAxios.get.mockResolvedValueOnce({ data: { models: [] } }) + + const result = await getOllamaModels(baseUrl, apiKey) + + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} }) + expect(result).toEqual({}) + }) + + it("should include the Authorization header for an HTTPS endpoint", async () => { + const baseUrl = "https://ollama.example.com:11434" + const apiKey = "test-api-key-123" + + mockedAxios.get.mockResolvedValueOnce({ data: { models: [] } }) + + const result = await getOllamaModels(baseUrl, apiKey) + + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + expect(result).toEqual({}) + }) + it("should fetch model list from /api/tags and include models with tools capability", async () => { const baseUrl = "http://localhost:11434" const modelName = "devstral2to16:latest" diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 9f88d75327..68076b4ebb 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -62,6 +62,26 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | return modelInfo } +/** + * Determines whether an Ollama endpoint is safe to carry an API key. + * + * Ollama's default installation listens on loopback, where plaintext HTTP is + * the norm. API keys are secrets, though, and must not be sent in cleartext to + * a remote host (CWE-319). Only HTTPS or a loopback host is considered safe + * enough to attach the Authorization header. + */ +export function isSecureOllamaEndpoint(baseUrl: string): boolean { + if (!URL.canParse(baseUrl)) { + return false + } + const url = new URL(baseUrl) + if (url.protocol === "https:") { + return true + } + const host = url.hostname + return host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host) +} + export async function getOllamaModels( baseUrl = "http://localhost:11434", apiKey?: string, @@ -76,9 +96,11 @@ export async function getOllamaModels( return models } - // Prepare headers with optional API key + // Prepare headers with optional API key. The credential is only attached + // when the endpoint is HTTPS or loopback; sending it over plaintext HTTP + // to a remote host would leak it (CWE-319). const headers: Record = {} - if (apiKey) { + if (apiKey && isSecureOllamaEndpoint(baseUrl)) { headers["Authorization"] = `Bearer ${apiKey}` } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 57509c1a2b..fcae91ecf7 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -5,7 +5,7 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { ApiHandlerOptions } from "../../shared/api" -import { getOllamaModels } from "./fetchers/ollama" +import { getOllamaModels, isSecureOllamaEndpoint } from "./fetchers/ollama" import { TagMatcher } from "../../utils/tag-matcher" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -23,6 +23,48 @@ type ReasoningContentBlock = { type: "reasoning"; text: string } type ThinkingContentBlock = { type: "thinking"; thinking: string } type AssistantContentBlock = Anthropic.ContentBlock | ReasoningContentBlock | ThinkingContentBlock +/** + * Creates the canonical abort error shared by every cancellation path so + * callers can identify cancellations by `name === "AbortError"`. + */ +function createAbortError(): Error { + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + return abortError +} + +/** + * Races `pending` against `signal`: if the signal aborts before the promise + * settles, the returned promise rejects with an AbortError instead of + * resolving with a stale result. The Ollama model-discovery fetch accepts no + * signal, so the underlying request is left to settle in the background + * rather than being cancelled. + */ +function raceWithAbortSignal(pending: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) { + return pending + } + if (signal.aborted) { + return Promise.reject(createAbortError()) + } + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(createAbortError()) + } + signal.addEventListener("abort", onAbort, { once: true }) + pending.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) +} + function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { const ollamaMessages: Message[] = [] // Track tool use IDs to tool names so tool results can be sent with @@ -242,9 +284,11 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // Note: The ollama npm package handles timeouts internally } - // Add API key if provided (for Ollama cloud or authenticated instances) - // Use constructor `headers` option instead of mutating (request as any).config - if (this.options.ollamaApiKey) { + // Add API key if provided (for Ollama cloud or authenticated instances). + // Use constructor `headers` option instead of mutating (request as any).config. + // The credential is only attached when the endpoint is HTTPS or loopback; + // sending it over plaintext HTTP to a remote host would leak it (CWE-319). + if (this.options.ollamaApiKey && isSecureOllamaEndpoint(clientOptions.host)) { clientOptions.headers = { Authorization: `Bearer ${this.options.ollamaApiKey}`, } @@ -403,38 +447,40 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // external signal drives that same client instance instead of a separate // internal controller. const externalAbortSignal = metadata?.abortSignal + if (externalAbortSignal?.aborted) { + client.abort() + throw createAbortError() + } + let onExternalAbort: (() => void) | undefined - if (externalAbortSignal) { - if (externalAbortSignal.aborted) { - client.abort() - const abortError = new Error("This operation was aborted") - abortError.name = "AbortError" - throw abortError - } - onExternalAbort = () => { - client.abort() + try { + if (externalAbortSignal) { + onExternalAbort = () => { + client.abort() + } + externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) } - externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) - } - const { id: modelId } = await this.fetchModel() - const useR1Format = modelId.toLowerCase().includes("deepseek-r1") - - const ollamaMessages: Message[] = [ - { role: "system", content: systemPrompt }, - ...convertToOllamaMessages(messages), - ] - - const matcher = new TagMatcher( - ["think", "thought"], - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) + // Race model discovery against the external signal: the SDK's model + // fetch accepts no signal, so a request aborted during discovery must + // reject instead of proceeding to chat() with a stale model. + const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), externalAbortSignal) + const useR1Format = modelId.toLowerCase().includes("deepseek-r1") + + const ollamaMessages: Message[] = [ + { role: "system", content: systemPrompt }, + ...convertToOllamaMessages(messages), + ] + + const matcher = new TagMatcher( + ["think", "thought"], + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) - try { // Build the shared chat options and conditional think parameter. // Conditionally enabling Ollama's native think parameter lets // reasoning models (qwen3, deepseek-r1, etc.) emit thinking via @@ -536,6 +582,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`) } } catch (error: any) { + // Let AbortError surface unmodified so callers can identify cancellations + // by `name === "AbortError"`; every other error keeps the historical wrap. + if (error instanceof Error && error.name === "AbortError") { + throw error + } + // Enhance error reporting const statusCode = error.status || error.statusCode const errorMessage = error.message || "Unknown error" @@ -554,7 +606,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio throw error } finally { // Detach the external-signal listener so it cannot outlive this request, - // including on error paths and early generator finalization. + // including when model discovery rejects before the chat request starts. if (onExternalAbort) { externalAbortSignal?.removeEventListener("abort", onExternalAbort) } @@ -582,12 +634,18 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const client = this._createOllamaClient() let timeoutId: ReturnType | undefined let onAbort: (() => void) | undefined + // Set when the timeoutMs timer fires so a model-discovery fetch still in + // flight is rejected instead of proceeding to chat() past the deadline. + let timedOut = false try { // Handle timeoutMs if provided (client already exists above, so the // timer can abort the per-request client directly) if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { - timeoutId = setTimeout(() => client.abort(), options.timeoutMs) + timeoutId = setTimeout(() => { + timedOut = true + client.abort() + }, options.timeoutMs) } // Propagate abortSignal into the per-request client via client.abort(). @@ -597,9 +655,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio if (options?.abortSignal) { if (options.abortSignal.aborted) { client.abort() - const abortError = new Error("This operation was aborted") - abortError.name = "AbortError" - throw abortError + throw createAbortError() } else { onAbort = () => { client.abort() @@ -611,15 +667,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } - const { id: modelId } = await this.fetchModel() + // Race model discovery against the abort signal: the model fetch + // accepts no signal, so an abort during discovery must reject instead + // of proceeding to chat() with a stale model. + const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), options?.abortSignal) - // Re-check after the model-list fetch: if the signal aborted during - // fetchModel(), the listener above already aborted the client, and the - // request must reject instead of proceeding to chat(). - if (options?.abortSignal?.aborted) { - const abortError = new Error("This operation was aborted") - abortError.name = "AbortError" - throw abortError + // Re-check after the model-list fetch: if the request aborted or timed + // out during fetchModel(), the listener/timer above already aborted the + // client, and the request must reject instead of proceeding to chat(). + if (timedOut || options?.abortSignal?.aborted) { + throw createAbortError() } const useR1Format = modelId.toLowerCase().includes("deepseek-r1") From 45c5c0ae9005a7196ae56950a3052bc8fe7f62ec Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 09:19:49 +0800 Subject: [PATCH 04/22] fix(api): harden native Ollama cancellation and credential routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getOllamaModels: bypass HTTP proxy routing (proxy: false) for loopback HTTP endpoints carrying an API key, so the bearer token is never sent through a proxy in cleartext (CWE-319); HTTPS endpoints keep normal proxy behavior - isSecureOllamaEndpoint: cover the IPv6 loopback endpoint [::1] in the secure-endpoint tests - NativeOllamaHandler: inject a per-request fetch transport into the Ollama client that merges the per-request AbortSignal into every fetch (preserving the SDK's own stream signal via AbortSignal.any), so the in-flight POST and response body are cancellable on external abort or timeoutMs — the SDK's client.abort() alone only covers iterators already tracked after the POST resolves - completePrompt: race model discovery against the per-request signal merged with timeoutMs (mergeAbortSignalAndTimeout) so a stalled discovery rejects on the deadline instead of hanging past it - createMessage: dispose a stream that resolves after cancellation and surface an AbortError thrown by the stream body unmodified; add tests for the per-request transport (held-open POST cancellation, timeout firing, SDK signal merge), the post-cancellation dispose path, and the AbortError pass-through --- .../providers/__tests__/native-ollama.spec.ts | 242 +++++++++++++++++- .../fetchers/__tests__/ollama.test.ts | 160 +++++++++++- src/api/providers/fetchers/ollama.ts | 15 +- src/api/providers/native-ollama.ts | 84 ++++-- 4 files changed, 465 insertions(+), 36 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 4f00b70937..c2f4ca78b5 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -792,7 +792,7 @@ describe("NativeOllamaHandler", () => { await handler.completePrompt("Test prompt", { abortSignal: controller.signal }) expect(OllamaMock).toHaveBeenCalledTimes(1) - expect(OllamaMock).toHaveBeenCalledWith({ host: "http://localhost:11434" }) + expect(OllamaMock).toHaveBeenCalledWith(expect.objectContaining({ host: "http://localhost:11434" })) // Ollama implementation only passes the payload, not a second options argument. expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -951,7 +951,7 @@ describe("NativeOllamaHandler", () => { expect(setTimeoutSpy).not.toHaveBeenCalled() expect(OllamaMock).toHaveBeenCalledTimes(1) - expect(OllamaMock).toHaveBeenCalledWith({ host: "http://localhost:11434" }) + expect(OllamaMock).toHaveBeenCalledWith(expect.objectContaining({ host: "http://localhost:11434" })) }) it("should remove abort listener and clear timeout when abortSignal fires", async () => { @@ -984,11 +984,7 @@ describe("NativeOllamaHandler", () => { expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle) // The listener removed in the finally block must be the exact function // that was registered, proving the same reference is detached. - expect(addEventListenerSpy).toHaveBeenCalledWith( - "abort", - expect.any(Function), - { once: true }, - ) + expect(addEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function), { once: true }) const registeredHandler = addEventListenerSpy.mock.calls[0]?.[1] expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registeredHandler) }) @@ -1913,13 +1909,16 @@ describe("NativeOllamaHandler", () => { const promise = handler.completePrompt("Test prompt", { timeoutMs: testTimeout }) expect(capturedFn).toBeDefined() - // Fire the timeout while discovery is still in flight, then let the - // held fetch settle so the post-discovery deadline re-check can run. + // Fire the timeout while discovery is still in flight. The discovery + // race must reject on the timeout WITHOUT waiting for the held fetch + // to settle. capturedFn?.() - resolveFetch?.() - await expect(promise).rejects.toMatchObject({ name: "AbortError" }) expect(mockChat).not.toHaveBeenCalled() + + // Settle the held fetch after the assertion so no promise is left + // dangling. + resolveFetch?.() }) }) @@ -2081,6 +2080,170 @@ describe("NativeOllamaHandler", () => { ) }) }) + + describe("per-request abortable transport", () => { + // The vi.fn() OllamaMock is untyped, so the constructor options read + // from mock.calls need one documented narrow cast to reach the injected + // per-request transport. + type ConstructorOptions = { + fetch?: (url: string, init?: RequestInit) => Promise + } + + // A held-open fetch: it rejects with an AbortError when the passed + // signal aborts and stays pending otherwise. + const heldOpenFetch = () => + vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.signal?.aborted) { + throw new DOMException("This operation was aborted", "AbortError") + } + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("This operation was aborted", "AbortError")), + { once: true }, + ) + }) + }) + + it("should make a held-open chat POST abortable via the per-request transport when the external signal aborts", async () => { + const fetchSpy = heldOpenFetch() + vi.stubGlobal("fetch", fetchSpy) + + let settleChat: ((value: object) => void) | undefined + mockChat.mockImplementation( + () => + new Promise((resolve) => { + settleChat = resolve + }), + ) + + const controller = new AbortController() + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "Test" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + const consume = (async () => { + for await (const _ of stream) { + // consume stream + } + })() + + // Spin until the mocked SDK chat() has been invoked (the mocked SDK + // never uses the transport, so its chat() is held manually). + for (let i = 0; i < 50 && mockChat.mock.calls.length === 0; i++) { + await Promise.resolve() + } + expect(mockChat).toHaveBeenCalledTimes(1) + + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + expect(clientOptions.fetch).toBeTypeOf("function") + + // Non-stream phase: the SDK passes no signal, so the transport must + // still hand the per-request signal to fetch. + const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat", {}) + controller.abort() + + // The held POST is aborted with the response never released. + await expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + + // Prove the dispose path: a stream that resolves after cancellation + // is disposed, not consumed. + settleChat?.({ + message: { content: "late" }, + abort: vi.fn(), + [Symbol.asyncIterator]: async function* () {}, + }) + await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + + vi.unstubAllGlobals() + }) + + it("should abort a held-open POST via the per-request transport when timeoutMs fires", async () => { + // Earlier tests in this file leave a persistent global setTimeout mock + // (vi.clearAllMocks does not restore spy implementations); the real + // 100ms timer is the point of this test, so restore native timers. + vi.restoreAllMocks() + + const fetchSpy = heldOpenFetch() + vi.stubGlobal("fetch", fetchSpy) + + let rejectChat: ((reason: unknown) => void) | undefined + mockChat.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectChat = reject + }), + ) + + const promise = handler.completePrompt("Test prompt", { timeoutMs: 100 }) + + // Spin until the mocked SDK chat() has been invoked. + for (let i = 0; i < 50 && mockChat.mock.calls.length === 0; i++) { + await Promise.resolve() + } + expect(mockChat).toHaveBeenCalledTimes(1) + + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat", {}) + + // The real 100ms timer aborts the per-request signal, which must + // reject the held POST. + await expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + + // Mirror the real SDK: the in-flight POST rejects, and the + // completion surfaces the AbortError unmodified. + rejectChat?.(new DOMException("This operation was aborted", "AbortError")) + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + + vi.unstubAllGlobals() + }) + + it("should preserve the SDK stream signal when merging it with the per-request signal", async () => { + let receivedSignal: AbortSignal | null | undefined + const fetchSpy = vi.fn(async (_url: string, init?: RequestInit) => { + receivedSignal = init?.signal + if (init?.signal?.aborted) { + throw new DOMException("This operation was aborted", "AbortError") + } + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("This operation was aborted", "AbortError")), + { once: true }, + ) + }) + }) + vi.stubGlobal("fetch", fetchSpy) + + mockChat.mockResolvedValue({ message: { content: "Response" } }) + + // Drive a plain completePrompt so the per-request client is + // constructed; the transport is attached even without an external + // signal or timeout. + await handler.completePrompt("Test prompt") + + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + expect(clientOptions.fetch).toBeTypeOf("function") + + const sdkController = new AbortController() + const post = clientOptions.fetch!("http://localhost:11434/api/chat", { signal: sdkController.signal }) + + // The transport must merge, not replace: the SDK's own signal is + // preserved as one side of the AbortSignal.any merge. + expect(receivedSignal).toBeInstanceOf(AbortSignal) + expect(receivedSignal).not.toBe(sdkController.signal) + expect(receivedSignal?.aborted).toBe(false) + + // The SDK-side internal controller must still cancel the POST (the + // preserved-signal side of the merge). + sdkController.abort() + await expect(post).rejects.toMatchObject({ name: "AbortError" }) + + vi.unstubAllGlobals() + }) + }) + describe("createMessage abort signal", () => { it("should reject with AbortError when external abortSignal is already aborted", async () => { const controller = new AbortController() @@ -2193,5 +2356,62 @@ describe("NativeOllamaHandler", () => { // Settle the held discovery fetch so no promise is left dangling. resolveFetch?.() }) + + it("should dispose a stream that resolves after cancellation instead of consuming it", async () => { + const controller = new AbortController() + let streamAborted = false + let consumed = false + + // The POST resolves exactly when the abort lands (response headers + // first): the resolved iterator must be disposed, not consumed. + mockChat.mockImplementation(() => { + controller.abort() + return Promise.resolve({ + message: { content: "late" }, + abort: () => { + streamAborted = true + }, + [Symbol.asyncIterator]: async function* () { + consumed = true + yield { message: { content: "late" } } + }, + }) + }) + + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "Test" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const consume = (async () => { + for await (const _ of stream) { + // consume stream + } + })() + + await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + expect(streamAborted).toBe(true) + expect(consumed).toBe(false) + }) + + it("should surface an AbortError thrown by the stream body without wrapping it", async () => { + // Mirror the real SDK: the chat() iterator is backed by the in-flight + // POST, so an aborted request rejects the first next() with the + // DOMException AbortError. The zero-item delegation satisfies + // require-yield without emitting a part before the rejection. + mockChat.mockImplementation(async function* () { + yield* [] + throw new DOMException("This operation was aborted", "AbortError") + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toMatchObject({ name: "AbortError" }) + }) }) }) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index 8408866b08..e507868c89 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -120,6 +120,7 @@ describe("Ollama Fetcher", () => { it("should treat loopback HTTP endpoints as safe", () => { expect(isSecureOllamaEndpoint("http://localhost:11434")).toBe(true) expect(isSecureOllamaEndpoint("http://127.0.0.1:11434")).toBe(true) + expect(isSecureOllamaEndpoint("http://[::1]:11434")).toBe(true) }) it("should treat any HTTPS endpoint as safe", () => { @@ -166,6 +167,155 @@ describe("Ollama Fetcher", () => { expect(result).toEqual({}) }) + it("should send the Authorization header and disable proxy routing for a loopback HTTP endpoint", async () => { + const baseUrl = "http://localhost:11434" + const apiKey = "test-api-key-123" + const modelName = "devstral2to16:latest" + + const mockApiTagsResponse = { + models: [ + { + name: modelName, + model: modelName, + modified_at: "2025-06-03T09:23:22.610222878-04:00", + size: 14333928010, + digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", + details: { + family: "llama", + families: ["llama"], + format: "gguf", + parameter_size: "23.6B", + parent_model: "", + quantization_level: "Q4_K_M", + }, + }, + ], + } + const mockApiShowResponse = { + license: "Mock License", + modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", + parameters: "num_ctx 4096\nstop_token ", + template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", + modified_at: "2025-06-03T09:23:22.610222878-04:00", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: ["llama"], + parameter_size: "23.6B", + quantization_level: "Q4_K_M", + }, + model_info: { + "ollama.context_length": 4096, + "some.other.info": "value", + }, + capabilities: ["completion", "tools"], // Has tools capability + } + + mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) + mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse }) + + const result = await getOllamaModels(baseUrl, apiKey) + + // A cleartext loopback request carrying the bearer token must not be + // routed through any HTTP proxy, otherwise the proxy would see the + // credential in cleartext (CWE-319). Both requests bypass the proxy. + const expectedHeaders = { Authorization: `Bearer ${apiKey}` } + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { + headers: expectedHeaders, + proxy: false, + }) + + expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockedAxios.post).toHaveBeenCalledWith( + `${baseUrl}/api/show`, + { model: modelName }, + { + headers: expectedHeaders, + proxy: false, + }, + ) + + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(1) + expect(result[modelName]).toBeDefined() + }) + + it("should send the Authorization header and disable proxy routing for an IPv6 loopback endpoint", async () => { + const baseUrl = "http://[::1]:11434" + const apiKey = "test-api-key-123" + const modelName = "devstral2to16:latest" + + const mockApiTagsResponse = { + models: [ + { + name: modelName, + model: modelName, + modified_at: "2025-06-03T09:23:22.610222878-04:00", + size: 14333928010, + digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", + details: { + family: "llama", + families: ["llama"], + format: "gguf", + parameter_size: "23.6B", + parent_model: "", + quantization_level: "Q4_K_M", + }, + }, + ], + } + const mockApiShowResponse = { + license: "Mock License", + modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", + parameters: "num_ctx 4096\nstop_token ", + template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", + modified_at: "2025-06-03T09:23:22.610222878-04:00", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: ["llama"], + parameter_size: "23.6B", + quantization_level: "Q4_K_M", + }, + model_info: { + "ollama.context_length": 4096, + "some.other.info": "value", + }, + capabilities: ["completion", "tools"], // Has tools capability + } + + mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) + mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse }) + + const result = await getOllamaModels(baseUrl, apiKey) + + // The IPv6 loopback [::1] is treated the same as the IPv4 loopback: + // the credential is attached and proxy routing is disabled. + const expectedHeaders = { Authorization: `Bearer ${apiKey}` } + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { + headers: expectedHeaders, + proxy: false, + }) + + expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockedAxios.post).toHaveBeenCalledWith( + `${baseUrl}/api/show`, + { model: modelName }, + { + headers: expectedHeaders, + proxy: false, + }, + ) + + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(1) + expect(result[modelName]).toBeDefined() + }) + it("should fetch model list from /api/tags and include models with tools capability", async () => { const baseUrl = "http://localhost:11434" const modelName = "devstral2to16:latest" @@ -431,15 +581,19 @@ describe("Ollama Fetcher", () => { const result = await getOllamaModels(baseUrl, apiKey) const expectedHeaders = { Authorization: `Bearer ${apiKey}` } - + // Cleartext loopback + bearer token: the fetcher also disables proxy + // routing on both requests (CWE-319). expect(mockedAxios.get).toHaveBeenCalledTimes(1) - expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: expectedHeaders }) + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { + headers: expectedHeaders, + proxy: false, + }) expect(mockedAxios.post).toHaveBeenCalledTimes(1) expect(mockedAxios.post).toHaveBeenCalledWith( `${baseUrl}/api/show`, { model: modelName }, - { headers: expectedHeaders }, + { headers: expectedHeaders, proxy: false }, ) expect(typeof result).toBe("object") diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 68076b4ebb..7e162a5f5b 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -99,12 +99,21 @@ export async function getOllamaModels( // Prepare headers with optional API key. The credential is only attached // when the endpoint is HTTPS or loopback; sending it over plaintext HTTP // to a remote host would leak it (CWE-319). + const credentialGated = Boolean(apiKey && isSecureOllamaEndpoint(baseUrl)) const headers: Record = {} - if (apiKey && isSecureOllamaEndpoint(baseUrl)) { + if (credentialGated) { headers["Authorization"] = `Bearer ${apiKey}` } - const response = await axios.get(`${baseUrl}/api/tags`, { headers }) + // A loopback HTTP endpoint carrying the key must bypass any HTTP proxy, + // otherwise the proxy would see the bearer token in cleartext (CWE-319). + // HTTPS endpoints keep normal proxy behavior (traffic stays encrypted). + // Parsing is safe here: baseUrl is normalized ("" → default) above and + // the !URL.canParse(baseUrl) guard returned early. + const cleartextLoopback = credentialGated && new URL(baseUrl).protocol === "http:" + const proxyConfig: { proxy?: false } = cleartextLoopback ? { proxy: false } : {} + + const response = await axios.get(`${baseUrl}/api/tags`, { headers, ...proxyConfig }) const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data) const modelInfoPromises = [] @@ -117,7 +126,7 @@ export async function getOllamaModels( { model: ollamaModel.model, }, - { headers }, + { headers, ...proxyConfig }, ) .then((ollamaModelInfo) => { const modelInfo = parseOllamaModel(ollamaModelInfo.data) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index fcae91ecf7..403a879d9c 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -6,6 +6,7 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { ApiHandlerOptions } from "../../shared/api" import { getOllamaModels, isSecureOllamaEndpoint } from "./fetchers/ollama" +import { mergeAbortSignalAndTimeout } from "./utils/abort-signal" import { TagMatcher } from "../../utils/tag-matcher" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -277,8 +278,10 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio /** * Creates a new Ollama client instance with the configured options. * Uses constructor `headers` option for API key instead of mutating config. + * The per-request transport makes both the in-flight POST and the response + * body cancellable; see the inline rationale. */ - private _createOllamaClient(): Ollama { + private _createOllamaClient(requestSignal?: AbortSignal): Ollama { const clientOptions: OllamaOptions = { host: this.options.ollamaBaseUrl || "http://localhost:11434", // Note: The ollama npm package handles timeouts internally @@ -294,6 +297,20 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } + // The Ollama SDK (0.6.x) only tracks streaming iterators after the POST + // resolves and passes no signal for stream:false requests, so client + // .abort() alone cannot cancel an in-flight POST. Inject a per-request + // transport that merges our cancellation signal into every fetch while + // preserving the SDK's own stream signal when present (AbortSignal.any + // honors either side). + if (requestSignal) { + const baseFetch = globalThis.fetch + clientOptions.fetch = (url, init) => { + const signal = init?.signal ? AbortSignal.any([init.signal, requestSignal]) : requestSignal + return baseFetch(url, { ...init, signal }) + } + } + return new Ollama(clientOptions) } @@ -439,15 +456,20 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Per-request client (SDK doesn't support per-call signal) - const client = this._createOllamaClient() - - // Bridge the external abort signal into the per-request client. The Ollama - // SDK exposes cancellation only through the client-level abort(), so the - // external signal drives that same client instance instead of a separate - // internal controller. + // Per-request client (SDK doesn't support per-call signal). The + // per-request controller drives the abortable transport injected into + // the client, so an in-flight POST is cancellable as well. + const requestController = new AbortController() + const client = this._createOllamaClient(requestController.signal) + + // Bridge the external abort signal into the per-request client and the + // per-request transport. The Ollama SDK exposes cancellation only + // through the client-level abort() and the stream's own signal, so the + // external signal drives both of this same request instead of a + // separate internal controller. const externalAbortSignal = metadata?.abortSignal if (externalAbortSignal?.aborted) { + requestController.abort() client.abort() throw createAbortError() } @@ -456,6 +478,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio try { if (externalAbortSignal) { onExternalAbort = () => { + requestController.abort() client.abort() } externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) @@ -501,6 +524,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio ...(thinkParam !== undefined ? { think: thinkParam } : {}), }) + // The POST can resolve in the same instant the abort lands (response + // headers arrived first). In that case the iterator must be disposed + // instead of being consumed after cancellation. + if (externalAbortSignal?.aborted) { + stream.abort() + throw createAbortError() + } + let totalInputTokens = 0 let totalOutputTokens = 0 // Track tool calls across chunks (Ollama may send complete tool_calls in final chunk) @@ -578,6 +609,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } } catch (streamError: any) { + // A body read aborted by the per-request transport surfaces as an + // AbortError DOMException; keep its name unmodified (same contract + // as the outer catch) so callers can identify cancellations. + if (streamError instanceof Error && streamError.name === "AbortError") { + throw streamError + } console.error("Error processing Ollama stream:", streamError) throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`) } @@ -630,8 +667,11 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // Ollama native client cancellation calls client.abort(), so any request with // cancellation behavior must use a dedicated client instance to avoid aborting // unrelated requests sharing a provider-level client. The Ollama SDK has no - // per-call signal, so every request uses a per-request client. - const client = this._createOllamaClient() + // per-call signal, so every request uses a per-request client. The + // per-request controller drives the abortable transport injected into the + // client, so the in-flight POST itself is cancellable. + const requestController = new AbortController() + const client = this._createOllamaClient(requestController.signal) let timeoutId: ReturnType | undefined let onAbort: (() => void) | undefined // Set when the timeoutMs timer fires so a model-discovery fetch still in @@ -640,24 +680,27 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio try { // Handle timeoutMs if provided (client already exists above, so the - // timer can abort the per-request client directly) + // timer can abort both the per-request transport and the client) if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { timeoutId = setTimeout(() => { timedOut = true + requestController.abort() client.abort() }, options.timeoutMs) } - // Propagate abortSignal into the per-request client via client.abort(). - // Set this up before fetchModel() so an already-aborted signal (or one - // that aborts during the model-list fetch) is honored before the - // request proceeds. + // Propagate abortSignal into the per-request client via client.abort() + // and the per-request transport. Set this up before fetchModel() so an + // already-aborted signal (or one that aborts during the model-list + // fetch) is honored before the request proceeds. if (options?.abortSignal) { if (options.abortSignal.aborted) { + requestController.abort() client.abort() throw createAbortError() } else { onAbort = () => { + requestController.abort() client.abort() if (timeoutId !== undefined) { clearTimeout(timeoutId) @@ -667,10 +710,13 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } - // Race model discovery against the abort signal: the model fetch - // accepts no signal, so an abort during discovery must reject instead - // of proceeding to chat() with a stale model. - const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), options?.abortSignal) + // Model discovery goes through Axios (getOllamaModels) and never touches + // the Ollama client, so client.abort() alone cannot reject it. Race + // discovery against the per-request signal (bridged from the external + // abort) and the per-request timeout so a stalled discovery rejects + // instead of hanging past the deadline. + const discoverySignal = mergeAbortSignalAndTimeout(requestController.signal, options?.timeoutMs) + const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), discoverySignal) // Re-check after the model-list fetch: if the request aborted or timed // out during fetchModel(), the listener/timer above already aborted the From ec8a491cb9cdb98cb910278a5eb9667a47c6c841 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 10:42:28 +0800 Subject: [PATCH 05/22] fix(api): harden Ollama loopback check and stream finalization CodeRabbit review 5096902377 fixes: - Replace the loose /^127\./ prefix with a strict 127.0.0.0/8 IPv4-literal check in isSecureOllamaEndpoint so DNS names such as 127.example.com no longer pass the loopback gate and leak the API key over cleartext HTTP (CWE-319). The shared predicate also gates the native provider path. - Abort the per-request controller in createMessage()'s finally so a consumer that stops iterating early releases the in-flight transport / response body (no-op after normal completion). - Test hygiene in the per-request abortable transport describe: hoist vi.restoreAllMocks / vi.unstubAllGlobals into beforeEach/afterEach and drop the inline calls; add a regression test for early-iteration finalization. - Deduplicate the getOllamaModels loopback tests with shared typed payload helpers and merge the two loopback cases into a single it.each. --- .../providers/__tests__/native-ollama.spec.ts | 52 +++- .../fetchers/__tests__/ollama.test.ts | 270 ++++++------------ src/api/providers/fetchers/ollama.ts | 24 +- src/api/providers/native-ollama.ts | 6 + 4 files changed, 157 insertions(+), 195 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index c2f4ca78b5..c8590c14a7 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2082,6 +2082,20 @@ describe("NativeOllamaHandler", () => { }) describe("per-request abortable transport", () => { + // Earlier tests in this file install a persistent global setTimeout spy + // (vi.spyOn; vi.clearAllMocks does not restore spy implementations), so + // restore the native mocks before each test to keep this suite + // order-independent. + beforeEach(() => { + vi.restoreAllMocks() + }) + + // Drop stubbed globals (the injected fetch) after each test so a fake + // transport cannot leak into later tests. + afterEach(() => { + vi.unstubAllGlobals() + }) + // The vi.fn() OllamaMock is untyped, so the constructor options read // from mock.calls need one documented narrow cast to reach the injected // per-request transport. @@ -2155,16 +2169,38 @@ describe("NativeOllamaHandler", () => { [Symbol.asyncIterator]: async function* () {}, }) await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + }) - vi.unstubAllGlobals() + it("should abort the per-request transport when the consumer stops iterating early", async () => { + const fetchSpy = heldOpenFetch() + vi.stubGlobal("fetch", fetchSpy) + mockChat.mockResolvedValue({ + message: { content: "Response" }, + abort: vi.fn(), + [Symbol.asyncIterator]: async function* () { + yield { message: { content: "first" } } + yield { message: { content: "second" } } + }, + }) + + // No metadata → no external abort bridge: finalization must still + // release the transport. + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const iterator = stream[Symbol.asyncIterator]() + await iterator.next() + + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat", {}) + // Finalization aborts the per-request controller, which must reject + // the held POST. Attach the expectation before the finalization + // triggers the rejection so it cannot surface as an unhandled + // rejection. + const released = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + await iterator.return?.(undefined) + await released }) it("should abort a held-open POST via the per-request transport when timeoutMs fires", async () => { - // Earlier tests in this file leave a persistent global setTimeout mock - // (vi.clearAllMocks does not restore spy implementations); the real - // 100ms timer is the point of this test, so restore native timers. - vi.restoreAllMocks() - const fetchSpy = heldOpenFetch() vi.stubGlobal("fetch", fetchSpy) @@ -2195,8 +2231,6 @@ describe("NativeOllamaHandler", () => { // completion surfaces the AbortError unmodified. rejectChat?.(new DOMException("This operation was aborted", "AbortError")) await expect(promise).rejects.toMatchObject({ name: "AbortError" }) - - vi.unstubAllGlobals() }) it("should preserve the SDK stream signal when merging it with the per-request signal", async () => { @@ -2239,8 +2273,6 @@ describe("NativeOllamaHandler", () => { // preserved-signal side of the merge). sdkController.abort() await expect(post).rejects.toMatchObject({ name: "AbortError" }) - - vi.unstubAllGlobals() }) }) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index e507868c89..dc781ae18d 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -123,6 +123,15 @@ describe("Ollama Fetcher", () => { expect(isSecureOllamaEndpoint("http://[::1]:11434")).toBe(true) }) + it("should only treat strict 127.0.0.0/8 IPv4 literals as safe", () => { + // Regression: the old /^127\./ prefix also matched DNS names such as + // 127.example.com, which would leak the key over cleartext HTTP. + expect(isSecureOllamaEndpoint("http://127.example.com:11434")).toBe(false) + expect(isSecureOllamaEndpoint("http://127.99.99.99:11434")).toBe(true) // rest of 127.0.0.0/8 + expect(isSecureOllamaEndpoint("http://127.0.0.255:11434")).toBe(true) // range edge + expect(isSecureOllamaEndpoint("http://1270.0.0.1:11434")).toBe(false) // 1270 is not a valid octet + }) + it("should treat any HTTPS endpoint as safe", () => { expect(isSecureOllamaEndpoint("https://ollama.example.com")).toBe(true) expect(isSecureOllamaEndpoint("https://10.0.0.5:11434")).toBe(true) @@ -139,6 +148,48 @@ describe("Ollama Fetcher", () => { }) describe("getOllamaModels", () => { + // Shared /api/tags and /api/show mock payloads. Each call returns fresh + // objects so tests cannot share mutable state. + const makeOllamaTagsPayload = (modelName: string) => ({ + models: [ + { + name: modelName, + model: modelName, + modified_at: "2025-06-03T09:23:22.610222878-04:00", + size: 14333928010, + digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", + details: { + family: "llama", + families: ["llama"], + format: "gguf", + parameter_size: "23.6B", + parent_model: "", + quantization_level: "Q4_K_M", + }, + }, + ], + }) + const makeOllamaShowPayload = () => ({ + license: "Mock License", + modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", + parameters: "num_ctx 4096\nstop_token ", + template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", + modified_at: "2025-06-03T09:23:22.610222878-04:00", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: ["llama"], + parameter_size: "23.6B", + quantization_level: "Q4_K_M", + }, + model_info: { + "ollama.context_length": 4096, + "some.other.info": "value", + }, + capabilities: ["completion", "tools"], // Has tools capability + }) + it("should omit the Authorization header for a remote HTTP endpoint", async () => { const baseUrl = "http://ollama.example.com:11434" const apiKey = "test-api-key-123" @@ -167,154 +218,45 @@ describe("Ollama Fetcher", () => { expect(result).toEqual({}) }) - it("should send the Authorization header and disable proxy routing for a loopback HTTP endpoint", async () => { - const baseUrl = "http://localhost:11434" - const apiKey = "test-api-key-123" - const modelName = "devstral2to16:latest" - - const mockApiTagsResponse = { - models: [ - { - name: modelName, - model: modelName, - modified_at: "2025-06-03T09:23:22.610222878-04:00", - size: 14333928010, - digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", - details: { - family: "llama", - families: ["llama"], - format: "gguf", - parameter_size: "23.6B", - parent_model: "", - quantization_level: "Q4_K_M", - }, - }, - ], - } - const mockApiShowResponse = { - license: "Mock License", - modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", - parameters: "num_ctx 4096\nstop_token ", - template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", - modified_at: "2025-06-03T09:23:22.610222878-04:00", - details: { - parent_model: "", - format: "gguf", - family: "llama", - families: ["llama"], - parameter_size: "23.6B", - quantization_level: "Q4_K_M", - }, - model_info: { - "ollama.context_length": 4096, - "some.other.info": "value", - }, - capabilities: ["completion", "tools"], // Has tools capability - } - - mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) - mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse }) - - const result = await getOllamaModels(baseUrl, apiKey) - - // A cleartext loopback request carrying the bearer token must not be - // routed through any HTTP proxy, otherwise the proxy would see the - // credential in cleartext (CWE-319). Both requests bypass the proxy. - const expectedHeaders = { Authorization: `Bearer ${apiKey}` } - expect(mockedAxios.get).toHaveBeenCalledTimes(1) - expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { - headers: expectedHeaders, - proxy: false, - }) - - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - expect(mockedAxios.post).toHaveBeenCalledWith( - `${baseUrl}/api/show`, - { model: modelName }, - { + it.each(["http://localhost:11434", "http://[::1]:11434"])( + "should send the Authorization header and disable proxy routing for a loopback endpoint %s", + async (baseUrl) => { + const apiKey = "test-api-key-123" + const modelName = "devstral2to16:latest" + + mockedAxios.get.mockResolvedValueOnce({ data: makeOllamaTagsPayload(modelName) }) + mockedAxios.post.mockResolvedValueOnce({ data: makeOllamaShowPayload() }) + + const result = await getOllamaModels(baseUrl, apiKey) + + // A cleartext loopback request carrying the bearer token must not + // be routed through any HTTP proxy, otherwise the proxy would see + // the credential in cleartext (CWE-319). Both requests bypass the + // proxy. The IPv6 loopback [::1] is treated the same as the IPv4 + // loopback: the credential is attached and proxy routing is + // disabled. + const expectedHeaders = { Authorization: `Bearer ${apiKey}` } + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: expectedHeaders, proxy: false, - }, - ) - - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(1) - expect(result[modelName]).toBeDefined() - }) - - it("should send the Authorization header and disable proxy routing for an IPv6 loopback endpoint", async () => { - const baseUrl = "http://[::1]:11434" - const apiKey = "test-api-key-123" - const modelName = "devstral2to16:latest" + }) - const mockApiTagsResponse = { - models: [ + expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockedAxios.post).toHaveBeenCalledWith( + `${baseUrl}/api/show`, + { model: modelName }, { - name: modelName, - model: modelName, - modified_at: "2025-06-03T09:23:22.610222878-04:00", - size: 14333928010, - digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", - details: { - family: "llama", - families: ["llama"], - format: "gguf", - parameter_size: "23.6B", - parent_model: "", - quantization_level: "Q4_K_M", - }, + headers: expectedHeaders, + proxy: false, }, - ], - } - const mockApiShowResponse = { - license: "Mock License", - modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", - parameters: "num_ctx 4096\nstop_token ", - template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", - modified_at: "2025-06-03T09:23:22.610222878-04:00", - details: { - parent_model: "", - format: "gguf", - family: "llama", - families: ["llama"], - parameter_size: "23.6B", - quantization_level: "Q4_K_M", - }, - model_info: { - "ollama.context_length": 4096, - "some.other.info": "value", - }, - capabilities: ["completion", "tools"], // Has tools capability - } - - mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) - mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse }) - - const result = await getOllamaModels(baseUrl, apiKey) - - // The IPv6 loopback [::1] is treated the same as the IPv4 loopback: - // the credential is attached and proxy routing is disabled. - const expectedHeaders = { Authorization: `Bearer ${apiKey}` } - expect(mockedAxios.get).toHaveBeenCalledTimes(1) - expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { - headers: expectedHeaders, - proxy: false, - }) - - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - expect(mockedAxios.post).toHaveBeenCalledWith( - `${baseUrl}/api/show`, - { model: modelName }, - { - headers: expectedHeaders, - proxy: false, - }, - ) + ) - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(1) - expect(result[modelName]).toBeDefined() - }) + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(1) + expect(result[modelName]).toBeDefined() + }, + ) it("should fetch model list from /api/tags and include models with tools capability", async () => { const baseUrl = "http://localhost:11434" @@ -535,48 +477,8 @@ describe("Ollama Fetcher", () => { const apiKey = "test-api-key-123" const modelName = "test-model:latest" - const mockApiTagsResponse = { - models: [ - { - name: modelName, - model: modelName, - modified_at: "2025-06-03T09:23:22.610222878-04:00", - size: 14333928010, - digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", - details: { - family: "llama", - families: ["llama"], - format: "gguf", - parameter_size: "23.6B", - parent_model: "", - quantization_level: "Q4_K_M", - }, - }, - ], - } - const mockApiShowResponse = { - license: "Mock License", - modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", - parameters: "num_ctx 4096\nstop_token ", - template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", - modified_at: "2025-06-03T09:23:22.610222878-04:00", - details: { - parent_model: "", - format: "gguf", - family: "llama", - families: ["llama"], - parameter_size: "23.6B", - quantization_level: "Q4_K_M", - }, - model_info: { - "ollama.context_length": 4096, - "some.other.info": "value", - }, - capabilities: ["completion", "tools"], // Has tools capability - } - - mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) - mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse }) + mockedAxios.get.mockResolvedValueOnce({ data: makeOllamaTagsPayload(modelName) }) + mockedAxios.post.mockResolvedValueOnce({ data: makeOllamaShowPayload() }) const result = await getOllamaModels(baseUrl, apiKey) diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 7e162a5f5b..a4bd42b28c 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -69,6 +69,9 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | * the norm. API keys are secrets, though, and must not be sent in cleartext to * a remote host (CWE-319). Only HTTPS or a loopback host is considered safe * enough to attach the Authorization header. + * + * Shared by the axios fetcher (getOllamaModels) and the native provider + * (NativeOllamaHandler), so this strict check gates credentials on both paths. */ export function isSecureOllamaEndpoint(baseUrl: string): boolean { if (!URL.canParse(baseUrl)) { @@ -79,7 +82,26 @@ export function isSecureOllamaEndpoint(baseUrl: string): boolean { return true } const host = url.hostname - return host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host) + return host === "localhost" || host === "::1" || host === "[::1]" || isIpv4LoopbackHost(host) +} + +/** + * Strict 127.0.0.0/8 IPv4-loopback literal check. `host` comes from + * `URL.hostname`, which is WHATWG-normalized (IPv4 literals carry no brackets + * and no leading zeros), so the anchored octet check is exact. A loose + * /^127\./ prefix would also match DNS names like `127.example.com`, letting + * the key leak over cleartext HTTP (CWE-319). + */ +function isIpv4LoopbackHost(host: string): boolean { + const octets = host.split(".") + if (octets.length !== 4) { + return false + } + if (!octets.every((octet) => /^\d{1,3}$/.test(octet))) { + return false + } + const values = octets.map((octet) => Number(octet)) + return values[0] === 127 && values.every((value) => value >= 0 && value <= 255) } export async function getOllamaModels( diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 403a879d9c..b9a7993dd5 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -647,6 +647,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio if (onExternalAbort) { externalAbortSignal?.removeEventListener("abort", onExternalAbort) } + // A consumer that stops iterating early (break/return) would otherwise + // leave the in-flight response body open, and when metadata.abortSignal + // is undefined no abort bridge exists at all. The per-request controller + // drives the injected fetch transport, so aborting it here releases the + // body; after normal completion the abort is a no-op. + requestController.abort() } } From 5e1b7199c7014870116123e1c53ff918e3a7416e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 3 Sep 2026 12:47:02 +0800 Subject: [PATCH 06/22] 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 95155b0df9bf2b06eab6d23f8471bfe5dca0389f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 4 Sep 2026 02:17:30 +0800 Subject: [PATCH 07/22] fix(api): harden native Ollama abort/timeout paths and add mutation-kill tests --- .../providers/__tests__/native-ollama.spec.ts | 337 +++++++++++++++++- .../fetchers/__tests__/ollama.test.ts | 29 +- src/api/providers/fetchers/ollama.ts | 6 +- src/api/providers/native-ollama.ts | 98 ++--- 4 files changed, 389 insertions(+), 81 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index c8590c14a7..4c16a15f10 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { NativeOllamaHandler } from "../native-ollama" +import { NativeOllamaHandler, raceWithAbortSignal } from "../native-ollama" import { ApiHandlerOptions } from "../../../shared/api" import { getOllamaModels } from "../fetchers/ollama" @@ -50,6 +50,18 @@ vitest.mock("../fetchers/ollama", async (importOriginal) => { const mockGetOllamaModels = vitest.mocked(getOllamaModels) +// Bound await helper: mutation runs time out at 5000ms per mutant, so a test +// that would hang under a mutant must fail fast instead. Capture the native +// setTimeout at module load so a lingering setTimeout spy cannot swallow it. +const nativeSetTimeout = globalThis.setTimeout +const withDeadline = async (promise: Promise, ms = 500): Promise => + Promise.race([ + promise, + new Promise((_resolve, reject) => { + nativeSetTimeout(() => reject(new Error(`withDeadline: unsettled after ${ms}ms`)), ms) + }), + ]) + describe("NativeOllamaHandler", () => { let handler: NativeOllamaHandler @@ -107,6 +119,43 @@ describe("NativeOllamaHandler", () => { expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 }) }) + it("should classify reasoning chunks by the think tag", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "secrethi" } } + }) + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const parts: { type: string; text: string }[] = [] + for await (const part of stream) { + parts.push(part as { type: string; text: string }) + } + const reasoning = parts + .filter((p) => p.type === "reasoning") + .map((p) => p.text) + .join("") + const text = parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("") + expect(reasoning).toBe("secret") + expect(text).toBe("hi") + }) + + it("should send the system prompt as the first message of the chat request", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + const stream = handler.createMessage("You are terse", [{ role: "user" as const, content: "Hi" }]) + const consume = (async () => { + for await (const _ of stream) { + // drain + } + })() + await withDeadline(consume, 500) + // documented narrow cast: mockChat is an untyped vi.fn() + const callArgs = mockChat.mock.calls[0][0] as { messages: { role: string; content: string }[] } + expect(callArgs.messages[0]).toEqual({ role: "system", content: "You are terse" }) + }) + it("should map tool_result array content to a concatenated string, flushing base64 images", async () => { mockChat.mockImplementation(async function* () { yield { message: { content: "ok" } } @@ -1014,6 +1063,35 @@ describe("NativeOllamaHandler", () => { expect(capturedDelay).toBe(5000) expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle) }) + + it("should wrap non-AbortError completion failures", async () => { + mockChat.mockRejectedValue(new Error("boom")) + const settledError = expect(handler.completePrompt("Test prompt")).rejects.toThrow( + "Ollama completion error: boom", + ) + await withDeadline(settledError, 500) + }) + + it("should detach the abort listener when completePrompt settles normally", async () => { + let abortCount = 0 + OllamaMock.mockImplementation(function (options?: { host?: string }) { + return { + chat: mockChat, + abort: () => { + abortCount++ + }, + _host: options?.host ?? "http://localhost:11434", + } + }) + mockChat.mockResolvedValue({ message: { content: "Response" } }) + const controller = new AbortController() + await handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + expect(abortCount).toBe(0) + // The listener is detached in the finally block, so a late abort must + // not reach the per-request client. + controller.abort() + expect(abortCount).toBe(0) + }) }) it("should send think parameter in completePrompt when reasoningEffort is set", async () => { @@ -1160,6 +1238,26 @@ describe("NativeOllamaHandler", () => { } }).rejects.toThrow("something else") }) + it("should rethrow stream AbortError without logging it", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + mockChat.mockImplementation(async function* () { + yield* [] + throw new DOMException("This operation was aborted", "AbortError") + }) + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const consume = (async () => { + for await (const _ of stream) { + // drain + } + })() + try { + const settledStream = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledStream, 500) + } finally { + consoleError.mockRestore() + } + expect(consoleError).not.toHaveBeenCalled() + }) }) describe("getModel", () => { @@ -1913,7 +2011,8 @@ describe("NativeOllamaHandler", () => { // race must reject on the timeout WITHOUT waiting for the held fetch // to settle. capturedFn?.() - await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + const settledTimeoutRace = expect(promise).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledTimeoutRace, 500) expect(mockChat).not.toHaveBeenCalled() // Settle the held fetch after the assertion so no promise is left @@ -2079,6 +2178,50 @@ describe("NativeOllamaHandler", () => { }), ) }) + + it("should default the client host to localhost when no base URL is configured", async () => { + const bareHandler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + }) + mockChat.mockResolvedValue({ message: { content: "Response" } }) + await bareHandler.completePrompt("Test prompt") + expect(OllamaMock.mock.calls[0][0]).toEqual(expect.objectContaining({ host: "http://localhost:11434" })) + }) + }) + + describe("raceWithAbortSignal", () => { + it("should resolve with the pending result when no signal is given", async () => { + const result = await raceWithAbortSignal(Promise.resolve("value"), undefined) + expect(result).toBe("value") + }) + + it("should reject with AbortError when the signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + const pending = new Promise(() => {}) + const settledPre = expect(raceWithAbortSignal(pending, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + await withDeadline(settledPre, 500) + }) + + it("should reject with AbortError when the signal aborts before the promise settles", async () => { + const controller = new AbortController() + const pending = new Promise(() => {}) + const raced = raceWithAbortSignal(pending, controller.signal) + controller.abort() + const settledAbort = expect(raced).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledAbort, 500) + }) + + it("should resolve when the promise settles before the signal aborts", async () => { + const controller = new AbortController() + const raced = raceWithAbortSignal(Promise.resolve("done"), controller.signal) + const result = await withDeadline(raced, 500) + expect(result).toBe("done") + controller.abort() // a late abort must not disturb the settled result + }) }) describe("per-request abortable transport", () => { @@ -2159,7 +2302,8 @@ describe("NativeOllamaHandler", () => { controller.abort() // The held POST is aborted with the response never released. - await expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + const settledHeldPost = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledHeldPost, 500) // Prove the dispose path: a stream that resolves after cancellation // is disposed, not consumed. @@ -2168,7 +2312,8 @@ describe("NativeOllamaHandler", () => { abort: vi.fn(), [Symbol.asyncIterator]: async function* () {}, }) - await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + const settledConsume = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledConsume, 500) }) it("should abort the per-request transport when the consumer stops iterating early", async () => { @@ -2197,7 +2342,7 @@ describe("NativeOllamaHandler", () => { // rejection. const released = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) await iterator.return?.(undefined) - await released + await withDeadline(released, 500) }) it("should abort a held-open POST via the per-request transport when timeoutMs fires", async () => { @@ -2225,12 +2370,14 @@ describe("NativeOllamaHandler", () => { // The real 100ms timer aborts the per-request signal, which must // reject the held POST. - await expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + const settledTimeoutPost = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledTimeoutPost, 500) // Mirror the real SDK: the in-flight POST rejects, and the // completion surfaces the AbortError unmodified. rejectChat?.(new DOMException("This operation was aborted", "AbortError")) - await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + const settledTimeoutPromise = expect(promise).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledTimeoutPromise, 500) }) it("should preserve the SDK stream signal when merging it with the per-request signal", async () => { @@ -2272,7 +2419,130 @@ describe("NativeOllamaHandler", () => { // The SDK-side internal controller must still cancel the POST (the // preserved-signal side of the merge). sdkController.abort() - await expect(post).rejects.toMatchObject({ name: "AbortError" }) + const settledMergedPost = expect(post).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledMergedPost, 500) + }) + + it("should abort the injected transport when the SDK fetch call carries no init signal", async () => { + const fetchSpy = heldOpenFetch() + vi.stubGlobal("fetch", fetchSpy) + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + for (let i = 0; i < 50 && mockChat.mock.calls.length === 0; i++) { + await Promise.resolve() + } + // Consume the stream to completion; the finally block aborts the + // per-request controller, which must release the held transport. + const consume = (async () => { + for await (const _ of stream) { + // drain + } + })() + await withDeadline(consume, 500) + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat") + const settledPost = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPost, 500) + }) + + it("should abort the per-request transport when completePrompt's abortSignal is already aborted", async () => { + const fetchSpy = heldOpenFetch() + vi.stubGlobal("fetch", fetchSpy) + + // Count the per-request client's abort() via a local spy factory, + // mirroring the capturedInstanceAbort pattern (scoped to the + // completePrompt describe, so not reachable from here). + let instanceAbortCount = 0 + OllamaMock.mockImplementation(function (options?: { host?: string }) { + return { + chat: mockChat, + abort: () => { + instanceAbortCount++ + }, + _host: options?.host ?? "http://localhost:11434", + } + }) + + const controller = new AbortController() + controller.abort() + const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + const settledPromise = expect(promise).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPromise, 500) + expect(mockChat).not.toHaveBeenCalled() + expect(instanceAbortCount).toBe(1) + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat") + const settledPost = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPost, 500) + }) + + it("should abort the per-request transport when completePrompt's abortSignal fires during discovery", async () => { + const fetchSpy = heldOpenFetch() + vi.stubGlobal("fetch", fetchSpy) + let resolveFetch: (() => void) | undefined + + // Hold model discovery open so the abort lands before the chat request. + mockGetOllamaModels.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => resolve({}) + }), + ) + + const controller = new AbortController() + const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + for (let i = 0; i < 50 && OllamaMock.mock.calls.length === 0; i++) { + await Promise.resolve() + } + controller.abort() + const settledPromise = expect(promise).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPromise, 500) + resolveFetch?.() + expect(mockChat).not.toHaveBeenCalled() + const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions + const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat") + const settledPost = expect(heldPost).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPost, 500) + }) + + it("should abort the per-request client only once when the external abort fires before the timeout", async () => { + let abortCount = 0 + OllamaMock.mockImplementation(function (options?: { host?: string }) { + return { + chat: mockChat, + abort: () => { + abortCount++ + }, + _host: options?.host ?? "http://localhost:11434", + } + }) + let resolveFetch: (() => void) | undefined + + // Hold model discovery open so the external abort lands while the + // per-request client is still in flight. + mockGetOllamaModels.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => resolve({}) + }), + ) + + const controller = new AbortController() + const promise = handler.completePrompt("Test prompt", { timeoutMs: 100, abortSignal: controller.signal }) + for (let i = 0; i < 50 && OllamaMock.mock.calls.length === 0; i++) { + await Promise.resolve() + } + controller.abort() + const settledPromise = expect(promise).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPromise, 500) + // The external-abort path must be the only abort: the cleared timer must + // not fire a second client.abort() afterwards. + expect(abortCount).toBe(1) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(abortCount).toBe(1) + resolveFetch?.() }) }) @@ -2287,11 +2557,16 @@ describe("NativeOllamaHandler", () => { makeCreateMessageMetadata({ abortSignal: controller.signal }), ) - await expect(async () => { + const settledPreAbort = expect(async () => { for await (const _ of stream) { // consume stream } }).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPreAbort, 500) + + // The pre-abort path must reject before allocating the per-request client. + expect(OllamaMock).not.toHaveBeenCalled() + expect(mockChat).not.toHaveBeenCalled() }) it("should abort the in-flight request when external abortSignal fires", async () => { @@ -2336,7 +2611,40 @@ describe("NativeOllamaHandler", () => { controller.abort() - await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + const settledInFlight = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledInFlight, 500) + }) + + it("should detach the abort listener when the stream settles normally", async () => { + let abortCount = 0 + OllamaMock.mockImplementation(function (options?: { host?: string }) { + return { + chat: mockChat, + abort: () => { + abortCount++ + }, + _host: options?.host ?? "http://localhost:11434", + } + }) + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + const controller = new AbortController() + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "Test" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + const consume = (async () => { + for await (const _ of stream) { + // drain + } + })() + await withDeadline(consume, 500) + // The listener must be detached in the finally block, so a late abort + // after settlement must not reach the per-request client. + controller.abort() + expect(abortCount).toBe(0) }) it("should reject with AbortError when the abort signal fires during model discovery", async () => { @@ -2380,7 +2688,8 @@ describe("NativeOllamaHandler", () => { // Abort while the model list is still being fetched. The race must // reject the generator with AbortError before chat() is attempted. controller.abort() - await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + const settledDiscovery = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledDiscovery, 500) expect(mockChat).not.toHaveBeenCalled() expect(mockGetOllamaModels).toHaveBeenCalledTimes(1) expect(clientAborted).toBe(true) @@ -2422,7 +2731,8 @@ describe("NativeOllamaHandler", () => { } })() - await expect(consume).rejects.toMatchObject({ name: "AbortError" }) + const settledDispose = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledDispose, 500) expect(streamAborted).toBe(true) expect(consumed).toBe(false) }) @@ -2439,11 +2749,12 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) - await expect(async () => { + const settledPassthrough = expect(async () => { for await (const _ of stream) { // consume stream } }).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledPassthrough, 500) }) }) }) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index dc781ae18d..b2db91eff9 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -1,6 +1,6 @@ import axios from "axios" -import { getOllamaModels, isSecureOllamaEndpoint, parseOllamaModel } from "../ollama" +import { getOllamaModels, isIpv4LoopbackHost, isSecureOllamaEndpoint, parseOllamaModel } from "../ollama" import ollamaModelsData from "./fixtures/ollama-model-details.json" // Mock axios @@ -130,6 +130,8 @@ describe("Ollama Fetcher", () => { expect(isSecureOllamaEndpoint("http://127.99.99.99:11434")).toBe(true) // rest of 127.0.0.0/8 expect(isSecureOllamaEndpoint("http://127.0.0.255:11434")).toBe(true) // range edge expect(isSecureOllamaEndpoint("http://1270.0.0.1:11434")).toBe(false) // 1270 is not a valid octet + expect(isSecureOllamaEndpoint("http://127.256.0.1:11434")).toBe(false) + expect(isSecureOllamaEndpoint("http://one.two.three.four:11434")).toBe(false) }) it("should treat any HTTPS endpoint as safe", () => { @@ -147,6 +149,31 @@ describe("Ollama Fetcher", () => { }) }) + describe("isIpv4LoopbackHost", () => { + it("should accept only four-octet 127/8 literals", () => { + expect(isIpv4LoopbackHost("127.0.0.1")).toBe(true) + expect(isIpv4LoopbackHost("127.255.255.255")).toBe(true) + expect(isIpv4LoopbackHost("0.0.0.1")).toBe(false) + }) + + it("should reject hosts with the wrong octet count", () => { + expect(isIpv4LoopbackHost("127.0.0")).toBe(false) + expect(isIpv4LoopbackHost("127.0.0.0.1")).toBe(false) + }) + + it("should reject non-numeric octets even when they parse as numbers", () => { + // Number("0x4") === 4 and Number("1e2") === 100, so only the anchored + // octet regex rejects these inputs. + expect(isIpv4LoopbackHost("127.0.0.0x4")).toBe(false) + expect(isIpv4LoopbackHost("127.1e2.0.1")).toBe(false) + }) + + it("should reject out-of-range octets", () => { + expect(isIpv4LoopbackHost("127.999.0.1")).toBe(false) + expect(isIpv4LoopbackHost("0127.0.0.1")).toBe(false) + }) + }) + describe("getOllamaModels", () => { // Shared /api/tags and /api/show mock payloads. Each call returns fresh // objects so tests cannot share mutable state. diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index a4bd42b28c..2b4888c31f 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -82,7 +82,7 @@ export function isSecureOllamaEndpoint(baseUrl: string): boolean { return true } const host = url.hostname - return host === "localhost" || host === "::1" || host === "[::1]" || isIpv4LoopbackHost(host) + return host === "localhost" || host === "[::1]" || isIpv4LoopbackHost(host) } /** @@ -92,7 +92,7 @@ export function isSecureOllamaEndpoint(baseUrl: string): boolean { * /^127\./ prefix would also match DNS names like `127.example.com`, letting * the key leak over cleartext HTTP (CWE-319). */ -function isIpv4LoopbackHost(host: string): boolean { +export function isIpv4LoopbackHost(host: string): boolean { const octets = host.split(".") if (octets.length !== 4) { return false @@ -101,7 +101,7 @@ function isIpv4LoopbackHost(host: string): boolean { return false } const values = octets.map((octet) => Number(octet)) - return values[0] === 127 && values.every((value) => value >= 0 && value <= 255) + return values[0] === 127 && values.every((value) => value <= 255) } export async function getOllamaModels( diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index b9a7993dd5..406b4c22b5 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -41,7 +41,7 @@ function createAbortError(): Error { * signal, so the underlying request is left to settle in the background * rather than being cancelled. */ -function raceWithAbortSignal(pending: Promise, signal: AbortSignal | undefined): Promise { +export function raceWithAbortSignal(pending: Promise, signal: AbortSignal | undefined): Promise { if (!signal) { return pending } @@ -49,20 +49,8 @@ function raceWithAbortSignal(pending: Promise, signal: AbortSignal | undef return Promise.reject(createAbortError()) } return new Promise((resolve, reject) => { - const onAbort = () => { - reject(createAbortError()) - } - signal.addEventListener("abort", onAbort, { once: true }) - pending.then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (error) => { - signal.removeEventListener("abort", onAbort) - reject(error) - }, - ) + signal.addEventListener("abort", () => reject(createAbortError())) + pending.then(resolve, reject) }) } @@ -281,7 +269,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio * The per-request transport makes both the in-flight POST and the response * body cancellable; see the inline rationale. */ - private _createOllamaClient(requestSignal?: AbortSignal): Ollama { + private _createOllamaClient(requestSignal: AbortSignal): Ollama { const clientOptions: OllamaOptions = { host: this.options.ollamaBaseUrl || "http://localhost:11434", // Note: The ollama npm package handles timeouts internally @@ -299,16 +287,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // The Ollama SDK (0.6.x) only tracks streaming iterators after the POST // resolves and passes no signal for stream:false requests, so client - // .abort() alone cannot cancel an in-flight POST. Inject a per-request - // transport that merges our cancellation signal into every fetch while - // preserving the SDK's own stream signal when present (AbortSignal.any - // honors either side). - if (requestSignal) { - const baseFetch = globalThis.fetch - clientOptions.fetch = (url, init) => { - const signal = init?.signal ? AbortSignal.any([init.signal, requestSignal]) : requestSignal - return baseFetch(url, { ...init, signal }) - } + // .abort() alone cannot cancel an in-flight POST. The client always + // receives a per-request transport that merges the request signal into + // the SDK's fetch calls while preserving the SDK's own stream signal when + // present (AbortSignal.any honors either side). + const baseFetch = globalThis.fetch + clientOptions.fetch = (url, init) => { + const signal = init?.signal ? AbortSignal.any([init.signal, requestSignal]) : requestSignal + return baseFetch(url, { ...init, signal }) } return new Ollama(clientOptions) @@ -456,24 +442,19 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Per-request client (SDK doesn't support per-call signal). The - // per-request controller drives the abortable transport injected into - // the client, so an in-flight POST is cancellable as well. - const requestController = new AbortController() - const client = this._createOllamaClient(requestController.signal) - - // Bridge the external abort signal into the per-request client and the - // per-request transport. The Ollama SDK exposes cancellation only - // through the client-level abort() and the stream's own signal, so the - // external signal drives both of this same request instead of a - // separate internal controller. + // The external abort signal drives both the per-request client and the + // per-request transport for this request, since the Ollama SDK exposes + // cancellation only through the client-level abort() and the fetch signal. const externalAbortSignal = metadata?.abortSignal if (externalAbortSignal?.aborted) { - requestController.abort() - client.abort() + // The request is already cancelled: reject before allocating the + // per-request client or transport so nothing is left to clean up. throw createAbortError() } + const requestController = new AbortController() + const client = this._createOllamaClient(requestController.signal) + let onExternalAbort: (() => void) | undefined try { if (externalAbortSignal) { @@ -481,13 +462,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio requestController.abort() client.abort() } - externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) + externalAbortSignal.addEventListener("abort", onExternalAbort) } // Race model discovery against the external signal: the SDK's model // fetch accepts no signal, so a request aborted during discovery must // reject instead of proceeding to chat() with a stale model. const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), externalAbortSignal) + // Stryker disable next-line MethodExpression,StringLiteral: DEEP_SEEK_DEFAULT_TEMPERATURE (0.0) is numerically equal to the 0 fallback, so the R1 check cannot change the emitted temperature const useR1Format = modelId.toLowerCase().includes("deepseek-r1") const ollamaMessages: Message[] = [ @@ -644,8 +626,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } finally { // Detach the external-signal listener so it cannot outlive this request, // including when model discovery rejects before the chat request starts. - if (onExternalAbort) { - externalAbortSignal?.removeEventListener("abort", onExternalAbort) + // Stryker disable next-line LogicalOperator: onExternalAbort is only assigned when externalAbortSignal is present, so || is equivalent + if (onExternalAbort && externalAbortSignal) { + externalAbortSignal.removeEventListener("abort", onExternalAbort) } // A consumer that stops iterating early (break/return) would otherwise // leave the in-flight response body open, and when metadata.abortSignal @@ -680,16 +663,13 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const client = this._createOllamaClient(requestController.signal) let timeoutId: ReturnType | undefined let onAbort: (() => void) | undefined - // Set when the timeoutMs timer fires so a model-discovery fetch still in - // flight is rejected instead of proceeding to chat() past the deadline. - let timedOut = false + const abortSignal = options?.abortSignal try { // Handle timeoutMs if provided (client already exists above, so the // timer can abort both the per-request transport and the client) if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { timeoutId = setTimeout(() => { - timedOut = true requestController.abort() client.abort() }, options.timeoutMs) @@ -699,8 +679,8 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // and the per-request transport. Set this up before fetchModel() so an // already-aborted signal (or one that aborts during the model-list // fetch) is honored before the request proceeds. - if (options?.abortSignal) { - if (options.abortSignal.aborted) { + if (abortSignal) { + if (abortSignal.aborted) { requestController.abort() client.abort() throw createAbortError() @@ -708,11 +688,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio onAbort = () => { requestController.abort() client.abort() - if (timeoutId !== undefined) { - clearTimeout(timeoutId) - } + clearTimeout(timeoutId) } - options.abortSignal.addEventListener("abort", onAbort, { once: true }) + abortSignal.addEventListener("abort", onAbort, { once: true }) } } @@ -724,13 +702,6 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const discoverySignal = mergeAbortSignalAndTimeout(requestController.signal, options?.timeoutMs) const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), discoverySignal) - // Re-check after the model-list fetch: if the request aborted or timed - // out during fetchModel(), the listener/timer above already aborted the - // client, and the request must reject instead of proceeding to chat(). - if (timedOut || options?.abortSignal?.aborted) { - throw createAbortError() - } - const useR1Format = modelId.toLowerCase().includes("deepseek-r1") // Reuse the shared request-option builder so single-shot @@ -755,11 +726,10 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } throw error } finally { - if (timeoutId) { - clearTimeout(timeoutId) - } - if (onAbort && options?.abortSignal) { - options.abortSignal.removeEventListener("abort", onAbort) + clearTimeout(timeoutId) + // Stryker disable next-line LogicalOperator: onAbort is only assigned when abortSignal is present, so || is equivalent + if (onAbort && abortSignal) { + abortSignal.removeEventListener("abort", onAbort) } } } From 192059d0b557e469a504cef648020335df5f4cb0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 4 Sep 2026 02:38:33 +0800 Subject: [PATCH 08/22] fix(api): kill remaining Ollama mutation survivors (thought tag, AbortError rethrow, redundant timeout clear) --- .../providers/__tests__/native-ollama.spec.ts | 28 ++++++++++++++++++- src/api/providers/native-ollama.ts | 4 ++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 4c16a15f10..ffcba936c3 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -140,6 +140,30 @@ describe("NativeOllamaHandler", () => { expect(text).toBe("hi") }) + // The matcher only opens a top-level tag at the start of the stream + // (TagMatcher position 0), so the thought tag needs its own stream to + // exercise the second entry of the tag list. + it("should classify reasoning chunks by the thought tag when it opens the stream", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "plandone" } } + }) + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const parts: { type: string; text: string }[] = [] + for await (const part of stream) { + parts.push(part as { type: string; text: string }) + } + const reasoning = parts + .filter((p) => p.type === "reasoning") + .map((p) => p.text) + .join("") + const text = parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("") + expect(reasoning).toBe("plan") + expect(text).toBe("done") + }) + it("should send the system prompt as the first message of the chat request", async () => { mockChat.mockImplementation(async function* () { yield { message: { content: "ok" } } @@ -1253,10 +1277,12 @@ describe("NativeOllamaHandler", () => { try { const settledStream = expect(consume).rejects.toMatchObject({ name: "AbortError" }) await withDeadline(settledStream, 500) + // Assert before mockRestore(): restoring the spy resets its call + // history, which would make the assertion below a no-op. + expect(consoleError).not.toHaveBeenCalled() } finally { consoleError.mockRestore() } - expect(consoleError).not.toHaveBeenCalled() }) }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 406b4c22b5..e6d7dc73c1 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -688,7 +688,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio onAbort = () => { requestController.abort() client.abort() - clearTimeout(timeoutId) + // The per-request timeout is cleared by the finally block when this prompt + // settles; an external abort always settles it promptly (the discovery race + // or the injected transport rejects), so no timer clear is needed here. } abortSignal.addEventListener("abort", onAbort, { once: true }) } From d2d0cbf23844d87e5827a2afb802abd08501493c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 03:08:40 +0800 Subject: [PATCH 09/22] chore: sync pnpm lockfile with root vitest devDependency --- 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 8cb94a5f1c9511f4a2fdd81aa420f8b5dd9dff09 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 03:12:21 +0800 Subject: [PATCH 10/22] test: replace double assertions in timer fakes with single structural casts --- .../providers/__tests__/native-ollama.spec.ts | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index ffcba936c3..cc1665d9b9 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -923,13 +923,21 @@ describe("NativeOllamaHandler", () => { }) // The timer id is never consumed (the callback is captured and fired - // manually), so bridge the ambient setTimeout signature through unknown. + // manually), so the fake returns a structurally-typed handle and a + // single cast bridges the ambient setTimeout signature. + const timeoutHandle = { + hasRef: () => false, + ref: () => timeoutHandle, + refresh: () => timeoutHandle, + unref: () => timeoutHandle, + [Symbol.toPrimitive]: () => 1, + } as NodeJS.Timeout vitest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void, ms?: number) => { if (ms === testTimeout) { capturedFn = fn } - return 0 - }) as unknown as typeof setTimeout) + return timeoutHandle + }) as typeof setTimeout) mockChat.mockResolvedValue({ message: { content: "Response" }, @@ -1029,9 +1037,16 @@ describe("NativeOllamaHandler", () => { it("should remove abort listener and clear timeout when abortSignal fires", async () => { const controller = new AbortController() - // The timer id is never consumed directly (clearTimeout is mocked in these - // tests), so bridge the ambient setTimeout return type through unknown. - const timeoutHandle = 1 as unknown as ReturnType + // The timer id is only compared by identity here (clearTimeout is spied), + // so satisfy the ambient NodeJS.Timeout structurally with a single cast + // instead of double-casting a number. + const timeoutHandle = { + hasRef: () => false, + ref: () => timeoutHandle, + refresh: () => timeoutHandle, + unref: () => timeoutHandle, + [Symbol.toPrimitive]: () => 1, + } as NodeJS.Timeout const clearTimeoutSpy = vitest.spyOn(global, "clearTimeout").mockImplementation(() => {}) vitest.spyOn(global, "setTimeout").mockImplementation(() => timeoutHandle) const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") @@ -1064,16 +1079,23 @@ describe("NativeOllamaHandler", () => { it("should clear timeoutId in finally block on success", async () => { let capturedDelay: number | undefined - // The timer id is never consumed directly (clearTimeout is mocked in these - // tests), so bridge the ambient setTimeout return type through unknown. - const timeoutHandle = 1 as unknown as ReturnType + // The timer id is only compared by identity here (clearTimeout is spied), + // so satisfy the ambient NodeJS.Timeout structurally with a single cast + // instead of double-casting a number. + const timeoutHandle = { + hasRef: () => false, + ref: () => timeoutHandle, + refresh: () => timeoutHandle, + unref: () => timeoutHandle, + [Symbol.toPrimitive]: () => 1, + } as NodeJS.Timeout vitest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void, ms?: number) => { if (ms === 5000) { capturedDelay = ms } return timeoutHandle - }) as unknown as typeof setTimeout) + }) as typeof setTimeout) const clearTimeoutSpy = vitest.spyOn(global, "clearTimeout").mockImplementation(() => {}) @@ -2022,13 +2044,21 @@ describe("NativeOllamaHandler", () => { ) // The timer id is never consumed (the callback is captured and fired - // manually), so bridge the ambient setTimeout signature through unknown. + // manually), so the fake returns a structurally-typed handle and a + // single cast bridges the ambient setTimeout signature. + const timeoutHandle = { + hasRef: () => false, + ref: () => timeoutHandle, + refresh: () => timeoutHandle, + unref: () => timeoutHandle, + [Symbol.toPrimitive]: () => 1, + } as NodeJS.Timeout vitest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void, ms?: number) => { if (ms === testTimeout) { capturedFn = fn } - return 0 - }) as unknown as typeof setTimeout) + return timeoutHandle + }) as typeof setTimeout) const promise = handler.completePrompt("Test prompt", { timeoutMs: testTimeout }) expect(capturedFn).toBeDefined() From 37dbd628a2b7db2c183fd044ebaf299ce3561592 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 03:24:16 +0800 Subject: [PATCH 11/22] chore: bump root vitest to 4.1.11 to clear dependency-review finding --- 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 4389c9c87bd228f95f4f1bca40f085137117a68f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 23:53:16 +0800 Subject: [PATCH 12/22] fix(native-ollama): detach abort listener, cancel discovery, align vitest peers - raceWithAbortSignal: remove the abort listener once pending settles (resolve and reject), so a reusable caller signal does not accumulate one listener per race - fetchModel: forward the signal to getOllamaModels (both /api/tags and /api/show) from the createMessage and completePrompt discovery call sites; the race remains the backstop - spec: the mid-request abort test now asserts the promise rejects with AbortError instead of resolving with a stale response; add listener-removal tests for raceWithAbortSignal and signal-forwarding assertions for both discovery call sites - align @vitest/coverage-v8 and @vitest/ui (4.1.9) with vitest 4.1.11 in the workspace manifests so the peer versions match --- apps/cli/package.json | 2 +- packages/cloud/package.json | 2 +- packages/core/package.json | 2 +- packages/telemetry/package.json | 2 +- pnpm-lock.yaml | 108 ++++++++---------- .../providers/__tests__/native-ollama.spec.ts | 58 +++++++++- src/api/providers/native-ollama.ts | 52 ++++++--- src/package.json | 2 +- webview-ui/package.json | 4 +- 9 files changed, 141 insertions(+), 91 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 6e769ebb72..4a6368d583 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,7 +43,7 @@ "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", "@types/react": "18.3.31", - "@vitest/coverage-v8": "4.1.9", + "@vitest/coverage-v8": "4.1.11", "ink-testing-library": "4.0.0", "rimraf": "6.0.1", "tsup": "8.5.1", diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 612b75afe9..2022ab5730 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -22,7 +22,7 @@ "@types/node": "22.20.1", "@types/vscode": "1.100.0", "globals": "16.5.0", - "@vitest/coverage-v8": "4.1.9", + "@vitest/coverage-v8": "4.1.11", "vitest": "4.1.11" } } diff --git a/packages/core/package.json b/packages/core/package.json index bcaa1e5c16..c180af7cc6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,7 @@ "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", - "@vitest/coverage-v8": "4.1.9", + "@vitest/coverage-v8": "4.1.11", "vitest": "4.1.11" } } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 045e5cb4c2..bcfae38fcb 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -21,7 +21,7 @@ "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", "@types/vscode": "1.100.0", - "@vitest/coverage-v8": "4.1.9", + "@vitest/coverage-v8": "4.1.11", "vitest": "4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5606c23b8..5d30dc95d5 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.11) '@types/node': specifier: 22.20.1 version: 22.20.1 @@ -76,7 +76,7 @@ importers: version: 5.9.3 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.11)(@vitest/ui@4.1.11)(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: @@ -139,8 +139,8 @@ importers: specifier: 18.3.31 version: 18.3.31 '@vitest/coverage-v8': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) ink-testing-library: specifier: 4.0.0 version: 4.0.0(@types/react@18.3.31) @@ -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.11)(@vitest/ui@4.1.11)(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) '@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.11)(@vitest/ui@4.1.11)(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: @@ -239,14 +239,14 @@ importers: specifier: 1.100.0 version: 1.100.0 '@vitest/coverage-v8': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) globals: specifier: 16.5.0 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.11)(@vitest/ui@4.1.11)(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: @@ -311,11 +311,11 @@ importers: specifier: 22.20.1 version: 22.20.1 '@vitest/coverage-v8': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(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.11)(@vitest/ui@4.1.11)(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: @@ -364,11 +364,11 @@ importers: specifier: 1.100.0 version: 1.100.0 '@vitest/coverage-v8': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(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.11)(@vitest/ui@4.1.11)(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.11)(@vitest/ui@4.1.11)(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.11)(@vitest/ui@4.1.11)(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: @@ -669,8 +669,8 @@ importers: specifier: 1.100.0 version: 1.100.0 '@vitest/coverage-v8': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) '@vscode/vsce': specifier: 3.9.2 version: 3.9.2 @@ -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.11)(@vitest/ui@4.1.11)(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: @@ -943,11 +943,11 @@ importers: specifier: 5.2.0 version: 5.2.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': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) '@vitest/ui': - specifier: 4.1.9 - version: 4.1.9(vitest@4.1.11) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -962,7 +962,7 @@ importers: version: 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: 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.11)(@vitest/ui@4.1.11)(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: @@ -3646,11 +3646,11 @@ packages: peerDependencies: vite: 8.1.0 - '@vitest/coverage-v8@4.1.9': - resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.9 - vitest: 4.1.9 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true @@ -3672,9 +3672,6 @@ packages: '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} @@ -3684,17 +3681,14 @@ packages: '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.9': - resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} peerDependencies: - vitest: 4.1.9 + vitest: 4.1.11 '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} - '@vscode/codicons@0.0.45': resolution: {integrity: sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==} @@ -9820,9 +9814,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)': 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.11)(@vitest/ui@4.1.11)(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 +11361,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.11)': 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.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(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: @@ -11933,10 +11927,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.9(vitest@4.1.11)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.4 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -11945,7 +11939,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)(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.11)(@vitest/ui@4.1.11)(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: @@ -11968,10 +11962,6 @@ snapshots: dependencies: tinyrainbow: 3.1.0 - '@vitest/pretty-format@4.1.9': - dependencies: - tinyrainbow: 3.1.0 - '@vitest/runner@4.1.11': dependencies: '@vitest/utils': 4.1.11 @@ -11986,16 +11976,16 @@ snapshots: '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.9(vitest@4.1.11)': + '@vitest/ui@4.1.11(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.11 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.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.11)(@vitest/ui@4.1.11)(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/utils@4.1.11': dependencies: @@ -12003,12 +11993,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vitest/utils@4.1.9': - dependencies: - '@vitest/pretty-format': 4.1.9 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@vscode/codicons@0.0.45': {} '@vscode/ripgrep-darwin-arm64@1.18.0': @@ -17742,7 +17726,7 @@ 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/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.11)(@vitest/ui@4.1.11)(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)) @@ -17767,8 +17751,8 @@ snapshots: 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.11) + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) jsdom: 26.1.0 transitivePeerDependencies: - msw diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index cc1665d9b9..473307014d 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -1035,7 +1035,7 @@ describe("NativeOllamaHandler", () => { expect(OllamaMock).toHaveBeenCalledWith(expect.objectContaining({ host: "http://localhost:11434" })) }) - it("should remove abort listener and clear timeout when abortSignal fires", async () => { + it("should reject with AbortError and remove the abort listener when abortSignal fires", async () => { const controller = new AbortController() // The timer id is only compared by identity here (clearTimeout is spied), // so satisfy the ambient NodeJS.Timeout structurally with a single cast @@ -1052,11 +1052,14 @@ describe("NativeOllamaHandler", () => { const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") - let resolveChat: (value: { message: { content: string } }) => void = () => {} + // A cancelled request must reject, not resolve: once the bridge + // aborts the per-request controller, the in-flight chat rejects with + // the canonical AbortError and the promise surfaces it unwrapped. + let rejectChat: (error: Error) => void = () => {} mockChat.mockImplementation( () => - new Promise((resolve) => { - resolveChat = resolve + new Promise((_resolve, reject) => { + rejectChat = reject }), ) @@ -1066,9 +1069,11 @@ describe("NativeOllamaHandler", () => { } controller.abort() - resolveChat({ message: { content: "Response" } }) + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + rejectChat(abortError) - await expect(promise).resolves.toBe("Response") + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle) // The listener removed in the finally block must be the exact function // that was registered, proving the same reference is detached. @@ -1077,6 +1082,19 @@ describe("NativeOllamaHandler", () => { expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registeredHandler) }) + it("should forward the abort signal to model discovery so discovery is cancellable", async () => { + const controller = new AbortController() + mockGetOllamaModels.mockResolvedValue({}) + mockChat.mockResolvedValue({ message: { content: "Response" } }) + + await handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + + // Discovery must be cancellable: the merged per-request/timeout + // signal is forwarded to the fetcher (not just raced against). + expect(mockGetOllamaModels).toHaveBeenCalledTimes(1) + expect(mockGetOllamaModels.mock.calls[0]?.[2]).toEqual({ signal: expect.any(AbortSignal) }) + }) + it("should clear timeoutId in finally block on success", async () => { let capturedDelay: number | undefined // The timer id is only compared by identity here (clearTimeout is spied), @@ -2278,6 +2296,31 @@ describe("NativeOllamaHandler", () => { expect(result).toBe("done") controller.abort() // a late abort must not disturb the settled result }) + + it("should remove the abort listener when pending settles, on resolve and reject", async () => { + const resolveController = new AbortController() + const resolveAddSpy = vitest.spyOn(resolveController.signal, "addEventListener") + const resolveRemoveSpy = vitest.spyOn(resolveController.signal, "removeEventListener") + const resolved = await raceWithAbortSignal(Promise.resolve("done"), resolveController.signal) + expect(resolved).toBe("done") + // The listener detached must be the exact reference that was + // registered, so a reused caller signal cannot keep it. + expect(resolveRemoveSpy).toHaveBeenCalledWith("abort", resolveAddSpy.mock.calls[0]?.[1]) + + const rejectController = new AbortController() + const rejectAddSpy = vitest.spyOn(rejectController.signal, "addEventListener") + const rejectRemoveSpy = vitest.spyOn(rejectController.signal, "removeEventListener") + const rejected = expect( + raceWithAbortSignal( + new Promise((_resolve, reject) => { + reject(new Error("boom")) + }), + rejectController.signal, + ), + ).rejects.toThrow("boom") + await withDeadline(rejected, 500) + expect(rejectRemoveSpy).toHaveBeenCalledWith("abort", rejectAddSpy.mock.calls[0]?.[1]) + }) }) describe("per-request abortable transport", () => { @@ -2748,6 +2791,9 @@ describe("NativeOllamaHandler", () => { await withDeadline(settledDiscovery, 500) expect(mockChat).not.toHaveBeenCalled() expect(mockGetOllamaModels).toHaveBeenCalledTimes(1) + // The external signal must reach the discovery fetch itself, so an + // aborted request is cancelled at the network level, not just raced. + expect(mockGetOllamaModels.mock.calls[0]?.[2]?.signal).toBe(controller.signal) expect(clientAborted).toBe(true) // Settle the held discovery fetch so no promise is left dangling. diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index e6d7dc73c1..4a10383579 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -37,9 +37,10 @@ function createAbortError(): Error { /** * Races `pending` against `signal`: if the signal aborts before the promise * settles, the returned promise rejects with an AbortError instead of - * resolving with a stale result. The Ollama model-discovery fetch accepts no - * signal, so the underlying request is left to settle in the background - * rather than being cancelled. + * resolving with a stale result. The abort listener is removed as soon as + * `pending` settles (in either direction), so a reusable caller signal does + * not accumulate one listener per race; a cancelled in-flight request still + * settles in the background. */ export function raceWithAbortSignal(pending: Promise, signal: AbortSignal | undefined): Promise { if (!signal) { @@ -49,8 +50,18 @@ export function raceWithAbortSignal(pending: Promise, signal: AbortSignal return Promise.reject(createAbortError()) } return new Promise((resolve, reject) => { - signal.addEventListener("abort", () => reject(createAbortError())) - pending.then(resolve, reject) + const onAbort = () => reject(createAbortError()) + signal.addEventListener("abort", onAbort) + pending.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) }) } @@ -465,10 +476,11 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio externalAbortSignal.addEventListener("abort", onExternalAbort) } - // Race model discovery against the external signal: the SDK's model - // fetch accepts no signal, so a request aborted during discovery must - // reject instead of proceeding to chat() with a stale model. - const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), externalAbortSignal) + // Race model discovery against the external signal: the discovery + // fetch receives the signal so an aborted request is cancelled at + // the network level, and the race rejects a request aborted during + // discovery instead of proceeding to chat() with a stale model. + const { id: modelId } = await raceWithAbortSignal(this.fetchModel(externalAbortSignal), externalAbortSignal) // Stryker disable next-line MethodExpression,StringLiteral: DEEP_SEEK_DEFAULT_TEMPERATURE (0.0) is numerically equal to the 0 fallback, so the R1 check cannot change the emitted temperature const useR1Format = modelId.toLowerCase().includes("deepseek-r1") @@ -639,8 +651,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } - async fetchModel() { - this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey) + /** + * Fetches the model list. The optional signal is forwarded to the + * discovery fetch (both /api/tags and /api/show) so an aborted or + * timed-out request is cancelled at the network level instead of + * settling in the background. + */ + async fetchModel(signal?: AbortSignal) { + this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey, { signal }) return this.getModel() } @@ -697,12 +715,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } // Model discovery goes through Axios (getOllamaModels) and never touches - // the Ollama client, so client.abort() alone cannot reject it. Race - // discovery against the per-request signal (bridged from the external - // abort) and the per-request timeout so a stalled discovery rejects - // instead of hanging past the deadline. + // the Ollama client, so client.abort() alone cannot reject it. The + // discovery fetch receives the per-request signal (bridged from the + // external abort, merged with the per-request timeout) so an aborted + // or timed-out request is cancelled at the network level; the race + // remains the backstop that rejects a stalled discovery instead of + // hanging past the deadline. const discoverySignal = mergeAbortSignalAndTimeout(requestController.signal, options?.timeoutMs) - const { id: modelId } = await raceWithAbortSignal(this.fetchModel(), discoverySignal) + const { id: modelId } = await raceWithAbortSignal(this.fetchModel(discoverySignal), discoverySignal) const useR1Format = modelId.toLowerCase().includes("deepseek-r1") diff --git a/src/package.json b/src/package.json index c142e02abb..052da196cb 100644 --- a/src/package.json +++ b/src/package.json @@ -557,7 +557,7 @@ "@types/ps-tree": "1.1.6", "@types/semver-compare": "1.0.3", "@types/vscode": "1.100.0", - "@vitest/coverage-v8": "4.1.9", + "@vitest/coverage-v8": "4.1.11", "@vscode/vsce": "3.9.2", "ai": "6.0.218", "esbuild-wasm": "0.28.1", diff --git a/webview-ui/package.json b/webview-ui/package.json index c5899c9dd5..34bd8044a4 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -100,8 +100,8 @@ "@types/stacktrace-js": "2.0.3", "@types/vscode-webview": "1.57.5", "@vitejs/plugin-react": "5.2.0", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "babel-plugin-react-compiler": "1.0.0", "jsdom": "26.1.0", "monocart-reporter": "^2.9.20", From d95082b60a2584cfdb92ea6959ae2f9aea83d8a7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 29 Sep 2026 23:04:04 +0800 Subject: [PATCH 13/22] fix(native-ollama): prove the forwarded discovery signal is linked to the caller's signal (CodeRabbit, R1, #1299) The "discovery is cancellable" test only asserted the shape of the forwarded third argument ({ signal: }), which a unrelated or never-aborted signal would also satisfy. Keep mockGetOllamaModels pending, capture the signal forwarded to the fetcher while discovery is still in flight, assert it is not aborted, abort the caller's controller, and assert the captured signal becomes aborted before the request settles (rejecting with AbortError). The linkage is now observed during execution, not after. 90/90 specs pass; eslint clean (suppressions unchanged). --- .../providers/__tests__/native-ollama.spec.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 473307014d..936922aeed 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -1084,15 +1084,31 @@ describe("NativeOllamaHandler", () => { it("should forward the abort signal to model discovery so discovery is cancellable", async () => { const controller = new AbortController() - mockGetOllamaModels.mockResolvedValue({}) + // Keep discovery pending so the linkage between the forwarded + // signal and the caller's signal is observable while discovery is + // still in flight (a settled call detaches the linkage). + mockGetOllamaModels.mockImplementation( + () => new Promise>>(() => {}), + ) mockChat.mockResolvedValue({ message: { content: "Response" } }) - await handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal }) + for (let i = 0; i < 10 && mockGetOllamaModels.mock.calls.length === 0; i++) { + await Promise.resolve() + } - // Discovery must be cancellable: the merged per-request/timeout - // signal is forwarded to the fetcher (not just raced against). + // Discovery must be cancellable: the per-request signal is forwarded + // to the fetcher (not just raced against). Aborting the caller's + // signal must abort the forwarded signal while discovery is pending. expect(mockGetOllamaModels).toHaveBeenCalledTimes(1) - expect(mockGetOllamaModels.mock.calls[0]?.[2]).toEqual({ signal: expect.any(AbortSignal) }) + const forwarded = mockGetOllamaModels.mock.calls[0]?.[2]?.signal + expect(forwarded).toBeInstanceOf(AbortSignal) + expect(forwarded?.aborted).toBe(false) + + controller.abort() + expect(forwarded?.aborted).toBe(true) + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) }) it("should clear timeoutId in finally block on success", async () => { From 4e318ebf2604c5d96fd29517e5426ae41bc5a203 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 10:59:04 +0800 Subject: [PATCH 14/22] Address review: caller-side transport cancellation coverage, credential help text, deps out - native-ollama.spec: pin the caller side of the transport merge. The new test aborts the caller's signal while the SDK's own fetch signal stays un-aborted and asserts the signal the transport handed to fetch becomes aborted (dropping requestSignal from the AbortSignal.any merge now fails this test, which previously escaped coverage). - fetchers/ollama help text (en locale): state that the API key is only sent to HTTPS or loopback endpoints, so remote HTTP users know the key is omitted rather than silently dropped. - Move the vitest 4.1.11 / @vitest/coverage-v8 bumps (and their lockfile churn) out of this unit per review; they go back up as their own dependency PR. Verification at this head: 120/120 green in native-ollama.spec.ts + fetchers/ollama.test.ts; tsc --noEmit 0 errors; eslint --max-warnings=0 clean on touched files; prettier clean. --- package.json | 3 +- pnpm-lock.yaml | 109 ++++++++++-------- .../providers/__tests__/native-ollama.spec.ts | 62 ++++++++++ src/package.json | 2 +- webview-ui/src/i18n/locales/en/settings.json | 2 +- 5 files changed, 126 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index b73c82bc92..759dfd6bee 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,7 @@ "rimraf": "6.0.1", "tsx": "4.22.4", "turbo": "2.10.0", - "typescript": "5.9.3", - "vitest": "4.1.11" + "typescript": "5.9.3" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,md}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4863f9f7d3..a8b3506c8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,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) + 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,9 +75,6 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 - vitest: - specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(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: @@ -140,8 +137,8 @@ importers: specifier: 18.3.31 version: 18.3.31 '@vitest/coverage-v8': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.11) ink-testing-library: specifier: 4.0.0 version: 4.0.0(@types/react@18.3.31) @@ -156,13 +153,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.11)(@vitest/ui@4.1.11)(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) + 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 @@ -213,7 +210,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.11)(@vitest/ui@4.1.11)(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: @@ -240,14 +237,14 @@ importers: specifier: 1.100.0 version: 1.100.0 '@vitest/coverage-v8': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.11) globals: specifier: 16.5.0 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.11)(@vitest/ui@4.1.11)(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: @@ -312,11 +309,11 @@ importers: specifier: 22.20.1 version: 22.20.1 '@vitest/coverage-v8': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + 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.11)(@vitest/ui@4.1.11)(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: @@ -365,11 +362,11 @@ importers: specifier: 1.100.0 version: 1.100.0 '@vitest/coverage-v8': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + 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.11)(@vitest/ui@4.1.11)(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: @@ -400,7 +397,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.11)(@vitest/ui@4.1.11)(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) @@ -418,7 +415,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.11)(@vitest/ui@4.1.11)(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: @@ -670,8 +667,8 @@ importers: specifier: 1.100.0 version: 1.100.0 '@vitest/coverage-v8': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.11) '@vscode/vsce': specifier: 3.9.2 version: 3.9.2 @@ -698,7 +695,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.11)(@vitest/ui@4.1.11)(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: @@ -944,11 +941,11 @@ importers: specifier: 5.2.0 version: 5.2.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': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.11) '@vitest/ui': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.11) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -963,7 +960,7 @@ importers: version: 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: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(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: @@ -3650,11 +3647,11 @@ packages: peerDependencies: vite: 8.1.0 - '@vitest/coverage-v8@4.1.11': - resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} peerDependencies: - '@vitest/browser': 4.1.11 - vitest: 4.1.11 + '@vitest/browser': 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -3676,6 +3673,9 @@ packages: '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} @@ -3685,14 +3685,17 @@ packages: '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.11': - resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} + '@vitest/ui@4.1.9': + resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} peerDependencies: - vitest: 4.1.11 + vitest: 4.1.9 '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vscode/codicons@0.0.45': resolution: {integrity: sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==} @@ -9844,9 +9847,9 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@copilotkit/aimock@1.35.0(vitest@4.1.11)': + '@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.11)(@vitest/ui@4.1.11)(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': {} @@ -11396,14 +11399,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)': + '@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.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(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: @@ -11962,10 +11965,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + '@vitest/coverage-v8@4.1.9(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.11 + '@vitest/utils': 4.1.9 ast-v8-to-istanbul: 1.0.4 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -11974,7 +11977,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.11)(@vitest/ui@4.1.11)(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)) '@vitest/expect@4.1.11': dependencies: @@ -11997,6 +12000,10 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@4.1.11': dependencies: '@vitest/utils': 4.1.11 @@ -12011,16 +12018,16 @@ snapshots: '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.11(vitest@4.1.11)': + '@vitest/ui@4.1.9(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.11 + '@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.11(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(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)) '@vitest/utils@4.1.11': dependencies: @@ -12028,6 +12035,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vscode/codicons@0.0.45': {} '@vscode/ripgrep-darwin-arm64@1.18.0': @@ -17777,7 +17790,7 @@ 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.11)(@vitest/ui@4.1.11)(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)): 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)) @@ -17802,8 +17815,8 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 22.20.1 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) - '@vitest/ui': 4.1.11(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.9(vitest@4.1.11) + '@vitest/ui': 4.1.9(vitest@4.1.11) jsdom: 26.1.0 transitivePeerDependencies: - msw diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 936922aeed..5c11f95e24 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2538,6 +2538,68 @@ describe("NativeOllamaHandler", () => { await withDeadline(settledMergedPost, 500) }) + it("should cancel the merged transport signal when the caller aborts while the SDK signal stays un-aborted", async () => { + let receivedSignal: AbortSignal | undefined + const fetchSpy = vi.fn((_url: string, init?: RequestInit) => { + receivedSignal = init?.signal ?? undefined + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("This operation was aborted", "AbortError")), + { once: true }, + ) + }) + }) + vi.stubGlobal("fetch", fetchSpy) + + // Keep chat() pending so the caller aborts while the request is in + // flight; the SDK's own fetch signal is never aborted in this scenario. + let resolveChat: ((value: { message: { content: string } }) => void) | undefined + mockChat.mockReturnValue( + new Promise<{ message: { content: string } }>((resolve) => { + resolveChat = resolve + }), + ) + + const controller = new AbortController() + // Attach the settlement handlers up front: the caller abort may reject the + // prompt (discovery race) or leave it pending on chat, and either outcome + // must be observed without an unhandled rejection. + const promptOutcome = handler.completePrompt("Test prompt", { abortSignal: controller.signal }).then( + (content) => ({ aborted: false as const, content }), + (error: unknown) => ({ aborted: true as const, error }), + ) + + // completePrompt constructs the per-request client synchronously. + const clientOptions = OllamaMock.mock.calls.at(-1)![0] as ConstructorOptions + expect(clientOptions.fetch).toBeTypeOf("function") + + const sdkController = new AbortController() + const post = clientOptions.fetch!("http://localhost:11434/api/chat", { signal: sdkController.signal }) + expect(receivedSignal).toBeInstanceOf(AbortSignal) + expect(receivedSignal).not.toBe(sdkController.signal) + expect(receivedSignal?.aborted).toBe(false) + + // Regression pin: caller-side cancellation must reach the transport + // even though the SDK's own signal stays un-aborted. Dropping + // requestSignal from the AbortSignal.any merge leaves this signal live + // and the POST pending forever. + controller.abort() + expect(receivedSignal?.aborted).toBe(true) + await withDeadline(expect(post).rejects.toMatchObject({ name: "AbortError" }), 500) + + // Let the mocked chat settle so no promise dangles, then observe the + // prompt's outcome: aborted mid-discovery it rejects with the contract + // AbortError; past discovery it resolves with the late response. + resolveChat?.({ message: { content: "late" } }) + const outcome = await promptOutcome + if (outcome.aborted) { + expect(outcome.error).toMatchObject({ name: "AbortError" }) + } else { + expect(outcome.content).toBe("late") + } + }) + it("should abort the injected transport when the SDK fetch call carries no init signal", async () => { const fetchSpy = heldOpenFetch() vi.stubGlobal("fetch", fetchSpy) diff --git a/src/package.json b/src/package.json index 9ea6560997..353fc40c40 100644 --- a/src/package.json +++ b/src/package.json @@ -557,7 +557,7 @@ "@types/ps-tree": "1.1.6", "@types/semver-compare": "1.0.3", "@types/vscode": "1.100.0", - "@vitest/coverage-v8": "4.1.11", + "@vitest/coverage-v8": "4.1.9", "@vscode/vsce": "3.9.2", "ai": "6.0.218", "esbuild-wasm": "0.28.1", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index de36f7ac3a..cbbeb2052e 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -686,7 +686,7 @@ "baseUrl": "Base URL (optional)", "modelId": "Model ID", "apiKey": "Ollama API Key", - "apiKeyHelp": "Optional API key for authenticated Ollama instances or cloud services. Leave empty for local installations.", + "apiKeyHelp": "Optional API key for authenticated Ollama instances or cloud services. The key is only sent to HTTPS or loopback endpoints; remote HTTP endpoints receive no credentials (the key would travel in cleartext), so use HTTPS for remote authenticated instances. Leave empty for local installations.", "numCtx": "Context Window Size (num_ctx)", "numCtxHelp": "Override the model's default context window size. Leave empty to use the model's Modelfile configuration. Minimum value is 128.", "thinking": "Enable Thinking", From fa64caf4568462ff43b9bffe97bd579ace7a5a5a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 11:03:04 +0800 Subject: [PATCH 15/22] Revert remaining vitest peer bumps with the dependency move The peer alignment touched six manifests; the previous commit reverted only the root and src ones, leaving the lockfile inconsistent with the remaining @vitest/coverage-v8/@vitest/ui 4.1.11 entries (frozen-lockfile installs failed). All manifests now match upstream/main again; the alignment goes back up as its own dependency PR. --- apps/cli/package.json | 2 +- packages/cloud/package.json | 2 +- packages/core/package.json | 2 +- packages/telemetry/package.json | 2 +- webview-ui/package.json | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 4a6368d583..6e769ebb72 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,7 +43,7 @@ "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", "@types/react": "18.3.31", - "@vitest/coverage-v8": "4.1.11", + "@vitest/coverage-v8": "4.1.9", "ink-testing-library": "4.0.0", "rimraf": "6.0.1", "tsup": "8.5.1", diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 2022ab5730..612b75afe9 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -22,7 +22,7 @@ "@types/node": "22.20.1", "@types/vscode": "1.100.0", "globals": "16.5.0", - "@vitest/coverage-v8": "4.1.11", + "@vitest/coverage-v8": "4.1.9", "vitest": "4.1.11" } } diff --git a/packages/core/package.json b/packages/core/package.json index c180af7cc6..bcaa1e5c16 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,7 @@ "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", - "@vitest/coverage-v8": "4.1.11", + "@vitest/coverage-v8": "4.1.9", "vitest": "4.1.11" } } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index bcfae38fcb..045e5cb4c2 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -21,7 +21,7 @@ "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", "@types/vscode": "1.100.0", - "@vitest/coverage-v8": "4.1.11", + "@vitest/coverage-v8": "4.1.9", "vitest": "4.1.11" } } diff --git a/webview-ui/package.json b/webview-ui/package.json index 34bd8044a4..c5899c9dd5 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -100,8 +100,8 @@ "@types/stacktrace-js": "2.0.3", "@types/vscode-webview": "1.57.5", "@vitejs/plugin-react": "5.2.0", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "babel-plugin-react-compiler": "1.0.0", "jsdom": "26.1.0", "monocart-reporter": "^2.9.20", From 91dc04c3d24c462314bae411d8eebf9ced9e2130 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 10:23:36 +0800 Subject: [PATCH 16/22] Address review: reuse shared abort-signal helpers; warn on dropped API key Replace the local raceWithAbortSignal/createAbortError pair with the shared utils/abort-signal exports (rejectOnAbort + createAbortError(providerName)), matching openai-codex.ts. The shared helpers are strictly better shaped: the already-aborted branch detaches a rejection handler from the pending promise, the listener registers with { once: true }, and the error carries the provider name (The Ollama request was aborted) per the series contract. Call sites that may see an undefined signal (no metadata signal; merged signal undefined when neither an external signal nor a positive timeout applies) keep the no-race path explicit. The local helpers and their dedicated unit block are removed; the shared helpers are covered by utils/__tests__/abort-signal.spec.ts. The plain-HTTP credential gate now console.warns when ollamaApiKey is set but the endpoint is neither https nor loopback, so a bare 401 behind an auth proxy is diagnosable; the warning never echoes the key. New tests pin the warning (content mentions ollamaApiKey, not the key) and its absence on secure endpoints. Negative controls: removing the warn fails exactly the remote-HTTP test (isolated); mutating the ported provider name fails exactly the already-aborted completePrompt test. Verification at this head: 87/87 green in native-ollama.spec.ts; tsc --noEmit 0 errors; eslint --max-warnings=0 clean with suppression counts unchanged; prettier clean. --- .../providers/__tests__/native-ollama.spec.ts | 90 ++++++------------- src/api/providers/native-ollama.ts | 74 +++++---------- 2 files changed, 51 insertions(+), 113 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 5c11f95e24..a4e1b84091 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { NativeOllamaHandler, raceWithAbortSignal } from "../native-ollama" +import { NativeOllamaHandler } from "../native-ollama" import { ApiHandlerOptions } from "../../../shared/api" import { getOllamaModels } from "../fetchers/ollama" @@ -973,7 +973,7 @@ describe("NativeOllamaHandler", () => { const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal }) controller.abort() - await expect(promise).rejects.toThrow("This operation was aborted") + await expect(promise).rejects.toThrow("The Ollama request was aborted") expect(capturedInstanceAbort).toBeDefined() expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) @@ -1000,7 +1000,7 @@ describe("NativeOllamaHandler", () => { }) await expect(handler.completePrompt("Test prompt", { abortSignal: controller.signal })).rejects.toThrow( - "This operation was aborted", + "The Ollama request was aborted", ) expect(capturedInstanceAbort).toBeDefined() @@ -2201,10 +2201,11 @@ describe("NativeOllamaHandler", () => { ) }) - it("should not attach the API key for a remote HTTP endpoint", async () => { + it("should not attach the API key for a remote HTTP endpoint and warn about the drop", async () => { mockChat.mockResolvedValue({ message: { content: "Response" }, }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) const handler = new NativeOllamaHandler({ apiModelId: "llama2", @@ -2219,6 +2220,28 @@ describe("NativeOllamaHandler", () => { // so the Authorization header must be omitted. const callArgs = OllamaMock.mock.calls[0][0] expect(callArgs.headers).toBeUndefined() + // The silent drop is a diagnosis trap (bare 401 behind an auth + // proxy), so the gate warns - without echoing the key itself. + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ollamaApiKey")) + expect(warnSpy.mock.calls.flat().join(" ")).not.toContain("test-api-key-123") + }) + + it("should not warn when the API key is attached to a secure endpoint", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "https://ollama.example.com:11434", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("Test prompt") + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("ollamaApiKey")) }) it("should attach the API key for an HTTPS endpoint", async () => { @@ -2280,65 +2303,6 @@ describe("NativeOllamaHandler", () => { }) }) - describe("raceWithAbortSignal", () => { - it("should resolve with the pending result when no signal is given", async () => { - const result = await raceWithAbortSignal(Promise.resolve("value"), undefined) - expect(result).toBe("value") - }) - - it("should reject with AbortError when the signal is already aborted", async () => { - const controller = new AbortController() - controller.abort() - const pending = new Promise(() => {}) - const settledPre = expect(raceWithAbortSignal(pending, controller.signal)).rejects.toMatchObject({ - name: "AbortError", - }) - await withDeadline(settledPre, 500) - }) - - it("should reject with AbortError when the signal aborts before the promise settles", async () => { - const controller = new AbortController() - const pending = new Promise(() => {}) - const raced = raceWithAbortSignal(pending, controller.signal) - controller.abort() - const settledAbort = expect(raced).rejects.toMatchObject({ name: "AbortError" }) - await withDeadline(settledAbort, 500) - }) - - it("should resolve when the promise settles before the signal aborts", async () => { - const controller = new AbortController() - const raced = raceWithAbortSignal(Promise.resolve("done"), controller.signal) - const result = await withDeadline(raced, 500) - expect(result).toBe("done") - controller.abort() // a late abort must not disturb the settled result - }) - - it("should remove the abort listener when pending settles, on resolve and reject", async () => { - const resolveController = new AbortController() - const resolveAddSpy = vitest.spyOn(resolveController.signal, "addEventListener") - const resolveRemoveSpy = vitest.spyOn(resolveController.signal, "removeEventListener") - const resolved = await raceWithAbortSignal(Promise.resolve("done"), resolveController.signal) - expect(resolved).toBe("done") - // The listener detached must be the exact reference that was - // registered, so a reused caller signal cannot keep it. - expect(resolveRemoveSpy).toHaveBeenCalledWith("abort", resolveAddSpy.mock.calls[0]?.[1]) - - const rejectController = new AbortController() - const rejectAddSpy = vitest.spyOn(rejectController.signal, "addEventListener") - const rejectRemoveSpy = vitest.spyOn(rejectController.signal, "removeEventListener") - const rejected = expect( - raceWithAbortSignal( - new Promise((_resolve, reject) => { - reject(new Error("boom")) - }), - rejectController.signal, - ), - ).rejects.toThrow("boom") - await withDeadline(rejected, 500) - expect(rejectRemoveSpy).toHaveBeenCalledWith("abort", rejectAddSpy.mock.calls[0]?.[1]) - }) - }) - describe("per-request abortable transport", () => { // Earlier tests in this file install a persistent global setTimeout spy // (vi.spyOn; vi.clearAllMocks does not restore spy implementations), so diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 1a3c39d441..65abdaf897 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -6,7 +6,7 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { getOllamaModels, isSecureOllamaEndpoint } from "./fetchers/ollama" -import { mergeAbortSignalAndTimeout } from "./utils/abort-signal" +import { createAbortError, mergeAbortSignalAndTimeout, rejectOnAbort } from "./utils/abort-signal" import { TagMatcher } from "../../utils/tag-matcher" import type { SingleCompletionHandler, @@ -29,47 +29,6 @@ type ReasoningContentBlock = { type: "reasoning"; text: string } type ThinkingContentBlock = { type: "thinking"; thinking: string } type AssistantContentBlock = Anthropic.ContentBlock | ReasoningContentBlock | ThinkingContentBlock -/** - * Creates the canonical abort error shared by every cancellation path so - * callers can identify cancellations by `name === "AbortError"`. - */ -function createAbortError(): Error { - const abortError = new Error("This operation was aborted") - abortError.name = "AbortError" - return abortError -} - -/** - * Races `pending` against `signal`: if the signal aborts before the promise - * settles, the returned promise rejects with an AbortError instead of - * resolving with a stale result. The abort listener is removed as soon as - * `pending` settles (in either direction), so a reusable caller signal does - * not accumulate one listener per race; a cancelled in-flight request still - * settles in the background. - */ -export function raceWithAbortSignal(pending: Promise, signal: AbortSignal | undefined): Promise { - if (!signal) { - return pending - } - if (signal.aborted) { - return Promise.reject(createAbortError()) - } - return new Promise((resolve, reject) => { - const onAbort = () => reject(createAbortError()) - signal.addEventListener("abort", onAbort) - pending.then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (error) => { - signal.removeEventListener("abort", onAbort) - reject(error) - }, - ) - }) -} - function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { const ollamaMessages: Message[] = [] // Track tool use IDs to tool names so tool results can be sent with @@ -304,9 +263,18 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // Use constructor `headers` option instead of mutating (request as any).config. // The credential is only attached when the endpoint is HTTPS or loopback; // sending it over plaintext HTTP to a remote host would leak it (CWE-319). - if (this.options.ollamaApiKey && isSecureOllamaEndpoint(clientOptions.host)) { - clientOptions.headers = { - Authorization: `Bearer ${this.options.ollamaApiKey}`, + if (this.options.ollamaApiKey) { + if (isSecureOllamaEndpoint(clientOptions.host)) { + clientOptions.headers = { + Authorization: `Bearer ${this.options.ollamaApiKey}`, + } + } else { + // The credential is dropped to avoid leaking it over plaintext + // HTTP (CWE-319); surface the drop so a bare 401 from an auth + // proxy is diagnosable. The key itself is never logged. + console.warn( + `[NativeOllama] ollamaApiKey is set but the endpoint "${clientOptions.host}" is not https or loopback; the Authorization header is not sent (the credential would leak over plaintext). Use an https:// or loopback endpoint to authenticate.`, + ) } } @@ -474,7 +442,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio if (externalAbortSignal?.aborted) { // The request is already cancelled: reject before allocating the // per-request client or transport so nothing is left to clean up. - throw createAbortError() + throw createAbortError("Ollama") } const requestController = new AbortController() @@ -494,7 +462,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // fetch receives the signal so an aborted request is cancelled at // the network level, and the race rejects a request aborted during // discovery instead of proceeding to chat() with a stale model. - const { id: modelId } = await raceWithAbortSignal(this.fetchModel(externalAbortSignal), externalAbortSignal) + const { id: modelId } = externalAbortSignal + ? await rejectOnAbort(this.fetchModel(externalAbortSignal), externalAbortSignal, "Ollama") + : await this.fetchModel(undefined) // Stryker disable next-line MethodExpression,StringLiteral: DEEP_SEEK_DEFAULT_TEMPERATURE (0.0) is numerically equal to the 0 fallback, so the R1 check cannot change the emitted temperature const useR1Format = modelId.toLowerCase().includes("deepseek-r1") @@ -537,7 +507,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // instead of being consumed after cancellation. if (externalAbortSignal?.aborted) { stream.abort() - throw createAbortError() + throw createAbortError("Ollama") } let totalInputTokens = 0 @@ -715,7 +685,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio if (abortSignal.aborted) { requestController.abort() client.abort() - throw createAbortError() + throw createAbortError("Ollama") } else { onAbort = () => { requestController.abort() @@ -736,7 +706,11 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // remains the backstop that rejects a stalled discovery instead of // hanging past the deadline. const discoverySignal = mergeAbortSignalAndTimeout(requestController.signal, options?.timeoutMs) - const { id: modelId } = await raceWithAbortSignal(this.fetchModel(discoverySignal), discoverySignal) + // mergeAbortSignalAndTimeout yields undefined when neither an external + // signal nor a positive timeout applies; no race is needed then. + const { id: modelId } = discoverySignal + ? await rejectOnAbort(this.fetchModel(discoverySignal), discoverySignal, "Ollama") + : await this.fetchModel(discoverySignal) const useR1Format = modelId.toLowerCase().includes("deepseek-r1") From 6e216e2c53409240b06aeb1cc573e4427e7b799e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 17:44:25 +0800 Subject: [PATCH 17/22] chore(api): remove Stryker suppression comments from touched files Per repo policy (AGENTS.md: Stryker suppression comments are banned; the mutation gate rejects them in touched files). Equivalence arguments for the resulting mutants are recorded in the PR body. --- src/api/providers/native-ollama.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 65abdaf897..99beaab1de 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -465,7 +465,6 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const { id: modelId } = externalAbortSignal ? await rejectOnAbort(this.fetchModel(externalAbortSignal), externalAbortSignal, "Ollama") : await this.fetchModel(undefined) - // Stryker disable next-line MethodExpression,StringLiteral: DEEP_SEEK_DEFAULT_TEMPERATURE (0.0) is numerically equal to the 0 fallback, so the R1 check cannot change the emitted temperature const useR1Format = modelId.toLowerCase().includes("deepseek-r1") const ollamaMessages: Message[] = [ @@ -622,7 +621,6 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } finally { // Detach the external-signal listener so it cannot outlive this request, // including when model discovery rejects before the chat request starts. - // Stryker disable next-line LogicalOperator: onExternalAbort is only assigned when externalAbortSignal is present, so || is equivalent if (onExternalAbort && externalAbortSignal) { externalAbortSignal.removeEventListener("abort", onExternalAbort) } @@ -737,7 +735,6 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio throw error } finally { clearTimeout(timeoutId) - // Stryker disable next-line LogicalOperator: onAbort is only assigned when abortSignal is present, so || is equivalent if (onAbort && abortSignal) { abortSignal.removeEventListener("abort", onAbort) } From e7ca0b0f81c126db42d19783e7ca06c980cf7ba1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 19:49:20 +0800 Subject: [PATCH 18/22] fix(api): echo only the endpoint origin in the credential-drop warning CodeRabbit (security minor): the drop warning interpolated ollamaBaseUrl verbatim, so URL credentials (user:pass@) or sensitive query values in the base URL leaked to the log. The warning now derives the WHATWG origin (protocol//host:port) via new URL() and falls back to a fixed phrase for an unparseable host. Tests cover a base URL carrying credentials and a query token: the warning must contain the origin and must not contain the secret, the token value, or the word "token"; the secure-endpoint test now also pins the attached Authorization header. Negative control: echoing the raw host fails exactly the drop-warning test. --- .../providers/__tests__/native-ollama.spec.ts | 20 +++++++++++++++---- src/api/providers/native-ollama.ts | 15 ++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index a4e1b84091..203d601e2c 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2210,7 +2210,9 @@ describe("NativeOllamaHandler", () => { const handler = new NativeOllamaHandler({ apiModelId: "llama2", ollamaModelId: "llama2", - ollamaBaseUrl: "http://ollama.example.com:11434", + // Credentials and a sensitive query value ride along: the warning + // must echo only the origin, never these. + ollamaBaseUrl: "http://user:secret@ollama.example.com:11434/v1?token=***", ollamaApiKey: "test-api-key-123", }) @@ -2221,9 +2223,15 @@ describe("NativeOllamaHandler", () => { const callArgs = OllamaMock.mock.calls[0][0] expect(callArgs.headers).toBeUndefined() // The silent drop is a diagnosis trap (bare 401 behind an auth - // proxy), so the gate warns - without echoing the key itself. + // proxy), so the gate warns - without echoing the key or any URL + // credential/query material, only the protocol/host:port origin. + const warned = warnSpy.mock.calls.flat().join(" ") expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ollamaApiKey")) - expect(warnSpy.mock.calls.flat().join(" ")).not.toContain("test-api-key-123") + expect(warned).not.toContain("test-api-key-123") + expect(warned).toContain("ollama.example.com:11434") + expect(warned).not.toContain("secret") + expect(warned).not.toContain("abc123") + expect(warned).not.toContain("token") }) it("should not warn when the API key is attached to a secure endpoint", async () => { @@ -2235,12 +2243,16 @@ describe("NativeOllamaHandler", () => { const handler = new NativeOllamaHandler({ apiModelId: "llama2", ollamaModelId: "llama2", - ollamaBaseUrl: "https://ollama.example.com:11434", + // Credentials on a secure endpoint must not trigger the drop + // warning either. + ollamaBaseUrl: "https://user:secret@ollama.example.com:11434", ollamaApiKey: "test-api-key-123", }) await handler.completePrompt("Test prompt") + const callArgs = OllamaMock.mock.calls[0][0] + expect(callArgs.headers).toEqual({ Authorization: "Bearer test-api-key-123" }) expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("ollamaApiKey")) }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 99beaab1de..c35af88568 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -271,9 +271,20 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } else { // The credential is dropped to avoid leaking it over plaintext // HTTP (CWE-319); surface the drop so a bare 401 from an auth - // proxy is diagnosable. The key itself is never logged. + // proxy is diagnosable. Neither the key nor the raw URL is + // logged: a base URL can embed credentials or sensitive query + // values, so only the WHATWG origin (protocol/host:port) is + // echoed, and an unparseable host falls back to a fixed phrase. + let endpointOrigin = "the configured endpoint" + try { + const parsed = new URL(clientOptions.host) + endpointOrigin = `${parsed.protocol}//${parsed.host}` + } catch { + // Unparseable host: keep the fixed phrase rather than echo + // a raw value that could carry credentials. + } console.warn( - `[NativeOllama] ollamaApiKey is set but the endpoint "${clientOptions.host}" is not https or loopback; the Authorization header is not sent (the credential would leak over plaintext). Use an https:// or loopback endpoint to authenticate.`, + `[NativeOllama] ollamaApiKey is set but the endpoint "${endpointOrigin}" is not https or loopback; the Authorization header is not sent (the credential would leak over plaintext). Use an https:// or loopback endpoint to authenticate.`, ) } } From 2c34c18364df695cac550fb00fb5e5afdbf3cd8c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 20:29:24 +0800 Subject: [PATCH 19/22] fix(api): reject non-finite and cap overflowing timeoutMs delays Port of the #1300 timeout guard finding (CodeRabbit): Node coerces setTimeout delays above 2^31-1 ms (and Infinity) to 1 ms, so completePrompt's timeout could abort almost immediately. The provider guard now requires Number.isFinite and caps the delay at 2^31-1 ms; the shared mergeAbortSignalAndTimeout helper gets the same treatment (AbortSignal.timeout throws ERR_OUT_OF_RANGE on Infinity and clamps overflowing delays to 1 ms, breaking discovery and merged-signal paths). Regression tests: Infinity schedules no provider timer and means "no timeout" in the helper; overflowing values schedule capped timers. Negative controls fail exactly the new tests per revert. --- .../providers/__tests__/native-ollama.spec.ts | 27 +++++++++++++++++++ src/api/providers/native-ollama.ts | 17 ++++++++---- .../utils/__tests__/abort-signal.spec.ts | 23 ++++++++++++++++ src/api/providers/utils/abort-signal.ts | 11 +++++--- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 203d601e2c..f983213367 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -1035,6 +1035,33 @@ describe("NativeOllamaHandler", () => { expect(OllamaMock).toHaveBeenCalledWith(expect.objectContaining({ host: "http://localhost:11434" })) }) + it("should not start a timeout timer for a non-finite timeoutMs", async () => { + const setTimeoutSpy = vitest.spyOn(global, "setTimeout") + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: Infinity }) + + // Infinity must mean "no timeout": Node would coerce a non-finite + // delay to 1ms and abort the request almost immediately. + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(OllamaMock).toHaveBeenCalledTimes(1) + }) + + it("should cap an overflowing timeoutMs instead of scheduling a 1ms timer", async () => { + const setTimeoutSpy = vitest.spyOn(global, "setTimeout") + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: 2_147_483_648 }) + + // 2^31 ms overflows Node's timer clamp to 1ms; the guard caps the + // delay at 2^31-1 so the request is not aborted within a millisecond. + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_147_483_647) + }) + it("should reject with AbortError and remove the abort listener when abortSignal fires", async () => { const controller = new AbortController() // The timer id is only compared by identity here (clearTimeout is spied), diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index c35af88568..dd315deb7d 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -679,11 +679,18 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio try { // Handle timeoutMs if provided (client already exists above, so the // timer can abort both the per-request transport and the client) - if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { - timeoutId = setTimeout(() => { - requestController.abort() - client.abort() - }, options.timeoutMs) + const timeoutMs = options?.timeoutMs + if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) { + // Node clamps delays above 2^31-1 ms to 1 ms (and coerces + // Infinity the same way), which would abort almost immediately; + // cap the delay and treat non-finite values as "no timeout". + timeoutId = setTimeout( + () => { + requestController.abort() + client.abort() + }, + Math.min(timeoutMs, 2_147_483_647), + ) } // Propagate abortSignal into the per-request client via client.abort() diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index f779d48e1f..65e511ed87 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -275,6 +275,29 @@ describe("abort-signal utilities", () => { await vi.waitFor(() => expect(result?.aborted).toBe(true)) }) + + it("treats a non-finite timeoutMs as no timeout", () => { + const controller = new AbortController() + + // Infinity must not reach AbortSignal.timeout(), which throws + // ERR_OUT_OF_RANGE for non-integer delays. + expect(() => mergeAbortSignalAndTimeout(undefined, Infinity)).not.toThrow() + expect(mergeAbortSignalAndTimeout(undefined, Infinity)).toBeUndefined() + expect(mergeAbortSignalAndTimeout(controller.signal, Infinity)).toBe(controller.signal) + }) + + it("caps an overflowing timeoutMs instead of aborting immediately", async () => { + // Node clamps AbortSignal.timeout delays above 2^31-1 ms to 1 ms; + // the helper caps the delay so an overflowing timeout does not abort + // the request within the first millisecond. + const result = mergeAbortSignalAndTimeout(undefined, 2_147_483_648) + + expect(result).toBeInstanceOf(AbortSignal) + expect(result?.aborted).toBe(false) + + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(result?.aborted).toBe(false) + }) }) describe("mergeAbortSignals", () => { diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index cbb7a13aaf..435af3b29e 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -1,18 +1,23 @@ /** * Merge an optional external abort signal with an optional timeout. * - * Timeout values <= 0 are treated as disabled. The timeout is created via the + * Timeout values <= 0 and non-finite values are treated as disabled; finite + * delays above 2^31-1 ms are capped (Node clamps them to 1 ms otherwise). The timeout is created via the * native AbortSignal.timeout() API, which self-manages its timer — callers do * not need to (and cannot) clear it manually. */ export function mergeAbortSignalAndTimeout(externalSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined { - const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0 + // Node's AbortSignal.timeout() throws ERR_OUT_OF_RANGE for non-integer + // delays (Infinity) and clamps delays above 2^31-1 ms to 1 ms, which would + // abort almost immediately; treat non-finite values as "no timeout" and + // cap the rest. + const hasTimeout = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 if (!hasTimeout) { return externalSignal } - const timeoutSignal = AbortSignal.timeout(timeoutMs) + const timeoutSignal = AbortSignal.timeout(Math.min(timeoutMs, 2_147_483_647)) if (!externalSignal) { return timeoutSignal From 153ffab67cfe0c456cdd7892352311896c4078b1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 21:44:09 +0800 Subject: [PATCH 20/22] fix(api): round fractional timeouts and pin the aborted-outcome assertion CodeRabbit threads on 2c34c1836: - mergeAbortSignalAndTimeout now rounds fractional delays up before AbortSignal.timeout's uint32 validation (1500.5/0.5 threw ERR_OUT_OF_RANGE); regression test for timeoutMs 1.5. - The merged-transport cancellation test asserted its outcome with an if/else that also accepted a post-abort success; controller. abort() is synchronous while rejectOnAbort awaits discovery, so the outcome is always the contract AbortError. Asserted unconditionally plus mockChat-not-called; the old weak form hid a resolve-after-abort regression (negative control: dropping the abort fails exactly this test). --- src/api/providers/__tests__/native-ollama.spec.ts | 15 +++++++-------- .../utils/__tests__/abort-signal.spec.ts | 14 ++++++++++++++ src/api/providers/utils/abort-signal.ts | 7 ++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index f983213367..31bcc92547 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2591,16 +2591,15 @@ describe("NativeOllamaHandler", () => { expect(receivedSignal?.aborted).toBe(true) await withDeadline(expect(post).rejects.toMatchObject({ name: "AbortError" }), 500) - // Let the mocked chat settle so no promise dangles, then observe the - // prompt's outcome: aborted mid-discovery it rejects with the contract - // AbortError; past discovery it resolves with the late response. + // Let the mocked chat settle so no promise dangles. controller.abort() + // is synchronous and rejectOnAbort is still awaiting discovery, so the + // outcome is ALWAYS the contract AbortError - asserting it + // unconditionally pins the rejection and would catch a regression + // where completePrompt resolves after the caller aborted. resolveChat?.({ message: { content: "late" } }) const outcome = await promptOutcome - if (outcome.aborted) { - expect(outcome.error).toMatchObject({ name: "AbortError" }) - } else { - expect(outcome.content).toBe("late") - } + expect(outcome).toMatchObject({ aborted: true, error: { name: "AbortError" } }) + expect(mockChat).not.toHaveBeenCalled() }) it("should abort the injected transport when the SDK fetch call carries no init signal", async () => { diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index 65e511ed87..c9fc051a9b 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -286,6 +286,20 @@ describe("abort-signal utilities", () => { expect(mergeAbortSignalAndTimeout(controller.signal, Infinity)).toBe(controller.signal) }) + it("rounds a fractional timeoutMs up before scheduling", async () => { + // Node validates AbortSignal.timeout delays as uint32: 1.5 or 0.5 + // would throw ERR_OUT_OF_RANGE. The helper rounds up, so a positive + // fraction never becomes 0 ms either. + let result: AbortSignal | undefined + expect(() => { + result = mergeAbortSignalAndTimeout(undefined, 1.5) + }).not.toThrow() + expect(result).toBeInstanceOf(AbortSignal) + expect(result?.aborted).toBe(false) + await vi.waitFor(() => expect(result?.aborted).toBe(true)) + expect(() => mergeAbortSignalAndTimeout(undefined, 0.5)).not.toThrow() + }) + it("caps an overflowing timeoutMs instead of aborting immediately", async () => { // Node clamps AbortSignal.timeout delays above 2^31-1 ms to 1 ms; // the helper caps the delay so an overflowing timeout does not abort diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index 435af3b29e..763a97c4c0 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -9,15 +9,16 @@ export function mergeAbortSignalAndTimeout(externalSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined { // Node's AbortSignal.timeout() throws ERR_OUT_OF_RANGE for non-integer // delays (Infinity) and clamps delays above 2^31-1 ms to 1 ms, which would - // abort almost immediately; treat non-finite values as "no timeout" and - // cap the rest. + // abort almost immediately; treat non-finite values as "no timeout", + // round fractional delays up to whole milliseconds (uint32 validation + // throws for 1500.5), and cap the rest. const hasTimeout = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 if (!hasTimeout) { return externalSignal } - const timeoutSignal = AbortSignal.timeout(Math.min(timeoutMs, 2_147_483_647)) + const timeoutSignal = AbortSignal.timeout(Math.min(Math.max(1, Math.ceil(timeoutMs)), 2_147_483_647)) if (!externalSignal) { return timeoutSignal From 6319fe5a6d88bdf7768ad4effcefb89ff8814473 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 23:23:30 +0800 Subject: [PATCH 21/22] test(api): assert the rounded delay reaching AbortSignal.timeout CodeRabbit: the fractional-timeout test only proved the call did not throw and eventually aborted - a round-down mutation (1.5 -> 1) would still pass. The test now spies AbortSignal.timeout and pins the received delay: 2 for 1.5 and 1 for 0.5 (never 0). Negative control: mutating Math.ceil to Math.floor fails exactly this test. --- .../utils/__tests__/abort-signal.spec.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index c9fc051a9b..3cd64e8fd1 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -288,16 +288,29 @@ describe("abort-signal utilities", () => { it("rounds a fractional timeoutMs up before scheduling", async () => { // Node validates AbortSignal.timeout delays as uint32: 1.5 or 0.5 - // would throw ERR_OUT_OF_RANGE. The helper rounds up, so a positive - // fraction never becomes 0 ms either. + // would throw ERR_OUT_OF_RANGE. Pin the rounded delay that actually + // reaches AbortSignal.timeout (1.5 -> 2, 0.5 -> 1, never 0) so a + // round-down mutation reddens this test instead of still aborting + // eventually. + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") let result: AbortSignal | undefined - expect(() => { - result = mergeAbortSignalAndTimeout(undefined, 1.5) - }).not.toThrow() + let tiny: AbortSignal | undefined + try { + expect(() => { + result = mergeAbortSignalAndTimeout(undefined, 1.5) + }).not.toThrow() + expect(timeoutSpy).toHaveBeenCalledWith(2) + expect(() => { + tiny = mergeAbortSignalAndTimeout(undefined, 0.5) + }).not.toThrow() + expect(timeoutSpy).toHaveBeenCalledWith(1) + } finally { + timeoutSpy.mockRestore() + } expect(result).toBeInstanceOf(AbortSignal) expect(result?.aborted).toBe(false) + expect(tiny).toBeInstanceOf(AbortSignal) await vi.waitFor(() => expect(result?.aborted).toBe(true)) - expect(() => mergeAbortSignalAndTimeout(undefined, 0.5)).not.toThrow() }) it("caps an overflowing timeoutMs instead of aborting immediately", async () => { From 0d18b4d1f68f943f5d3a167902fed141552253dc Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 02:50:14 +0800 Subject: [PATCH 22/22] fix(api): round fractional completion timeouts up before scheduling Port of the #1300 CodeRabbit finding: the raw setTimeout path now applies Math.ceil before the cap so fractional delays round up (1500.5 -> 1501) instead of Node truncating to 1500, matching mergeAbortSignalAndTimeout semantics. New test pins the delay handed to setTimeout; negative control fails exactly that test. --- src/api/providers/__tests__/native-ollama.spec.ts | 13 +++++++++++++ src/api/providers/native-ollama.ts | 8 +++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 31bcc92547..f716a5c42c 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -1049,6 +1049,19 @@ describe("NativeOllamaHandler", () => { expect(OllamaMock).toHaveBeenCalledTimes(1) }) + it("should round a fractional timeoutMs up before scheduling the timer", async () => { + // Node truncates non-integer setTimeout delays (1500.5 fires at + // 1500 ms); round up so the semantics match the shared helper. + const setTimeoutSpy = vitest.spyOn(global, "setTimeout") + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: 1500.5 }) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1501) + }) + it("should cap an overflowing timeoutMs instead of scheduling a 1ms timer", async () => { const setTimeoutSpy = vitest.spyOn(global, "setTimeout") mockChat.mockResolvedValue({ diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index dd315deb7d..b92181f9d4 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -682,14 +682,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const timeoutMs = options?.timeoutMs if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) { // Node clamps delays above 2^31-1 ms to 1 ms (and coerces - // Infinity the same way), which would abort almost immediately; - // cap the delay and treat non-finite values as "no timeout". + // Infinity the same way), which would abort almost immediately, + // and truncates fractional delays; round up and cap so the + // semantics match mergeAbortSignalAndTimeout, and treat + // non-finite values as "no timeout". timeoutId = setTimeout( () => { requestController.abort() client.abort() }, - Math.min(timeoutMs, 2_147_483_647), + Math.min(Math.ceil(timeoutMs), 2_147_483_647), ) }