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/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 8fcbf4a0a1..764d509090 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -6,28 +6,76 @@ 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" +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(() => ({ + 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(), } }) -// Mock the getOllamaModels function -vitest.mock("../fetchers/ollama", () => ({ - getOllamaModels: vitest.fn(), -})) +// Export OllamaMock for test access +const OllamaMock = mockedOllama.OllamaMock + +// 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) +// 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 +// 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, + new Promise((_resolve, reject) => { + nativeSetTimeout(() => reject(new Error(`withDeadline: unsettled after ${ms}ms`)), ms) + }), + ]) + describe("NativeOllamaHandler", () => { let handler: NativeOllamaHandler @@ -85,6 +133,63 @@ 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") + }) + + // 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" } } + }) + const stream = handler.createMessage("You are terse", [{ role: "user" as const, content: "Hi" }]) + 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 }[] } + 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" } } @@ -115,9 +220,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 @@ -169,9 +272,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( @@ -195,9 +296,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( @@ -227,9 +326,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( @@ -306,9 +403,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({ @@ -350,9 +445,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({ @@ -368,9 +461,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() @@ -397,9 +488,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() @@ -424,9 +513,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() @@ -450,9 +537,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({ @@ -478,9 +563,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -513,9 +596,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -549,9 +630,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -585,9 +664,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -625,9 +702,7 @@ describe("NativeOllamaHandler", () => { ] const stream = handler.createMessage("System", messages) - for await (const _ of stream) { - // consume - } + await collectStream(stream) expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -659,9 +734,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() @@ -687,15 +760,18 @@ 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() }) 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" }, @@ -756,6 +832,362 @@ 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(expect.objectContaining({ 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 + + // 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 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 timeoutHandle + }) 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(capturedInstanceAbort).toBeDefined() + expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) + }) + + it("should call instance.abort() when abortSignal is aborted", async () => { + const controller = new AbortController() + + // 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("The Ollama request 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() + + // 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( + "The Ollama request was aborted", + ) + + expect(capturedInstanceAbort).toBeDefined() + expect(capturedInstanceAbort).toHaveBeenCalledTimes(1) + }) + + 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" }, + }) + + await handler.completePrompt("Test prompt", { timeoutMs: 0 }) + + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(OllamaMock).toHaveBeenCalledTimes(1) + 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 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({ + 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), + // 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") + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + + // 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, reject) => { + rejectChat = reject + }), + ) + + 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() + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + rejectChat(abortError) + + 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. + expect(addEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function), { once: true }) + const registeredHandler = addEventListenerSpy.mock.calls[0]?.[1] + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registeredHandler) + }) + + it("should forward the abort signal to model discovery so discovery is cancellable", async () => { + const controller = new AbortController() + // 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" } }) + + 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 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) + 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 () => { + let capturedDelay: number | undefined + // 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 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 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 () => { @@ -831,9 +1263,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") }) @@ -845,9 +1275,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") }) @@ -864,9 +1292,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") }) @@ -881,9 +1307,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") }) @@ -897,11 +1321,27 @@ 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 () => { + 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 = collectStream(stream) + 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() + } + }) }) describe("getModel", () => { @@ -961,9 +1401,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( @@ -1021,9 +1459,7 @@ describe("NativeOllamaHandler", () => { }) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify tools were passed expect(mockChat).toHaveBeenCalledWith( @@ -1062,9 +1498,7 @@ describe("NativeOllamaHandler", () => { }) // Consume the stream - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // Verify tools were NOT passed expect(mockChat).toHaveBeenCalledWith( @@ -1286,9 +1720,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( @@ -1323,9 +1755,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( @@ -1386,9 +1816,7 @@ describe("NativeOllamaHandler", () => { tools, }) - for await (const _ of stream) { - // consume stream - } + await collectStream(stream) // additionalProperties should be stripped from the parameters expect(mockChat).toHaveBeenCalledWith( @@ -1472,9 +1900,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 @@ -1528,9 +1954,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 @@ -1584,9 +2008,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") @@ -1602,5 +2024,923 @@ 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 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 timeoutHandle + }) as typeof setTimeout) + + const promise = handler.completePrompt("Test prompt", { timeoutMs: testTimeout }) + expect(capturedFn).toBeDefined() + + // 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?.() + 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 + // dangling. + resolveFetch?.() + }) + }) + + 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 = makeHandler({ + 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 = makeHandler({ + 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 = makeHandler({ + 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 = makeHandler({ + ollamaBaseUrl: "http://custom-ollama:11434", + }) + + await handler.completePrompt("Test prompt") + + expect(OllamaMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: "http://custom-ollama:11434", + }), + ) + }) + + 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", + ollamaModelId: "llama2", + // 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", + }) + + 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 = makeHandler({ + ollamaBaseUrl: "http://ollama.example.com:11434", + ollamaApiKey: "test-api-key-123", + }) + + 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) + } 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 = makeHandler({ + ollamaBaseUrl: "secret-host:11434/path?token=abc", + ollamaApiKey: "test-api-key-123", + }) + + 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") + } finally { + warnSpy.mockRestore() + } + }) + + 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" }, + }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + + const handler = new NativeOllamaHandler({ + apiModelId: "llama2", + ollamaModelId: "llama2", + // 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", + }) + + 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")) + } finally { + warnSpy.mockRestore() + } + }) + + it("should attach the API key for an HTTPS endpoint", async () => { + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + const handler = makeHandler({ + 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 = makeHandler({ + 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", + }, + }), + ) + }) + + 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" } }) + await bareHandler.completePrompt("Test prompt") + expect(OllamaMock.mock.calls[0][0]).toEqual(expect.objectContaining({ host: "http://localhost:11434" })) + }) + }) + + 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. + 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 = collectStream(stream) + + // Spin until the mocked SDK chat() has been invoked (the mocked SDK + // never uses the transport, so its chat() is held manually). + await untilChatCalled() + 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. + 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. + settleChat?.({ + message: { content: "late" }, + abort: vi.fn(), + [Symbol.asyncIterator]: async function* () {}, + }) + 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 () => { + 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 withDeadline(released, 500) + }) + + it("should abort a held-open POST via the per-request transport when timeoutMs fires", async () => { + 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. + await untilChatCalled() + 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. + 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")) + 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 () => { + 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() + const settledMergedPost = expect(post).rejects.toMatchObject({ name: "AbortError" }) + 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. 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 + 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 () => { + 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" }]) + await untilChatCalled() + // Consume the stream to completion; the finally block aborts the + // per-request controller, which must release the held transport. + 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") + 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?.() + }) + }) + + 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 }), + ) + + const settledPreAbort = expect(async () => { + await collectStream(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 () => { + 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 = collectStream(stream) + + // Wait until the in-flight request has actually started. + await untilChatCalled() + expect(mockChat).toHaveBeenCalledTimes(1) + + controller.abort() + + 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 = 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. + controller.abort() + expect(abortCount).toBe(0) + }) + + 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 = collectStream(stream) + + // Abort while the model list is still being fetched. The race must + // reject the generator with AbortError before chat() is attempted. + controller.abort() + const settledDiscovery = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + 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. + 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 = collectStream(stream) + + const settledDispose = expect(consume).rejects.toMatchObject({ name: "AbortError" }) + await withDeadline(settledDispose, 500) + 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" }]) + + const settledPassthrough = expect(async () => { + 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 756a8dc69a..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", () => { @@ -479,6 +499,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 29e5e33fd3..58beb977d6 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, isIpv4LoopbackHost, isSecureOllamaEndpoint, parseOllamaModel } from "../ollama" import ollamaModelsData from "./fixtures/ollama-model-details.json" // Mock axios @@ -116,7 +116,196 @@ 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) + 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 + 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", () => { + 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) + }) + + 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", () => { + 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 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) + }) + + 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. + 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" + + 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.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(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" @@ -267,6 +456,46 @@ 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") + ;(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" @@ -336,61 +565,25 @@ 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) 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/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 68ecb5babd..1ac0399674 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(), @@ -64,6 +65,50 @@ 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. + * + * 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)) { + return false + } + const url = new URL(baseUrl) + if (url.protocol === "https:") { + return true + } + const host = url.hostname + return host === "localhost" || 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). + */ +export 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 <= 255) +} + +const keyDropWarnedOrigins = new Set() + export async function getOllamaModels( baseUrl = "http://localhost:11434", apiKey?: string, @@ -79,13 +124,38 @@ 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 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 (apiKey) { + if (credentialGated) { headers["Authorization"] = `Bearer ${apiKey}` } - const response = await axios.get(`${baseUrl}/api/tags`, { headers, signal: opts?.signal }) + // 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, + signal: opts?.signal, + }) const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data) const modelInfoPromises = [] @@ -98,7 +168,7 @@ export async function getOllamaModels( { model: ollamaModel.model, }, - { headers, signal: opts?.signal }, + { headers, ...proxyConfig, signal: opts?.signal }, ) .then((ollamaModelInfo) => { const modelInfo = parseOllamaModel(ollamaModelInfo.data) @@ -123,7 +193,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 36b422eac9..7e17ad17ec 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -5,8 +5,11 @@ import { ModelInfo, ModelRecord, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" -import { getOllamaModels } from "./fetchers/ollama" +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, @@ -230,8 +233,8 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: Ollama | undefined protected models: Record = {} + private warnedKeyDropped = false constructor(options: ApiHandlerOptions) { super() @@ -247,27 +250,61 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return getOllamaModels(options.baseUrl, options.apiKey, ...fetchOpts) } - 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 - } + /** + * 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(requestSignal: AbortSignal): Ollama { + 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}`, - } + // 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). + // 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) { + const viaCleartextProxy = isProxyEnabled() && !/^https:/i.test(clientOptions.host ?? "") + if (isSecureOllamaEndpoint(clientOptions.host) && !viaCleartextProxy) { + 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. 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. + // 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, 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.`, + ) } - - this.client = new Ollama(clientOptions) - } catch (error: any) { - throw new Error(`Error creating Ollama client: ${error.message}`) } } - return this.client + + // 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. 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) } /** @@ -412,25 +449,54 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const client = this.ensureClient() - 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, - ) + // 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) { + // The request is already cancelled: reject before allocating the + // per-request client or transport so nothing is left to clean up. + throw createAbortError("Ollama") + } + + const requestController = new AbortController() + 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() + client.abort() + } + externalAbortSignal.addEventListener("abort", onExternalAbort) + } + + // 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 } = externalAbortSignal + ? await rejectOnAbort(this.fetchModel(externalAbortSignal), externalAbortSignal, "Ollama") + : await this.fetchModel(undefined) + 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, + ) + // 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 @@ -451,6 +517,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("Ollama") + } + let totalInputTokens = 0 let totalOutputTokens = 0 // Track tool calls across chunks (Ollama may send complete tool_calls in final chunk) @@ -528,10 +602,22 @@ 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"}`) } } 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" @@ -548,11 +634,29 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`) throw error + } 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) { + 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() } } - 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() } @@ -564,10 +668,76 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } } - async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise { + 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. The + // per-request controller drives the abortable transport injected into the + // client, so the in-flight POST itself is cancellable. + const requestController = new AbortController() + let timeoutId: ReturnType | undefined + let onAbort: (() => void) | undefined + const abortSignal = options?.abortSignal + try { - const client = this.ensureClient() - const { id: modelId } = await this.fetchModel() + // 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 + // 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(Math.ceil(timeoutMs), 2_147_483_647), + ) + } + + // 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 (abortSignal) { + if (abortSignal.aborted) { + requestController.abort() + client.abort() + throw createAbortError("Ollama") + } else { + onAbort = () => { + requestController.abort() + client.abort() + // 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 }) + } + } + + // Model discovery goes through Axios (getOllamaModels) and never touches + // 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) + // 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") // Reuse the shared request-option builder so single-shot @@ -585,10 +755,17 @@ 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 { + clearTimeout(timeoutId) + if (onAbort && abortSignal) { + abortSignal.removeEventListener("abort", onAbort) + } } } } diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index f779d48e1f..3cd64e8fd1 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -275,6 +275,56 @@ 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("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. 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 + 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)) + }) + + 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/__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/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index cbb7a13aaf..763a97c4c0 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -1,18 +1,24 @@ /** * 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", + // 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(timeoutMs) + const timeoutSignal = AbortSignal.timeout(Math.min(Math.max(1, Math.ceil(timeoutMs)), 2_147_483_647)) if (!externalSignal) { return timeoutSignal 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 f1b2590f7f..754acd2b6c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -386,7 +386,7 @@ }, "api/providers/native-ollama.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 } }, "api/providers/openai-codex.ts": { 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" }], 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 0000000000..c24fd3f2c6 Binary files /dev/null and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/ollama-api-key-help-guidance-dark.png differ 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 de36f7ac3a..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. 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": "啟用思考",