Skip to content
Merged
278 changes: 278 additions & 0 deletions src/api/providers/__tests__/nanogpt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,276 @@ describe("NanoGptHandler", () => {
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature")
})

it("uses the model's advertised reasoning effort when settings are unset", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["disable", "low", "high"],
reasoningEffort: "high",
},
})

await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages))

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" })
})

it("uses the first supported effort when the model cannot disable reasoning", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["high", "medium", "low"],
},
})

await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages))

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})

it.each([
["an unsupported configured effort", { reasoningEffort: "max" as const }, ["low", "high"] as const, undefined],
["a none model default", {}, ["none", "low"] as const, "none" as const],
["a minimal model default", {}, ["minimal", "low"] as const, "minimal" as const],
])("uses a canonical fallback for %s", async (_name, settings, supportsReasoningEffort, reasoningEffort) => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: [...supportsReasoningEffort],
reasoningEffort,
},
})

await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking", ...settings }).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})

it("uses a configured effort when reasoning support is boolean", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: true,
},
})

await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking", reasoningEffort: "high" }).createMessage(
"sys",
messages,
),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" })
})

it("honors disable when optional reasoning support is boolean", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: true,
reasoningEffort: "high",
},
})

await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking", reasoningEffort: "disable" }).createMessage(
"sys",
messages,
),
)

expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort")
})

it("omits an unset optional effort when disable is supported and no default is advertised", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["disable", "low", "high"],
},
})

await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages))

expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort")
})

it.each([
["a stale disable effort", { reasoningEffort: "disable" as const }],
["a stale disabled toggle", { enableReasoningEffort: false }],
])("uses a supported fallback for %s when the model cannot disable reasoning", async (_name, settings) => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["low", "high"],
},
})

await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking", ...settings }).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})

it.each([undefined, true] as const)(
"omits reasoning effort when the disable option is selected and enableReasoningEffort is %s",
async (enableReasoningEffort) => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["disable", "low", "high"],
reasoningEffort: "high",
},
})
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort,
reasoningEffort: "disable",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort")
},
)

it("resolves none to the canonical lowest supported effort when reasoning is enabled", async () => {
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort: true,
reasoningEffort: "none",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})

it("resolves none to the canonical lowest supported effort even when the model supports disable", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["disable", "low", "high"],
},
})
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort: true,
reasoningEffort: "none",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it.each([undefined, false] as const)(
"omits reasoning effort for stale none when enableReasoningEffort is %s",
async (enableReasoningEffort) => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["disable", "low"],
reasoningEffort: "high",
},
})
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort,
reasoningEffort: "none",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort")
},
)

it("resolves none to the lowest canonical effort when reasoning support is boolean", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: true,
},
})
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort: true,
reasoningEffort: "none",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})

it("resolves none to the first supported effort when low is not available", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["high"],
},
})
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort: true,
reasoningEffort: "none",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" })
})

it("omits reasoning effort when reasoning is explicitly disabled", async () => {
vi.mocked(getModels).mockResolvedValue({
"model:thinking": {
maxTokens: 128000,
contextWindow: 1050000,
supportsPromptCache: false,
supportsReasoningEffort: ["disable", "low", "high"],
reasoningEffort: "high",
},
})
await collectStream(
new NanoGptHandler({
nanoGptModelId: "model:thinking",
enableReasoningEffort: false,
reasoningEffort: "high",
}).createMessage("sys", messages),
)

expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort")
})

it("keeps Muse Spark tool-result history contiguous across turns", async () => {
const modelId = "meta/muse-spark-1.2-contributor"
vi.mocked(getModels).mockResolvedValue({
Expand Down Expand Up @@ -370,6 +640,14 @@ describe("NanoGptHandler", () => {
})

describe("completePrompt", () => {
it("uses the same default reasoning effort as streaming requests", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] })

await new NanoGptHandler({ nanoGptModelId: "model:thinking" }).completePrompt("prompt")

expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" })
})

it("requests cache-capable routing without changing the completion model ID", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] })
const handler = new NanoGptHandler({
Expand Down
32 changes: 29 additions & 3 deletions src/api/providers/nanogpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,41 @@ type NanoGptCachingRequest = { caching?: true }
const NANO_GPT_MERGED_TOOL_RESULT_MODELS = new Set(["meta/muse-spark-1.2-contributor"])

const NANO_GPT_ASTRA_MODEL_IDS = new Set(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"])
const NANO_GPT_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const

function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): ReasoningEffortExtended | undefined {
const configured = options.reasoningEffort
// "none" with enableReasoningEffort: true is an explicit level selection, not a disable.
const reasoningDisabled =
configured === "disable" || configured === "none" || options.enableReasoningEffort === false
configured === "disable" ||
(configured === "none" && options.enableReasoningEffort !== true) ||
options.enableReasoningEffort === false
const supported = info.supportsReasoningEffort

if (!reasoningDisabled && configured && configured !== "minimal") {
if (supported === true || (Array.isArray(supported) && supported.includes(configured))) return configured
if (reasoningDisabled && (supported === true || (Array.isArray(supported) && supported.includes("disable")))) {
return undefined
}

// When "none" is explicitly enabled, resolve it to the lowest canonical supported effort.
const noneEnabled = !reasoningDisabled && configured === "none"
const candidates = [reasoningDisabled ? undefined : configured, info.reasoningEffort]
if (noneEnabled || (Array.isArray(supported) && !supported.includes("disable"))) {
candidates.push(
NANO_GPT_REASONING_EFFORTS.find(
(effort) => supported === true || (Array.isArray(supported) && supported.includes(effort)),
),
)
}

for (const effort of candidates) {
if (
effort &&
effort !== "none" &&
effort !== "minimal" &&
(supported === true || (Array.isArray(supported) && supported.includes(effort)))
) {
return effort
}
}

const fallback = info.reasoningEffort
Expand Down
21 changes: 6 additions & 15 deletions webview-ui/src/components/settings/ThinkingBudget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,10 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
? ["disable", ...baseAvailableOptions]
: baseAvailableOptions

// Default reasoning effort - use model's default if available
// GPT-5 models have "medium" as their default in the model configuration
// Use the model's declared default when present; otherwise fall back based on requiredReasoningEffort.
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined
const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort
? modelDefaultReasoningEffort || "medium"
: "disable"
const defaultReasoningEffort: ReasoningEffortOption =
modelDefaultReasoningEffort ?? (modelInfo?.requiredReasoningEffort ? "medium" : "disable")
// Current reasoning effort from settings, or fall back to default.
// Clamp to availableOptions so the Select trigger always renders a valid option.
const storedReasoningEffort = apiConfiguration.reasoningEffort as ReasoningEffortOption | undefined
Expand All @@ -116,23 +114,16 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
? rawReasoningEffort
: fallbackReasoningEffort

// Set default reasoning effort when model supports it and no value is set
// Keep normalized defaults pending so Save persists them to the provider profile.
useEffect(() => {
if (
isReasoningEffortSupported &&
modelInfo?.requiredReasoningEffort &&
storedReasoningEffort !== currentReasoningEffort &&
currentReasoningEffort !== "disable"
) {
setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended, false)
setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended)
}
}, [
isReasoningEffortSupported,
storedReasoningEffort,
currentReasoningEffort,
modelInfo?.requiredReasoningEffort,
setApiConfigurationField,
])
}, [isReasoningEffortSupported, storedReasoningEffort, currentReasoningEffort, setApiConfigurationField])

// Sync enableReasoningEffort based on selection
// "disable" turns off reasoning; "none" is a valid level (reasoning enabled)
Expand Down
Loading
Loading