Skip to content
Open
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
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
122 changes: 122 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,128 @@ 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", () => {
// qwen3 is a thinking model that accepts the full low/medium/high/max
// set documented by Ollama's generic `think` API and honors `think:
// false`, so it advertises ["disable","low","medium","high","max"]. The
// "disable" sentinel is part of the capability array (not a UI-side
// prepend) so the selector respects it verbatim and never offers an
// un-disableable "None". No "xhigh" is advertised.
const modelDataWithThinking = {
...ollamaModelsData["qwen3-2to16:latest"],
capabilities: ["completion", "tools", "thinking"],
}

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

expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high", "max"])
expect(parsedModel!.reasoningEffort).toBe("medium")
})

it("should advertise only low/medium/high for gpt-oss thinking models (no max, no disable)", () => {
// Regression: Ollama's generic `think` API documents low/medium/high/max,
// but gpt-oss only accepts low/medium/high
// (https://ollama.com/library/gpt-oss: "Easily adjust the reasoning
// effort (low, medium, high) ..."). gpt-oss also ignores `think: false`,
// so reasoning cannot be disabled — the advertised array must omit both
// "max" and the "disable" sentinel. Advertising "max" would surface a
// choice the model rejects at request time, and advertising "disable"
// would offer an un-disableable "None". The array must be model-specific,
// not a single constant keyed off the boolean "thinking" capability.
// Ollama reports family "gptoss" and architecture "gptoss" (no hyphen)
// for gpt-oss in the /api/show response; the model id keeps the hyphen
// ("gpt-oss:20b"). Detection must match all three forms.
const gptOssModelData = {
...ollamaModelsData["qwen3-2to16:latest"],
details: {
...ollamaModelsData["qwen3-2to16:latest"].details,
family: "gptoss",
families: ["gptoss"],
parameter_size: "9.5B",
},
model_info: {
...ollamaModelsData["qwen3-2to16:latest"].model_info,
"general.architecture": "gptoss",
},
capabilities: ["completion", "tools", "thinking"],
}

const parsedModel = parseOllamaModel(
gptOssModelData as Parameters<typeof parseOllamaModel>[0],
"gpt-oss:20b",
)

expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high"])
// "max" must not be advertised for gpt-oss
expect(parsedModel!.supportsReasoningEffort).not.toContain("max")
// "disable" must not be advertised for gpt-oss (it ignores think: false)
expect(parsedModel!.supportsReasoningEffort).not.toContain("disable")
expect(parsedModel!.reasoningEffort).toBe("medium")
})

it("should detect gpt-oss from the architecture field alone (gptoss, no hyphen)", () => {
// Ollama strips the hyphen for general.architecture, reporting "gptoss".
// The heuristic must match the no-hyphen form even when the model id and
// family are absent or different.
const gptOssByArchitectureOnly = {
...ollamaModelsData["qwen3-2to16:latest"],
details: {
...ollamaModelsData["qwen3-2to16:latest"].details,
family: "other",
},
model_info: {
...ollamaModelsData["qwen3-2to16:latest"].model_info,
"general.architecture": "gptoss",
},
capabilities: ["completion", "tools", "thinking"],
}

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

expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high"])
})

it("should detect gpt-oss from the model id even when family/architecture differ", () => {
// The model id ("gpt-oss:120b") keeps the hyphen and is the most reliable
// signal the user actually selected gpt-oss. Detection must match on the
// id even if Ollama reports a different family/architecture (e.g. a
// future quant or custom Modelfile).
const gptOssByIdOnly = {
...ollamaModelsData["qwen3-2to16:latest"],
details: {
...ollamaModelsData["qwen3-2to16:latest"].details,
family: "unknown",
},
model_info: {
...ollamaModelsData["qwen3-2to16:latest"].model_info,
"general.architecture": "unknown",
},
capabilities: ["completion", "tools", "thinking"],
}

const parsedModel = parseOllamaModel(
gptOssByIdOnly as Parameters<typeof parseOllamaModel>[0],
"gpt-oss:120b",
)

expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high"])
})

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
89 changes: 87 additions & 2 deletions src/api/providers/fetchers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,73 @@ const OllamaModelInfoResponseSchema = z.object({
capabilities: z.array(z.string()).optional(),
})

// Reasoning-effort levels a thinking-capable Ollama model advertises. These are
// model-specific, not a single constant: Ollama's generic `think` API documents
// low/medium/high/max (https://docs.ollama.com/capabilities/thinking), but
// gpt-oss only accepts low/medium/high (see https://ollama.com/library/gpt-oss:
// "Easily adjust the reasoning effort (low, medium, high) ..."). Advertising
// "max" for gpt-oss surfaces a choice the model rejects at request time, so the
// fetcher must pick the array per model rather than from the boolean capability.
//
// Whether reasoning can be turned *off* is also model-specific. Ollama's native
// `think` parameter has no string "none" level — disabling thinking is `think:
// false`. Most thinking models (qwen3, deepseek-r1, ...) honor `think: false`,
// but gpt-oss ignores it and always reasons, so it must not advertise a
// "disable"/"None" option. We model off-support explicitly by including the
// "disable" UI sentinel in the capability array for models that honor
// `think: false`, and omitting it for gpt-oss. The shared
// `getReasoningEffortSelection` then respects explicit arrays verbatim (it
// only auto-adds "disable" for `supportsReasoningEffort === true`), so the
// settings page and chat selector render exactly what the model advertises
// without a UI-side "none" prepend.
//
// `getOllamaThinkingEfforts` resolves the advertised levels from model metadata
// when present, falling back to a family-based heuristic. Tests cover both the
// gpt-oss regression (no "max", no "disable") and the default thinking-model
// case (low/medium/high/max plus "disable").
const GPT_OSS_THINKING_EFFORTS = ["low", "medium", "high"] as const
const DEFAULT_THINKING_EFFORTS = ["disable", "low", "medium", "high", "max"] as const

// gpt-oss reports family "gptoss" and architecture "gptoss" in model_info /
// details (Ollama strips the hyphen from the model id "gpt-oss" for these
// fields — see https://ollama.com/library/gpt-oss, which lists arch "gptoss").
// The model id itself keeps the hyphen ("gpt-oss:20b" / "gpt-oss:120b"), so we
// match on the id too. Detect on any of id / family / architecture, comparing
// a hyphen/underscore-normalized form so "gpt-oss", "gptoss", and "gpt_oss" all
// match regardless of which field Ollama populates for a given tag.
function isGptOssModel(rawModel: OllamaModelInfoResponse, modelId?: string): boolean {
const normalize = (value: unknown): string =>
typeof value === "string" ? value.toLowerCase().replace(/[-_]/g, "") : ""

if (normalize(modelId).startsWith("gptoss")) {
return true
}

if (normalize(rawModel.details.family) === "gptoss") {
return true
}

const architecture = rawModel.model_info["general.architecture"]
if (normalize(architecture) === "gptoss") {
return true
}

return false
}

// Resolve the reasoning-effort levels a thinking-capable Ollama model advertises,
// including the "disable" UI sentinel when the model honors `think: false`.
// gpt-oss only accepts low/medium/high and ignores `think: false`, so it omits
// "disable"; other thinking models (qwen3, etc. on Ollama Cloud) accept the full
// low/medium/high/max set and honor `think: false`, so they include "disable".
// Exported for tests.
export function getOllamaThinkingEfforts(
rawModel: OllamaModelInfoResponse,
modelId?: string,
): readonly ("disable" | "low" | "medium" | "high" | "max")[] {
return isGptOssModel(rawModel, modelId) ? GPT_OSS_THINKING_EFFORTS : DEFAULT_THINKING_EFFORTS
}

const OllamaModelsResponseSchema = z.object({
models: z.array(OllamaModelSchema),
})
Expand All @@ -37,7 +104,7 @@ type OllamaModelsResponse = z.infer<typeof OllamaModelsResponseSchema>

type OllamaModelInfoResponse = z.infer<typeof OllamaModelInfoResponseSchema>

export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | null => {
export const parseOllamaModel = (rawModel: OllamaModelInfoResponse, modelId?: string): ModelInfo | null => {
const contextKey = Object.keys(rawModel.model_info).find((k) => k.includes("context_length"))
const contextWindow =
contextKey && typeof rawModel.model_info[contextKey] === "number" ? rawModel.model_info[contextKey] : undefined
Expand All @@ -59,6 +126,24 @@ 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. Thinking levels — and whether reasoning can be
// turned off at all — are model-specific, not a single constant:
// - gpt-oss accepts only low/medium/high and ignores `think: false`, so it
// advertises exactly ["low","medium","high"] (no "max", no "disable").
// - Other thinking models (qwen3, etc. on Ollama Cloud) accept the full
// low/medium/high/max set and honor `think: false`, so they advertise
// ["disable","low","medium","high","max"].
// (see https://docs.ollama.com/capabilities/thinking and
// https://ollama.com/library/gpt-oss). Including the "disable" UI sentinel
// explicitly means off-support is part of the capability array the selector
// respects verbatim, rather than a UI-side "none" prepend that would also
// offer an un-disableable "None" for gpt-oss.
if (rawModel.capabilities?.includes("thinking")) {
modelInfo.supportsReasoningEffort = [...getOllamaThinkingEfforts(rawModel, modelId)]
modelInfo.reasoningEffort = "medium"
}

return modelInfo
}

Expand Down Expand Up @@ -98,7 +183,7 @@ export async function getOllamaModels(
{ headers },
)
.then((ollamaModelInfo) => {
const modelInfo = parseOllamaModel(ollamaModelInfo.data)
const modelInfo = parseOllamaModel(ollamaModelInfo.data, ollamaModel.model)
// Only include models that support native tools
if (modelInfo) {
models[ollamaModel.name] = modelInfo
Expand Down
Loading
Loading