From d56bda1148a6442c4b4a8adaa3596e66bf1e0e09 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 20 Aug 2026 04:36:45 +0800 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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), ) } From d9c2969190746e24463bb469b741aad2f7bd72e7 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 20 Oct 2026 03:14:32 +0000 Subject: [PATCH 23/27] chore(lint): guard api providers against repeat abort and logging mistakes --- .../config-eslint/api-provider-hygiene.js | 37 ++++++++ .../api-provider-hygiene.test.js | 90 +++++++++++++++++++ packages/config-eslint/package.json | 5 +- src/eslint-suppressions.json | 10 +++ src/eslint.config.mjs | 2 + 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/config-eslint/api-provider-hygiene.js create mode 100644 packages/config-eslint/api-provider-hygiene.test.js diff --git a/packages/config-eslint/api-provider-hygiene.js b/packages/config-eslint/api-provider-hygiene.js new file mode 100644 index 0000000000..d9a560fd33 --- /dev/null +++ b/packages/config-eslint/api-provider-hygiene.js @@ -0,0 +1,37 @@ +// Guards for repeat review findings in src/api. Built-in no-restricted-syntax only: no custom rule code. +// Sources: shared helpers live in src/api/providers/utils/abort-signal.ts. +// Paths are relative to the `src` package, where this config is loaded. + +const sharedAbortHelpers = + "mergeAbortSignalAndTimeout|mergeAbortSignals|throwIfAborted|isRequestAborted|createAbortError|rejectOnAbort|resolveModelWithAbort" + +// Names that usually hold a secret or an endpoint that can embed credentials. +// `cacheKey` and `contextTokens` must not match. +const sensitiveName = "^(url|apiKey|secret|password)$|(Url|URL|ApiKey|Secret|Token)$" + +export const apiProviderHygieneSelectors = [ + { + selector: `:matches(FunctionDeclaration, VariableDeclarator)[id.name=/^(${sharedAbortHelpers})$/]`, + message: "Import this helper from providers/utils/abort-signal instead of declaring a local copy.", + }, + { + selector: + "CallExpression[callee.object.name='AbortSignal'][callee.property.name='timeout']:not([arguments.0.type='Literal']):not([arguments.0.name=/^[A-Z][A-Z0-9_]*$/])", + message: + "Use mergeAbortSignalAndTimeout for a variable delay. AbortSignal.timeout throws on Infinity and fractions, and clamps large values to 1 ms.", + }, + { + selector: `CallExpression[callee.object.name='console'] TemplateLiteral > :matches(Identifier[name=/${sensitiveName}/], MemberExpression[property.name=/${sensitiveName}/])`, + message: + "Do not log a URL, key, secret or token. A base URL can embed credentials or query secrets. Log the origin only.", + }, +] + +export const apiProviderHygieneConfig = { + files: ["api/**/*.ts"], + // The helper module owns the shared names. Tests build their own fixtures. + ignores: ["api/providers/utils/abort-signal.ts", "**/__tests__/**", "**/*.test.ts", "**/*.spec.ts"], + rules: { + "no-restricted-syntax": ["error", ...apiProviderHygieneSelectors], + }, +} diff --git a/packages/config-eslint/api-provider-hygiene.test.js b/packages/config-eslint/api-provider-hygiene.test.js new file mode 100644 index 0000000000..ae981a0d05 --- /dev/null +++ b/packages/config-eslint/api-provider-hygiene.test.js @@ -0,0 +1,90 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { Linter } from "eslint" +import typescriptEslint from "typescript-eslint" + +import { apiProviderHygieneConfig } from "./api-provider-hygiene.js" + +const linter = new Linter({ configType: "flat" }) +const config = [{ files: ["**/*.ts"], languageOptions: { parser: typescriptEslint.parser } }, apiProviderHygieneConfig] + +const lint = (code, filename = "api/providers/example.ts") => + linter.verify(code, config, { filename }).map((message) => message.message) + +describe("api provider hygiene: shared abort helpers", () => { + for (const code of [ + "function createAbortError(name: string) { return new Error(name) }", + "const rejectOnAbort = () => {}", + "async function resolveModelWithAbort() {}", + ]) { + it(`flags a local copy: ${code}`, () => { + assert.equal(lint(code).length, 1) + }) + } + + it("allows importing the helper", () => { + assert.deepEqual(lint('import { createAbortError } from "./utils/abort-signal"\ncreateAbortError("x")'), []) + }) + + it("does not apply to the helper module", () => { + assert.deepEqual(lint("export function createAbortError() {}", "api/providers/utils/abort-signal.ts"), []) + }) + + it("does not apply to tests", () => { + assert.deepEqual(lint("function createAbortError() {}", "api/providers/__tests__/example.spec.ts"), []) + }) + + it("does not apply outside src/api", () => { + assert.deepEqual(lint("function createAbortError() {}", "services/example.ts"), []) + }) +}) + +describe("api provider hygiene: AbortSignal.timeout", () => { + for (const code of [ + "AbortSignal.timeout(timeoutMs)", + "AbortSignal.timeout(options.timeoutMs)", + "AbortSignal.timeout(seconds * 1000)", + ]) { + it(`flags a variable delay: ${code}`, () => { + assert.equal(lint(code).length, 1) + }) + } + + for (const code of [ + "AbortSignal.timeout(10_000)", + "AbortSignal.timeout(MODEL_CATALOG_FETCH_TIMEOUT_MS)", + "AbortSignal.any([a, b])", + ]) { + it(`allows: ${code}`, () => { + assert.deepEqual(lint(code), []) + }) + } +}) + +describe("api provider hygiene: sensitive values in logs", () => { + for (const code of [ + "console.warn(`Error connecting at ${baseUrl}`)", + "console.warn(`Error connecting at ${this.options.ollamaBaseUrl}`)", + "console.error(`refresh failed for ${this._serverUrl}`)", + "console.log(`key ${apiKey}`)", + "console.log(`auth ${accessToken}`)", + "console.log(`from ${url}`)", + ]) { + it(`flags: ${code}`, () => { + assert.equal(lint(code).length, 1) + }) + } + + for (const code of [ + "console.error(`Error writing ${cacheKey} to disk`)", + "console.warn(`Current tokens: ${contextTokens}`)", + "console.warn(`Endpoint ${endpointOrigin} is not secure`)", + "const message = `at ${baseUrl}`", + 'console.warn("static message")', + ]) { + it(`allows: ${code}`, () => { + assert.deepEqual(lint(code), []) + }) + } +}) diff --git a/packages/config-eslint/package.json b/packages/config-eslint/package.json index dce3b27016..6ff5f23b0d 100644 --- a/packages/config-eslint/package.json +++ b/packages/config-eslint/package.json @@ -6,10 +6,11 @@ "./base": "./base.js", "./react": "./react.js", "./next-js": "./next.js", - "./provider-identifiers": "./provider-identifiers.js" + "./provider-identifiers": "./provider-identifiers.js", + "./api-provider-hygiene": "./api-provider-hygiene.js" }, "scripts": { - "test": "node --test provider-identifiers.test.js" + "test": "node --test provider-identifiers.test.js api-provider-hygiene.test.js" }, "devDependencies": { "@eslint/js": "9.39.4", diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 754acd2b6c..24a276d127 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -359,6 +359,16 @@ "count": 1 } }, + "api/providers/fetchers/lmstudio.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, + "api/providers/fetchers/ollama.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "api/providers/gemini.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index 691987b4fc..c42d6b3b16 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -1,4 +1,5 @@ import { config } from "@roo-code/config-eslint/base" +import { apiProviderHygieneConfig } from "@roo-code/config-eslint/api-provider-hygiene" import { createProviderIdentifierConfig } from "@roo-code/config-eslint/provider-identifiers" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" @@ -6,6 +7,7 @@ import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types export default [ ...config, createProviderIdentifierConfig({ providerIdentifiers, retiredProviderIdentifiers }), + apiProviderHygieneConfig, { rules: { "prefer-const": ["error", { destructuring: "all" }], From ef1035ed536ef2932185d733c56b757f11097d68 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 20 Oct 2026 03:43:06 +0000 Subject: [PATCH 24/27] fix(api): log only the endpoint origin in fetcher warnings --- .../fetchers/__tests__/lmstudio.test.ts | 12 ++++++++++++ .../fetchers/__tests__/ollama.test.ts | 12 ++++++++++++ src/api/providers/fetchers/lmstudio.ts | 5 +++-- src/api/providers/fetchers/ollama.ts | 3 ++- src/api/providers/native-ollama.ts | 10 ++-------- .../utils/__tests__/endpoint-origin.spec.ts | 18 ++++++++++++++++++ src/api/providers/utils/endpoint-origin.ts | 16 ++++++++++++++++ src/eslint-suppressions.json | 10 ---------- 8 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 src/api/providers/utils/__tests__/endpoint-origin.spec.ts create mode 100644 src/api/providers/utils/endpoint-origin.ts diff --git a/src/api/providers/fetchers/__tests__/lmstudio.test.ts b/src/api/providers/fetchers/__tests__/lmstudio.test.ts index 756a8dc69a..afab44875d 100644 --- a/src/api/providers/fetchers/__tests__/lmstudio.test.ts +++ b/src/api/providers/fetchers/__tests__/lmstudio.test.ts @@ -479,6 +479,18 @@ describe("LMStudio Fetcher", () => { consoleInfoSpy.mockRestore() }) + it("should log only the origin of a base URL that holds credentials and a query", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}) + const error = new Error("Connection refused") + ;(error as NodeJS.ErrnoException).code = "ECONNREFUSED" + mockedAxios.get.mockRejectedValueOnce(error) + + await getLMStudioModels("http://user:pass@localhost:1234/path?token=secret") + + expect(warnSpy).toHaveBeenCalledWith("Error connecting to LMStudio at http://localhost:1234") + warnSpy.mockRestore() + }) + it("should return an empty object and log error if listDownloadedModels fails", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) const listError = new Error("LMStudio SDK internal error") diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index d45364e678..6f21c80313 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -435,6 +435,18 @@ describe("Ollama Fetcher", () => { consoleInfoSpy.mockRestore() // Restore original console.info }) + it("should log only the origin of a base URL that holds credentials and a query", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}) + const error = new Error("Connection refused") + ;(error as NodeJS.ErrnoException).code = "ECONNREFUSED" + mockedAxios.get.mockRejectedValueOnce(error) + + await getOllamaModels("http://user:pass@localhost:11434/path?token=secret") + + expect(warnSpy).toHaveBeenCalledWith("Failed connecting to Ollama at http://localhost:11434") + warnSpy.mockRestore() + }) + it("should handle models with null families field in API response", async () => { const baseUrl = "http://localhost:11434" const modelName = "test-model:latest" diff --git a/src/api/providers/fetchers/lmstudio.ts b/src/api/providers/fetchers/lmstudio.ts index 87cec5cc76..bd74c87666 100644 --- a/src/api/providers/fetchers/lmstudio.ts +++ b/src/api/providers/fetchers/lmstudio.ts @@ -4,6 +4,7 @@ import { LLM, LLMInfo, LLMInstanceInfo, LMStudioClient } from "@lmstudio/sdk" import { type ModelInfo, lMStudioDefaultModelInfo, providerIdentifiers } from "@roo-code/types" import { throwIfAborted } from "../utils/abort-signal" +import { getLoggableOrigin } from "../utils/endpoint-origin" import type { ApiHandler } from "../../index" @@ -33,7 +34,7 @@ export const forceFullModelDetailsLoad = async ( modelsWithLoadedDetails.add(modelId) } catch (error) { if (error.code === "ECONNREFUSED") { - console.warn(`Error connecting to LMStudio at ${baseUrl}`) + console.warn(`Error connecting to LMStudio at ${getLoggableOrigin(baseUrl)}`) } else { console.error( `Error refreshing LMStudio model details: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -135,7 +136,7 @@ export async function getLMStudioModels( throwIfAborted(opts?.signal) if (error.code === "ECONNREFUSED") { - console.warn(`Error connecting to LMStudio at ${baseUrl}`) + console.warn(`Error connecting to LMStudio at ${getLoggableOrigin(baseUrl)}`) } else { console.error( `Error fetching LMStudio models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index d838cc8d9d..15e732259c 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -3,6 +3,7 @@ import { ModelInfo, ollamaDefaultModelInfo } from "@roo-code/types" import { z } from "zod" import { throwIfAborted } from "../utils/abort-signal" +import { getLoggableOrigin } from "../utils/endpoint-origin" const OllamaModelDetailsSchema = z.object({ family: z.string(), @@ -180,7 +181,7 @@ export async function getOllamaModels( } } catch (error) { if (error.code === "ECONNREFUSED") { - console.warn(`Failed connecting to Ollama at ${baseUrl}`) + console.warn(`Failed connecting to Ollama at ${getLoggableOrigin(baseUrl)}`) } else { console.error( `Error fetching Ollama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index b92181f9d4..d2174f8f6c 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -7,6 +7,7 @@ import { BaseProvider } from "./base-provider" import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { getOllamaModels, isSecureOllamaEndpoint } from "./fetchers/ollama" import { createAbortError, mergeAbortSignalAndTimeout, rejectOnAbort } from "./utils/abort-signal" +import { getLoggableOrigin } from "./utils/endpoint-origin" import { TagMatcher } from "../../utils/tag-matcher" import type { SingleCompletionHandler, @@ -275,14 +276,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // 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. - } + const endpointOrigin = getLoggableOrigin(clientOptions.host) console.warn( `[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.`, ) diff --git a/src/api/providers/utils/__tests__/endpoint-origin.spec.ts b/src/api/providers/utils/__tests__/endpoint-origin.spec.ts new file mode 100644 index 0000000000..4b4aeee335 --- /dev/null +++ b/src/api/providers/utils/__tests__/endpoint-origin.spec.ts @@ -0,0 +1,18 @@ +import { getLoggableOrigin } from "../endpoint-origin" + +describe("getLoggableOrigin", () => { + it.each([ + ["http://localhost:11434", "http://localhost:11434"], + ["https://example.com/v1/path?key=secret", "https://example.com"], + ["https://user:pass@example.com:8443/x", "https://example.com:8443"], + ])("returns the origin of %s", (input, expected) => { + expect(getLoggableOrigin(input)).toBe(expected) + }) + + it.each(["localhost:11434", "127.0.0.1:11434", "not a url", "", "ftp://user:pass@host/"])( + "returns the fixed phrase for %j", + (input) => { + expect(getLoggableOrigin(input)).toBe("the configured endpoint") + }, + ) +}) diff --git a/src/api/providers/utils/endpoint-origin.ts b/src/api/providers/utils/endpoint-origin.ts new file mode 100644 index 0000000000..c237817f52 --- /dev/null +++ b/src/api/providers/utils/endpoint-origin.ts @@ -0,0 +1,16 @@ +const FALLBACK = "the configured endpoint" + +/** + * Returns a string that is safe to log for an endpoint URL. + * A base URL can hold credentials, a path or query secrets, so only + * the origin (protocol and host:port) of an http(s) URL is returned. + * Any other input gives a fixed phrase. + */ +export function getLoggableOrigin(url: string): string { + try { + const { protocol, host } = new URL(url) + return protocol === "http:" || protocol === "https:" ? `${protocol}//${host}` : FALLBACK + } catch { + return FALLBACK + } +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 24a276d127..754acd2b6c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -359,16 +359,6 @@ "count": 1 } }, - "api/providers/fetchers/lmstudio.ts": { - "no-restricted-syntax": { - "count": 2 - } - }, - "api/providers/fetchers/ollama.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "api/providers/gemini.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 From 2ec4d685c13c9800d4e4c9f7739f7b70d86aaeac Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 20 Oct 2026 03:45:29 +0000 Subject: [PATCH 25/27] fix(api): harden ollama key-drop warning and client creation --- .../providers/__tests__/native-ollama.spec.ts | 48 ++++++++++++++++++ .../fetchers/__tests__/ollama.test.ts | 49 +++++++++++++++++++ src/api/providers/fetchers/ollama.ts | 12 +++++ src/api/providers/native-ollama.ts | 26 +++++++--- 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index f716a5c42c..681b96a88c 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -2274,6 +2274,54 @@ describe("NativeOllamaHandler", () => { expect(warned).not.toContain("token") }) + it("should warn about the dropped key once per handler, not once per request", async () => { + mockChat.mockResolvedValue({ message: { content: "Response" } }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://ollama.example.com:11434", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("one") + await handler.completePrompt("two") + + const dropWarnings = warnSpy.mock.calls.filter(([m]) => String(m).includes("[NativeOllama] ollamaApiKey")) + expect(dropWarnings).toHaveLength(1) + }) + + it("should use a fixed phrase in the warning when the host does not parse", async () => { + mockChat.mockResolvedValue({ message: { content: "Response" } }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "secret-host:11434/path?token=abc", + ollamaApiKey: "test-api-key-123", + }) + + await handler.completePrompt("Test prompt") + + const warned = warnSpy.mock.calls.flat().join(" ") + expect(warned).toContain('the endpoint "the configured endpoint" is not https or loopback') + expect(warned).not.toContain("secret-host") + expect(warned).not.toContain("token") + }) + + it("should wrap an error thrown while the client is created", async () => { + OllamaMock.mockImplementationOnce(function () { + throw new Error("constructor boom") + }) + const handler = new NativeOllamaHandler({ apiModelId: "llama2", ollamaModelId: "llama2" }) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow( + "Ollama completion error: constructor boom", + ) + }) + it("should not warn when the API key is attached to a secure endpoint", async () => { mockChat.mockResolvedValue({ message: { content: "Response" }, diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index 6f21c80313..58beb977d6 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -147,6 +147,20 @@ describe("Ollama Fetcher", () => { it("should treat unparseable endpoints as unsafe", () => { expect(isSecureOllamaEndpoint("not a url")).toBe(false) }) + + it("should judge the real host, not look-alike text", () => { + // userinfo spoof: the host is evil.com + expect(isSecureOllamaEndpoint("http://127.0.0.1@evil.com:11434")).toBe(false) + expect(isSecureOllamaEndpoint("http://localhost@evil.com:11434")).toBe(false) + // look-alike host names + expect(isSecureOllamaEndpoint("http://localhost.evil.com:11434")).toBe(false) + expect(isSecureOllamaEndpoint("http://notlocalhost:11434")).toBe(false) + }) + + it("should fail closed for a schemeless host", () => { + expect(isSecureOllamaEndpoint("localhost:11434")).toBe(false) + expect(isSecureOllamaEndpoint("127.0.0.1:11434")).toBe(false) + }) }) describe("isIpv4LoopbackHost", () => { @@ -156,6 +170,13 @@ describe("Ollama Fetcher", () => { expect(isIpv4LoopbackHost("0.0.0.1")).toBe(false) }) + it("should accept an octet of 255 and reject 256", () => { + expect(isIpv4LoopbackHost("127.255.255.255")).toBe(true) + expect(isIpv4LoopbackHost("127.0.0.256")).toBe(false) + expect(isIpv4LoopbackHost("127.0.256.0")).toBe(false) + expect(isIpv4LoopbackHost("127.256.0.0")).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) @@ -435,6 +456,34 @@ describe("Ollama Fetcher", () => { consoleInfoSpy.mockRestore() // Restore original console.info }) + it("should warn once per origin when the API key is dropped for a remote HTTP endpoint", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}) + mockedAxios.get.mockResolvedValue({ data: { models: [] } }) + const url = "http://user:secret@drop-warn.example.com:11434/v1?token=abc" + + await getOllamaModels(url, "test-api-key") + await getOllamaModels(url, "test-api-key") + + const dropWarnings = warnSpy.mock.calls.filter(([m]) => String(m).includes("API key is set")) + expect(dropWarnings).toHaveLength(1) + const text = String(dropWarnings[0][0]) + expect(text).toContain("http://drop-warn.example.com:11434") + expect(text).not.toMatch(/secret|test-api-key|token/) + expect(mockedAxios.get.mock.calls[0][1]).toEqual(expect.objectContaining({ headers: {} })) + warnSpy.mockRestore() + }) + + it("should not warn about a dropped key when none is set or the endpoint is secure", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}) + mockedAxios.get.mockResolvedValue({ data: { models: [] } }) + + await getOllamaModels("http://nokey-warn.example.com:11434") + await getOllamaModels("https://secure-warn.example.com", "test-api-key") + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("API key is set")) + warnSpy.mockRestore() + }) + it("should log only the origin of a base URL that holds credentials and a query", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}) const error = new Error("Connection refused") diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 15e732259c..1ac0399674 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -107,6 +107,8 @@ export function isIpv4LoopbackHost(host: string): boolean { return values[0] === 127 && values.every((value) => value <= 255) } +const keyDropWarnedOrigins = new Set() + export async function getOllamaModels( baseUrl = "http://localhost:11434", apiKey?: string, @@ -126,6 +128,16 @@ export async function getOllamaModels( // 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)) + if (apiKey && !credentialGated) { + // Model discovery runs on every request, so warn once per origin. + const origin = getLoggableOrigin(baseUrl) + if (!keyDropWarnedOrigins.has(origin)) { + keyDropWarnedOrigins.add(origin) + console.warn( + `[Ollama] API key is set but the endpoint ${origin} is not https or loopback; the Authorization header is not sent.`, + ) + } + } const headers: Record = {} if (credentialGated) { headers["Authorization"] = `Bearer ${apiKey}` diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index d2174f8f6c..b888d84708 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -233,6 +233,7 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions protected models: Record = {} + private warnedKeyDropped = false constructor(options: ApiHandlerOptions) { super() @@ -276,10 +277,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // 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. - const endpointOrigin = getLoggableOrigin(clientOptions.host) - console.warn( - `[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.`, - ) + // Every request builds a client, so warn once per handler. + if (!this.warnedKeyDropped) { + this.warnedKeyDropped = true + const endpointOrigin = getLoggableOrigin(clientOptions.host) + console.warn( + `[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.`, + ) + } } } @@ -451,10 +456,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } const requestController = new AbortController() - const client = this._createOllamaClient(requestController.signal) let onExternalAbort: (() => void) | undefined try { + // Inside the try so a constructor error gets the same handling as a + // request error. + const client = this._createOllamaClient(requestController.signal) if (externalAbortSignal) { onExternalAbort = () => { requestController.abort() @@ -665,14 +672,17 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // 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 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) + // Inside the try so a constructor error gets the same wrap as a + // request error. + const client = this._createOllamaClient(requestController.signal) + + // Handle timeoutMs if provided (the client exists, so the timer can + // abort both the per-request transport and the client) 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 From 926b46da86a3d4603a686229318157b5cc55e2a5 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 20 Oct 2026 04:22:51 +0000 Subject: [PATCH 26/27] test(webview): add Ollama key help visual baseline and translations --- .../providers/__tests__/native-ollama.spec.ts | 321 ++++++------------ .../fetchers/__tests__/lmstudio.test.ts | 20 ++ webview-ui/playwright/gallery/stories.tsx | 5 + webview-ui/playwright/vite.config.ts | 22 +- .../__tests__/Ollama.visual.fixture.tsx | 72 ++++ .../providers/__tests__/Ollama.visual.tsx | 35 ++ .../ollama-api-key-help-guidance-dark.png | Bin 0 -> 51401 bytes webview-ui/src/i18n/locales/ca/settings.json | 2 +- webview-ui/src/i18n/locales/de/settings.json | 2 +- webview-ui/src/i18n/locales/en/settings.json | 2 +- webview-ui/src/i18n/locales/es/settings.json | 2 +- webview-ui/src/i18n/locales/fr/settings.json | 2 +- webview-ui/src/i18n/locales/hi/settings.json | 2 +- webview-ui/src/i18n/locales/id/settings.json | 2 +- webview-ui/src/i18n/locales/it/settings.json | 2 +- webview-ui/src/i18n/locales/ja/settings.json | 2 +- webview-ui/src/i18n/locales/ko/settings.json | 2 +- webview-ui/src/i18n/locales/nl/settings.json | 2 +- webview-ui/src/i18n/locales/pl/settings.json | 2 +- .../src/i18n/locales/pt-BR/settings.json | 2 +- webview-ui/src/i18n/locales/ru/settings.json | 2 +- webview-ui/src/i18n/locales/tr/settings.json | 2 +- webview-ui/src/i18n/locales/vi/settings.json | 2 +- .../src/i18n/locales/zh-CN/settings.json | 2 +- .../src/i18n/locales/zh-TW/settings.json | 2 +- 25 files changed, 281 insertions(+), 230 deletions(-) create mode 100644 webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/Ollama.visual.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/__screenshots__/ollama-api-key-help-guidance-dark.png diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 681b96a88c..b6725c541c 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -8,6 +8,7 @@ import { getOllamaModels } from "../fetchers/ollama" import { makeCreateMessageMetadata } from "../../../test-utils/api" import { clearAllMocks } from "../../../test-utils/reset" +import { collectStream } from "../../../test-utils/stream" // Mock the ollama package const mockChat = vitest.fn() @@ -54,6 +55,17 @@ const mockGetOllamaModels = vitest.mocked(getOllamaModels) // 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 +// Spin until the mocked SDK chat() has been invoked. The mocked SDK never +// uses the transport, so tests hold chat() manually. +const makeHandler = (overrides: Partial = {}) => + new NativeOllamaHandler({ apiModelId: "llama2", ollamaModelId: "llama2", ...overrides }) + +const untilChatCalled = async () => { + for (let i = 0; i < 50 && mockChat.mock.calls.length === 0; i++) { + await Promise.resolve() + } +} + const withDeadline = async (promise: Promise, ms = 500): Promise => Promise.race([ promise, @@ -169,11 +181,7 @@ describe("NativeOllamaHandler", () => { 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 - } - })() + const consume = collectStream(stream) 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 }[] } @@ -210,9 +218,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Text blocks are joined with "\n"; the image emits a placeholder. // Tool results are text-only in Ollama, so the image is flushed onto a @@ -264,9 +270,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) // The unknown block contributes "" so the join produces "before\n\nafter" expect(mockChat).toHaveBeenCalledWith( @@ -290,9 +294,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify that num_ctx was NOT included in the options expect(mockChat).toHaveBeenCalledWith( @@ -322,9 +324,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify that num_ctx was included with the specified value expect(mockChat).toHaveBeenCalledWith( @@ -401,9 +401,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -445,9 +443,7 @@ describe("NativeOllamaHandler", () => { handler = new NativeOllamaHandler(options) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -463,9 +459,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as Record expect(callArgs.think).toBeUndefined() @@ -492,9 +486,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as Record expect(callArgs.think).toBeUndefined() @@ -519,9 +511,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as Record expect(callArgs.think).toBeUndefined() @@ -545,9 +535,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -573,9 +561,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -608,9 +594,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -644,9 +628,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -680,9 +662,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -720,9 +700,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -754,9 +732,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as Record expect(callArgs.think).toBeUndefined() @@ -782,9 +758,7 @@ describe("NativeOllamaHandler", () => { }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) - for await (const _ of stream) { - // consume - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as Record expect(callArgs.think).toBeUndefined() @@ -1287,9 +1261,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) await expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toThrow("Ollama service is not running") }) @@ -1301,9 +1273,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) await expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toThrow("Model llama2 not found in Ollama") }) @@ -1320,9 +1290,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) await expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toThrow("Ollama stream processing error: stream blew up") }) @@ -1337,9 +1305,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) await expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toThrow("Ollama stream processing error: Unknown error") }) @@ -1353,9 +1319,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) await expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toThrow("something else") }) it("should rethrow stream AbortError without logging it", async () => { @@ -1365,11 +1329,7 @@ describe("NativeOllamaHandler", () => { 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 - } - })() + const consume = collectStream(stream) try { const settledStream = expect(consume).rejects.toMatchObject({ name: "AbortError" }) await withDeadline(settledStream, 500) @@ -1439,9 +1399,7 @@ describe("NativeOllamaHandler", () => { ) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify tools were passed to the API expect(mockChat).toHaveBeenCalledWith( @@ -1499,9 +1457,7 @@ describe("NativeOllamaHandler", () => { }) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify tools were passed expect(mockChat).toHaveBeenCalledWith( @@ -1540,9 +1496,7 @@ describe("NativeOllamaHandler", () => { }) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify tools were NOT passed expect(mockChat).toHaveBeenCalledWith( @@ -1764,9 +1718,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // The tool result should use Ollama's native "tool" role with tool_name expect(mockChat).toHaveBeenCalledWith( @@ -1801,9 +1753,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // No preceding tool_use -> fall back to "user" role expect(mockChat).toHaveBeenCalledWith( @@ -1864,9 +1814,7 @@ describe("NativeOllamaHandler", () => { tools, }) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // additionalProperties should be stripped from the parameters expect(mockChat).toHaveBeenCalledWith( @@ -1950,9 +1898,7 @@ describe("NativeOllamaHandler", () => { tools, }) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as any const params = callArgs.tools[0].function.parameters @@ -2006,9 +1952,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as any @@ -2062,9 +2006,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) const callArgs = mockChat.mock.calls[0][0] as any const toolMessages = callArgs.messages.filter((m: any) => m.role === "tool") @@ -2157,9 +2099,7 @@ describe("NativeOllamaHandler", () => { message: { content: "Response" }, }) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "http://localhost:11434", }) @@ -2178,9 +2118,7 @@ describe("NativeOllamaHandler", () => { message: { content: "Response" }, }) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "http://localhost:11434", ollamaApiKey: "test-api-key-123", }) @@ -2202,9 +2140,7 @@ describe("NativeOllamaHandler", () => { message: { content: "Response" }, }) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "http://localhost:11434", }) @@ -2226,9 +2162,7 @@ describe("NativeOllamaHandler", () => { message: { content: "Response" }, }) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "http://custom-ollama:11434", }) @@ -2256,59 +2190,69 @@ describe("NativeOllamaHandler", () => { 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() - // The silent drop is a diagnosis trap (bare 401 behind an auth - // 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(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") + try { + 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() + // The silent drop is a diagnosis trap (bare 401 behind an auth + // 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(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") + } finally { + warnSpy.mockRestore() + } }) it("should warn about the dropped key once per handler, not once per request", async () => { mockChat.mockResolvedValue({ message: { content: "Response" } }) const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "http://ollama.example.com:11434", ollamaApiKey: "test-api-key-123", }) - await handler.completePrompt("one") - await handler.completePrompt("two") + try { + await handler.completePrompt("one") + await handler.completePrompt("two") - const dropWarnings = warnSpy.mock.calls.filter(([m]) => String(m).includes("[NativeOllama] ollamaApiKey")) - expect(dropWarnings).toHaveLength(1) + const dropWarnings = warnSpy.mock.calls.filter(([m]) => + String(m).includes("[NativeOllama] ollamaApiKey"), + ) + expect(dropWarnings).toHaveLength(1) + } finally { + warnSpy.mockRestore() + } }) it("should use a fixed phrase in the warning when the host does not parse", async () => { mockChat.mockResolvedValue({ message: { content: "Response" } }) const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "secret-host:11434/path?token=abc", ollamaApiKey: "test-api-key-123", }) - await handler.completePrompt("Test prompt") + try { + await handler.completePrompt("Test prompt") - const warned = warnSpy.mock.calls.flat().join(" ") - expect(warned).toContain('the endpoint "the configured endpoint" is not https or loopback') - expect(warned).not.toContain("secret-host") - expect(warned).not.toContain("token") + const warned = warnSpy.mock.calls.flat().join(" ") + expect(warned).toContain('the endpoint "the configured endpoint" is not https or loopback') + expect(warned).not.toContain("secret-host") + expect(warned).not.toContain("token") + } finally { + warnSpy.mockRestore() + } }) it("should wrap an error thrown while the client is created", async () => { @@ -2337,11 +2281,15 @@ describe("NativeOllamaHandler", () => { ollamaApiKey: "test-api-key-123", }) - await handler.completePrompt("Test prompt") + try { + 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")) + const callArgs = OllamaMock.mock.calls[0][0] + expect(callArgs.headers).toEqual({ Authorization: "Bearer test-api-key-123" }) + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("ollamaApiKey")) + } finally { + warnSpy.mockRestore() + } }) it("should attach the API key for an HTTPS endpoint", async () => { @@ -2349,9 +2297,7 @@ describe("NativeOllamaHandler", () => { message: { content: "Response" }, }) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "https://ollama.example.com:11434", ollamaApiKey: "test-api-key-123", }) @@ -2373,9 +2319,7 @@ describe("NativeOllamaHandler", () => { message: { content: "Response" }, }) - const handler = new NativeOllamaHandler({ - apiModelId: "llama2", - ollamaModelId: "llama2", + const handler = makeHandler({ ollamaBaseUrl: "http://127.0.0.1:11434", ollamaApiKey: "test-api-key-123", }) @@ -2393,10 +2337,7 @@ 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", - }) + const bareHandler = makeHandler() mockChat.mockResolvedValue({ message: { content: "Response" } }) await bareHandler.completePrompt("Test prompt") expect(OllamaMock.mock.calls[0][0]).toEqual(expect.objectContaining({ host: "http://localhost:11434" })) @@ -2459,17 +2400,11 @@ describe("NativeOllamaHandler", () => { [{ role: "user" as const, content: "Test" }], makeCreateMessageMetadata({ abortSignal: controller.signal }), ) - const consume = (async () => { - for await (const _ of stream) { - // consume stream - } - })() + const consume = collectStream(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() - } + await untilChatCalled() expect(mockChat).toHaveBeenCalledTimes(1) const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions @@ -2539,9 +2474,7 @@ describe("NativeOllamaHandler", () => { 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() - } + await untilChatCalled() expect(mockChat).toHaveBeenCalledTimes(1) const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions @@ -2670,16 +2603,10 @@ describe("NativeOllamaHandler", () => { 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() - } + await untilChatCalled() // 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 - } - })() + const consume = collectStream(stream) await withDeadline(consume, 500) const clientOptions = OllamaMock.mock.calls[0][0] as ConstructorOptions const heldPost = clientOptions.fetch!("http://localhost:11434/api/chat") @@ -2798,9 +2725,7 @@ describe("NativeOllamaHandler", () => { ) const settledPreAbort = expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toMatchObject({ name: "AbortError" }) await withDeadline(settledPreAbort, 500) @@ -2837,16 +2762,10 @@ describe("NativeOllamaHandler", () => { makeCreateMessageMetadata({ abortSignal: controller.signal }), ) - const consume = (async () => { - for await (const _ of stream) { - // consume stream - } - })() + const consume = collectStream(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() - } + await untilChatCalled() expect(mockChat).toHaveBeenCalledTimes(1) controller.abort() @@ -2875,11 +2794,7 @@ describe("NativeOllamaHandler", () => { [{ role: "user" as const, content: "Test" }], makeCreateMessageMetadata({ abortSignal: controller.signal }), ) - const consume = (async () => { - for await (const _ of stream) { - // drain - } - })() + const consume = collectStream(stream) 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. @@ -2919,11 +2834,7 @@ describe("NativeOllamaHandler", () => { makeCreateMessageMetadata({ abortSignal: controller.signal }), ) - const consume = (async () => { - for await (const _ of stream) { - // consume stream - } - })() + const consume = collectStream(stream) // Abort while the model list is still being fetched. The race must // reject the generator with AbortError before chat() is attempted. @@ -2968,11 +2879,7 @@ describe("NativeOllamaHandler", () => { makeCreateMessageMetadata({ abortSignal: controller.signal }), ) - const consume = (async () => { - for await (const _ of stream) { - // consume stream - } - })() + const consume = collectStream(stream) const settledDispose = expect(consume).rejects.toMatchObject({ name: "AbortError" }) await withDeadline(settledDispose, 500) @@ -2993,9 +2900,7 @@ describe("NativeOllamaHandler", () => { const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) const settledPassthrough = expect(async () => { - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) }).rejects.toMatchObject({ name: "AbortError" }) await withDeadline(settledPassthrough, 500) }) diff --git a/src/api/providers/fetchers/__tests__/lmstudio.test.ts b/src/api/providers/fetchers/__tests__/lmstudio.test.ts index afab44875d..dc86b757ea 100644 --- a/src/api/providers/fetchers/__tests__/lmstudio.test.ts +++ b/src/api/providers/fetchers/__tests__/lmstudio.test.ts @@ -79,6 +79,26 @@ describe("LMStudio Fetcher", () => { ) expect(hasLoadedFullDetails(modelId)).toBe(true) }) + + it("warns with only the origin when the connection is refused for a URL with credentials and a query", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}) + try { + const error = new Error("Connection refused") + ;(error as NodeJS.ErrnoException).code = "ECONNREFUSED" + mockedAxios.get.mockRejectedValueOnce(error) + const handler = { fetchModels: vi.fn() } as unknown as ApiHandler + + await forceFullModelDetailsLoad( + "http://user:pass@localhost:1234/path?token=secret", + "any-model", + handler, + ) + + expect(warnSpy).toHaveBeenCalledWith("Error connecting to LMStudio at http://localhost:1234") + } finally { + warnSpy.mockRestore() + } + }) }) describe("parseLMStudioModel", () => { diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 17c53f40d4..560a3570d2 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -164,6 +164,11 @@ export const stories: Record = { const { ModelInfoViewFixture } = await import("@/components/settings/__tests__/ModelInfoView.visual.fixture") return }, + "ollama-api-key-help": async () => { + const { OllamaApiKeyHelpFixture } = + await import("@/components/settings/providers/__tests__/Ollama.visual.fixture") + return + }, "openai-codex": async () => { const { OpenAICodexFixture } = await import("@/components/settings/providers/__tests__/OpenAICodex.visual.fixture") diff --git a/webview-ui/playwright/vite.config.ts b/webview-ui/playwright/vite.config.ts index 6a25d792f5..0678eb76c4 100644 --- a/webview-ui/playwright/vite.config.ts +++ b/webview-ui/playwright/vite.config.ts @@ -15,6 +15,12 @@ const rooCodeTypesShimImporters = [ "/webview-ui/src/components/settings/UISettings.tsx", ] +// Every story gets the Vitest toolkit mock, which draws a text field's label +// below its input. These importers get the real toolkit so the story looks +// like the extension. Add an importer here only with its own baseline. +const realToolkitImporters = ["/settings/providers/Ollama.tsx"] +const toolkitMock = path.resolve(webviewRoot, "src/__mocks__/@vscode/webview-ui-toolkit/react.tsx") + export default defineConfig({ root: path.resolve(dirname, "gallery"), plugins: [ @@ -31,6 +37,15 @@ export default defineConfig({ } }, }, + { + name: "playwright-gallery-toolkit-mock", + enforce: "pre", + resolveId(source, importer) { + if (source !== "@vscode/webview-ui-toolkit/react") return + if (importer && realToolkitImporters.some((suffix) => importer.endsWith(suffix))) return + return toolkitMock + }, + }, react({ babel: { plugins: [["babel-plugin-react-compiler", { target: "18" }]], @@ -55,10 +70,6 @@ export default defineConfig({ { find: "@", replacement: path.resolve(webviewRoot, "src") }, { find: "@src", replacement: path.resolve(webviewRoot, "src") }, { find: "@roo", replacement: path.resolve(webviewRoot, "../src/shared") }, - { - find: "@vscode/webview-ui-toolkit/react", - replacement: path.resolve(webviewRoot, "src/__mocks__/@vscode/webview-ui-toolkit/react.tsx"), - }, { find: "vscode", replacement: path.resolve(webviewRoot, "../src/__mocks__/vscode.js") }, ], }, @@ -71,6 +82,9 @@ export default defineConfig({ "process.env.PKG_RELEASE_CHANNEL": JSON.stringify("stable"), }, optimizeDeps: { + // Pre-bundle the real toolkit (see realToolkitImporters). If Vite finds it + // during the first page load, it re-optimizes and reloads mid-test. + include: ["@vscode/webview-ui-toolkit/react"], exclude: ["@vscode/codicons"], }, publicDir: path.resolve(webviewRoot, "../src/assets/images"), diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx new file mode 100644 index 0000000000..49cfdeb783 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx @@ -0,0 +1,72 @@ +/* v8 ignore file -- Playwright component fixture is covered by the visual test. */ +import React, { useState } from "react" +import i18next from "i18next" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { TranslationContext as AppTranslationContext } from "@/i18n/TranslationContext" +import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext" +import { TooltipProvider } from "@src/components/ui/tooltip" +import { Ollama } from "../Ollama" +import enSettings from "@/i18n/locales/en/settings.json" + +function flattenTranslations(obj: Record, prefix = "settings:"): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + const fullKey = `${prefix}${key}` + if (typeof value === "string") { + result[fullKey] = value + } else if (value !== null && typeof value === "object" && !Array.isArray(value)) { + Object.assign(result, flattenTranslations(value as Record, `${fullKey}.`)) + } + } + return result +} + +// An uninitialized instance is a valid `i18n` for the context. The stories read `t`. +const i18n = i18next.createInstance() + +const translations = flattenTranslations(enSettings as Record) + +const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.ollama, + ollamaBaseUrl: "http://localhost:11434", + ollamaApiKey: "", + ollamaModelId: "", +} + +const Providers = ({ children }: React.PropsWithChildren) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }), + ) + + return ( + translations[key] ?? key, + i18n, + }}> + translations[key] ?? key, + i18n, + }}> + {children} + + + ) +} + +export const OllamaApiKeyHelpFixture = () => ( + + +
+ {}} /> +
+
+
+) diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.tsx new file mode 100644 index 0000000000..facd2f285f --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.tsx @@ -0,0 +1,35 @@ +import { expect, test } from "../../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../../playwright/mounted-story" + +test("renders the API key help text in the VS Code dark theme", async ({ mount, page }) => { + // The full provider bundle leaves bare Zod references after gallery tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined, z$1: undefined })) + const component = mountedStory(await mount("ollama-api-key-help")) + + await component.evaluate((element) => { + const { document } = element.ownerDocument.defaultView! + document.documentElement.className = "vscode-dark" + document.body.className = "vscode-dark" + document.body.dataset.vscodeThemeId = "Default Dark Modern" + }) + + await expect + .poll(() => + component.evaluate((element) => { + return getComputedStyle(element.ownerDocument.body) + .getPropertyValue("--vscode-editor-background") + .trim() + }), + ) + .toBe("#1f1f1f") + + // The real toolkit text field draws the label above the input once upgraded. + await page.evaluate(() => customElements.whenDefined("vscode-text-field")) + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("ollama-api-key-help-guidance-dark.png") +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/ollama-api-key-help-guidance-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/ollama-api-key-help-guidance-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c24fd3f2c6ebd3d7ebde3f2fdb9170e20471659f GIT binary patch literal 51401 zcmce-Wl&trw>}ypxVr~;cMtB);1V1L_uy}Wdyv812L^W!4ucNv1PJc#@aO%VbE^LL z)ctTj-4DBVt*-8_uHCCwKkIqcj!;vPMMWY)diU-fs=S<(#=Cd#|GYhu2p`^BZbn(1 z-o3+kCod(g^*!^b1Km)|qw_V;+3MRj8A8*$f*=kgBwhl7&$GG(8sE%XrG_1K17?j@ zJy(~O#GYH547`?a8jrhf8XFCc)91C9s)y*o)oQj`Sdsr&U}3mV5|V!V9W?&?H_DxF zmH<22E=n&-uJXONBf1yA+xV{EOQ+cB#G~7eNAUUiInIx-Uobwz+5XoX%Pbf1(+7&L z6ra=oZN~Zk+r-p(q11f6K##K+w|FI}_S_>byTv`8BRZZ*qR`k8xt!r#tqSbCypt=d zjSkCMq+|#)t`@US<>-*`+YS>kW(pvVS-0{o(Y5i4dL>L%}e6@7a zG0?GV?Us2&Yeeg#$I_Oz^vPD2{?gMGw)BI1vQBd2v5tYX?~5Iu$9h3|MO%)4OKdU1 zrw`p(h~ASoZh*k0O!O1qKM|{Aun|5-Em?J)1UFPRk-Jqtx?7Pusd$%@2iYd8A-@(* zW{2ki6ra=apa4SMG)7;Kn-6K>wZtRYPcP<)jpHfK?m({-jxx0%QOlgKUksJ+PQ}}% z6%Xyu=RHd`trVoRN=QSQowgp1`jPXccU2D1PP}bZJxWBBS|HzyVj1;6^{ViZGp}{L zerKbJ)Tml-WTg@$Agew<8$b3v*h5@uUsLfv?mket<^)kfmiWhqDf_2pqmE|-%Z@Eh z@QO#*>UmWAbq9XLE_DcP1Z$SXsER-iCShE->YRGjfTr7`6f{i%bSFbWubs^>^0_Vunv3D)q4 zwk?`op{<>=U*b$C@dcyvT#55DOTyTgT)aCkxBEgG;IWmZC2}V#&SCB8G9|-OAjBKF zhkkL>1CG(>7F!H{;-s!D*_vL;w9F&UQR$G`XR{lg%&#?Czttm3P(ex=Bi{apAQjZn z{-ioCu#8dC)S;C3Mnh@RV!GG6ws+#?=)Hv*TgUh5#`b&Dncx4&<)?z#0ycNiMeZy( zD)hS;==8gscDoaI({Dv@uf`|Xq6@drU4=r}qa<7$PpiV%CPoaHgS^MrY6H%HzgUy6 zJpHMAJhnYVN{(f$T-7z0$Lw&gG3)VoJ0+bUrJ*3au~9z@*;6f^6}*GC2IoDju3tA(K4Y087Ijluw+`4O|ATAhL>HI0G~EFWNew& zt5V86q1GS<6?HtkwdUkookxep4~eq`qDxok5=?D%l;n6arw|Os88JkN&tP6va0ra? z9>Fc(0-oCNqFGGnK&2j3HYKqUf-z|=a?=Rv-KbP>c*M+Y#}8uSoC>o8TGC~>&7TL_ zix^z6c5UbMn}gl26;*}XgxQ={x4f52-RR11+uJqi;^nQ1?R{RZl`C;jkgnuer%jVw1sHm zQFb#BA4_h~fU+*nY6i5Lp1*c(M}=uus?~F1ZO5d-pv&;F zKJdL^TUO&z5Dn*oREYp+6i0n`TrW#r&cgIazB_?^L1oGD_Ry6zyvb=L-GMFO;zd%X zE!-Q$qGeYY^HJ#wh78C4ZfI`6LM|J~F+;e%OU7aT5*wGJ%!{m>d3ChoC~@Rsf;DxyxnCR zZm1_z8;Q|RSr0Qan#RNE8&}s9HM=?I)Qd72T7STe%tG0%PT8ppa3NJf35Zhg<8^_eS;cy2H~ahu3BH9g&dXK^TNpOk zp({F}{hG;KX3YzDw~j+--|m)trplz#vV3E;Tk)E(<6C8L<=iy&JLNAJD+rNsaV3>R zQjtfpo` z`+u2{UuhHXJ;c9%Z%%OvIoCrMO(J$ZisC&i-K{C1vnnml;k!mr>)Df5+)+O!)n3n@ zkJW9zFY%hNv6@0w?%dtDzGOvkyx(|`G9S}fMFV0fsdnC4i|N}x=XdU=`v%k;7yl?| z7!UXMKzxm`%ft`{93DH}eYnTx5R^XTLpouB4;efV*9?fUZM-8J6D zNp^MBm%#(vMxjuOGmDq0qo!cPH zJ#fh?BrGD}blDO0uoUgzj)hQAHDQoO0Q53yzj*P8SS9bcnlXQEcn(5FK5a26a4sTG zPX(o5Dk}p$-%idt3TtnbO0YzTRj>IxTO?*SA6do1r3{b9WoY{$E*IX~>u*Sv&kn0{ zbRJ~@fndiS>EJOs1Z9>OoVpjjpXX_Q+{w2lvZTzwz1+BF?{_`nRDabiO2<^%cz`w_ zDoZyQ6^%RaqS#0(3S4UiAycK^l4j2b);c*^j=&AHulh~2f}(Kp{Eu1e6VOcEWsHsI z^7~TNWPO)mP_Ns7DPFJkGT<1TiIg^H@@KQSV+GGT*#8_!jSV7(zFHqT=FQ|(2r z(5RcV$5YYP+kU9%c1HxVtmD+YdWW@dn>lPJgv4Lx@5wl1vVeSs)^1_0 z`nEx<)3<)R^!#T~>*V*Dy&+AhFb$a`)E)2bgOFno$p1hp^6MAB5Qg|PL}&9Qjl+X)q46}e|cazdnhZKPNn;i|9L|JJ|$=AWVj9RG$$~yUWl8+ zX?dr8mgPYwu94h-3(&Dj=!_7fS6X;=%9#^_83CR2ta$mrV|FfqeY*0ig#YHiZQ09p3)bGXx2;8f2bOx+nb1?kLQx?MSqGyct;+alXR?>8X_ zGlsAW24hxc3bDGo|F-q4!k2Br45MtG?N=g8->`D@kjZh?o`vF0=MWNa98%lz3@r9}*5;^wb;_tVUts zVsc4Gy+L6wrZ~=*;+t79a@_3GMghhqG4)|%j~88YS#6n7_k+DsLjp1IWaF1uETH`< z_E;(=<8^9t?$`zbU=5<@*Lh)MV6a^tP0Xrgs4%zzZJFR7bK8UQG3r0yM9BnVcRg%` zIcchS7TnCv$8vIzrS1wPvT3yk0|u_&Qjlh(RG*IyUWR+3fefRJI6mG@Nd@TDl;)s^F_PFS+brOcH9in3PVg`FC?<`ZP@& zn~f+ZdX>=XaRiMiw8dw{AXDx0r=oC`zeA4*Lu+h*+>9DncAU1qi}R59q$axbI6x-@ z#6rM5XkV5fHVN`BZdUh}UG7|eeM-EO=38&PSgY`^a(MmSc`@F15=m;}E<}R)@0yA3 zd_Zx*d3jVX9(z}6WmU@#AC(C(bND+ROXC->GBI;V{PVUy-qMki_~RcX{cOqP7^J+p zaBD>NNAt6q#!=Csr#d>uPx3yTxj}Cf3)O98*T3v_Al%v+Ibm=a6^!j{Jm?TAyG1IY z1D7~NX?R5Qt=Nn6=@}TAJiuxrd%f#U9)6LH8l$tqkmBPQf$^NI11mGF?{8Hm*5fb!Rq@{Q0>z)n?;fHF`W4YnCul&9u!XlW$;2m?l7U zb7zbguc5uMU}~@VoBd()Bz|}ELP{iW!X+9x`GT9f*2_J9Zw-EAM9;d=bHT20#c@=p z_vHa7e}C6YtNuD=v;1n7Vs*((-DxRsL!tAA2Hn z^ZxN4quQUG+HOb2CL?WaH#-$=yZEZXp)#Y@P3?~dbTXNKS26RHCwfiSX&&L$?&Pa= z1vl=GSI|ajFUQBYjEUQ8!Z_RV7%!`Dr)Ph1n@D^WR_5lP#VtpnvtougM1mq_tp%3r zW(C9w|HB2?nYb@Yr9|Fz4y9d+b-^TP)-IzQ4mRZac1)-&>{m=YPm))XHogutTF)eP zSfaen!_+&xs?2&@!*W`%3HRs7EBuaE4;;-R5rfOoyVegxtj9@!!0@4kc9z2@yYb!q zw{&)6d18OMQ1d--v^|{y%@dMAL241-zqM_}cm{`?b4| zs_HEb(B-V92Xt2I2NGWXySPsF_vreCqyA564um%cDLHo={N$iMVLvgzvM(T${x zko4Q!CR_nx@GNi1`GjWX%JWs7rB4a&^gKx9miu|cx)FEBmN*h#*eJfn@vwEftcB~C zbgbdg>vW}@IMw8ChKgaps$2rBHCmXCehMz0839DvAR4t^ zks60*^6iFxbYuaYkCe=5w&mPqH74F0r`^tM9fVrv&)sJuB`J4jso6W^f9<~+Yvkrz|Ttpu58J2`V%hcT`G?cqPD2hqtH|IQ&~pf%bX(x7UMCS99<1^h+9Z`02_SFvJgM@ zYj)}=K!Zac6_r%p)2f-3mB)AsIIJ1r%p)O^w3e7al_=cRc32c-P% z+dHs+7^%?{F*DI)8Kwop2p8UH%u5xi0F~^xWyui?AXM;$OS4h*^+B8W*p1E(y|dk| zOXn?}i}VUdvm{$r`ASH@*mikrqfHeL(w@J znZLB%-7`Mg1s%>+tTi9U29jR-AW!IQd-CUyAZoH2lvU52@ZPkVbi(}$uVPj?Dg7sT0 zCOm)43jWk*LiPg4>)0DUp5^{GxTK(7XgY}@YFa=HN@{gd_%IINXa!a_Yy=m>j_@S! z3E{c`)*hybV;V6PinrdxSetKyg5QM%^S_1v|Bu{mQbhZO!k37NX0E{FW4_g2t>5Dr zDI)VxkMTc?QZZsnkUau8v-#fZ%Qfw{ z9NiP4>)@t=BRDk9Dm{K!Z^au9}z9o7*n2?dvf_TNYp%dKW)L zdFhy>88q=0Zgdcx07430r0gSFdM)V|X}?@2V?c5t$(DF{)Ow>5X-WC^#pxFpKKfr^_MDr=3J*(-7iTJ~SKnN5Otb z0x;jVgI}mwFUg3j!RzL4=^Z1VSvWkblqBD82Gs(zR?+bCF$t*Co7k~D|IRh+;5A~E z@~Q7ca`>iRx%i8Q*PyTz2Yyv+c;$VaqDrL4v!w|XgNPSdiPwo@EAXrmuJx0`h_ zTUjwH1>coQ^G-QH4HJYtc&KnPfASB8(}GU>f6v=!#DM(>H+ zUTf{5{e8Rg7R3h6>XRDpQePu@|!v6jC4&jhLcF21KpY2Q7?es4~&ok zQR}~wP!){|CK{wMhw`Q?eC?9PPu7Qe9@!S)Fd@wYcN&v`c2nE&OPe1<7mf_lqIxxJ zXD*5p@XMCKsiQql5S-rS&kFR(OF++KphsUNeQ>`Wq+Z>KhX(h$RDRZocxE^zRM439 z?=WLaOJ{pckr5~z94Rt2p7sK_;ezNI*0RPa6M`5r^+`E4GPJ=Cn--$Ml&;`8bNh|I zieXgG%*Uw2?Qhv8wL=B3x7klM6Wec(v{rK$k%bFajhj=S(~8+=wObBDXfwDOn!Yei zs}+VE(iN9Qdd{}B+x^g`#h+6C++?piR$;}6%@r1+S=Ga8v>4mUTh1OZ8w$B9P9}oa zh6C39TGQFm z05F)vN-1RWu2N6+4pOtJWMFj6Pg)Ei;=T>2TQHr$OW9u{jo*j6KWK@ep;|4TT|GFv zz%CianaAmhhSU6h(E1aSG#*EiNmbl5wx4ccupPVWfp^t}npVIlC;ZSE5mSVxf{W(a zQZ_`=Btu`UujmO#E82(4=KA&!Zm=73+Kj}i&1RL5L=EJ2DXt~0B4kKK1elEoaFsAb z$+E&0H*u48)qA_*Ya;9{KXKUV?GLRZUmJyka{ZT-EO}AX%PQRGX~G7U^wrleQY@KR zL?u`l2wweo^-KXUML}c|24&YHy-R{7-;T<b8r5qQH#~ZKMFHANE?FnlfJ5e}<%# z8QuP=IZdmk+nH~u#x>JitbBH8ep-T1dxyj1`f2+SQ9;&6@|CUW-`KWy@mP`$N*P6a zlGYNBIcePN?y}~suzi1aFq?hUMb(0x%h!?2$ z+r~Jc(LN{r%qikmtsyvIA2tn^)gZ$dnV#Q4$WVw>%lRPfXPC)Z<6NFToTp2RGa@ZZ zl)-#oJ^5MD>}LUZ<+q#ar>O=0nwEjz+z#m4CuT}8`idY&E?9xVviN=97l-1I{qz+r zf;oQh!WSG)Ok%h4JZon)v%1M0Ufp}~y#CLUG&on&TL4Tq>x>q_gwj5*Aub!4aLKfu ztJ|EywS#hNt55S4#Z-KT!Kf9ZQC4}+n0b+zK1$p>QE^RVec+^39s2-B-=gQ|V)wbZ zkEiuWl+&{{3f?bsTGk=ZLM!h*3jYqhqG6N7$)z&8QoD!%rX2p=&^f7?V|%KVT_s#@nTL!~C7Z{Oiz z>!aW)7^3T`-T9K#ILeh~5RS5tu+>*uo2{9aUj{folqgC@ugArwiv3GaU-2o)N*F|< ziQ@P+XBxH0*y`#9iS2*6AaS2_iYErurlQmjdY+#@ZA|}h{cYrZN^0ejC_1;jVoLQ3 zx-H=biV$$@X;El8{XCXAPNi+x-`_vXM<;mr?h-T7LAqu+6J!mX(meYqIf`>s*NG)h zYgAUwuvc`nM7fyHQfY2Pj$VOr@&~`gqpUaNg~(=CS4BA-v}foR!p&+t;C|EHj0j{C zsIKg~(vcHR{zt+ACZw!=^l<=uzsuzk#NQ%(lJ`1ljXnu>S>hGJ;^f1r2thEC#pH<_mi3yve&FL#xOz7fO;|?r0Tlr9Nu#A>2?eu=fB)&@ zppvOKvh0STU|tyOIq;bL?NXCh#TqY8M>-oJJFA6KWDd4g$ig(6UU=>bb}Qwmpo&RS z=XSgNr5haEm;HRM=n&R?l77@@Ymdqf3fWsh^3eG1taO0n@w2+TVW(pXDTo~}DiRiQ zmBgC=m6O7On``4m3IMs>$#j(T9#JxezJ%1V*#9X<>GH zD^0LKfo3f#U+qZd7`(xFLG#g^x3}mJ-bGg71k#Toy^{_A*I9jcRLFab`Cef4yI1+s z1_wvHqh59DGPB0r=Oj0BHI+1_dL4tUt@|Te7qi`1r5hp-zoFEWTcOlnpA@htb!dk| zAYQw_i70~waLD<}GLW0I=t{8>Y5O?*!wLDAwBieVG`qlHkcO2NBUn4jc2d&l9;OJF za3&Y=yMqy+r&kji>1u?&U4+~E4B0^3=o%4P0GQ}Y*w$X~*`+Qur4lhIFm5y;rLN); zOPFQ2_DN)b%i8+|>A5pP(pMx7ew$jS5FOc;P7YXX;}vr%a!FcBo1^#p4xP4l7ORKM zOq04RDuYB>x$NXeN*ve2m^8QSV~QRyvFDviVH9NX1OcfR@L>%NwU8PNA-PaKCZ=@P zS|nc6RRz=65?EU8teR7Pl9^G|ZgXH@Mq|FHqLFh~sNUmT5*N1C&#=qSzUk1^CFo-2 zxc&Izs1M7B*wo}+Svu=daGu+oqjtAlDcDvESUdAH)D}e+fXf5YBJ5E86Qupg3@HFI z;F;EMR#|4hzmMNEJ+<;Aaj^OkP;B7FYj5glYe&SacL2GU{rMfvblFi-(6e|iUBjUS zre>U2)E$sB)8eIPMd?Z0MWD7dLcOnOjq{*+DXWSOvBVZ7 z9phMxE_T*;hsqmn>m8;w1GzH|)~XMz)n`#%)2i@Dx;hdGH-oHHSDYe}EK z-)hR|4|WpznR-Q11kR7Vns>sLs$T!NQN{x&pr8nlN8CRSc0!Pd^mH`C>I7hP2mFVL z4#$IkBb`6d_kG1i_$2X<#G4TIf0>-|CH$Wf?q~$`_Fcxw$Muz103cASXVvmxLLpB` z+HsLI;5bE&HyFW{-saHXlE>)Zi@*JZ%~c(SHSz+SUoeA-|<+TZKn)6W7-ueWR2d0LQRk z=EC*{_!f_P7XK7>t(MrEvo;cG2XGBYfd*_#Qng~|53RL(M^ro&83Bw^>=qCxv9Urz zX{!o!65R2*3}pg18N&5yC``)yJ7hXJb!f0#J(v{_tp{nM@MBKwKOZmWjGP!(#mBlc zD@HZ0)HNqM30S!cnAJ;*f{ni%hU`1^_wziJ>`R&@Y?3@^clp-l#PHib@l8^k>xIA# z?_A{LAGCdj+dxczYJd}nB|YW#H~Q(<$!WVkR23t?_Y=)?w*6WG^|GyuCxl3GvGQYY znMRJkOpV~TDdt2P-c|xX=i!y9PMF_uAKQRYncA@8R zU1sjAq^BN8%t6h{qYiYF)6_H<6LU^0{0evRcT|gbb!zR@>)~1E*pAKaE#-@?`N8T` za-->J-J7{B4u2Ql4pPUE7$XLXaC0h6mI4Nnr+vA+x)%F@jA8tPfHK+|7l zl#EaM%@WN>fNgc2zSG5>#w6QObiv$)Q@|BRLmU~GvYqcOvd`R}rQ zZ+=rOM34nYn=zeA25F}`(=cf;rbIZffQl@ytr$$40a#VzWAY$mQatPe75TQ4w4}r; zPn)RQHa`SJou3JtBWX+DJNPnFI0}N8#N|iy&|4)^xfr|Sh_)SL=sRG_w+%B}PKv2H zPeMdcrosdF61>zyoQSqEmSRa-^Fu|Z&%lh~n{l#{R^-8wn$fG%fQ!!YJ9UcP0NZ)|FK zqiLlSC#2AWmM>yQ+%68Jr)*Q3RD6+QY!_rS&Nd{^byQb6%V{)>te?ov;B~1lpb0l@ z7&4IXurV7Z1s;!fU|Ce3l@6gRWau}G(CAfv&jS2Y#!S{E&$HiCX2do#IW_Y%rYC{E z|BcLJXG(QnPG?{NctNS!`h6W$AgE?__#IC~i*7NxY4eSn9q@Dby>)Vrnlue24%GR#2I6RzY zRA{S}DTFbTKmC*ywlaJmh5b8aMpX@h1T+R7u8M>@MbndC0M=k;+_szgC9^5FX>3c` z%qASBWE)O!Swf^DQ#(fLE(|M48YC>Bxsx{NG4)j$BO9$6KI1pOCXXIxOKlCB z%&M*pGsDt!?0db`qFAAF#Xr#v`HEc=8z}?H ztat}BalAfxK4TgH9l{IZ6t^jL9S;7kMI$m^TFc9UyIrkI(&L=MtKOAod^)7wA2ON5 zI5(x$ix1n<;cU{+561%>86iqLL9V)|>ELDM)h5fy zg|3$Ig787nGv$Hl$z+1^^}#y%GCL;~pLgc0f`*fcsPGTet&6ow5Lz^cd1ZENHV`WF z%gjxCvv%v}+b_ws&NL+SfaCf#V91csRFExB>?ay3Tp|5&El#T7W#2`h%Y?2wcYoqU zhefWyL+Q`>A^onj^2dX`d@LB8J8p46d39%qeg+Yptp_p%TKN)^g*i?kK$UB%{)~6* z^W~o}pQ9w1$2WD>n;PganF*^A0Gc^N&yi~&0pDN{*PRpNH)z-c#yP;U?ss|FxO6$Ft~8- zS6X%{&LA?X{`7bCuo@`4%@og~t5$@9f+NhfE0&&ZRYSXPRUK2Vr@t|~vQhg9-q(zo zUQRiQ&poEbbf>C#`x9cPOyX9MLWwRTH(_wZK~g%*iTjVdTIih+NcnU236s|?W}a!^ zo*8!FQ;xvVdjp`^_Ys;XZ~1DE%%A)Z{tlm=o&ztwPrko#wF}fTAdC^73y(1$CP_k< zzX%3+&k9kJ_v6Z5Lm)zMch9LtUB=}+{Z`wBg0rum!XLNsA!C=(S9AZv1w9SoM4}JtCZl4} zVy{hhj>5`Jk6&?L;X$RNC*2Pc2G8bBD^Bx(d$hCK{BH^s1f^nzb6eD{Bc@BYY_bBb z3gGR>;k`ljwdyJO;@_c5sKj%{1N=bo0mV7>>|2#%jFHwvSXf$mzNw^605Vv z)}G98yl;VJ`^bFNtn;kXhkhzl_Q37OZOsl4w+x|VD*1+;zHBy?Jn!Lw+yFonu;VKc zWVTuYukFd_xqKpah~LKH1g7ey6EZSguhxSpJ?oL^+3FA`fSBHl3z@ccsaX@}YMzdQ zl?HbVq{F5p-ctd(HUD6|r+UgVSliZ6)#OE+%n@CP$iG{xju05wI11k$(4-NsQXxYmj{QO#mw? zC@C{zHXG0J)RG{p=EMd(kN7?&Q&dWOW{6JN+lK;y$akz#IMoFwE#QyF0=w zqw?%%F&i8-kKpAXZ5Ca`r^6^G80j&R+eM6DyA1CNmzJC^-cEP508V<3GH113v3u0D=~?lhp*8S zHMIOhKO>h3F`OUs}x=%l^cg@t;FiPak4d4D~cr$T(>+D!CUNwYMQ- zd81b8j%75J*joqJzvZ?BK%y`Qw zC1}_S%r^cJwcYjUSTE)ym~oyi|JuJh&@IWjAf)Yrli!KoMDCgHYP!eU z&|};>X@_KRI_U?<#?l%!*qm=lyGfMZZQIq(Iu7DZ;AcdeHi=bVj}zk+6|Xx_EAti6 z$&Kd-&dvtL-*@yKd#%M6jVr0D0%)Yv>+_y}8esM$&e<>gDoD4o5%#BWCi_H0$p37- zq>|u1Kag3STs3|ZCstYvnT}1X)oO6;bp^I_9I6Ip@c-oyGQk`yvZmd;a=<$<=%t>a z9W=F{9YchzH@^sv&mTdBlE+oyKvDA1;(o zG?$ha=uCD7$vwu0Wg5n498|*|(j%chb2WZ#c}-R*3ItQCrll-})zB{Y1fO z-D@bhaVinI?d$LyZQKl$EL4*6X&z#Lf|zwg;-_-6b)%Mx8SA zAkny?jFbgf3liOW$}3zoodh<_9&;Ceo-#8BH^4m?J?fjK z#8ka!k{=qMkWZ$NF5%YRE^2fc$M1d4tN$5G%mS}4lIr9DnJOtN>i(k5xIG#N9Dz<^ z%ZyBr@eanIWY#7y8bJ3P-Q*=bP>d#BWop?(WD|*^U<0Maoxa=yH1QAdG|xf>7pmTq z43^u@u#`7haED|>w_=Bv1joM5y zwd6+?GzhNJkkbU%co;(qwd@&JuhU(0b*hUrh5(fQ9TdEZEYrAeT04!3ZhTRs9oUsW zdTEo>s4b|*?VxW!Bm0mkTpkuZg@O{RG*ie4Pr#L;*Q{;Mi~5>wQ(dBNkjNT0gGW9& zQs-nX=m@j>oCkCex2kO~5{GKaY!8hamc0)%>!(NUEh=t09NPy+)PA-w0w=&)BTWUO z#Fr+mH2X&+uVVSr#w-&1p`WZ=+E+YV#0weI;?>@}>iNI5pR9|~(=!0;;0bd2d zDQw;C|0@5B&9!-Hgz}jPt2`QeT5zcwfw;a9Pxy+S&WYKs~~^c+oJGnR&56 zlFdjXHG{}&lwx;i0uK~}LK5UB{k~-RXqO2kW)3dK4w3+#?r+^pIrPQQkc(G6bUv(? z&wQFc^HA7Ng1G-jK8w-<70AaPbC<-zfR4T0@em388QkO267!8?_EjaeouOsCOE^U~ zWNeN}64HK*mY;O3dsex!@}fuiQvK@to-lT$HpuIip=k#*c)e=vHFgdZ*}8I;C&oVPyCH2T3jB>W zWu>25Ijk(LI4Ix$InQ^w`5}mo#`t5w*IlhsoLgNRO=3}0q>XIFelFn^pYLWDr58&B zQJQZ>s4RQ;;DZLQ{`y8)Yw7O5D*SuP*3hRZ|K#~y^ zq!x^;X%cUPw;yHhowya=kV?Ha9Wr11uTSHr6f_ZfE&ItA`Cdy8HP<*Ln}%%ePz@KJ zkLym>#t*?oXZ*3aCMfYH^n5FJ|J>x7%7x4{sp&BmNz-!0B*-7|HKd2^M?~<}v>30` zD#@u0$cOBtv$jQEj7#Z*KnS?OC4c`?H-PZSZAvJ)w&9ShdEI>&nHYFuBMYu@Ljx2} z!VF7vHstb{N+g8caK3yX8 zLaPR}pThL=uPae?ALozBZ=VCmc)r1LD7x0+T#s^opr~&)bj)$d8c+NflGq(hreY*G z(AjW^V?1&DP8L6j15(u(JVfuhuH z?uk~X1M62$hzr`X^9T(d>~prjmOVsD_i6QxXx7h?kk|W;ufMA9NP$AWj ziX$;AL8j|g$Wo`+w*CCsqfxIA(IwhF_lvN2hXn3^GJ0`$Fxjkv2p%w+C|6iss^%lo z0L?95G&gW)af6w#%|en{(cNO5h^i%Qqi0~_e*Nu6ioDt^k0nw|hZ}RfAFa(+KWB(B zX_xjTH$FTUS;pX$!z&2Txtm-}XiH} z8~y!5d;h&Kfr(O3znn`7JS_XgA!UROgYzJ~k$}&rJAkQQ*2Jdt z)-;6BbS0LG@W+3(fo5o~31f=+M-z|&lwq__1w})APn_Uz^ut(^rTg;p<5M%(2kp}>-jxzmT_oC6AvTfw!^RuERw6=EWslkAJtcF6#(UzIXN=ge( zbZEoa3ZlGSG{vlZ5~@_AdxZ1A!Un!Pvk!jTsprsiA|g`B5I9+7!dA+pP$jcH??;$LwQR7M{#wG&39I4NMO((VAA`Xtx8*|j4{hg)52-7UV@{>I!_X7&c223a_Vx&hPd9TT{R6ULS7*pmjHKf`67YwKyP7Vpr3%C3Rx`w%;6xe$07N!fxe@* zYrb0NoW9Rw4`85{dOb=Bw_}#lEoI+K>VY*Pg66)G+{zOP1UvF_m%Q(s2pQ3Ru6|Kf zVW`a605d-6h+;d>v`-$i3!w(Z=R~8f~wfIZKINkZQHh4so1tEwr$&XQn79K$~pJ``riNO(LGvkGRBV9T651e z=XcNbd7jDK!Zv2oRssZwFv>qCuxrLzl&oI1jw8gXHsZ%-cQ;S&E`?ic3^N@1wvMMmJY^q~>ys zlSot%Y08z1Wo|zXWR7FxH{{nBoyu@3Sl$SiY^h@g6XGr9rItMOQiKpk3-AoJY zj5e4kIE5;&=p}Dq87sQz@e8|Yzyjt#Jlg6E|Ks9V#8PigE_mP#4LiMhSV%1E8SOCJ zm5V*5xDe62n!jk#IEVBm1p~*iM{V`;TYaaR68+Mr_RSLeY#hmx-D zLJf4NJq7Z)k!Wyx%(HdJda^1mPs@nPqTHazFXeg!S1wqfI6DVmnCz}-Opka+^Lu=P zX$4_@ID115;c2ut;=E81v>rSPrXBLlz9{*_d4!+ly2=wSi@Sw)8O=19Y55xYrIL@* z^^|L~#j?qGdvmXc$% z5$tZuKY|#@mEDTTt0in#I*EE%z$yA&WMbEF1ux z{tPFbxz(Bx;YXZ;wIA2*-jt?syLnUc7v8NQm%GLg)|=$iIPBWO$h>CRe71|5gYcsq ziz{5Ry)6gz_(FwgSuct_nh^0bI=|;V&~~Sb=Csgt0r&(#MXk7-w@Y1>k~S-1=Hbfa zL&Tu5<(Rnci^yRwRjA)F``S=wxkyxtH@Y+t4{je0f$LC@H9tRG-Oqo*fw5j%YH~#pnHZ%Q zWk|l}*!k-EC5wsOxhFEs?LcKfDc++~67=yl3jEb2+p3&EW!co!$_rodsogeAZF|V! zHc~Zn{ibJ;fDOsY%)5;RzXX>=V6kqXjDpLshjf=w+DuE5STQH#uc~?lC9-O}uPpRO zz9g2enhTwH+!?((!^vvHtS-~On%s6u?d4l*%R&Qo5z~u>;@pGV=|$M(+0KzQj^oM( zHy2jEaCw340(crc?dfV*IXjo_yFflY)gH^R1;)io@nB8?8MM10+ z-D^7^&mv#KFnLcqMe(?Zs8;j#8$jG0Jt5~2nb9G1RD+4#=)&h z+Qx$jR~p5b?)fh7MVJClTvgYgora$Y&+mL2sqzpC!cB@1QMYZ)7sRA@$F=*?9Vd-bzl4$^i0JPfjE$KMS;NDndG7U~j z1wtA}`FXysczis%Vb!PPV-@53nmT{L1@B_GfN$8@ZP{6 zfAfkZ7p_O;#8{bi~8 zbj*;XOgrd2YaQcEJlhlg_*FXz=VIs2>%2gEAZ!3Rj1N|}d-4#KGtygC`FU5=e6e(? z`p?_Ad;m^r-D~5SLE;Yv{(qo+#ovYsv1q4LA8=4|jKHv}6Mkt({S{>Ibgr*fAp6#h8`B6&lfG786 zS~`@;Dd%=;6D2aJO3Dj7i1-oyr)71wB*J>D?IzW0_@;w;whA*=bvAl49|0OvEuGUt z|MBaYxT%?y+ZVcq?-~wmP%;V!XiMoN!K%N3j(x!2kQxCwY8}_R?pT@{oOUx~ zEcq%LT^$uMF6Cz9H9HDdQ?y~4Ikwiz1BgOK`jA8U7^U_nCx7>tf^ehNnG!^kv3aa- zVeU7Q2(sntGsbD$($JAFz<$%alz(n~zEGVkI-2U~S za}ew#!-ZZtby9D5E6Jz1MAH6XxTZh&*3!M3u&p&+5AknJH?=@Vq;Azc>=f@8a+Te z?I&l@3cGk1En!oU89{7eNjhyLf7uxY?Kj#*sh|)$rk$VJ3o_B%O|3;#ZvMFdZCdn} z+OTjCAR3MAYyZ>d)m%bL zA%k7OAbKW>sI3D-69De`Q6l&TtSl*QOzvLpd^&!7i*lK})wOKaFNi$eA5BlJMuUgr zi)wpTcsQvAjW?LzJp8cAi8e8faiz1WJIzV+{W(AWfJ+Ia1pw|^t4Q^hELJ&+`n$)%m z0PXxPpUc+m_eQhI?F6bp<71|e)ea%KT7fn6axJM$p3{ZLoq}~Q1SAG|+wjq;H7}ok zIx&8Q@TKqXN|m|nrWx?@bS|sfC|pbgpaVhCPkPx@EzHUoW`9trA>aSTPX9w`0k*tp zBLk4A-!Ebz(WE<|9o*eHtEN6A7O1|yzJFUR+Clj-Q~_@QKsuo#`2N@0ef|~u z!NtYJ;Gjqoq6fscZ#jSCuTs|5*2>C>u72116Y}zY8){fM(PDX&N~J#qopup$qf9^k zS^H_{P1AF}Qps(n56DAe3X{CfZ&Y^I_t*Q0bWY(9Kyq$uG?~%f-v0RL2m=G7aEP89 zgc|9qa+%r}@5fkao-E*4K6Jm-$|8b-e(rN5@5g)ro+PBeRgODvc(O`dSLqLs0oR7t zX!~?y8ro+!fbHDLuhq)Nr~43$c`#R^+`|!Toc%GK5Tep(9Gsoan=p-yjm`Qc{q_-> zbhWXGdiY!D@n!L#u&@xg%7_SA{ zP^x-;?){$1Rbv}Z5HqH{zq@OYOBIwL1F{eGWVE*V>VD5ET;Wc%@-Th(BYj7a5wzQ+ zQay;f2RP-#E5r`(!q3{=R8(Oq`z3L7hG&G4<=Q%BG_+n$?rWkY0#)DRv&Ox4lJn{u zK|&6+*dWsZ78vad!PHDT-qz-VzgHj|0U=>ab2C}DVvtBe)#2t`c8@gg!}Wd$%wd=V zttFRMTgO4u0M5!xm5!@et;q{hhR4+q0;zTTLWMx|~K@7rZ0wR*h_ z;@0DSrgo){Pa>k0`zdqoxtGD+$tm40%Z6M(vz$`K~-Ruo=b%_CP>J6oQj-&@}no4(c66()<@y6lxdI4G! zb$pmb`XbCK->HE_U+9p#D@7( z2a0FX6(WEWJ_GFMb^p5`k4w6s2Kg*JX8U_5Jp3l<1ERZvuXy=&<3H}yKH8UQ*X z7}O0YKbC-19Gzeg(h}@DgZ0~k?ZL^3g-RRm%O7OsuSg9INZ=>S|_q?D-s$>Sy#)A#K*yjRMA88Bic0e|_d5A|}S~ zwwd6**I`BQej@O-Ihe%nmH6{zVRp80AmY@i<+z}})HYDCKZ4i;Sdh^}Jpq>h3li>0 zca~=1;8?zGnhh2p;Pcqa3dAX{sBr!w2cZmt$T$f!DG7=wzQ?zNxx`cMa*Ynl^pFx5 zypUm)4*G44C7d$t4mmzJba%JXs-6rSOoP8{{NQQt?6L-8H`}Va4oFD7M1yn%gCx5h zkLSDOT5tz;e8?VwUU7+T52;6G)1#}dYp|5WOz`Ioqz$I+iF`2)W$vCTa`yZr^vjbN~9QKq}pBH(t3_&w0X4S?Y4s4`zL2+R8(hlqEB0Y#KyVTJ*P`cBfPoOhWj~p+T*JIMAx8RA_Frs zGB_*_7aAt!^GVIb*4ahA^U307223e$_KNrO$_AVl-@!KS`@_JlyG;~aQs;N?r^AA{ zObMV`2zu3J7WR^GiSK@y<#fKif|~o)nA|lg-R3yA`#cPTT~IUxVvPY0hTDSjZqQh` z2nGcS$@eGeI&Q__y9Pf+yq^8^D_%FM66qQECf{}=<peEV%Dhdy$h z^#;$dG>P8tDedd4E2r~yNnq;!tjkr|BP%Dz;A2clLcki5j@U>F)B>dH{vX9fdY*V< z2A3;B>SElx#0C37KtMoHP|#A1@rPpWI~s^T|0mML+sgb7iI;E+QmHhRCOcw+pKnKu ze-{ORm0nXftX|!%SW7>|E62K-mVU=msWVe_c5XP{2mAOBztf_j8#~V1?$-_ON=VQ6 z2K*jS zzbrvMva@~IXMn;cw#D}CfK?1Q!v8qLBznKc-dh0ckKM>R1Ypaz8IlX|R~eGPsV3RIBw*&1|8ZyeTPSAq zi~fM};_sg{4k!QiY5#n_^zkXUBrO=Nm-71GKWH3q`?6jm3LHY25z!mw$*#jV7C3IX z9SQPvTj{_5WF)VTUh!WqPzVwP{ywAs^C-sulFazmOGApeZ9gggA3p>M;CX;I_n+zw z3I>J)3#MglePLnWhF=n1UjMglC?NrXP9iN(WbNd3bl_%^1DE(vkBEfCWVu}X=Z{X6 zO4vl@?AqE|qC?(%NA9_c)i zfm|+=ou7)}j%`x@e_y6fsV>5gbO#3qiGJPmSrT0d2`D3|hw;C~D62np{apRNJ>R04 zs2H>Ne2%S{wlT_DZuj4b2W4* zHmABRQ%hZ${5=$fH+ zycOD)x2zBWf-bJ_AHtVLv7hV#=`X-=WSSyW*=@lBOyLC6#|xwtgs5(q@P3}B=^aeD5>IRMmoe%1t`DAao1Df^$jxA1mh=~59NP9grb-~EA3(yvoH zt)AN4+&uGsf*2*yTLR2>uSnpn4pk~##Bv3|$-q6Jh4%vm^5gZsv!mlj`Q|Vq$<56z z9D#LdPANr%ph|_}UH9_JGC3HlKtcSWz11M@Jm?Y{*}d}rhT1RoWQq`Iu zk&ktThtHo0jmOiHe`<3^pYlB1UCeD({XL*ue3c;9n@0@gkhpM#dU$NabxrdH+M)F? zLiVaZ{TI8l0*5CjND!dCLICsdOIQcTpI=v47@S#_#7c{v8W8;T2*s*=U=cPz*YaUW zmY5~FcqWH|Q7S=B#)Dn|81BAFWUEjKscZx9#q7?7dZ;h#)>K%bFKZM3c+DrhNv1{=-v)Z@OqAf!)>Bg^!Pq=;HYoa4e7QpPwAq zuwGwZSyG|rUBC7~0{53HSbEd~^2az5_dTQQqYM8}h>GM6DNrP9{o*>)dgaL%`hKrW zt6d-uE{%JB`80FBNF>_!n19%$^^Y`>X1-biO^&p)c1X4W_4Dp^BrWgzVZoCY34S!g zIjgt(mC_3QsjYE$cHzmp%iI28dRa8@!zoE$A}(&;Kufwv^y|(!jtmnn*D`kzS_c`$ zw!3}Kuwn|Bpqf8hgA_$gDXP-FrZ9Vpo4x!|d z7sk#}hZ{6i{*;JsG2AvQ zxNN{Exj&<_%IG*PnoD2FTr4{FCw-ZfXP-}xYeXfEg-dd|u-A*}^C6NO$+!EH(5bcsB-2h&G@ISIAQ{U{oV z@QCkU>ZUHgDvi9LgvmPh`i@#{-h`^eqN{q#A$2TL4(IB6i~AAqqE^e}%fzco;hYU~ zrL|by)wi1wN2asuhwY_wTHdC%=H^nX+xpco&#}NDR7pGuAkl7;)cPeR7QAFFuy#$Q z;avt>LMX}XwFh&brQ>B01eWVTWlGerE5Xs!CjSJGxW*hM0um-4@XV6f!UaR&ndLS2 zVos>#bm@1i4y|0Z`-A@4@oZ8Yk3U@nBZYbD4TU*m+g<7I;os(wsUm>dHk0+!j zdVR_DVy9`ZZGxLyFFl?0d~(iF424R`kN)jS?~A6h%8G?DjS_(aV!nucNLby#THUrpH&_6*4GxbeTHTNWVg@+@Jrnr+oLtHYSm2(wRWt7;agaod^`aR-eb&S2ji z8n-p<9qXnd(7HaPQAz4N54|`XmfI{lRt~qcgDT&#c~>PO6;US2j0)VyllkXcpMwK& zTuK+&SdG{8UdIP;+%AF{N+?*}yU<`NYQU<&VO|fr#&|chI2oOf`@cMGtp!5wJG5ka zIY~dZryI`qeQBad)_Z)Kt8UI-lPI~#D4kc`r_rP$s z*@SM&u-)||?6GpD4!neu$bMhwTQV=5MY5o0MO9M|O$vEzSUbfJqt%WE6q>{}7Z>Z> z&ec4Mpn~{^&R-q%}J#6-SHD z(>R(HB2_L$zl5OK80FgTWeW5T56ru>=@1=Q8jrfo`|K()j?tXg|E1V6q&Yq{VNvsy z{e(k{R_;@M#X)FUx^jJ@FWssj-r~7YLc7uCt#g_B0Nqa@P4VeBN`QLv+gGCM7w#(gB=cBTH7=Te~QnOt}Hg!*_tWA*yRi-Ze@BsgX) z&3(c8-8J#p4~9_RUek!)g@mGi+5K=WcAX+27W28&MimS!$qsr^xcn>5$P%_9h*NV- z>`>J+WZM920`opk%2bp4Gw}@f&syJ{U?93IfzzR{(=Ofc@3nx*`Pe!kvze>B5MOsq zb*t>cu(^_)lzW`oX^=R%U(iJTO2Im5n>kb4{shU5*21S}q!~Q3>`&jK?OfOOiksNk z2(1sh_FnbRFLYHsSDPJeg#yS%8#f@t@j1$m2S;RR^N>Ks+>`JsmCZLR)lJ3zDfaa3OvX@7BrTDCNG0EBaO2haTdyy^Af>EFY6z&!#4HoFY61q8kzh zl~-4;wolcbJ|EwvQTr#E`Wm_VN_<#Jx45;;6@tu~cW|87$((d;s`Y${beNTSh%-onc{>K36|;_EmoWl~#{nf~rh?-~ zOna`pi%;Ewti@I3u=3^WjcQ7-S2Yvu3X~%&No58etRz9l_&Up>543q2De!AWGmIHn z1f64TU(sYs%Aa-~U^!K8$FO6*j7(?4sYR%z#qMcjteE<_`Jx(g>O<*X&nwL;(rr{}FT zaFp8Y3q~rJ!s`M#4MvoW1%eAVU)k#yX~4$2{)l(Cs%Ww`V>>0ssQ#j>5fH02=tjYq z5v$`-iEl$&f=tbPz1Uv4LV*IONy~6cD%N?O_IaIneOg*`+jTR}VejZsVEsPRtAYp- zYxJ4-Hvia(=hb}s>Gn(Hi&Ne8&)c1@^bW7S!_6Zn-Uj>a1ZwY>Q~?BT``^0v_PcKG zmEH0bN`4f?>bS3|pJBGgxxJUsTP?R4y?bnf2h|#`Wdcp!n#-2D=2k)-9cb%}olmz{ z>i3x6-Oh(%=Wm@~FRksh-L95oVT-t5+C%3~k0d+Dfk*WP%U7}NW< zW1P!D>HI}L+Unh&cBsD8`{?l&qLu{tAPdFXc-VNh3}5W6%qvnc7Ue;%w%AIlSfG zfXn}Ik4;Um*CNU3j8zTzI7%l~p*kGyZ zY4=dTA1jnQ3M`SfHm#C=2a`JUJZpM=vryJkcHTA7CJt!Y(o?II)7^=<9M{0Y zD+L$UCm#9Xf}wKNBGR=l+CC${Dwu{)%o`tpjgl}3fi_eHPyTm zFl%L;t^Gzfn{osk-bT_n?)_~#SfB{J)9B^z?Uy0PS)SZFqM2F?!?7Gz2>FFYl ze!a%lG$IU{M< zB&0%p&{+X=i-q>5D|gE`n9-=k+0j`QdSp5_EEe$JB>vyAQt=q$QQPva2fhbwPctfB z4ZR->W;%G3zeCoa_g1mA-Cmh9`&j6Z27f5sq+?vo3n=M1@{1QFkzrEjjA|t9{sP1B zQnqen;EpzU!Dx;5+Qf5@MLpjOPom0+`PSJa1Jk3Hu^H9K0kdYi$Ql9E~>inMI9On@YRinN_h zVz^rvFGs)o`*!&%B)@!9msE6cST#GYV5cI*>31l?fYF}yaEz!wOIv+yX}h^h_TcES zSa|$b@_!^{YQs778bZmK$EJJnWc;aJuIR`azlX-eqPMM3J~CL6?%qKJ z_5gxGWmttL%^z4m1#PYewI#(&;wA%t%~M1}d2BvbWVGy)M{>{zex$oQ`$Xc{Op@|? zSw7_b2OeH$A|q2|yVHv=%(E?Z)hp)`iQjbZO+6J|*{f5uqHwk4Z%^q9XvW9JXUCC$voK7w3QRS%V;*7{7D_#$95D}5kZ_Z-%13CZPR67drnv1U_WvS^ zQX@l>N0wTY|3c__lIKutL7qb~Nu@0tMOi{lGqV*&wcuE{_)C+# z!l_$9yqtkGjRFhGDVq)wNhyWI`D})5y|q7`@sET>i|FB`V)J6Eg?d$P#R`hhp$52p zCZz;y2J-W}Gz)iAQRo#+K$57+ekhi#Lgj!)AuguAWBzSsC1itzsA60)KGZ)>h6MFk zT-&XzE81rJ0OoL|6doZqB|aXVRKHkCS=B3djzDpD$Cy}biiIV%Jo_bxxap^9WQAIB(l@b3V_Wq6_kgqrc+W3 zWEA?zz-eIDw3uaWk`))6Bx<7J{Cke2`IgcFZ5WbrelK^3{F~%TF8{vHc7)Z;AjIs# z0w%;g^j^-MyO`f+ZdGC z$^UrF_$K5_$wsG=LhiJ${`AV(%BxP{*sm6sJyOVDmUpD=Ecvq+RY5DtNhVAyyiJ>1 zMu?zwrZM##Q`@FE=+2d+nwglIz3owD6;QQV>dc&4TTjy;(}fRbt=-yDTC`9IQlQAp zZ~CBTE0}Ld7}B;bi1is;lSR=-!)WnPQq)3LKRj(YWB5awO76CVsJj;?A!E)PQDBY? z$OvOJPZpMmCJLK{!qzr_B^RBgV_msSiSG8x6611}O^J{2=#beU?$sHcs%HD5(Yk_^ORKS5n+Rj(l>mrL#F=Hq-u&(CZ}54cgtZ$=gK1gE*1K7iVt(yqS(bX zXdRxkCfTA|CApI-B5`H;hz0^xtJqEdu)JYZPSK0cNYA$`YOrKIj;gM;Xve?UiGrkt zj@NwiIB-CuJ^&}VB>rf05n#j=M?q8bks=$zUr!a?wTkpcoxS*)RaX`XWy|I4IfyI} znXM8d0f8WcD<*{r4m1;eCVkY@JI|D z&Y>eDb`sK)VUq67Y;QT*xSpNx4U4JV#_$x%s0Sqy8kj_w^_A;<51s#yw0r5QY|Xdw zP}Zq4%tQ6V*b~Q8Y}NR&S4?JF3(%-5h30zQfo6G49t$`*KM*ow$oe5p-oa?|U$lP% zdbh}-n~b^S+zWl4hM^ceOG)Pa{F1G$xL|+vxzT&QWEm<{Ry-ITE6l1hnC3BomLn;= z@Mz9a<~=X7%8g00%FdsVyO6fFL$;V()k#FT41t0qrT3K%{^pZrKe3S}HBqwou?XV( z`zUrF)#a0E`xuH;X^?BfYh!wJ=?^3ivJ-IFs>>dBDsS2RqPO6ZKa!ov6?Df9sANv2&wvy zwT<69B0j{lN}HNkXIQVCm66qarhNvi!76=pdN>YL)q+_*#U;6Ocs4b-h*doj`WB4T zl&A!UVcuv~sosK21+##5Wp0oyBu%}WKb6ceO~NQN>xRRZONfS9WEVyrk-HF!eZa`d zj6R3KlI5FzsqA*^k>VhhWxP@T{3w_&wIcmmHYVlLra&2qW+)*gEARB=vV!&iRc6d8 z1%Z)hgqntSvA@4;?==U}98Wt%F&oOlhhUAt8zN5*v$~LVCb)>*Tn;;qNr}n|6-G_X zA)_!=PDNViUAbGjV$M(2N{~IP#R#0o&ws5r$PRZOWy?TZVIG(vh8E zMI`^wNr7}&(8qPf*eL(u&-49aShqNd&!0`mUPk*32>mjZeu`yP;4hxl-{;y(Nc5_a zG9DUJrJQSEK0G>F=@>d=-0kpjS`MG}Qwbigb4cv(8?i8|Ipr#2ES!nAO+Z`Rr?|$O zciIb>);yaUut`c4msJC2w(3P$Q&Mj>*We9IjOqDDx;6l8Dgpl1GZKTxFn@a7b@$Yu zWf~?SGBi567PT&?0;`zWM`7@ep$j`mZE-zv0c27eCk6Y@<>7?beM$eZwY|h)H(5 zFxb_1*7W+dXlzs*FJA*QF6et=;%NPd1CWJ`+_K~ms2H{RLyH7i*IujZ&t9CYB^jIN zZ7vl@LzZ(koTOU`RvUMiC3hz#L81~PqoW&XTJmxlb8pUI+Q$E03xK}+{kl$6TW?ag z*H}uW$W}v*hLAI?Rg!Q>S%>yn=|`++>99m(4O88uukS@<(ITj8&~@z%;AvaeCgcD# zZ{K!Ry7ZIjw&Lyfq+%X`RoW1^aY6nG@?;4XR}U(CUOy(~!q{%lrEaX; za3e7s(r>yi5sfbNSJqkKwKAVFeUlpy^)|<$B4Ua8{@cBl(Bwp)lPvMV9LeAx|b6agA)?AuG_TE`zq^6($|IKiAdcup0>IQ`g~G$e1(Wr z;QrOn)8KEibgX4H9TBS| zF>Tcl^NhXRfX6&3rlrneQhiKiUX16nHZLVBGT4@T1xr^|OOi;8r_b3at|;xSm+vA$ zLq2hiV^wS0C@THjT9dTx_;F9Xl{Ub zJ!NOSlh8I z<_TFK-((uD0YyIxTHc`FNfxByhh9O+U(dngWvB3d{I0{xV`2r7`#LWJ_eT`PGLD$j zRi_4~MN~O0mdxQ4P=D3h{ad6bFB%Vz!eMNwAs);){U`4j#>o~D%i71po{Y&q)j$uw zTHfg3JhA?7rCo8Yi#H`zEj95xI)|OUfx~adTI5^RqhHU=5`IPb8t&wOh4U!s4-Wx` zIg8We#ZLpLZP?heNve*w39yvzRfr4}133NduuyjwY8{!yMHWMSV7VW1IyizGrsg$w zG@(Scx}S$I2e5m^!wEP?sZi0F#BiU{OE*~e*@m64>D-vM7h5O?gG(t~E8<)APgWq{ zPe|d~vqS(k-!8VOzgkZ(y`u~c_Md%3v-|jf2ZH|-IryJ#%gmg9q20pbG}CH2WLpWe zQV{9RMS91eT9?l_y19egEZQ0N=qYGtK#u76ua1XUzn_i$7~tGPUQIMJW#idpaHQ#D zyAui@;COX$tTPshYb!LbX5AmaZ0BO97X&<#NB20(&0MQ8ta4^PdVR27S8cQF6)C^0 zmk!tsH+HsYPzgia(%)Vrx>38>`ErjU&MjMw>2K!}3!Z7J!L24nMl{O-7edGhbK1el zH>KSDy|t{NR(x(|a-+g+g_hfuk|=>jV69#r3no>vSRw?Po&&cij!aRkYD`EHgB71u z?B1^IdS5Z|%UO8-XQ3rZz5;7=%a!*l%GumX)xySHHqjD$V;&-sCXW}9<7mk+;W0EDvZbgbdniUlh|=bHDH67xQxQ)%6hfLw z6{7R!Mejzuw_G9ys%E%jpU-EdAM^7lo!}p4u6q1(h;$mo;dqFa#Str^*~sFdLL()%l54lx zJv(UTzYl44w(k!ddJ(=|*pkWWn(LjYegWdimt zY7{1mhp97{H!&~LXcn@{OglrNrENg$5wtcEo)cQ$P&FuTVR7e>|( zy&>{@aF$4o!l`xMAXwIV5$;%34h1}L_rBSWzIaN{sLzoGyAhCtlS?b=x~z1{MuwWN zp34?@>YAEY=Wz%QTiA+L)hne_lMlVR3)SYEEtI>3Qq|z`YA&fbiS9{Jp`VQkqV-~u=g~c@G4V8vq)vWehx%)B+kA^vubWos8ab~z-?Ji}j+zk4Qp_^6` z^{J&$mMThR6NFa0qP0R-TLrR>h*-?8G&p7qcvua#-GyuAE0Ir&MJWa-tTSbH2Q*P<;=W)wuSHpIYV%xzI%-v<8{ za}yRlBd+ZO*>;~uj-gS0{^E{9Y(hTLs)2`S-xP1ZsMQn8c0S0r({dRR*M-Fvhi~=$ zt)^dL3MSyQDR0ekH#vJa#E-?rij&BcgSfQLOlCM7V|Gq$?evjwA8L^SOF3U&ZR5}+vlAo3s*v;LRr3eDMh*927A}z zSV!(J89?r41Lb;G>c@(cgY=c#^rX!>WsJU}-(wPK=i5}c2Vsz?c4{h4D7HjVj*WS7 zQq&Cc@2J7vxG#+3tX{4!Y1rPOMx#Sk<>_ z+^#Q7mK`FH-30p@jHF+|u>nBpwrt#YTs;0derm=a##}n_a`v!XD_d0<*#0}kIu0Bg z3Bh)ZnIY@AfsJ5^`C6|qV!JNWJBw34GN~jCA`4Wmuho^2HYLTZqmX-@Pmp9ZHV~BF zdN7so9V1xL`{tT9BE4=RiIMad``@9%(OLJ&&!(eXyB0-3*)+?CGt^HsD}A!sfqi8A zV+sB;zIR$rtvdO$)47~;?o)w&zWh52WCHtA&Z9ww_S7*#lFqfPnR&^a4rwfcB*J5G z_g{0f+wTxh_iZPg<|m1zL(U;~8~92Ji?CD!=Fqng?y$W6wH705T3*)02lH5HvVv z8~O8-Ld(kWrvgbt2wJTe9CQpj4JZUD%vu$&A34TD0(>^}ImlBne@ticA^r7qZA?sL z8no^OPw8_!`2>0Jh_jq zaVw=7=nQV6*+8jGWOBGiB(pC|H;kYPiTwCQ#pAC8WX(2@bwi+UbUN0Im~n&^dh}oZ zY(?qY1R+4^DqOxY>6%ndf}`?~v2ihQIXsODqK8EP7!oHk#!M*%iL+=HnVgI4{Uwlv z51|DGMPKe}{==QpKIJ=$GP3UJDK5szG43zIvGv3*-Z9>f=q5&z9tgJ%y?XvwGV;{a zv}sGwYp1#1FAnKssviVJ76FwFsW=Cm8?;H-%!Il7X@pFA^P`idrCmsVV{u>ZXPE9T zKy_thXHNGylIS2t`rhs;ikAc5A!4*@$mn-!BV0$N46=>M^^N>gCT_p==Q6eqzQmyA$r)7MI4n0yqZ^N=Ik(o0KSdE0u*4i_Ry{s_mql{cb* zmtG}r-=P1EY^~#)Iepxo|NBP9|L>`<7Z_UrYxsLFWUP07AObu!Mb9MQxBU^HC3vv> zt(&gv`uzMnz#YrHOQiGRfJ5v#hXeO8#!c5t+Y8P4_zq~K$1B*)M`|5_yfnRWIGs)| z2LK7n)qzW_na-W%)F3 zy(|HtwUsJ;@Do#OYg!mHCXBaaYHI2U-gmBD*vebF)%LU|k4HNyW}cUeA0I8b3NZ=S z>)d{wPTTPUY`c91yLq$F6A%J(K8?(-vO{C@a<$26i=6t-cUaRd?5^pNJ!pZ&%FouR2qoI6Dmo!86!F) z-VcO%LqbFCrT6F6t^LhkKK}G}Ufq1YwCS=R)3d2Xu1J8R0Kqz1>~>r4Lk$nVI?h68C-6YZKrt;4@9l%=lkCfy{1_xD6dQH@0AB*e~%YpB1YX zXmIbV*v!uP9&cXHCnl1t=4OtM#uY+@19Jmr@b_&{KHUXAsz=?{<>loFTwWj$?1ANj z{MPaQ%==Kr7W8lhpfHP!n_ejr`52^?Cuih6h~Bqz%9|GiwXYT4ug4LbXBWi0KyJ3h z>GbIMnA7IZUt!Gq>Mk>b{Xjzb1Lb-cyzpPzrfdA5mxhOj|2*5%_`Gtv-H_1~6&I^j z>U@gmO!c~7bCyM6n*~6rzLdYWW1j*Q2-=QU*4iBSt=Xa8uyR`I`9KG>(e3d9VZdK4 zVA^wz!IXnPUHHYsLfi=|eVx}F7t)>Ahu(r^Kz2dO)whLgtl2lZZPcky?pyI)u#u2% zMzr6qfc-oHkm>~#e!wOj_BjGJ$??zB`Dzm)W=s;VP1*-uFa>(1Z-Yu}x8dgh{S($^ zL8jNTSsj7RC(JP%;YAi%%UdyR;us`HKgV*I7U+^L5GqNXnN%oHR%1zlYnU45nH&A6{(s zoF(e$M}=d9fYz~EuS;m+fPn){(I1de@SxGoG2Hu?-H}98RMdBX^%B%E{*0jvOigBZ zcw8oaR+^argV^u3iZ*`e3(GV3_Ui+f>!!il^{~R=bg6U*qBf~hi>P_I8lmNVx#(Fu z`=PTrTO=QpCzFUSflu3ZgV0iP=wH$P^!4&Go>I9R7xk7Z9hm|66D884hREwGHdN_f8N*9laZ!L=e4q z2@+xSHi+JdAVd!d61_xc^wCT7D5IC?ef00j{oK#{<2&Bt`|}*9$zkb<0Noi??-~(nqZxf2V!`on6(gu=dqR`XbG&Ejzq*H1xoOU$eR? zZ-DKP?Gt<38%an%M+{2c*KiN)oW>8tj@Yd01v>tKeWnzQSVaB`8yFCE;Y4pvfv zdzUy|;%H7vtex0Lb-UeP%yyfv2-ERPOJ|8vpKXmO<#eOI(9$YucY3K@6%4%G#z4xU znl%S6)zGJ>8o-tIuv?s`J1R-*A;Hir>@Zq&gc~kEa2tVp94FB3`1Y+kj6n{kNH1%| z(8MGL3&QB2)(FeOH0c&r;oRHjUCoK&8R*UD${(?8HPQvTw%sSucV~J#quJ=bIz!N zh1C9btv8NK$JO=dr$OnsBuESh2-rC+zcMrFE)$j-FVDun%%$HT)@}!V3G^B-UT_s> zebAbpooxtyd;q?la5C-9W6H1$r%NOR7V|y6T%h+RRwU9LJ1GH<`t`zNB zd0t0kxD1K+nOZZFDQ;{>hw0tF4l+9gb2vfa&*~Y^qhWq)*qrI_UC({ecp?I`b8muJ zpdTzBcO}T(C5k==9L)4SR_k;^joGh^xsI9+ZP&=isF2uKrmL&{1w|O$m2FZLTEF^n z?UW42hZ;qwq+(X9>_n68J@h9U8zA6>Q(>w-T9rikhP#p6`@)&}FZtcS+FUF1Z zXg$82ojX(D!30c{few)=2a0#@)s)dep(;{T+7h&WN);>KHoa#+4kIou-aYsA_u>Lh z_Yn|ZjE3FlSsJC>A;)UpcxzJk<$e#HsW#xc*QP$%@pX6&{*=#*N= z@kcXfJ-@47*74cS@hN~EEHsRXeq8k`P#i~vFkGq3UpFZ2vAG_zuV8lHob6#Zj1CQT zt9RBAoIJ0idyM8GzFPDkcHg60PxW{jYi3u}>8QAjJfkg&t-cGS_{@j@6YY??Mc1>9 zotPP<`bvieKcfz**~h$PB~30e{(|!`PFkOfgH@lz?AB37;)SDzr|3*$zNwj+*@VJL zXJAZ1a@e|aJY{<0g3BH5!s-n332 z_(@H8Kxgmzh~M9R9lWbwTIL~{Ad5C(7TLSpgPkDe;H$X`)eUAb!__CaNia=J<<$%4M^ z`=q{Npt#1LrKR}o5%2{Y0$Z<$(v_Ji1$3?Z4$j{EZUucbQ`)ec;P5s^UTJP-Eq;Fr zmk^UY*~G(6|B?N_Dd@U1rF0XhE3Els9~1ieR3)VP`CK~rvxOm1H2hxf|dF7HiqzecG%QDwiu)5YY9v) z-aCBgrrTD>2Zg*ajnH5nV#^nUAzn2|fx^{UVFt@GvhlByHm^s&WTaSYlJuxGRrQ_w z>SafI+mzyW6F*x=k8Er4>0XoS7a)b1O8km-Zv4C;k=KR~GiSmNy$t;oIvW{6XqCjg zZ~Jaolx3a`av~w#>!@7VsLFCXZCoBU*9F9qq&4e``(A;d5WgiaX&N*mR!h3sfLxQ0 z%(I)$L7dO^jThx#=+Snt(KlJJtc@b7>Zlb7_G6|{`aq^d-uPl6trZ5 zdM%^1qRm(WK-9+spx^VZ771OKcuAUlw7^+Pe@gC1OyE_R+h#Ocq?-H`vq0*vRnBo(b_OE;mCw1qepRLh+j z0&P7$7LxfCJCz{s4Cndl#lo;hk`&~ZOUr>+nNw9sl%GmfR~j#aV0!T|jG-2dK6!n6 z{vr+EytiUxB4W<0qoC71zi;k6nZ#G;wf=+WoF$gCDd0mEjek}g=6%#T47)5QG|E>G zxEdSFixtvcacXNj!}}7O2%<^$0Ln~a(Sb^4^N433+wjr|G~nRSAsca5@pe^@i#Kwo|K)TG|!hg3AGDwPqFbJUAF@lO8#@V7{!I4 zR0~CLM))h2)-Ivx-2e$6sQ9ZxKAA-vg~%|F=2=HfD6FQ{rU>I#@Nsi@ZPCY5o1!5K zl#b8P*>GSqPPU^PynXjlw3-ooa=Z!EPLUTySDv7&sxi=4PcAxtzSGFgq#n|zkeo>M zq5Ag=B>rcXx`VWMYAggpvV+WX77Fnq!>ACdzQ6V~t~(>dvgagy^uu_Eqw~eDLzq#) z)E6I|2yAZLjz<4`68zA-(2Oz(!Cx{0#UN!-w0~s1_ss(V$qAD9?ePpdDrQy3va5duVs${t!b(_-;c!*QS21YO* zJ#VzrjcqXcN5J6X7BF{_B4C9=DDb9x+Kl2p9cWmd+Q8i!!?!O8)jY%lYb& zlq+7yLLw~@zUSY+AAHA+NcXj?pfR`@62@<>r{h^DFGg0gG90>QiT=|i4M1zv>;PJd z^Xk9-@|$?HJKw&sCo*LT=-Mo+)VKD-EQtE1Wm+!(yjx#eE2+$i!TqBtFH3Z*wNKt- z6gj|CkuV*adRCkZ^jSCd0#!oE^WrTwBTJ>k-u1~z-gas{hi0Zx&c?XVvCmJt2}7Dy@R?g3q^d% zb23f;Pj>6tA7egs$zYGwUgw0gKkUKI-kYKuB_|@(fdUkx1SIG{vFDR?G34sgTGwvX zAs;X$kp1s1$gjWRsbvfnZqN+7sj6ae69w0RA5&g@_w(~}d>9;jafQe_#;ilKnn7EK zU?-EF7QWXaW3RX_ZEklbiv=v2J-3Fxl$V#Q&&w_xzOd-0SL2LlL*0Q$K-eUFfL7@; z3TAK=^qwz0pRrZg-U%qQkXPehmHLm3L%pY>{j;fM!7I76=Q{T2SE1wy{>K`K(YP5F zn17oOmewHCbU%`B#})w69OHm&rUv%R%YYHagd}s}m#|>8h`U!|;HJG+pbb1)zPmjQ z5XxKuJd1X$otdkQd7&YybwYB5K5YQjmEmhXFHA#lm>FkBNUlXnM^FC@v9kBqE&R}$ zXS{8``26E@0+K}v{PE~$98V^F_4n`jR8hBa%m%oKTJ_5f--*y!tm7PD)_MThcHrF! zoQuUu*tKj1eyp6Ck>Mtt%GjhB1h^6PB`UYCu)4Pc@S-6+Ow)!pQ0^$K-|EgA71^Z^ z9T0q3E`fcm%GrUm37*CaKot(TdA-?V4#BA7(*8fo>-I%t48@;pK?{JOoLpS`<~p91 z)o0_U>l&Gv(Odmn+h~7{=g$?MS&s;^$ZmbCgl&@5wT$9&fb_fGH!N+bj3?5$<+Scx zy9w!8StXq^NxEaaNj=s3&$2$c3*?043BkmGRYl`K;as=eY2N?fBdKubzoOpQM7HYV zCBah`PVz}n`ewTA`fAfEvBxF@BH0I)BG`PMPs$fD{12V14Y)K$#Y*2=Gwu%8!x(gs zX!eJGm7c@n6`4C+-a+0kA#XZMg& zy&KvVx=56osM;`O=OdF^L0oktZZS?O7~*-+M~3E!jZWgz1IxIWcZMW(Y~`Cw0Huy2 zZ~cQstPel3Xh366Z}h1ES?76t-|N~Dy#8ZMYMl_8Z%8T=+oi|q^XrlmWx@MiSM&p& zj950wNYqOJ@MWe#O_BL*>WqVU3hj)wuFktgGleGeSwYf+*NX=vuiYJUo5BXj4`Sau z0FF#MhwrPaLd}@Gx+KUx(0BniG-9Gngl=Rk=u$W}?td0zfuD;p>rdnVF301{f7fGl zQ{wQuuu+NZg2D_BtK` zj#QMQk&{TtZ_qw!1!)2Qpu6D?{idP58f1a%Hs_U7HE>ppsui%NWE_0b zt%w`TwMeyUmG!LV-p;kbn}HAfC4rW;sE5C+6-VT>cKf&ax!g;0mHceG&*VP} zvk!~+#9c~k(mPV5fpcQs>(bX1)HIb>SWqy>`{dPt3q2&Uu~~L=KdS7e9p4DNk;NfU zeuN2JCAz8}EE*-2V#@2i1UFA(CHaF~$!sS&NsHE!2%Q_Byo?#K^< zoeWcHZUJs8N1a^d`eV*vK{)j#>tmnxBsE8&-xiV7IMC$&fdo&L=yr`u4w?fx7f!@r z!HYhoYVq$%_=p-r^epd0WqdR)ml-VVNQzvi3Ga{D6R|o!XtnaM0g` zC3fB*!2$`mK;GR#L(S`FB>W|G>!3T}dEbMQwbBOsg_;i^Q z7JccxrzBri*dXQSTvMOgM1AJN4qnYOXqlozR_7z$--ca!xcLPVSn&_+(ipe{J>T*;T-62Z!1} zk?Q1E?gL^qudldTVlmd&*L5&jmJW4JJ^_$Vj1CrwLa5W<9jV}_o>6iTjJ91K zA;#X)8yXq43<2-8JycNx4)5Jmr;>#E2h9L_$s!EE?m;5*`Sa%>KdYeYr*a+Q=l5!d zslv#PSj(=S7G(jG81;@!Umu+j02Ka0>1VS^Zo?(Xol_OaVlw#50;f2`08y-3 zC!b76W0J^1watS#+6+yz=*2elrhHCIfW;nB;AXq_pJfN46U{T;1yTp=-dfThG_%f4 z>tt;`E?F9S32dahI{GPIVBp2eM9-r&a8lFAycna#;ySMGuFs6hj6(<-O9>(EUA$}S zZRQYrBf%ecNH{n#F|qIWD&U~`kI_MfA+RzHo+(#R@^jxQD>KOfY!^bnC&toWGRlBD zTyrDtZL_}Avuz$zkalA!o-akm#>e#Zt4?g@jz3+nNZ9@+Pk8+GmHSfH(}f*O3F`y`g6c&*Aqt4mrB`jUcU0Zny;zf8+D>fOo$Y`A|riWoyWviizu0d}@=dlv9K1mJG`QN z=>TXYygA#Nbj9B;cp{IoIM(cU@{W_^!L;yPjZUW3%si z@F^u_`mGnBWYf|j32AHnUo2o0;6f`qZtH`O!n&S-Pt?FS$$LiYR?>j=V{&pb z&_s#)JSeEWdlj%B-3soVDZUa{Z880lNn+HVg29`z&&gv{VgBpw^siaVmbsW-+mpE> z=wa)b7J(mg@DO*}3xHdJ1{XF-Yoznbdi|x4GY4qLhGqXfAZn>txS9V+ncB#BDf^NP z7W|xsf^by^Q+iy{b22h;=tmyZ>DrA|RC)n^oG}T@ zf1*I8VaeM}TYIddL*Bq(`eYrBkB`6b=MRXwQWPT@pHFFhUI;P5cuXxABdwLd>OI~$c7g+E^% zLiR5=FV@a`BcR69{7r41+1sPSYYC^6gd&f!4KSNsV~;*_^O+*c<}IC$y>W8_>u4b` zzT^S!o!pwG)2uV$V}K`<9Wj%MvU1Ns10pe21qJVEL}kwmL1LlA{sI=)Hoglatpm4a zF!r^cRvWjkLkU0h!}T%J47zf{Ut(ZSGp}kwfQ7yXW7z~;@w&gPnUOEz9Q0QN%~V=h z>FTciWDR;Znko9OOsZ4zpGW3DdsS#+Qj+$I7g7M9{pfzqe|dFNB5w#~F#1|wO#;|X z6_;X-dvoCkeF#WPOIx+P^FIHbG66qq+GeVqkLOU$+|f{9bpYSa1A+^+!5=d%w!L^X zyjMY?v5Gf4myH;~U5ums{qk5J9Tt1xR(Z?Nvk{uCeyq1le4%Vi8c|Ye;vMUiYr7g^K@EzIy^Tl^LizWxYgL<{O_g~05uL;KWPE1Ywe%&Qgx#w7)Vi9y>hgg zB6dk&Qk10ZT!dr(q|l5BiU_R&*LFqAr}>LK&wkO3O=D=j)2_U7MVs^}tJoIt06JO@+aA&E)_}9U{AkfQd^; z_)g$HzC0JpQ1KiGh??d;$(KxPht|&4 zkq+8&Zfh7)-cG{!fO}eg-TVW7`)GnLr8sAjyejSU?Pcd81zQDUwfI@*qD@aF(?Ep z{>p!>77hEosyHaAvw8|}$2PufdHZqH#t`ZA0ACX08f%U%^*1dmh}Qs2v2&7oHhTvV z;VP!>vH;B%{G7gB z#nuI&rl2cjY&*9vqnYzpwFGKkqc&DG zR7WZAXzvfTlJWt4oR?CYm>WD7;x}HL%{)BjNuVqImMy2`_g3$&>HUJ(oI)80hyZ%W z4MtTzs@*fVIN}9hqRAAx@_BGjr$5EJ9l*8E{qxcK=e5(-h7i>w__vLl6uzpKm2CX7 zuvcuxJT^j}jP|!5Ti7Fp8UtkVFz+88?_rNov3>4jiIH%l25U%jF1MS6!l0wIFE$9H z#7%^AOO+sZYikQ&Kxql%?E$?>{B&%9)D8k-37RjsUz}8a%fBR--cd*GHCbKZwMv$C znUQ~ceds^KYbhcNso^25X95LDj4iBS#6N~<0RfQDM@Jq>1n7aROcr9lOKpD_UOrkI zzh#z_5e{71@A0r{L-dCIaQ<{eL9{y&mI?_KsxwU+4IJ_4 z{w$}F%l+J40fOU98gAEsyJVqKzP>SGb+D}@ddeV}@Ye?uMys!9onX4F?|^ehlv&6Z z5(q2dqsfBVTD{Qm|{&CKWVd6kB7&zYIdMQVRv#V2w`;nHw!f4_-tC z{$h!j=9M-FixHqnceVc|K@XEfOK0F0uHvokY>ZUoV-IF9I zO5q#h-6|VR3ASw9VY_$!sOu!O9e*<}T#)H@qt^6v-@SwN=&*T*C(!33sL?QuRguey z{8b#*waL+Ncq_tPTx5 zMIOU3A2zd`g4~d4s*=6)l6%%cd8@_oSzX_t|9JG$DtL7A&O78YrNu2S58(B*m*>vCJ=#BnjI?2t4wccMY zM3Z)UsbF_)5&!OSp$UC$+-yrxeTn8l9GX?abJ=Tm7+YzaW%r_+AqM}1FWYL6{I12? zCeG3gFwXC$vTGsU&Zn$02`5T;O@$Q+FW%C*cJzPX2g5{{X5TY~l*4xvX@3Yxq5q{y zBcSg&nUQ2UGqx(+S0{{?1hJT`{+L+!^ncxsI7@=R5rZk} z!wPkXto?xc_hJF@Ff+tBkh0;v- z<_I_LpcBIy&n=jgpc6ks5?bxQjuwR@Hd@#Mc^c9kfC#**CPw$&rk~KRDu|-T*V}Q+ z8<5WUX|n_O8f_iZ-rR{ z5gYcZ#^0=N@q)a3L(bONN3nYnC!q@v?f=d$Ce`>S!uvlyoBxIQ|95urzaQ^dg!Hp1 z1XEZ^Dsa+=zN z@DEXY>;5lk|402lN^tk2cQwCz;@8>c_;3nXIvew+w=kR3*be|{rq=b#j912i2%q3q zq||Kkr(aEWIjw@5ri094B~%Y=+hUinnmf@2S2NY?Bo?lnSq;}eKC_vR&Ck|HewA)T zjL}?uYN2L-5Rpx|sXrbwn6G8G(f?5G?$WBr{k13)#i`{v?G7Ne`#JqK$;u+&;8%Mq z9txJfIsLX#Xga+i?ab8~9~C~&umo-U-KoYE^X)Z$seu^(I@O4V2kf{DKjp5oS0z?z ze$vD2PP8_V#yMh^L+_zV!)O0?lX(@E$5cX45bDj86YYB!_%mV_u3XOV=MZRe{Wvq# z2Dx<);q)TyqQnYR?VMD}clI*F_u&@S~rJHov-D}yj&~4M-W* z)@-vsxX$}%1Jc@>PtT#%>(!PjXP_xmwo3*AH_Qk)0a@Kc(Q)wLDb1h__~ z*@OS>DJYqw{aE8d4J`btdzT77LgH&h;kp6i8NEy6@=<;69Sn=u5SdQ9OIfTnTj#TK zCOTOR&d3!LW|z=szZpp$^W5TpXj`movvawN^HOWc7Cm$A{u2C`&LJG*>JQ_c241D+ zpiZB-S6(-+17G|b`QI<+eW*Duq!>K99c0{)dLDTrvYW(*52J(Ph`uNazYX-b%G;AD zY`ww_GhIClg-MLb(4ezjhnutQ?y#_6XyDt*r9>2bj*Pc@0s4+yv0-Ky_!uo-aBi&x zvY;%!9|kin^DtwTgqc>XuobzLSN9(Vg$BAbJz(}a#iU_GOi z*b3AfX|wR7pfYpg>l$LY`F!I#*H8Ejf#o-*d*rd?!gs`!Jx`?x89liS;e6KR#;SJO zs_=ujhKKfUfZODEFzm^F6f+YfXIC&Mc*zgWV-xtR!!y17|tZ!egn02l4+G z3!om@d=lzpmDv(xWwBm;&~Ew9j9>j4s8n!->)tQa1NAJiDpg_fv}oj)50rDTU~r_r zmzU^v1SwF?^YjVxQuJ}Zq%3=1LT*TzI~~?n5;=`L^nnfieN#nNmnxjw!O{Pg7TAfw zQA_53F=iON$%`!j?GDeobZa(dEiCK&>n~ByR=*wTW;&$V*svyRU*i0p|Me~%7!g1j z8L?Ts^{~e_cMoXqM=YW+$+3h8t~?waaki6g^}dU~o9R#n&Yi7LcX-bm0>Y4YJlx!` z7%o*_I43+2CX3ks#&~d+$z<8+Gsf>8F14Ka0pk>ax;DzG?`er~hAVW>vaULFLF4ix z1(j5Z)Zn!X-=GuXd#?&rq&xPlGLBLeDG zuxsKdQp30vx$O7_cu0wSx28%5gqS} zwKg!<-aIb43y<+1)+`Q4#O?I69x!)DYT&9)106hfim8sPkghhW^u>acD%a=NU=ov+(%2($) zroQz$#wYzVp=B#_xLiE==+C!c#&@5iBHj`@yJG(E!+*O%6cnNU z#O)2z1Lz3b`^#^hfzf?62k*{w#D6n*XvEhFD}(mWOVLvcnv~1DzkBXaSGFA-G@Pcj zUK~cC78UXSVTDbk4%YM{DQ3AD7JA6+>vCQ0!#M(X%3!<_5wq+2)3{?QKq+L!5%A-X zt4u5u%e8ET&&jq(*M8-8lM+2Fta8y!nq&X?qqWvT-~k{wwpe4~-!(w`1O^5|dbo4U6FHNYRwtOX zq+UV?P{3zZL)qtTt;QuyO|;v!1*uGW9t};<2rG#Pep)O07y+jvA~mlDU>XQH`eEDv z!8^3eMvW0#4|FuP?1k#))C_lP9)KG8v#?;3-!`OsmbHK%6BEPF$Vwq5BeVF_dzk55 z$OkLIQKjpTtuyp!>1uwR+plLYAwIO^J%nUyYeo{!r|`#@LJ2VFSe~x1FRIy?jGhpE zBvXs1>K)B7tV~7*#9G?~5?bSAX6%YGY~JqbKylf)w`9rS9PEeLo4y+Vs)yx0Vt{Z` z9ltd7`=-opnkA9cX76^642|gjU7PtmgSblBw_y|G@8;HgZ9H|^nSH!avN2bqA=89= z;=@7t-@SqjFUG9~Gt(sjxGstVD;OC+%pmC@=BlvPsTZlRZAy~6)x2>Hiiqavtf+3- z>y(j^h<@Znza()Gl=cs>F*sSRIvMtF$RHcUiL0MB*#;#-6m9Y!O>bWh__$yF$`v^_ zRKN37jOc1e6sEPL;JXqxD$zA|fR59Rb;Jc1?tbDgNykII)yg*inUQcY^DCKD$W1+7 zIOAd9OYQwA)3VJ$!sqbsaAmhYi8svvtJY1@F&4Uw1HQVy`l5r;tz%@g9e0iZhPOmd zZb^icUi>YsY!1d(@>uJ^HFTT$RozF;1975lN$lY7zij978v-(C(`d5!+Hi#S3?rVch!wio9^DE3P_LA|+Mmpp zfipkV`fmO>D~rx-Y0iS|j~5z`_wd86M!Bu=jF)HE7dkDEKXv2D>4tOIQTv4vRW_+s z?<9Z73O_8!Ft;S1q_5f%KL(z#vi4VbqNBeIMUSVXISmqse?%VdLf*`SfqaWbkm}*- zWLQ{nLIs8~RT=!zQ2b66Z5Erl;;ff#dc}ij4#RkpaZahxb#?={)SkGWO z{_@#B<=w1XCBDn^V(x{-n^X%xb+i*mGkvs+$DS3+{4ghUqsMV8uI3JR=YLV9@BgC$ z)$QlDt6AU7SVw*qSG@;XE|v8y9zgna(f|DM!w;A?@&Jq(sXpYhCEbsU<8mMfF?fhz z8@Ot!>WVl}`iL4CMk(k0V=b|i`QXLJ%GdECz}SX4PxGTudE?KIP#iG&-EhFfiy~E? z?$Z-vN=x}Sb1%D)&C4@kAgL~j)HA}U!=Hbl(-y``zMWRk6U-OkRRpK|IwSMnhvNXa z>@N>;0@9`>7vRS;@L|lcaL1U;8oiyiK;}Er4p)~;Ri^3EiOi~209pi#c~xsiD}H6XwuP_bG2}5?g}pfz)t()ak1YGaeU>YR8X*YJF;P)Gpg*e-rWi8Xrq|HMRoKWgFgaA|l!|ISEOL^m6^G~;oMPrMdJ*8bA>IRYVbWbC)_DZ`PQW&Rg; zRv}=7thJaT?x35VfTW#t(*tVhLo@C8AD`;fSnVif@{dmfi4f7V&!D9;JAlo*S7;^H zO&j@*8u(358MVs5q#e9Ljx70j*UaV5q%U;D>}c_q55nuTKlIx*MF`0;wB_l+HS$(2 zO1|&t`^~#jBROb(Oa(R2y#fP4>T;d>hs)RAKG6aE<@yr59=2e9kP{ zAPcQJ?fS435J^Z~HnhbhC+@YFB4b5EA!5dTU)ep}f(fcz7SxWpeb`bL6*?Lm`z@i8 z*Ij*9l8A@*mzmDLzWGgs-t9y$qsMxKMc&(*%eA`MBvEFuTtAoI@<*TZ(i^QUqkSN? z->a8(t^Ra8`!ZcDr_fFnYMgl2)S@{1sL@1)39~UA9`B8NoMO~SjBk7*)@R!QzJGoJ zG;Ht=_%TmXV_@FjMedQ?3yE}Y7kRf2W>O=aDm1fw9J*RN!%lJo*)s1eF0Wi8qcG;+!dI|ijDa5 z+a)VP{OYwtO^aU3JXV6sA71y784d<6 z67b|4r7rlavHy7{!=53k4SKpG*tqom#6yfv_kMO&qcZHv)8w7uCH`l}%$g>&E56*v_O8x}a51Cu}=!Js}a*NKfa8 ztPb;1mDQsbpsFK60vQ-txb!5G>b%VdF{WdA z7WKMe@yb$i__dBpA~&~?edY%?b}Rq8p5W!wkcx%**{EkSBR_VBwGnAGKB9pFndi}Y zQ(h|ezSk%y*oV)R74$tH=AP-_Bg7pFgeL5cUS{7a9;EZEZ8OllU*{^8S?;?6XOr^Y zI2^t>$9MpFrq}scT%0)BE#F~eC#@`J)5Z@o_4OA!jQZ=31Dm066d1P^{cJ;^fp5Xm z?8qhOnRYDFlDl(VgW%!SeedD4&un|R4<+n-_uM*9x=~$DoHwx z{(dcHm>p17aD{5^U4aI8RQa`@>qdsh2S{@XIM>o7?MBbR-2S9iJ!Nb z`|TQ@t<+yQ*EAxyUzM*48Ms0`9>-(}S5JRXVO9IIcrW{=o4N`ElTf0K$qHlWey~an z)cHqMzdNi%XRER4b}?4ctpZ#fl$0m9o-?E2y`41V`a{aDOP6S`i1={8j<}|vZ!+Uf z+vD?^E?>1Ta4H=fzj5ccH0p8>DwYwFE4I-(grbOm_qt(C_`-WN?6vcL&@3EcwFYrG9 zCVUr8+yEa_NRfb&^Qz&GY_wF^+0XS}KZM{^`?lH&w?i}3>)a2kJaH927ccc>c84oE zi?O~W@JMN3N%nFWzo>MpU^e0$X7nVY?0LT;qdO`NT7|wPxc109W|urNx)C3ZRe6l` zIG=46Ag-sSQU<-f!ogw9*ts9Q6S3=33{I`bIYChGB|TVm8=d8Z?pS8hxQL?Vq}GZe z+V-@{#%2Hh@TgmsH5BTVcoA>X)857K%fD){Z`nNV|B%Xpyb`lNCn^1vlk6IthKw~k9 zULajPMCvv?dHtkV4iM0OCzEOKK`CX;Kt^$M2YYv4><_(`IK8_|jPmX@(A~}oBx!+* zsQLxwg{eIcvbwpO?N!d*jtEf-C=9cWgevltxYx5%cMDEzTRccT#XmFYepX zE@$kv(~$W+I?6Vzpl%amzi`h?xuV*WyZMZ0|6|8mEfd^K6aJ`HTgT$pIiV%|B9?7V zveRpA4~@qLn8(LJqqLJ`FvHwo&*-*)d4#wXISV2-0|5_M9|@;S*u#3 zD(01+0)YpFTA4sKq8O6zJHkkPbKv;OKK}HZ)Y00w6S{YWWow{H*D#GC4Vrgi0v^rV8%yMhJ-s3B z;PLTZ0V}Dh_5J*5%KN!IL%^ut+D(yF3SLSRS-z)2#)C#*%EE1g-M*dF8aXQ4>)(7a1Pxzt_7qJ@79-3R#KkO zB@)uGhc0*BSXwS`kAv0Eo?gTMo`rNYXpRtFw6WN7K%ZN|3&@8%9KVvwZa-7(0R1vO zLO52*&u2DAGk>_uaV_|EZ+v>!eq8RC*WGYH?N&9EAQ!;Hzmjj_zYpZWzA_$rBWDhS zb4x3@XMCUQuKF9c0*RFWKdmtTe>p7t*GBXI*ZGm{fqqQD7vDQ V5msO6{{05!xr&x@g`#!n{{z_;65Id) literal 0 HcmV?d00001 diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f599e40db9..9a8d9ef0a7 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL base (opcional)", "modelId": "ID del model", "apiKey": "Clau API d'Ollama", - "apiKeyHelp": "Clau API opcional per a instàncies d'Ollama autenticades o serveis al núvol. Deixa-ho buit per a instal·lacions locals.", + "apiKeyHelp": "Clau API opcional per a instàncies d'Ollama que necessiten autenticació o per a serveis al núvol. Zoo Code envia la clau només a punts finals HTTPS o de loopback. Un punt final HTTP remot no rep la clau, perquè la clau viatjaria en text pla. Utilitza HTTPS per a una instància remota que necessita una clau. Deixa això buit per a una instal·lació local.", "numCtx": "Mida de la finestra de context (num_ctx)", "numCtxHelp": "Sobreescriu la mida de la finestra de context per defecte del model. Deixeu-ho en blanc per utilitzar la configuració del Modelfile del model. El valor mínim és 128.", "thinking": "Activa el raonament", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 845c9e575f..2f4e8756c6 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -606,7 +606,7 @@ "baseUrl": "Basis-URL (optional)", "modelId": "Modell-ID", "apiKey": "Ollama API-Schlüssel", - "apiKeyHelp": "Optionaler API-Schlüssel für authentifizierte Ollama-Instanzen oder Cloud-Services. Leer lassen für lokale Installationen.", + "apiKeyHelp": "Optionaler API-Schlüssel für Ollama-Instanzen, die eine Authentifizierung brauchen, oder für Cloud-Dienste. Zoo Code sendet den Schlüssel nur an HTTPS- oder Loopback-Endpunkte. Ein entfernter HTTP-Endpunkt erhält keinen Schlüssel, weil der Schlüssel als Klartext übertragen würde. Verwende HTTPS für eine entfernte Instanz, die einen Schlüssel braucht. Lass dieses Feld für eine lokale Installation leer.", "numCtx": "Kontextfenstergröße (num_ctx)", "numCtxHelp": "Überschreibt die Standard-Kontextfenstergröße des Modells. Lassen Sie das Feld leer, um die Modelfile-Konfiguration des Modells zu verwenden. Der Mindestwert ist 128.", "thinking": "Denken aktivieren", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index cbbeb2052e..1254f9bf0c 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. 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.", + "apiKeyHelp": "Optional API key for Ollama instances that need authentication, or for cloud services. Zoo Code sends the key only to HTTPS or loopback endpoints. A remote HTTP endpoint gets no key, because the key would travel in plain text. Use HTTPS for a remote instance that needs a key. Leave this empty for a local installation.", "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", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 1e6f87d553..1f165547c5 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL base (opcional)", "modelId": "ID del modelo", "apiKey": "Clave API de Ollama", - "apiKeyHelp": "Clave API opcional para instancias de Ollama autenticadas o servicios en la nube. Deja vacío para instalaciones locales.", + "apiKeyHelp": "Clave API opcional para instancias de Ollama que necesitan autenticación o para servicios en la nube. Zoo Code envía la clave solo a endpoints HTTPS o de loopback. Un endpoint HTTP remoto no recibe la clave, porque la clave viajaría en texto plano. Usa HTTPS para una instancia remota que necesita una clave. Deja este campo vacío para una instalación local.", "numCtx": "Tamaño de la ventana de contexto (num_ctx)", "numCtxHelp": "Sobrescribe el tamaño de la ventana de contexto predeterminado del modelo. Déjelo vacío para usar la configuración del Modelfile del modelo. El valor mínimo es 128.", "thinking": "Activar razonamiento", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ec6ee0f692..bbef9d5390 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL de base (optionnel)", "modelId": "ID du modèle", "apiKey": "Clé API Ollama", - "apiKeyHelp": "Clé API optionnelle pour les instances Ollama authentifiées ou les services cloud. Laissez vide pour les installations locales.", + "apiKeyHelp": "Clé API optionnelle pour les instances Ollama qui demandent une authentification, ou pour les services cloud. Zoo Code envoie la clé uniquement aux points de terminaison HTTPS ou loopback. Un point de terminaison HTTP distant ne reçoit pas la clé, car la clé circulerait en texte brut. Utilisez HTTPS pour une instance distante qui demande une clé. Laissez ce champ vide pour une installation locale.", "numCtx": "Taille de la fenêtre de contexte (num_ctx)", "numCtxHelp": "Remplace la taille de la fenêtre de contexte par défaut du modèle. Laissez vide pour utiliser la configuration du Modelfile du modèle. La valeur minimale est 128.", "thinking": "Activer le raisonnement", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index fa725cd2c5..30d53e9c9c 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -606,7 +606,7 @@ "baseUrl": "बेस URL (वैकल्पिक)", "modelId": "मॉडल ID", "apiKey": "Ollama API Key", - "apiKeyHelp": "प्रमाणित Ollama इंस्टेंसेस या क्लाउड सेवाओं के लिए वैकल्पिक API key। स्थानीय इंस्टॉलेशन के लिए खाली छोड़ें।", + "apiKeyHelp": "प्रमाणीकरण चाहने वाले Ollama इंस्टेंस या क्लाउड सेवाओं के लिए वैकल्पिक API key। Zoo Code key केवल HTTPS या loopback endpoints को भेजता है। दूरस्थ HTTP endpoint को key नहीं मिलती, क्योंकि key सादे टेक्स्ट में जाएगी। key चाहने वाले दूरस्थ इंस्टेंस के लिए HTTPS का उपयोग करें। स्थानीय इंस्टॉलेशन के लिए इसे खाली छोड़ें।", "numCtx": "संदर्भ विंडो आकार (num_ctx)", "numCtxHelp": "मॉडल के डिफ़ॉल्ट संदर्भ विंडो आकार को ओवरराइड करें। मॉडल की मॉडलफ़ाइल कॉन्फ़िगरेशन का उपयोग करने के लिए खाली छोड़ दें। न्यूनतम मान 128 है।", "thinking": "थिंकिंग सक्षम करें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bd3e0fcc96..5db3fab71c 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -606,7 +606,7 @@ "baseUrl": "Base URL (opsional)", "modelId": "Model ID", "apiKey": "Ollama API Key", - "apiKeyHelp": "API key opsional untuk instance Ollama yang terautentikasi atau layanan cloud. Biarkan kosong untuk instalasi lokal.", + "apiKeyHelp": "API key opsional untuk instance Ollama yang memerlukan autentikasi, atau untuk layanan cloud. Zoo Code mengirim key hanya ke endpoint HTTPS atau loopback. Endpoint HTTP jarak jauh tidak mendapat key, karena key akan dikirim sebagai teks biasa. Gunakan HTTPS untuk instance jarak jauh yang memerlukan key. Biarkan kosong untuk instalasi lokal.", "numCtx": "Ukuran Jendela Konteks (num_ctx)", "numCtxHelp": "Ganti ukuran jendela konteks default model. Biarkan kosong untuk menggunakan konfigurasi Modelfile model. Nilai minimum adalah 128.", "thinking": "Aktifkan Thinking", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 2b604e720f..dd048ca8fe 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL base (opzionale)", "modelId": "ID modello", "apiKey": "Chiave API Ollama", - "apiKeyHelp": "Chiave API opzionale per istanze Ollama autenticate o servizi cloud. Lascia vuoto per installazioni locali.", + "apiKeyHelp": "Chiave API opzionale per istanze Ollama che richiedono autenticazione, o per servizi cloud. Zoo Code invia la chiave solo a endpoint HTTPS o loopback. Un endpoint HTTP remoto non riceve la chiave, perché la chiave viaggerebbe in chiaro. Usa HTTPS per un'istanza remota che richiede una chiave. Lascia vuoto per un'installazione locale.", "numCtx": "Dimensione della finestra di contesto (num_ctx)", "numCtxHelp": "Sovrascrive la dimensione predefinita della finestra di contesto del modello. Lasciare vuoto per utilizzare la configurazione del Modelfile del modello. Il valore minimo è 128.", "thinking": "Abilita ragionamento", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 2ca3ef0396..62bfa5d59d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -606,7 +606,7 @@ "baseUrl": "ベースURL(オプション)", "modelId": "モデルID", "apiKey": "Ollama APIキー", - "apiKeyHelp": "認証されたOllamaインスタンスやクラウドサービス用のオプションAPIキー。ローカルインストールの場合は空のままにしてください。", + "apiKeyHelp": "認証が必要なOllamaインスタンス、またはクラウドサービス用のオプションAPIキー。Zoo CodeはキーをHTTPSまたはループバックのエンドポイントにのみ送信します。リモートのHTTPエンドポイントにはキーを送信しません。キーが平文で送られるためです。キーが必要なリモートインスタンスにはHTTPSを使用してください。ローカルインストールの場合は空のままにしてください。", "numCtx": "コンテキストウィンドウサイズ (num_ctx)", "numCtxHelp": "モデルのデフォルトのコンテキストウィンドウサイズを上書きします。モデルのModelfile構成を使用するには、空のままにします。最小値は128です。", "thinking": "思考を有効化", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 89dcd74c40..73f10567ac 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -606,7 +606,7 @@ "baseUrl": "기본 URL (선택사항)", "modelId": "모델 ID", "apiKey": "Ollama API 키", - "apiKeyHelp": "인증된 Ollama 인스턴스나 클라우드 서비스용 선택적 API 키. 로컬 설치의 경우 비워두세요.", + "apiKeyHelp": "인증이 필요한 Ollama 인스턴스 또는 클라우드 서비스용 선택적 API 키. Zoo Code는 키를 HTTPS 또는 루프백 엔드포인트에만 보냅니다. 원격 HTTP 엔드포인트에는 키를 보내지 않습니다. 키가 평문으로 전달되기 때문입니다. 키가 필요한 원격 인스턴스에는 HTTPS를 사용하세요. 로컬 설치의 경우 비워두세요.", "numCtx": "컨텍스트 창 크기(num_ctx)", "numCtxHelp": "모델의 기본 컨텍스트 창 크기를 재정의합니다. 모델의 Modelfile 구성을 사용하려면 비워 둡니다. 최소값은 128입니다.", "thinking": "추론 활성화", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 2eeea89e92..f4952679f0 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -606,7 +606,7 @@ "baseUrl": "Basis-URL (optioneel)", "modelId": "Model-ID", "apiKey": "Ollama API-sleutel", - "apiKeyHelp": "Optionele API-sleutel voor geauthenticeerde Ollama-instanties of cloudservices. Laat leeg voor lokale installaties.", + "apiKeyHelp": "Optionele API-sleutel voor Ollama-instanties die authenticatie nodig hebben, of voor cloudservices. Zoo Code stuurt de sleutel alleen naar HTTPS- of loopback-eindpunten. Een extern HTTP-eindpunt krijgt geen sleutel, omdat de sleutel als leesbare tekst zou reizen. Gebruik HTTPS voor een externe instantie die een sleutel nodig heeft. Laat dit leeg voor een lokale installatie.", "numCtx": "Contextvenstergrootte (num_ctx)", "numCtxHelp": "Overschrijft de standaard contextvenstergrootte van het model. Laat leeg om de Modelfile-configuratie van het model te gebruiken. De minimumwaarde is 128.", "thinking": "Redeneren inschakelen", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 0de3a36c4e..63737ceabd 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL bazowy (opcjonalnie)", "modelId": "ID modelu", "apiKey": "Klucz API Ollama", - "apiKeyHelp": "Opcjonalny klucz API dla uwierzytelnionych instancji Ollama lub usług chmurowych. Pozostaw puste dla instalacji lokalnych.", + "apiKeyHelp": "Opcjonalny klucz API dla instancji Ollama, które wymagają uwierzytelnienia, lub dla usług chmurowych. Zoo Code wysyła klucz tylko do punktów końcowych HTTPS lub loopback. Zdalny punkt końcowy HTTP nie dostaje klucza, ponieważ klucz byłby przesłany jawnym tekstem. Użyj HTTPS dla zdalnej instancji, która wymaga klucza. Dla instalacji lokalnej pozostaw to pole puste.", "numCtx": "Rozmiar okna kontekstu (num_ctx)", "numCtxHelp": "Zastępuje domyślny rozmiar okna kontekstu modelu. Pozostaw puste, aby użyć konfiguracji Modelfile modelu. Minimalna wartość to 128.", "thinking": "Włącz wnioskowanie", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 893504febe..637c8592f7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL Base (opcional)", "modelId": "ID do Modelo", "apiKey": "Chave API Ollama", - "apiKeyHelp": "Chave API opcional para instâncias Ollama autenticadas ou serviços em nuvem. Deixe vazio para instalações locais.", + "apiKeyHelp": "Chave API opcional para instâncias Ollama que precisam de autenticação ou para serviços em nuvem. O Zoo Code envia a chave somente para endpoints HTTPS ou loopback. Um endpoint HTTP remoto não recebe a chave, porque a chave seguiria em texto simples. Use HTTPS para uma instância remota que precisa de uma chave. Deixe vazio para uma instalação local.", "numCtx": "Tamanho da janela de contexto (num_ctx)", "numCtxHelp": "Substitui o tamanho da janela de contexto padrão do modelo. Deixe em branco para usar a configuração do Modelfile do modelo. O valor mínimo é 128.", "thinking": "Ativar raciocínio", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 3d55ee12d3..1a17e654a2 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -606,7 +606,7 @@ "baseUrl": "Базовый URL (опционально)", "modelId": "ID модели", "apiKey": "API-ключ Ollama", - "apiKeyHelp": "Опциональный API-ключ для аутентифицированных экземпляров Ollama или облачных сервисов. Оставьте пустым для локальных установок.", + "apiKeyHelp": "Необязательный API-ключ для экземпляров Ollama, которым нужна аутентификация, или для облачных сервисов. Zoo Code отправляет ключ только на конечные точки HTTPS или loopback. Удалённая конечная точка HTTP не получает ключ, потому что ключ шёл бы открытым текстом. Используйте HTTPS для удалённого экземпляра, которому нужен ключ. Для локальной установки оставьте поле пустым.", "numCtx": "Размер контекстного окна (num_ctx)", "numCtxHelp": "Переопределяет размер контекстного окна модели по умолчанию. Оставьте пустым, чтобы использовать конфигурацию Modelfile модели. Минимальное значение — 128.", "thinking": "Включить рассуждение", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5f93ec8035..60d4d9deb6 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -606,7 +606,7 @@ "baseUrl": "Temel URL (İsteğe bağlı)", "modelId": "Model Kimliği", "apiKey": "Ollama API Anahtarı", - "apiKeyHelp": "Kimlik doğrulamalı Ollama örnekleri veya bulut hizmetleri için isteğe bağlı API anahtarı. Yerel kurulumlar için boş bırakın.", + "apiKeyHelp": "Kimlik doğrulaması gereken Ollama örnekleri veya bulut hizmetleri için isteğe bağlı API anahtarı. Zoo Code anahtarı yalnızca HTTPS veya loopback uç noktalarına gönderir. Uzak bir HTTP uç noktası anahtar almaz, çünkü anahtar düz metin olarak gider. Anahtar gereken uzak bir örnek için HTTPS kullanın. Yerel kurulum için bu alanı boş bırakın.", "numCtx": "Bağlam Penceresi Boyutu (num_ctx)", "numCtxHelp": "Modelin varsayılan bağlam penceresi boyutunu geçersiz kılar. Modelin Modelfile yapılandırmasını kullanmak için boş bırakın. Minimum değer 128'dir.", "thinking": "Akıl yürütmeyi etkinleştir", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 5ec6e8c105..924711b43d 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -606,7 +606,7 @@ "baseUrl": "URL cơ sở (tùy chọn)", "modelId": "ID mô hình", "apiKey": "Khóa API Ollama", - "apiKeyHelp": "Khóa API tùy chọn cho các phiên bản Ollama đã xác thực hoặc dịch vụ đám mây. Để trống cho cài đặt cục bộ.", + "apiKeyHelp": "Khóa API tùy chọn cho các phiên bản Ollama cần xác thực, hoặc cho dịch vụ đám mây. Zoo Code chỉ gửi khóa đến endpoint HTTPS hoặc loopback. Endpoint HTTP từ xa không nhận khóa, vì khóa sẽ đi dưới dạng văn bản thuần. Hãy dùng HTTPS cho phiên bản từ xa cần khóa. Để trống cho cài đặt cục bộ.", "numCtx": "Kích thước cửa sổ ngữ cảnh (num_ctx)", "numCtxHelp": "Ghi đè kích thước cửa sổ ngữ cảnh mặc định của mô hình. Để trống để sử dụng cấu hình Modelfile của mô hình. Giá trị tối thiểu là 128.", "thinking": "Bật suy luận", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9e0a94d908..3f4c2331c1 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -606,7 +606,7 @@ "baseUrl": "基础 URL(可选)", "modelId": "模型 ID", "apiKey": "Ollama API 密钥", - "apiKeyHelp": "用于已认证 Ollama 实例或云服务的可选 API 密钥。本地安装请留空。", + "apiKeyHelp": "可选的 API 密钥,用于需要认证的 Ollama 实例或云服务。Zoo Code 只把密钥发送到 HTTPS 或环回端点。远程 HTTP 端点不会收到密钥,因为密钥会以明文传输。需要密钥的远程实例请使用 HTTPS。本地安装请留空。", "numCtx": "上下文窗口大小 (num_ctx)", "numCtxHelp": "覆盖模型的默认上下文窗口大小。留空以使用模型的 Modelfile 配置。最小值为 128。", "thinking": "启用思考", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index c2fd65c755..34702d440f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -633,7 +633,7 @@ "baseUrl": "基礎 URL(選用)", "modelId": "模型 ID", "apiKey": "Ollama API 金鑰", - "apiKeyHelp": "用於已驗證 Ollama 執行個體或雲端服務的選用 API 金鑰。本機安裝請留空。", + "apiKeyHelp": "選用的 API 金鑰,用於需要驗證的 Ollama 執行個體或雲端服務。Zoo Code 只把金鑰傳送到 HTTPS 或迴路端點。遠端 HTTP 端點不會收到金鑰,因為金鑰會以明文傳輸。需要金鑰的遠端執行個體請使用 HTTPS。本機安裝請留空。", "numCtx": "上下文視窗大小(num_ctx)", "numCtxHelp": "覆寫模型的預設上下文視窗大小。留空以使用模型的 Modelfile 設定。最小值為 128。", "thinking": "啟用思考", From 44cf1444550dd6159b6c32ab74272fe3a4f6d1da Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 21 Oct 2026 02:49:05 +0000 Subject: [PATCH 27/27] fix(api): drop ollama key when debug proxy would see it in cleartext --- .../providers/__tests__/native-ollama.spec.ts | 38 +++++++++++++++++++ src/api/providers/native-ollama.ts | 8 +++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index b6725c541c..764d509090 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -12,6 +12,8 @@ import { collectStream } from "../../../test-utils/stream" // Mock the ollama package const mockChat = vitest.fn() +const mockIsProxyEnabled = vi.hoisted(() => vitest.fn(() => false)) +vitest.mock("../../../utils/networkProxy", () => ({ isProxyEnabled: mockIsProxyEnabled })) // Use vi.hoisted to define mocks that can be referenced both inside and outside the hoisted vi.mock const mockedOllama = vi.hoisted(() => ({ @@ -2336,6 +2338,42 @@ describe("NativeOllamaHandler", () => { ) }) + it.each([ + ["completePrompt", (h: NativeOllamaHandler) => h.completePrompt("Test prompt")], + ["createMessage", (h: NativeOllamaHandler) => collectStream(h.createMessage("sys", []))], + ])("should not send the API key to the debug proxy over cleartext loopback (%s)", async (_name, run) => { + mockIsProxyEnabled.mockReturnValue(true) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + mockChat.mockImplementation(async (req: { stream?: boolean }) => + req.stream + ? (async function* () { + yield { message: { content: "Response" } } + })() + : { message: { content: "Response" } }, + ) + try { + await run(makeHandler({ ollamaBaseUrl: "http://127.0.0.1:11434", ollamaApiKey: "test-api-key-123" })) + expect(OllamaMock.mock.calls[0][0].headers).toBeUndefined() + } finally { + mockIsProxyEnabled.mockReturnValue(false) + warnSpy.mockRestore() + } + }) + + it("should still send the API key over HTTPS when the debug proxy is active", async () => { + mockIsProxyEnabled.mockReturnValue(true) + mockChat.mockResolvedValue({ message: { content: "Response" } }) + try { + await makeHandler({ + ollamaBaseUrl: "https://ollama.example.com:11434", + ollamaApiKey: "test-api-key-123", + }).completePrompt("Test prompt") + expect(OllamaMock.mock.calls[0][0].headers).toEqual({ Authorization: "Bearer test-api-key-123" }) + } finally { + mockIsProxyEnabled.mockReturnValue(false) + } + }) + it("should default the client host to localhost when no base URL is configured", async () => { const bareHandler = makeHandler() mockChat.mockResolvedValue({ message: { content: "Response" } }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index b888d84708..7e17ad17ec 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -9,6 +9,7 @@ import { getOllamaModels, isSecureOllamaEndpoint } from "./fetchers/ollama" import { createAbortError, mergeAbortSignalAndTimeout, rejectOnAbort } from "./utils/abort-signal" import { getLoggableOrigin } from "./utils/endpoint-origin" import { TagMatcher } from "../../utils/tag-matcher" +import { isProxyEnabled } from "../../utils/networkProxy" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, @@ -265,8 +266,11 @@ 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). + // The debug proxy patches globalThis.fetch, so a plaintext loopback request + // would reach the proxy in cleartext; treat that case as insecure too. if (this.options.ollamaApiKey) { - if (isSecureOllamaEndpoint(clientOptions.host)) { + const viaCleartextProxy = isProxyEnabled() && !/^https:/i.test(clientOptions.host ?? "") + if (isSecureOllamaEndpoint(clientOptions.host) && !viaCleartextProxy) { clientOptions.headers = { Authorization: `Bearer ${this.options.ollamaApiKey}`, } @@ -282,7 +286,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio this.warnedKeyDropped = true const endpointOrigin = getLoggableOrigin(clientOptions.host) console.warn( - `[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.`, + `[NativeOllama] ollamaApiKey is set but the endpoint "${endpointOrigin}" is not https or loopback, or the debug proxy is active; the Authorization header is not sent (the credential would leak over plaintext). Use an https:// endpoint, or a loopback endpoint without the debug proxy, to authenticate.`, ) } }