diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 73327a3012c..a6a914c5114 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -605,4 +605,233 @@ describe("NativeOllamaHandler", () => { expect(firstEndIndex).toBeGreaterThan(lastPartialIndex) }) }) + + describe("native thinking support", () => { + it("should yield reasoning from native thinking field", async () => { + // Mock response with native thinking field (Ollama 0.5.0+) + mockChat.mockImplementation(async function* () { + yield { + message: { + content: "", + thinking: "Let me analyze this problem...", + }, + } + yield { + message: { + content: "", + thinking: " First, I need to consider X.", + }, + } + yield { + message: { + content: "The answer is 42", + }, + } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "What is the answer?" }]) + const results = [] + + for await (const chunk of stream) { + results.push(chunk) + } + + // Should have reasoning chunks from native thinking field + const reasoningChunks = results.filter((r) => r.type === "reasoning") + expect(reasoningChunks).toHaveLength(2) + expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "Let me analyze this problem..." }) + expect(reasoningChunks[1]).toEqual({ type: "reasoning", text: " First, I need to consider X." }) + + // Should also have the text response + const textChunks = results.filter((r) => r.type === "text") + expect(textChunks.some((c) => c.text === "The answer is 42")).toBe(true) + }) + + it("should pass think option when reasoning is enabled with model support", async () => { + mockGetOllamaModels.mockResolvedValue({ + "thinking-model": { + contextWindow: 4096, + maxTokens: 4096, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: true, + }, + }) + + const options: ApiHandlerOptions = { + apiModelId: "thinking-model", + ollamaModelId: "thinking-model", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + reasoningEffort: "high", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "Response" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + + for await (const _ of stream) { + // consume stream + } + + // Verify think option was passed with high value + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: "high", + }), + ) + }) + + it("should pass think: medium when reasoningEffort is medium", async () => { + mockGetOllamaModels.mockResolvedValue({ + "thinking-model": { + contextWindow: 4096, + maxTokens: 4096, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: true, + }, + }) + + const options: ApiHandlerOptions = { + apiModelId: "thinking-model", + ollamaModelId: "thinking-model", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + reasoningEffort: "medium", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "Response" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + + for await (const _ of stream) { + // consume stream + } + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: "medium", + }), + ) + }) + + it("should pass think: low when reasoningEffort is low or minimal", async () => { + mockGetOllamaModels.mockResolvedValue({ + "thinking-model": { + contextWindow: 4096, + maxTokens: 4096, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: true, + }, + }) + + const options: ApiHandlerOptions = { + apiModelId: "thinking-model", + ollamaModelId: "thinking-model", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + reasoningEffort: "low", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "Response" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + + for await (const _ of stream) { + // consume stream + } + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: "low", + }), + ) + }) + + it("should not pass think option when reasoning is not enabled", async () => { + mockGetOllamaModels.mockResolvedValue({ + llama2: { + contextWindow: 4096, + maxTokens: 4096, + supportsImages: false, + supportsPromptCache: false, + // No supportsReasoningEffort + }, + }) + + const options: ApiHandlerOptions = { + apiModelId: "llama2", + ollamaModelId: "llama2", + ollamaBaseUrl: "http://localhost:11434", + // No enableReasoningEffort + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "Response" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + + for await (const _ of stream) { + // consume stream + } + + // Verify think option was NOT passed (or is undefined) + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: undefined, + }), + ) + }) + + it("should handle both native thinking and tag-based reasoning", async () => { + // Some models might use both methods + mockChat.mockImplementation(async function* () { + yield { + message: { + content: "", + thinking: "Native thinking here", + }, + } + yield { + message: { + content: "Tag-based thinkingThe final answer", + }, + } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const results = [] + + for await (const chunk of stream) { + results.push(chunk) + } + + // Should have reasoning from both sources + const reasoningChunks = results.filter((r) => r.type === "reasoning") + expect(reasoningChunks.length).toBeGreaterThanOrEqual(2) + + // Check native thinking was captured + expect(reasoningChunks.some((c) => c.text === "Native thinking here")).toBe(true) + + // Check tag-based thinking was captured + expect(reasoningChunks.some((c) => c.text === "Tag-based thinking")).toBe(true) + }) + }) }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 99c1dc03cfa..29a9f38cade 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -5,6 +5,7 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningEffort } from "../../shared/api" import { getOllamaModels } from "./fetchers/ollama" import { TagMatcher } from "../../utils/tag-matcher" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -14,6 +15,9 @@ interface OllamaChatOptions { num_ctx?: number } +// Ollama think option type: boolean or effort level +type OllamaThinkOption = boolean | "high" | "medium" | "low" + function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { const ollamaMessages: Message[] = [] @@ -155,6 +159,37 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio this.options = options } + /** + * Determines the Ollama `think` option value based on model and settings. + * Returns undefined if thinking is not enabled, otherwise returns the + * appropriate effort level or true for basic thinking. + */ + private getThinkOption(modelInfo: ModelInfo): OllamaThinkOption | undefined { + // Check if reasoning should be enabled based on model and settings + const useReasoning = shouldUseReasoningEffort({ + model: modelInfo, + settings: this.options, + }) + + if (!useReasoning) { + return undefined + } + + // Map reasoning effort to Ollama think option + const effort = this.options.reasoningEffort + + if (effort === "high" || effort === "xhigh") { + return "high" + } else if (effort === "medium") { + return "medium" + } else if (effort === "low" || effort === "minimal") { + return "low" + } + + // Default to true (let Ollama decide) when reasoning is enabled but no specific effort + return true + } + private ensureClient(): Ollama { if (!this.client) { try { @@ -206,7 +241,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const client = this.ensureClient() - const { id: modelId } = await this.fetchModel() + const { id: modelId, info: modelInfo } = await this.fetchModel() const useR1Format = modelId.toLowerCase().includes("deepseek-r1") const ollamaMessages: Message[] = [ @@ -214,6 +249,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio ...convertToOllamaMessages(messages), ] + // Determine if native thinking should be enabled + const thinkOption = this.getThinkOption(modelInfo) + const matcher = new TagMatcher( "think", (chunk) => @@ -235,12 +273,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio } // Create the actual API request promise + // Include think option if reasoning is enabled (Ollama 0.5.0+) const stream = await client.chat({ model: modelId, messages: ollamaMessages, stream: true, options: chatOptions, tools: this.convertToolsToOllama(metadata?.tools), + think: thinkOption, }) let totalInputTokens = 0 @@ -252,8 +292,15 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio try { for await (const chunk of stream) { + // Handle native thinking field (Ollama 0.5.0+) + // This is the preferred method for models that support it + const thinking = (chunk.message as Message & { thinking?: string }).thinking + if (typeof thinking === "string" && thinking.length > 0) { + yield { type: "reasoning", text: thinking } + } + if (typeof chunk.message.content === "string" && chunk.message.content.length > 0) { - // Process content through matcher for reasoning detection + // Process content through matcher for reasoning detection (fallback for tags) for (const matcherChunk of matcher.update(chunk.message.content)) { yield matcherChunk }