Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
63 changes: 60 additions & 3 deletions src/api/providers/__tests__/native-ollama.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,13 @@ describe("NativeOllamaHandler", () => {

it("should map reasoningEffort levels to Ollama think values", async () => {
const cases: Array<
[NonNullable<ApiHandlerOptions["reasoningEffort"]>, boolean | "high" | "medium" | "low"]
[NonNullable<ApiHandlerOptions["reasoningEffort"]>, boolean | "high" | "medium" | "low" | "max"]
> = [
["low", "low"],
["medium", "medium"],
["high", "high"],
["xhigh", "high"],
["max", "high"],
["none", true],
["max", "max"],
["minimal", true],
["disable", false],
]
Expand Down Expand Up @@ -362,6 +361,36 @@ describe("NativeOllamaHandler", () => {
}
})

it("should send think: false when reasoningEffort is 'none'", async () => {
// Ollama has no native "none" thinking level; the "None" option in
// the selector means thinking disabled, so it maps to think: false
// rather than enabling thinking with the default budget.
const options: ApiHandlerOptions = {
apiModelId: "qwen3",
ollamaModelId: "qwen3",
ollamaBaseUrl: "http://localhost:11434",
enableReasoningEffort: true,
reasoningEffort: "none",
}

handler = new NativeOllamaHandler(options)

mockChat.mockImplementation(async function* () {
yield { message: { content: "ok" } }
})

const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }])
for await (const _ of stream) {
// consume
}

expect(mockChat).toHaveBeenCalledWith(
expect.objectContaining({
think: false,
}),
)
})

it("should not send think parameter when reasoningEffort is undefined", async () => {
mockChat.mockImplementation(async function* () {
yield { message: { content: "ok" } }
Expand Down Expand Up @@ -782,6 +811,34 @@ describe("NativeOllamaHandler", () => {
)
})

it("should send think: max in completePrompt when reasoningEffort is max", async () => {
// The Ollama API accepts "max" as the highest thinking level; the SDK
// passes it through verbatim. Selectors surface "max" for models that
// advertise it (e.g. qwen3 on Ollama Cloud), so the single-shot path
// must honor it rather than clamping to "high".
const options: ApiHandlerOptions = {
apiModelId: "qwen3",
ollamaModelId: "qwen3",
ollamaBaseUrl: "http://localhost:11434",
enableReasoningEffort: true,
reasoningEffort: "max",
}

handler = new NativeOllamaHandler(options)

mockChat.mockResolvedValue({
message: { content: "Response" },
})

await handler.completePrompt("Test prompt")

expect(mockChat).toHaveBeenCalledWith(
expect.objectContaining({
think: "max",
}),
)
})

it("should not send think parameter in completePrompt when reasoningEffort is undefined", async () => {
mockChat.mockResolvedValue({
message: { content: "Response" },
Expand Down
28 changes: 28 additions & 0 deletions src/api/providers/fetchers/__tests__/ollama.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,34 @@ describe("Ollama Fetcher", () => {
expect(parsedModel!.supportsImages).toBe(true)
expect(parsedModel!.contextWindow).toBeGreaterThan(0)
})

it("should advertise native reasoning effort for models with the 'thinking' capability", () => {
const modelDataWithThinking = {
...ollamaModelsData["qwen3-2to16:latest"],
capabilities: ["completion", "tools", "thinking"],
}

const parsedModel = parseOllamaModel(modelDataWithThinking as Parameters<typeof parseOllamaModel>[0])

expect(parsedModel).not.toBeNull()
// Ollama Cloud accepts low/medium/high/max and rejects "xhigh", so the
// selector must surface exactly those native effort levels.
expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high", "max"])
expect(parsedModel!.reasoningEffort).toBe("medium")
})

it("should not advertise reasoning effort when the 'thinking' capability is absent", () => {
const modelDataWithoutThinking = {
...ollamaModelsData["qwen3-2to16:latest"],
capabilities: ["completion", "tools"],
}

const parsedModel = parseOllamaModel(modelDataWithoutThinking as Parameters<typeof parseOllamaModel>[0])

expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsReasoningEffort).toBeUndefined()
expect(parsedModel!.reasoningEffort).toBeUndefined()
})
})

describe("getOllamaModels", () => {
Expand Down
10 changes: 10 additions & 0 deletions src/api/providers/fetchers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo |
// Inherit the sane default (4096) from ollamaDefaultModelInfo instead.
})

// Models that advertise the "thinking" capability expose a native
// reasoning-effort control. Ollama Cloud accepts low/medium/high/max and
// rejects "xhigh", so advertise exactly those values (matching the user's
// API verification) so the reasoning selector shows the model's real options
// instead of falling back to the generic low/medium/high defaults.
if (rawModel.capabilities?.includes("thinking")) {
modelInfo.supportsReasoningEffort = ["low", "medium", "high", "max"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ollama documents GPT-OSS as accepting only low, medium, or high, so this advertises an unsupported Max choice for that model. Can we make the capability list model-specific and add a GPT-OSS regression test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously reasoning selector had none - high for every models, thanks for noting about for gpt-oss and https://docs.ollama.com/capabilities/thinking#python-2 according to ollama docs, changes has been made to reflect the reasoning selector for gpt-oss. So, Detection matches on model id, family, or architecture, hyphen/underscore-normalized so "gpt-oss" / "gptoss" / "gpt_oss" all match — Ollama reports family/architecture as gptoss (no hyphen) while the model id keeps the hyphen (gpt-oss:20b/gpt-oss:120b), so checking all three is robust. The supportsReasoningEffort is always an explicit array (never bare true), so the selector respects it verbatim.

Added 3 regression tests in ollama.test.ts: gpt-oss advertises exactly ["low","medium","high"] with no max/disable; detection by architecture alone (gptoss); detection by model id alone.

modelInfo.reasoningEffort = "medium"
}

return modelInfo
}

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

// The installed ollama SDK (v0.6.x) types the `think` request field as
// `boolean | "high" | "medium" | "low"`, but the Ollama API also accepts
// `"max"` (see https://docs.ollama.com/capabilities/thinking). The runtime
// serializes the field verbatim, so `getOllamaThinkParam` returns the wider
// union and the `client.chat` call sites cast down to the SDK's narrower
// type. Remove those casts once the SDK types catch up.
export type OllamaThinkParam = boolean | "high" | "medium" | "low" | "max" | undefined

// 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
Expand Down Expand Up @@ -311,7 +319,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio

/**
* Maps the configured reasoning effort setting to Ollama's native `think`
* request parameter (boolean | "high" | "medium" | "low").
* request parameter (boolean | "high" | "medium" | "low" | "max").
*
* Requires an explicit Ollama opt-in (`enableReasoningEffort === true`)
* before translating `reasoningEffort`. This prevents inherited
Expand All @@ -323,19 +331,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
* 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)
* - "none" -> false (thinking off; Ollama has no native "none" level, and
* the "None" selector label means thinking disabled)
* - "minimal" -> true (enable thinking with default budget)
* - "low" / "medium" / "high" / "max" -> the matching effort level
* - "xhigh" -> "high" (highest level the Ollama API accepts; see
* https://docs.ollama.com/capabilities/thinking)
*/
private getOllamaThinkParam(): boolean | "high" | "medium" | "low" | undefined {
private getOllamaThinkParam(): OllamaThinkParam {
// 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
Expand All @@ -351,8 +356,8 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio

switch (effort) {
case "disable":
return false
case "none":
return false
case "minimal":
return true
case "low":
Expand All @@ -362,7 +367,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
case "high":
case "xhigh":
case "max":
return "high"
// "max" is accepted verbatim by the Ollama API; "xhigh" (used by
// some third-party model catalogs) clamps to "high".
return effort === "max" ? "max" : "high"
default:
return undefined
}
Expand All @@ -377,9 +384,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
* 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] {
private buildChatRequestOptions(useR1Format: boolean): [OllamaChatOptions, OllamaThinkParam] {
const chatOptions: OllamaChatOptions = {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
}
Expand Down Expand Up @@ -427,14 +432,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
// 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.
// avoid sending an explicit `think: undefined` to the runtime. The
// cast narrows our wider OllamaThinkParam (which includes "max",
// accepted by the API) to the SDK's stale union type.
const stream = await client.chat({
model: modelId,
messages: ollamaMessages,
stream: true,
options: chatOptions,
tools: this.convertToolsToOllama(metadata?.tools),
...(thinkParam !== undefined ? { think: thinkParam } : {}),
...(thinkParam !== undefined ? { think: thinkParam as boolean | "high" | "medium" | "low" } : {}),
})

let totalInputTokens = 0
Expand Down Expand Up @@ -566,7 +573,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
messages: [{ role: "user", content: prompt }],
stream: false,
options: chatOptions,
...(thinkParam !== undefined ? { think: thinkParam } : {}),
...(thinkParam !== undefined ? { think: thinkParam as boolean | "high" | "medium" | "low" } : {}),
})

return response.message?.content || ""
Expand Down
2 changes: 2 additions & 0 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { StandardTooltip } from "@src/components/ui"
import Thumbnails from "../common/Thumbnails"
import { ModeSelector } from "./ModeSelector"
import { ApiConfigSelector } from "./ApiConfigSelector"
import { ReasoningEffortSelector } from "./ReasoningEffortSelector"
import { AutoApproveDropdown } from "./AutoApproveDropdown"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
Expand Down Expand Up @@ -1319,6 +1320,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
/>
<ReasoningEffortSelector triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a visible control to the compact chat toolbar, but the PR adds no *.visual.tsx baseline even though webview-ui/AGENTS.md requires one for at-a-glance layout changes. Can we add a representative chat-input snapshot, including the narrow-width state, and commit its Docker-generated baseline?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx + ChatTextArea.visual.fixture.tsx.

<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
</div>
<div className={cn("flex flex-shrink-0 items-center gap-0.5 h-5 leading-none pr-2")}>
Expand Down
Loading
Loading