Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
541 changes: 541 additions & 0 deletions src/api/providers/__tests__/native-ollama.spec.ts

Large diffs are not rendered by default.

206 changes: 171 additions & 35 deletions src/api/providers/native-ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ interface OllamaChatOptions {
num_ctx?: number
}

// Narrow local types for non-Anthropic content blocks that may be carried in
// the conversation history. The Anthropic SDK union does not include the
// custom `reasoning` block (used by non-Anthropic protocols) or the
// Anthropic-protocol `thinking` block, so we declare them here to keep type
// checking intact for the rest of the union instead of falling back to `any`.
type ReasoningContentBlock = { type: "reasoning"; text: string }
type ThinkingContentBlock = { type: "thinking"; thinking: string }
type AssistantContentBlock = Anthropic.ContentBlock | ReasoningContentBlock | ThinkingContentBlock

function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
const ollamaMessages: Message[] = []

Expand Down Expand Up @@ -97,32 +106,64 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
// Assistant message conversion: only `text`, `tool_use`, and the
// custom `reasoning`/`thinking` blocks are relevant here.
//
// Note on the removed `image` branch: the previous code checked
// `block.type === "image"` and typed `nonToolMessages` as
// `(Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]`. This
// was removed because:
// 1. The Anthropic API only accepts image blocks in *user*
// messages — assistants cannot produce or send images, so
// the branch was dead code (the original comment even said
// "impossible as the assistant cannot send images").
// 2. `Anthropic.ContentBlock` (the response/output union) does
// not include an `image` variant, so the comparison
// `block.type === "image"` triggered TS2367 ("this comparison
// appears to be unintentional because the types have no
// overlap").
// 3. Image handling for *user* messages (where images are
// actually sent to the model) is preserved unchanged in the
// `anthropicMessage.role === "user"` branch above.
const { nonToolMessages, toolMessages, reasoningText } = anthropicMessage.content.reduce<{
nonToolMessages: Anthropic.TextBlockParam[]
toolMessages: Anthropic.ToolUseBlockParam[]
reasoningText: string
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
// `part` is typed as an Anthropic content block, but the
// conversation history may also carry custom `reasoning`
// blocks (used by non-Anthropic protocols) or Anthropic
// `thinking` blocks that are not part of the SDK union.
// Cast to the augmented union to access them while
// preserving type safety for the rest of the block.
const block = part as AssistantContentBlock
if (block.type === "tool_use") {
acc.toolMessages.push(block)
} else if (block.type === "text") {
acc.nonToolMessages.push(block)
} else if (block.type === "reasoning") {
// Non-Anthropic protocols store reasoning as a block
// with a `text` field. Pass it back so Ollama can
// preserve thinking context across turns.
if (block.text.length > 0) {
acc.reasoningText += (acc.reasoningText ? "\n" : "") + block.text
}
} else if (block.type === "thinking") {
// Anthropic-protocol thinking blocks carry `thinking`.
if (block.thinking.length > 0) {
acc.reasoningText += (acc.reasoningText ? "\n" : "") + block.thinking
}
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
{ nonToolMessages: [], toolMessages: [], reasoningText: "" },
)

// Process non-tool messages
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
content = nonToolMessages.map((part) => part.text).join("\n")
}

// Convert tool_use blocks to Ollama tool_calls format
Expand All @@ -140,6 +181,8 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa
role: "assistant",
content,
tool_calls: toolCalls,
// Round-trip prior reasoning so multi-turn thinking is preserved.
thinking: reasoningText || undefined,
})
}
}
Expand Down Expand Up @@ -203,6 +246,90 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
}))
}

/**
* Maps the configured reasoning effort setting to Ollama's native `think`
* request parameter (boolean | "high" | "medium" | "low").
*
* Requires an explicit Ollama opt-in (`enableReasoningEffort === true`)
* before translating `reasoningEffort`. This prevents inherited
* `apiConfiguration.reasoningEffort` values (left over from another
* provider) from silently emitting a `think` param when the Ollama UI
* checkbox is unchecked.
*
* Returns undefined when reasoning is not explicitly enabled, leaving
* the model/Modelfile in control (preserving prior behavior where models
* that emit think/thought tags in content are still handled by TagMatcher).
*
* Note: The Ollama API itself also accepts `"max"` (see
* https://docs.ollama.com/capabilities/thinking), but the installed
* `ollama` SDK (v0.6.x) only types `think` as
* `boolean | "high" | "medium" | "low"`. Until the SDK types catch up,
* "xhigh"/"max" efforts are clamped to "high".
*
* - enableReasoningEffort !== true -> undefined (no think param sent)
* - "disable" -> false (thinking off)
* - "none" / "minimal" -> true (enable thinking with default budget)
* - "low" / "medium" / "high" -> the matching effort level
* - "xhigh" / "max" -> "high" (highest level the SDK currently supports)
*/
private getOllamaThinkParam(): boolean | "high" | "medium" | "low" | undefined {
// Require an explicit Ollama opt-in before mapping reasoningEffort.
// Without this guard, a stale reasoningEffort inherited from another
// provider config could still emit a think param when the UI checkbox
// is unchecked.
if (this.options.enableReasoningEffort !== true) {
return undefined
}

const effort = this.options.reasoningEffort
if (effort === undefined) {
return undefined
}

switch (effort) {
case "disable":
return false
case "none":
case "minimal":
return true
case "low":
return "low"
case "medium":
return "medium"
case "high":
case "xhigh":
case "max":
return "high"
default:
return undefined
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Builds the shared chat request options (temperature, num_ctx) and the
* conditional `think` parameter used by both `createMessage` and
* `completePrompt`. Centralizing this avoids drift between the streaming
* and single-shot request paths.
*
* Returns a tuple of `[chatOptions, thinkParam]` where `thinkParam` is
* `undefined` when no `think` field should be sent to Ollama.
*/
private buildChatRequestOptions(
useR1Format: boolean,
): [OllamaChatOptions, boolean | "high" | "medium" | "low" | undefined] {
const chatOptions: OllamaChatOptions = {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
}

// Only include num_ctx if explicitly set via ollamaNumCtx
if (this.options.ollamaNumCtx !== undefined) {
chatOptions.num_ctx = this.options.ollamaNumCtx
}

const thinkParam = this.getOllamaThinkParam()
return [chatOptions, thinkParam]
}

override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
Expand All @@ -227,23 +354,24 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
)

try {
// Build options object conditionally
const chatOptions: OllamaChatOptions = {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
}

// Only include num_ctx if explicitly set via ollamaNumCtx
if (this.options.ollamaNumCtx !== undefined) {
chatOptions.num_ctx = this.options.ollamaNumCtx
}

// Create the actual API request promise
// 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
// the dedicated message.thinking field instead of (or in addition
// to) think/thought tags embedded in content.
const [chatOptions, thinkParam] = this.buildChatRequestOptions(useR1Format)

// Create the actual API request promise. The `stream: true` literal
// is kept inline so TypeScript selects the streaming overload of
// client.chat. The `think` parameter is spread conditionally to
// avoid sending an explicit `think: undefined` to the runtime.
const stream = await client.chat({
model: modelId,
messages: ollamaMessages,
stream: true,
options: chatOptions,
tools: this.convertToolsToOllama(metadata?.tools),
...(thinkParam !== undefined ? { think: thinkParam } : {}),
})

let totalInputTokens = 0
Expand All @@ -255,6 +383,18 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio

try {
for await (const chunk of stream) {
// Process Ollama's native thinking field. When the think
// parameter is enabled (or the model thinks by default),
// Ollama streams reasoning via message.thinking separately
// from message.content. Surface it as a reasoning chunk so
// it is rendered and preserved like other providers.
if (typeof chunk.message.thinking === "string" && chunk.message.thinking.length > 0) {
yield {
type: "reasoning",
text: chunk.message.thinking,
}
}

if (typeof chunk.message.content === "string" && chunk.message.content.length > 0) {
// Process content through matcher for reasoning detection
for (const matcherChunk of matcher.update(chunk.message.content)) {
Expand Down Expand Up @@ -353,21 +493,17 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
const { id: modelId } = await this.fetchModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")

// Build options object conditionally
const chatOptions: OllamaChatOptions = {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
}

// Only include num_ctx if explicitly set via ollamaNumCtx
if (this.options.ollamaNumCtx !== undefined) {
chatOptions.num_ctx = this.options.ollamaNumCtx
}
// Reuse the shared request-option builder so single-shot
// completions respect the same reasoning configuration as the
// streaming path.
const [chatOptions, thinkParam] = this.buildChatRequestOptions(useR1Format)

const response = await client.chat({
model: modelId,
messages: [{ role: "user", content: prompt }],
stream: false,
options: chatOptions,
...(thinkParam !== undefined ? { think: thinkParam } : {}),
})

return response.message?.content || ""
Expand Down
47 changes: 46 additions & 1 deletion webview-ui/src/components/settings/providers/Ollama.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useState, useCallback, useMemo, useEffect } from "react"
import { useEvent } from "react-use"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { Checkbox } from "vscrui"

import type { ProviderSettings, ExtensionMessage, ModelRecord } from "@roo-code/types"
import { type ProviderSettings, type ExtensionMessage, type ModelRecord, ollamaDefaultModelInfo } from "@roo-code/types"

import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
import { vscode } from "@src/utils/vscode"

import { inputEventTransform } from "../transforms"
import { ModelPicker } from "../ModelPicker"
import { ThinkingBudget } from "../ThinkingBudget"

type OllamaProps = {
apiConfiguration: ProviderSettings
Expand Down Expand Up @@ -131,6 +133,49 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
{t("settings:providers.ollama.numCtxHelp")}
</div>
</VSCodeTextField>
<div className="flex flex-col gap-1">
<Checkbox
checked={apiConfiguration.enableReasoningEffort ?? false}
onChange={(checked: boolean) => {
setApiConfigurationField("enableReasoningEffort", checked)

if (checked) {
// Restore the last selected effort level if one was
// previously chosen; otherwise default to "medium" so
// the request actually enables Ollama's native think
// parameter. Without a value, the ThinkingBudget Select
// would show "None" (disable) and getOllamaThinkParam()
// would return undefined, sending no think parameter
// despite the checkbox being on. Preserving the prior
// value avoids wiping the user's effort choice when
// toggling the checkbox off and back on.
setApiConfigurationField("reasoningEffort", apiConfiguration.reasoningEffort ?? "medium")
}
// When unchecked, leave reasoningEffort untouched so the
// user's prior selection is preserved across toggles. The
// handler gates on enableReasoningEffort === true, so a
// stale reasoningEffort value will not emit a think param
// while the checkbox is off.
}}>
{t("settings:providers.ollama.thinking")}
</Checkbox>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.thinkingHelp")}
</div>
{!!apiConfiguration.enableReasoningEffort && (
<ThinkingBudget
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
// Ollama models don't advertise reasoning capabilities, so
// synthesize a model info that exposes the effort levels
// Ollama's native `think` parameter supports (low/medium/high).
modelInfo={{
...ollamaDefaultModelInfo,
supportsReasoningEffort: true,
}}
/>
)}
</div>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.ollama.description")}
<span className="text-vscode-errorForeground ml-1">{t("settings:providers.ollama.warning")}</span>
Expand Down
Loading
Loading