Skip to content
Draft
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
3 changes: 2 additions & 1 deletion scripts/stryker-diff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ export function preferDirectTestFiles(testFiles, sourceFiles) {
const testName = path.posix.basename(testFile)
return sourceNames.some(
(sourceName) =>
testName.startsWith(`${sourceName}.`) && /\.(?:test|spec)(?:\.[^.]+)?\.[cm]?[jt]sx?$/.test(testName),
(testName.startsWith(`${sourceName}.`) || testName.startsWith(`${sourceName}-`)) &&
/\.(?:test|spec)(?:\.[^.]+)?\.[cm]?[jt]sx?$/.test(testName),
)
})
return direct.length > 0 ? direct : testFiles
Expand Down
2 changes: 2 additions & 0 deletions scripts/stryker-diff.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,12 @@ describe("preferDirectTestFiles", () => {
const related = [
"webview-ui/src/__tests__/App.spec.tsx",
"webview-ui/src/utils/__tests__/path-mentions.test.ts",
"webview-ui/src/utils/__tests__/path-mentions-edge-cases.spec.ts",
"webview-ui/src/components/chat/__tests__/ChatView.spec.tsx",
]
assert.deepEqual(preferDirectTestFiles(related, ["webview-ui/src/utils/path-mentions.ts"]), [
"webview-ui/src/utils/__tests__/path-mentions.test.ts",
"webview-ui/src/utils/__tests__/path-mentions-edge-cases.spec.ts",
])
assert.deepEqual(preferDirectTestFiles(related, ["webview-ui/src/utils/unmatched.ts"]), related)
})
Expand Down
144 changes: 143 additions & 1 deletion src/api/providers/__tests__/lite-llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"

import { LiteLLMHandler } from "../lite-llm"
import { ApiHandlerOptions } from "../../../shared/api"
import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types"
import { litellmDefaultModelId, litellmDefaultModelInfo, type ModelInfo } from "@roo-code/types"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import { clearAllMocks } from "../../../test-utils/reset"

Expand Down Expand Up @@ -89,6 +89,60 @@ describe("LiteLLMHandler", () => {
handler = new LiteLLMHandler(mockOptions)
})

describe("reasoning effort normalization", () => {
const baseInfo: ModelInfo = {
maxTokens: 128_000,
contextWindow: 1_050_000,
supportsPromptCache: false,
}

it.each([
["non-array support", "max", { supportsReasoningEffort: true }, undefined],
[
"supported configured effort",
"max",
{ supportsReasoningEffort: ["low", "medium", "max"], reasoningEffort: "medium" },
"max",
],
[
"unsupported configured effort",
"high",
{ supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" },
"medium",
],
[
"disabled configured effort",
"disable",
{ supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" },
"medium",
],
[
"listed disable sentinel",
"disable",
{ supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium" },
"medium",
],
[
"unset configured effort",
undefined,
{ supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" },
"medium",
],
["missing fallback", undefined, { supportsReasoningEffort: ["low", "medium"] }, undefined],
[
"unsupported fallback",
undefined,
{ supportsReasoningEffort: ["low"], reasoningEffort: "medium" },
undefined,
],
] as const)("handles %s", (_case, reasoningEffort, overrides, expected) => {
const currentHandler = new LiteLLMHandler({ ...mockOptions, reasoningEffort })
const info = { ...baseInfo, ...overrides } as ModelInfo

expect(currentHandler["getReasoningEffort"](info)).toBe(expected)
})
})

describe("prompt caching", () => {
it("should add cache control headers when litellmUsePromptCache is enabled", async () => {
const optionsWithCache: ApiHandlerOptions = {
Expand Down Expand Up @@ -402,6 +456,7 @@ describe("LiteLLMHandler", () => {
handler = new LiteLLMHandler({
...mockOptions,
litellmModelId: "gpt-6-astra",
litellmUsePromptCache: true,
reasoningEffort: "none",
modelTemperature: 0.7,
})
Expand Down Expand Up @@ -441,8 +496,95 @@ describe("LiteLLMHandler", () => {
})
expect(request.max_tokens).toBeUndefined()
expect(request.temperature).toBeUndefined()
expect(request.messages[0]).toEqual({ role: "system", content: "You are helpful" })
})

it("keeps manual cache controls for non-Responses models", async () => {
handler = new LiteLLMHandler({ ...mockOptions, litellmUsePromptCache: true })
vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "cache-model",
info: { ...litellmDefaultModelInfo, supportsPromptCache: true, requiresResponsesApi: false },
})
mockCreate.mockReturnValue({ withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }) })

await collectStream(handler.createMessage("System", [{ role: "user", content: "Hello" }]))

const request = mockCreate.mock.calls[0][0]
expect(request.messages[0].content).toEqual([
expect.objectContaining({ type: "text", text: "System", cache_control: { type: "ephemeral" } }),
])
})

it.each([
[false, true, false],
[true, false, false],
[true, true, true],
] as const)(
"omits manual cache controls for enabled=%s supported=%s Responses=%s",
async (litellmUsePromptCache, supportsPromptCache, requiresResponsesApi) => {
handler = new LiteLLMHandler({ ...mockOptions, litellmUsePromptCache })
vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "cache-model",
info: { ...litellmDefaultModelInfo, supportsPromptCache, requiresResponsesApi },
})
mockCreate.mockReturnValue({ withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }) })

await collectStream(handler.createMessage("System", [{ role: "user", content: "Hello" }]))

expect(mockCreate.mock.calls[0][0].messages[0]).toEqual({ role: "system", content: "System" })
},
)

it.each(["streaming", "completion"] as const)("omits temperature for metadata-disabled %s", async (mode) => {
handler = new LiteLLMHandler({ ...mockOptions, modelTemperature: 0.7 })
vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "custom-model",
info: { ...litellmDefaultModelInfo, supportsTemperature: false },
})

if (mode === "streaming") {
mockCreate.mockReturnValue({ withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }) })
await collectStream(handler.createMessage("System", []))
} else {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "Response" } }] })
await handler.completePrompt("Hello")
}

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

it.each(["streaming", "completion"] as const)(
"uses standard token, temperature, and reasoning fields for ordinary %s",
async (mode) => {
handler = new LiteLLMHandler({ ...mockOptions })
vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "custom-model",
info: {
...litellmDefaultModelInfo,
maxTokens: 4_096,
supportsTemperature: true,
supportsReasoningEffort: false,
},
})

if (mode === "streaming") {
mockCreate.mockReturnValue({
withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }),
})
await collectStream(handler.createMessage("System", []))
} else {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "Response" } }] })
await handler.completePrompt("Hello")
}

const request = mockCreate.mock.calls[0][0]
expect(request.max_tokens).toBe(4_096)
expect(request).not.toHaveProperty("max_completion_tokens")
expect(request.temperature).toBe(0)
expect(request).not.toHaveProperty("reasoning_effort")
},
)

it("uses safe Astra parameters for completePrompt", async () => {
handler = new LiteLLMHandler({
...mockOptions,
Expand Down
129 changes: 128 additions & 1 deletion src/api/providers/__tests__/nanogpt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ vi.mock("vscode", () => ({
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import { nanoGptDefaultModelId, providerIdentifiers } from "@roo-code/types"
import { nanoGptDefaultModelId, providerIdentifiers, type ModelInfo } from "@roo-code/types"

import { buildApiHandler } from "../../index"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import { NanoGptHandler } from "../nanogpt"
import { getModels } from "../fetchers/modelCache"
import type { ApiHandlerOptions } from "../../../shared/api"

vi.mock("openai")
vi.mock("../fetchers/modelCache", () => ({
Expand Down Expand Up @@ -204,6 +205,103 @@ describe("NanoGptHandler", () => {
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature")
})

it("uses safe parallel-tool handling for the Astra Pro route", async () => {
const modelId = "openai/gpt-6-astra-pro"
vi.mocked(getModels).mockResolvedValue({
[modelId]: {
maxTokens: 128_000,
contextWindow: 1_050_000,
supportsPromptCache: true,
supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"],
requiredReasoningEffort: true,
reasoningEffort: "medium",
supportsTemperature: false,
},
})

await collectStream(
new NanoGptHandler({ nanoGptModelId: modelId }).createMessage("sys", messages, {
taskId: "task",
parallelToolCalls: true,
}),
)

expect(mockCreate.mock.calls[0][0]).toMatchObject({ model: modelId, parallel_tool_calls: false })
})

it.each([
["boolean support", { reasoningEffort: "high" }, { supportsReasoningEffort: true }, "high"],
["array support", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low", "high"] }, "high"],
["unsupported effort", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low"] }, undefined],
["disabled effort", { reasoningEffort: "disable" }, { supportsReasoningEffort: ["low"] }, undefined],
[
"listed disable sentinel",
{ reasoningEffort: "disable" },
{ supportsReasoningEffort: ["disable", "low"] },
undefined,
],
["none effort", { reasoningEffort: "none" }, { supportsReasoningEffort: ["low"] }, undefined],
["listed none sentinel", { reasoningEffort: "none" }, { supportsReasoningEffort: ["none", "low"] }, undefined],
[
"disabled toggle",
{ reasoningEffort: "high", enableReasoningEffort: false },
{ supportsReasoningEffort: ["low", "high"] },
undefined,
],
["minimal effort", { reasoningEffort: "minimal" }, { supportsReasoningEffort: ["minimal"] }, undefined],
[
"required fallback",
{ reasoningEffort: "high" },
{ supportsReasoningEffort: ["low", "medium"], requiredReasoningEffort: true, reasoningEffort: "medium" },
"medium",
],
[
"required default",
{},
{ supportsReasoningEffort: ["low", "medium"], requiredReasoningEffort: true, reasoningEffort: "medium" },
"medium",
],
[
"invalid required fallback",
{},
{ supportsReasoningEffort: ["low"], requiredReasoningEffort: true, reasoningEffort: "none" },
undefined,
],
["optional fallback", {}, { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, undefined],
] satisfies ReadonlyArray<readonly [string, Partial<ApiHandlerOptions>, Partial<ModelInfo>, string | undefined]>)(
"normalizes %s",
async (_case, options, modelOverrides, expectedEffort) => {
const modelId = "reasoning-model"
vi.mocked(getModels).mockResolvedValue({
[modelId]: {
maxTokens: 8_192,
contextWindow: 128_000,
supportsPromptCache: false,
...modelOverrides,
},
})

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

const request = mockCreate.mock.calls[0][0]
if (expectedEffort) expect(request.reasoning_effort).toBe(expectedEffort)
else expect(request).not.toHaveProperty("reasoning_effort")
},
)

it.each([
[undefined, true],
[false, false],
] as const)("uses %s parallel-tool preference as %s for ordinary models", async (parallelToolCalls, expected) => {
const metadata = parallelToolCalls === undefined ? undefined : { taskId: "task", parallelToolCalls }
await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages, metadata),
)
expect(mockCreate.mock.calls[0][0].parallel_tool_calls).toBe(expected)
})

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 +468,35 @@ describe("NanoGptHandler", () => {
})

describe("completePrompt", () => {
it("omits temperature when it is not configured", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] })
await new NanoGptHandler({ nanoGptModelId: "model:thinking" }).completePrompt("prompt")
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature")
})

it.each([
["supported model", "temperature-model", undefined, 0.7],
["metadata-disabled model", "temperature-model", false, undefined],
["name-disabled model", "openai/o3-mini-test", undefined, undefined],
] as const)(
"handles configured temperature for a %s",
async (_case, modelId, supportsTemperature, expected) => {
vi.mocked(getModels).mockResolvedValue({
[modelId]: {
maxTokens: 8_192,
contextWindow: 128_000,
supportsPromptCache: false,
supportsTemperature,
},
})
mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] })
await new NanoGptHandler({ nanoGptModelId: modelId, modelTemperature: 0.7 }).completePrompt("prompt")
const request = mockCreate.mock.calls[0][0]
if (expected === undefined) expect(request).not.toHaveProperty("temperature")
else expect(request.temperature).toBe(expected)
},
)

it("requests cache-capable routing without changing the completion model ID", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] })
const handler = new NanoGptHandler({
Expand Down
Loading
Loading