diff --git a/packages/types/src/__tests__/opencode-go.test.ts b/packages/types/src/__tests__/opencode-go.test.ts index 2f434939a1..cfcde4089c 100644 --- a/packages/types/src/__tests__/opencode-go.test.ts +++ b/packages/types/src/__tests__/opencode-go.test.ts @@ -8,7 +8,10 @@ import { isOpencodeGoAnthropicFormatModel, isOpencodeGoResponsesFormatModel, getOpencodeGoModelInfo, + OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS, + opencodeGoResponsesSupportsTemperature, } from "../providers/opencode-go.js" +import { reasoningEffortsExtended } from "../model.js" describe("opencode-go registry", () => { const anthropicFormatModels = [ @@ -170,6 +173,7 @@ describe("opencode-go registry", () => { it("curates gpt-5.6-luna with its Go Responses capabilities", () => { expect(getOpencodeGoModelInfo("gpt-5.6-luna")).toMatchObject({ supportsMaxTokens: true, + supportsTemperature: false, maxTokens: 128_000, contextWindow: 1_050_000, supportsImages: true, @@ -221,6 +225,58 @@ describe("opencode-go registry", () => { }) }) + describe("opencodeGoResponsesSupportsTemperature (Zoo-Code-Org/Zoo-Code#2055)", () => { + const gptLunaModels = ["gpt-5.6-luna", "gpt-6-luna"] + const alwaysSupportedModels = ["grok-4.6", "muse-spark-1.3-contributor", "muse-spark-1.2-contributor"] + + it.each(alwaysSupportedModels)("%s supports temperature for every effort and with none sent", (id) => { + expect(getOpencodeGoModelInfo(id)?.supportsTemperature).toBe(true) + expect(opencodeGoResponsesSupportsTemperature(id, undefined)).toBe(true) + for (const effort of reasoningEffortsExtended) { + expect(opencodeGoResponsesSupportsTemperature(id, effort)).toBe(true) + } + }) + + it.each(gptLunaModels)("%s supports temperature only with reasoning effort none", (id) => { + expect(getOpencodeGoModelInfo(id)?.supportsTemperature).toBe(false) + expect(opencodeGoResponsesSupportsTemperature(id, "none")).toBe(true) + for (const effort of reasoningEffortsExtended.filter((e) => e !== "none")) { + expect(opencodeGoResponsesSupportsTemperature(id, effort)).toBe(false) + } + // No effort on the wire is not a verified configuration. + expect(opencodeGoResponsesSupportsTemperature(id, undefined)).toBe(false) + }) + + it("lists only the GPT Luna models in the effort-conditional table", () => { + expect(Object.keys(OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS).sort()).toEqual(gptLunaModels) + }) + + it("lists only advertised efforts for Responses models in the effort-conditional table", () => { + for (const [id, efforts] of Object.entries(OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS)) { + expect(isOpencodeGoResponsesFormatModel(id)).toBe(true) + // The table overrides a baseline opt-out, so the model must declare one. + expect(getOpencodeGoModelInfo(id)?.supportsTemperature).toBe(false) + const advertised = getOpencodeGoModelInfo(id)?.supportsReasoningEffort + expect(Array.isArray(advertised)).toBe(true) + for (const effort of efforts) { + expect(advertised).toContain(effort) + } + } + }) + + it("declares an explicit temperature capability for every Responses-format model", () => { + for (const id of OPENCODE_GO_RESPONSES_FORMAT_MODELS) { + expect(typeof getOpencodeGoModelInfo(id)?.supportsTemperature).toBe("boolean") + } + }) + + it("returns false for unknown and non-Responses models", () => { + expect(opencodeGoResponsesSupportsTemperature("some-future-model", "none")).toBe(false) + // kimi-k2.6 declares supportsTemperature: true but is not a Responses model. + expect(opencodeGoResponsesSupportsTemperature("kimi-k2.6", undefined)).toBe(false) + }) + }) + describe("opencodeGoModels registry invariants", () => { it.each([ { diff --git a/packages/types/src/providers/opencode-go.ts b/packages/types/src/providers/opencode-go.ts index 49f190ccd6..827cac1c58 100644 --- a/packages/types/src/providers/opencode-go.ts +++ b/packages/types/src/providers/opencode-go.ts @@ -1,4 +1,4 @@ -import type { ModelInfo } from "../model.js" +import type { ModelInfo, ReasoningEffortExtended } from "../model.js" // Opencode "Go" plan — OpenAI-compatible gateway. // https://opencode.ai/docs/go/ · base URL: https://opencode.ai/zen/go/v1 @@ -574,6 +574,9 @@ export const opencodeGoModels: Record = { supportsImages: true, supportsPromptCache: true, supportsMaxTokens: true, + // Temperature is accepted only with reasoning effort "none"; see + // OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS. + supportsTemperature: false, supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], reasoningEffort: "medium", inputPrice: 0.2, @@ -596,8 +599,8 @@ export const opencodeGoModels: Record = { supportsImages: true, supportsPromptCache: true, supportsMaxTokens: true, - // The OpenCode Go Responses model silently drops `temperature`, so the - // UI temperature control would otherwise render as a dead control. + // Temperature is accepted only with reasoning effort "none"; see + // OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS. supportsTemperature: false, supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], reasoningEffort: "medium", @@ -640,6 +643,7 @@ export const opencodeGoModels: Record = { supportsImages: true, supportsPromptCache: true, supportsMaxTokens: true, + supportsTemperature: true, supportsReasoningEffort: ["low", "medium", "high", "xhigh"], reasoningEffort: "high", inputPrice: 2.0, @@ -660,6 +664,7 @@ export const opencodeGoModels: Record = { supportsImages: true, supportsPromptCache: true, supportsMaxTokens: true, + supportsTemperature: true, supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh"], reasoningEffort: "medium", inputPrice: 0.1, @@ -674,6 +679,7 @@ export const opencodeGoModels: Record = { supportsImages: true, supportsPromptCache: true, supportsMaxTokens: true, + supportsTemperature: true, supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh"], reasoningEffort: "medium", inputPrice: 0.1, @@ -760,6 +766,54 @@ export function isOpencodeGoResponsesFormatModel(modelId: string): boolean { return OPENCODE_GO_RESPONSES_FORMAT_MODELS.has(modelId) } +/** + * Reasoning efforts for which a Responses-format model accepts `temperature` + * even though its registry entry declares `supportsTemperature: false`. + * + * Live probes of the Go gateway (Zoo-Code-Org/Zoo-Code#2006) found that the + * GPT Luna models accept every tested temperature (0–2) with reasoning effort + * `"none"`. With any other effort they return HTTP 400 "unsupported + * parameter" for every value except `1`. Temperature `1` is the OpenAI + * reasoning-model default, so accepting it does not show real support: the + * gateway allows only the default value. Temperature is therefore omitted for + * every effort other than `"none"`, including a request that would send `1` + * (Zoo-Code-Org/Zoo-Code#2055). + */ +export const OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS: Readonly> = { + "gpt-5.6-luna": ["none"], + "gpt-6-luna": ["none"], +} + +/** + * Returns `true` when a Responses-format Go model accepts `temperature` for + * the effective reasoning effort that will be sent on the wire. + * + * Support is opt-in (Zoo-Code-Org/Zoo-Code#2055): a Responses model must + * declare `supportsTemperature: true` in the registry, or list the effort in + * {@link OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS}. A model without either + * entry, or an unknown model ID, never receives `temperature`. Timeouts and + * HTTP 5xx results are not evidence for or against support. + * + * @param modelId - The Go model ID. + * @param reasoningEffort - The effective effort sent with the request, or + * `undefined` when the request omits `reasoning.effort`. + */ +export function opencodeGoResponsesSupportsTemperature( + modelId: string, + reasoningEffort: ReasoningEffortExtended | undefined, +): boolean { + if (!isOpencodeGoResponsesFormatModel(modelId)) { + return false + } + + const efforts = OPENCODE_GO_TEMPERATURE_REASONING_EFFORTS[modelId] + if (efforts) { + return reasoningEffort !== undefined && efforts.includes(reasoningEffort) + } + + return getOpencodeGoModelInfo(modelId)?.supportsTemperature === true +} + /** * Returns the native {@link ModelInfo} for a Go-plan model ID, or `undefined` * when the ID is not part of the curated registry. Callers should fall back to diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 9c7977c9cd..b7d708ab94 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -43,6 +43,9 @@ vitest.mock("../fetchers/modelCache", () => ({ // Responses-format models (Zoo-Code-Org/Zoo-Code#1431 and issue #1979). "gpt-5.6-luna": { ...opencodeGoModels["gpt-5.6-luna"] }, "gpt-6-luna": { ...opencodeGoModels["gpt-6-luna"] }, + "grok-4.6": { ...opencodeGoModels["grok-4.6"] }, + "muse-spark-1.3-contributor": { ...opencodeGoModels["muse-spark-1.3-contributor"] }, + "muse-spark-1.2-contributor": { ...opencodeGoModels["muse-spark-1.2-contributor"] }, }) }), refreshModels: vitest.fn().mockImplementation(function () { @@ -51,6 +54,9 @@ vitest.mock("../fetchers/modelCache", () => ({ "qwen3.7-max": { ...opencodeGoModels["qwen3.7-max"] }, "gpt-5.6-luna": { ...opencodeGoModels["gpt-5.6-luna"] }, "gpt-6-luna": { ...opencodeGoModels["gpt-6-luna"] }, + "grok-4.6": { ...opencodeGoModels["grok-4.6"] }, + "muse-spark-1.3-contributor": { ...opencodeGoModels["muse-spark-1.3-contributor"] }, + "muse-spark-1.2-contributor": { ...opencodeGoModels["muse-spark-1.2-contributor"] }, }) }), getModelsFromCache: vitest.fn().mockReturnValue(undefined), @@ -1115,7 +1121,7 @@ describe("OpencodeGoHandler", () => { expect(callArgs.store).toBe(false) const input = callArgs.input as unknown[] expect(input.some((item) => (item as { role?: string }).role === "system")).toBe(false) - // The gateway rejects temperature for Responses-format models. + // No custom temperature is set, so the provider default is not sent. expect(callArgs.temperature).toBeUndefined() // No tools were provided, so no tools/tool_choice are sent. expect(callArgs.tools).toBeUndefined() @@ -1500,6 +1506,141 @@ describe("OpencodeGoHandler", () => { }).rejects.toThrow("Opencode Go completion error: internal server error") }) + describe("temperature (Zoo-Code-Org/Zoo-Code#2055)", () => { + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + const streamRequest = async (options: ApiHandlerOptions) => { + await collectStream(new OpencodeGoHandler(options).createMessage("sys", messages)) + return mockResponsesCreate.mock.calls[0][0] as Record + } + + const completeRequest = async (options: ApiHandlerOptions) => { + mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) + await new OpencodeGoHandler(options).completePrompt("ping") + return mockResponsesCreate.mock.calls[0][0] as Record + } + + const paths = [ + ["streaming", streamRequest], + ["non-streaming", completeRequest], + ] as const + + describe.each(paths)("%s request", (_path, sendRequest) => { + it.each([ + ["grok-4.6", undefined], + ["muse-spark-1.3-contributor", undefined], + ["muse-spark-1.2-contributor", undefined], + ["gpt-5.6-luna", "none"], + ["gpt-6-luna", "none"], + ] as const)("forwards a custom temperature for %s (effort %s)", async (modelId, reasoningEffort) => { + const callArgs = await sendRequest({ + opencodeGoApiKey: "test-key", + opencodeGoModelId: modelId, + modelTemperature: 0.37, + ...(reasoningEffort && { reasoningEffort }), + }) + + // The value keeps the UI's 0.01 resolution. + expect(callArgs.temperature).toBe(0.37) + }) + + it.each([ + ["grok-4.6", "low"], + ["grok-4.6", "high"], + ["muse-spark-1.3-contributor", "minimal"], + ["muse-spark-1.2-contributor", "high"], + ] as const)( + "forwards a custom temperature for %s with explicit effort %s", + async (modelId, reasoningEffort) => { + const callArgs = await sendRequest({ + opencodeGoApiKey: "test-key", + opencodeGoModelId: modelId, + modelTemperature: 0.37, + reasoningEffort, + }) + + // Grok and Muse support does not depend on reasoning effort. + expect(callArgs.reasoning).toEqual({ effort: reasoningEffort }) + expect(callArgs.temperature).toBe(0.37) + }, + ) + + it("omits temperature for a stale none effort when reasoning effort is switched off", async () => { + // A saved "none" does not reach the wire while the toggle is off, + // so the configuration is unverified and temperature is omitted. + const callArgs = await sendRequest({ + ...lunaOptions, + modelTemperature: 0.5, + reasoningEffort: "none", + enableReasoningEffort: false, + }) + + expect(callArgs).not.toHaveProperty("reasoning") + expect(callArgs).not.toHaveProperty("temperature") + }) + + it.each([ + ["gpt-5.6-luna", "low"], + ["gpt-5.6-luna", "max"], + ["gpt-6-luna", "medium"], + ["gpt-6-luna", "xhigh"], + ["gpt-6-luna", "disable"], + ] as const)("omits a saved temperature for %s with effort %s", async (modelId, reasoningEffort) => { + const callArgs = await sendRequest({ + opencodeGoApiKey: "test-key", + opencodeGoModelId: modelId, + modelTemperature: 0.5, + reasoningEffort, + }) + + expect(callArgs).not.toHaveProperty("temperature") + }) + + it("omits temperature 1 for a GPT Luna model with a non-none effort", async () => { + // Temperature 1 is the only value the gateway accepted with + // reasoning, but it is the model default, so it is omitted. + const callArgs = await sendRequest({ + opencodeGoApiKey: "test-key", + opencodeGoModelId: "gpt-5.6-luna", + modelTemperature: 1, + reasoningEffort: "high", + }) + + expect(callArgs).not.toHaveProperty("temperature") + }) + + it("omits a saved temperature for a GPT Luna model on its default effort", async () => { + const callArgs = await sendRequest({ ...gpt6LunaOptions, modelTemperature: 0.5 }) + + expect(callArgs.reasoning).toEqual({ effort: "medium" }) + expect(callArgs).not.toHaveProperty("temperature") + }) + + it.each([undefined, null])( + "omits temperature when no custom value is set (%s)", + async (modelTemperature) => { + const callArgs = await sendRequest({ + opencodeGoApiKey: "test-key", + opencodeGoModelId: "grok-4.6", + modelTemperature, + }) + + expect(callArgs).not.toHaveProperty("temperature") + }, + ) + + it("forwards temperature 0 as an explicit custom value", async () => { + const callArgs = await sendRequest({ + opencodeGoApiKey: "test-key", + opencodeGoModelId: "grok-4.6", + modelTemperature: 0, + }) + + expect(callArgs.temperature).toBe(0) + }) + }) + }) + it("classifies documented Responses models as Responses-format and other models as not", () => { expect(isOpencodeGoResponsesFormatModel("gpt-5.6-luna")).toBe(true) expect(isOpencodeGoResponsesFormatModel("gpt-6-luna")).toBe(true) diff --git a/src/api/providers/fetchers/__tests__/opencode-go.spec.ts b/src/api/providers/fetchers/__tests__/opencode-go.spec.ts index e51a21f115..62f17ab900 100644 --- a/src/api/providers/fetchers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/fetchers/__tests__/opencode-go.spec.ts @@ -75,6 +75,26 @@ describe("Opencode Go Fetchers", () => { }) }) + it("keeps curated Responses temperature support after merging live metadata", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { id: "grok-4.6", context_window: 400_000 }, + { id: "muse-spark-1.2-contributor", max_tokens: 65_536 }, + { id: "gpt-5.6-luna", context_window: 900_000 }, + ], + }, + }) + + const models = await getOpencodeGoModels("k") + + // Live limits never override the curated temperature capability + // (Zoo-Code-Org/Zoo-Code#2055). + expect(models["grok-4.6"]).toMatchObject({ contextWindow: 400_000, supportsTemperature: true }) + expect(models["muse-spark-1.2-contributor"]).toMatchObject({ maxTokens: 65_536, supportsTemperature: true }) + expect(models["gpt-5.6-luna"]).toMatchObject({ contextWindow: 900_000, supportsTemperature: false }) + }) + it("uses curated gpt-6-luna metadata when the live model omits metadata", async () => { mockedAxios.get.mockResolvedValue({ data: { data: [{ id: "gpt-6-luna" }] } }) @@ -86,9 +106,9 @@ describe("Opencode Go Fetchers", () => { supportsImages: true, supportsPromptCache: true, supportsMaxTokens: true, - // The Opencode Go Responses gateway rejects `temperature` for - // `gpt-6-luna`, so the curated registry must opt out so the UI - // hides the dead control rather than rendering it. + // The Go gateway accepts `temperature` for `gpt-6-luna` only with + // reasoning effort "none", so the static baseline opts out and the + // effort-aware rule opts back in (Zoo-Code-Org/Zoo-Code#2055). supportsTemperature: false, supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], reasoningEffort: "medium", diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index db31cf1c92..c6b995f161 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -12,6 +12,7 @@ import { OPENCODE_GO_DEFAULT_TEMPERATURE, isOpencodeGoAnthropicFormatModel, isOpencodeGoResponsesFormatModel, + opencodeGoResponsesSupportsTemperature, providerIdentifiers, } from "@roo-code/types" @@ -79,9 +80,9 @@ type OpencodeGoFormat = "anthropic" | "openai" | "responses" * - Anthropic Messages (`/v1/messages`) — used by Qwen (qwen3.8-max, * qwen3.7-max, qwen3.7-plus, qwen3.6-plus) and MiniMax (minimax-m3, * minimax-m2.7, minimax-m2.5) models. - * - OpenAI Responses (`/v1/responses`) — used by `gpt-5.6-luna` and - * `gpt-6-luna`, whose chat-completions adapters are not supported by the - * Go gateway. + * - OpenAI Responses (`/v1/responses`) — used by the models in + * `OPENCODE_GO_RESPONSES_FORMAT_MODELS` (GPT Luna, Grok, and Muse Spark), + * whose chat-completions adapters are not supported by the Go gateway. * * Sending an Anthropic-format model to the chat completions endpoint is * rejected with `401 Model is not supported for format oa-compat`, so this @@ -213,9 +214,9 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio * * Anthropic-format models (Qwen/MiniMax) are streamed via * {@link streamAnthropicMessage} against `/v1/messages`; Responses-format - * models (`gpt-5.6-luna` and `gpt-6-luna`) are streamed via {@link streamResponsesMessage} - * against `/v1/responses`; all other models use the OpenAI-compatible chat - * completions endpoint. + * models ({@link isOpencodeGoResponsesFormatModel}) are streamed via + * {@link streamResponsesMessage} against `/v1/responses`; all other models + * use the OpenAI-compatible chat completions endpoint. * * For OpenAI-format models that require reasoning_content to be passed back * during multi-turn tool calls (`preserveReasoning`), messages are @@ -239,7 +240,6 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio yield* this.streamResponsesMessage( modelId, info, - temperature, maxTokens, reasoningEffort, systemPrompt, @@ -318,9 +318,31 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio } } + /** + * Returns the `temperature` field for a Responses request, or an empty + * object to omit it. + * + * Temperature is sent only when the user has set a custom value and the + * model accepts it for the effective reasoning effort + * ({@link opencodeGoResponsesSupportsTemperature}). The provider default is + * never sent, which keeps the gateway's own default when no custom value + * is set. A saved value for an unsupported configuration is kept in + * settings but not sent (Zoo-Code-Org/Zoo-Code#2055). + */ + private getResponsesTemperature( + modelId: string, + reasoningEffort: ReasoningEffortExtended | undefined, + ): { temperature?: number } { + const temperature = this.options.modelTemperature + return typeof temperature === "number" && opencodeGoResponsesSupportsTemperature(modelId, reasoningEffort) + ? { temperature } + : {} + } + /** * Streams an OpenAI Responses-format completion for Go models that only - * accept the `/v1/responses` endpoint (`gpt-5.6-luna` and `gpt-6-luna`). + * accept the `/v1/responses` endpoint + * ({@link isOpencodeGoResponsesFormatModel}). * * Follows the focused xAI handler pattern: the conversation is converted * with the shared {@link convertToResponsesApiInput} transform, the system @@ -338,7 +360,6 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio private async *streamResponsesMessage( modelId: string, info: ModelInfo, - _temperature: number | undefined, maxTokens: number | undefined, reasoningEffort: ReasoningEffortExtended | undefined, systemPrompt: string, @@ -381,8 +402,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio } as OpenAI.Responses.ResponseCreateParamsStreaming["reasoning"], } : {}), - // OpenCode Go Responses models currently reject temperature, even when - // the model metadata does not explicitly opt out of it. + ...this.getResponsesTemperature(modelId, reasoningEffort), // Honour the same includeMaxTokens/modelMaxTokens override logic as // the chat-completions and Anthropic streaming paths. ...(maxTokens !== undefined @@ -760,7 +780,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio model: modelId, input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], store: false, - // OpenCode Go Responses models currently reject temperature. + ...this.getResponsesTemperature(modelId, reasoningEffort), // Honour the same includeMaxTokens/modelMaxTokens override // logic as the chat-completions path. ...(maxTokens !== undefined diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 08d680678a..beb9ffeb67 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -275,6 +275,11 @@ export const stories: Record = { await import("@/components/settings/providers/__tests__/OpenAICompatible.visual.fixture") return }, + "opencode-go-temperature": async () => { + const { OpencodeGoTemperatureFixture } = + await import("@/components/settings/__tests__/OpencodeGoTemperature.visual.fixture") + return + }, "rendered-content-contrast": async () => { const [{ AppProviders }, { RenderedContentContrastFixture }] = await Promise.all([ import("../AppProviders"), diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index e531515abe..ab7457915e 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -29,6 +29,7 @@ import { handleModelChangeSideEffects, toBedrockModelId, } from "./utils/providerModelConfig" +import { isTemperatureControlSupported } from "./utils/temperatureSupport" import { vscode } from "@src/utils/vscode" import { validateApiConfigurationExcludingModelErrors, getModelValidationError } from "@src/utils/validate" @@ -836,7 +837,12 @@ const ApiOptions = ({ todoListEnabled={apiConfiguration.todoListEnabled} onChange={(field, value) => setApiConfigurationField(field, value)} /> - {selectedModelInfo?.supportsTemperature !== false && ( + {isTemperatureControlSupported({ + provider: activeSelectedProvider, + modelId: selectedModelId, + modelInfo: selectedModelInfo, + apiConfiguration, + }) && ( { expect(screen.queryByTestId("rate-limit-seconds-control")).not.toBeInTheDocument() }) + describe("OpenCode Go Responses temperature (Zoo-Code-Org/Zoo-Code#2055)", () => { + const selectOpencodeGoModel = (modelId: string) => { + vi.mocked(useSelectedModel).mockImplementation(() => ({ + provider: providerIdentifiers.opencodeGo, + id: modelId, + info: opencodeGoModels[modelId], + isLoading: false, + isError: false, + })) + } + + const opencodeGoConfig = (modelId: string, settings: ProviderSettings = {}): ProviderSettings => ({ + apiProvider: providerIdentifiers.opencodeGo, + opencodeGoModelId: modelId, + modelTemperature: 0.42, + ...settings, + }) + + afterEach(() => { + vi.mocked(useSelectedModel).mockReset() + }) + + it("shows the control for a model that always supports temperature", () => { + selectOpencodeGoModel("grok-4.6") + renderApiOptions({ apiConfiguration: opencodeGoConfig("grok-4.6") }) + + expect(screen.getByTestId("temperature-control")).toBeInTheDocument() + }) + + it("hides the control for a GPT Luna model on its default reasoning effort", () => { + selectOpencodeGoModel("gpt-6-luna") + renderApiOptions({ apiConfiguration: opencodeGoConfig("gpt-6-luna") }) + + expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument() + }) + + it("updates visibility when the reasoning effort changes without clearing the saved value", () => { + const setApiConfigurationField = vi.fn() + selectOpencodeGoModel("gpt-5.6-luna") + const renderWithEffort = (reasoningEffort: ProviderSettings["reasoningEffort"]) => ( + {}} + uriScheme={undefined} + apiConfiguration={opencodeGoConfig("gpt-5.6-luna", { reasoningEffort })} + setApiConfigurationField={setApiConfigurationField} + /> + ) + const view = renderWithExtensionState(renderWithEffort("none")) + expect(screen.getByTestId("temperature-control")).toBeInTheDocument() + + view.rerender(renderWithEffort("high")) + expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument() + + // Returning to "none" shows the control again with the saved value. + view.rerender(renderWithEffort("none")) + expect(within(screen.getByTestId("temperature-control")).getByRole("slider")).toHaveValue("0.42") + + const temperatureWrites = setApiConfigurationField.mock.calls.filter( + ([field]) => field === "modelTemperature", + ) + expect(temperatureWrites).toEqual([]) + }) + }) + describe("thinking functionality", () => { it("should show ThinkingBudget for Anthropic models that support thinking", () => { renderApiOptions({ diff --git a/webview-ui/src/components/settings/__tests__/OpencodeGoTemperature.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/OpencodeGoTemperature.visual.fixture.tsx new file mode 100644 index 0000000000..a10a7190fc --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/OpencodeGoTemperature.visual.fixture.tsx @@ -0,0 +1,62 @@ +import React from "react" + +import { type ProviderSettings, opencodeGoModels, providerIdentifiers } from "@roo-code/types" + +import { TemperatureControl } from "../TemperatureControl" +import { isTemperatureControlSupported } from "../utils/temperatureSupport" +import { AppProviders } from "../../../../playwright/AppProviders" + +const SAVED_TEMPERATURE = 0.42 + +const cases: { modelId: string; reasoningEffort: ProviderSettings["reasoningEffort"] }[] = [ + { modelId: "gpt-6-luna", reasoningEffort: "none" }, + { modelId: "gpt-6-luna", reasoningEffort: "high" }, + { modelId: "grok-4.6", reasoningEffort: "high" }, +] + +/** + * Renders the temperature control through the same gate ApiOptions uses, for + * supported and unsupported OpenCode Go Responses states (#2055). Each case has + * a saved custom temperature, so a hidden control shows the value is kept but + * not offered. + */ +export function OpencodeGoTemperatureFixture() { + return ( + +
+ {cases.map(({ modelId, reasoningEffort }) => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.opencodeGo, + opencodeGoModelId: modelId, + reasoningEffort, + modelTemperature: SAVED_TEMPERATURE, + } + const supported = isTemperatureControlSupported({ + provider: providerIdentifiers.opencodeGo, + modelId, + modelInfo: opencodeGoModels[modelId], + apiConfiguration, + }) + return ( +
+

+ {modelId} · reasoning effort {reasoningEffort} +

+ {supported ? ( + undefined} + maxValue={2} + /> + ) : ( +

Temperature control hidden

+ )} +
+ ) + })} +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/OpencodeGoTemperature.visual.tsx b/webview-ui/src/components/settings/__tests__/OpencodeGoTemperature.visual.tsx new file mode 100644 index 0000000000..b45d3d12a5 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/OpencodeGoTemperature.visual.tsx @@ -0,0 +1,9 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" + +test("shows the temperature control only for supported OpenCode Go Responses states", async ({ mount }) => { + const component = mountedStory(await mount("opencode-go-temperature")) + const story = component.getByTestId("opencode-go-temperature-story") + + await expect(story).toHaveScreenshot("opencode-go-temperature.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/opencode-go-temperature.png b/webview-ui/src/components/settings/__tests__/__screenshots__/opencode-go-temperature.png new file mode 100644 index 0000000000..e1828c9b6b Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/opencode-go-temperature.png differ diff --git a/webview-ui/src/components/settings/utils/__tests__/temperatureSupport.spec.ts b/webview-ui/src/components/settings/utils/__tests__/temperatureSupport.spec.ts new file mode 100644 index 0000000000..a5afe52083 --- /dev/null +++ b/webview-ui/src/components/settings/utils/__tests__/temperatureSupport.spec.ts @@ -0,0 +1,81 @@ +import { type ProviderSettings, opencodeGoModels, providerIdentifiers } from "@roo-code/types" + +import { isTemperatureControlSupported } from "../temperatureSupport" + +describe("isTemperatureControlSupported", () => { + const opencodeGo = (modelId: string, apiConfiguration: ProviderSettings = {}) => + isTemperatureControlSupported({ + provider: providerIdentifiers.opencodeGo, + modelId, + modelInfo: opencodeGoModels[modelId], + apiConfiguration: { + apiProvider: providerIdentifiers.opencodeGo, + opencodeGoModelId: modelId, + ...apiConfiguration, + }, + }) + + describe("OpenCode Go Responses models (Zoo-Code-Org/Zoo-Code#2055)", () => { + it.each(["grok-4.6", "muse-spark-1.3-contributor", "muse-spark-1.2-contributor"])( + "shows the control for %s", + (modelId) => { + expect(opencodeGo(modelId)).toBe(true) + expect(opencodeGo(modelId, { reasoningEffort: "low" })).toBe(true) + }, + ) + + it.each(["gpt-5.6-luna", "gpt-6-luna"])("shows the control for %s only with effort none", (modelId) => { + expect(opencodeGo(modelId, { reasoningEffort: "none" })).toBe(true) + expect(opencodeGo(modelId, { reasoningEffort: "high" })).toBe(false) + expect(opencodeGo(modelId, { reasoningEffort: "disable" })).toBe(false) + expect(opencodeGo(modelId, { enableReasoningEffort: false })).toBe(false) + // A stale "none" is not sent while reasoning effort is switched off. + expect(opencodeGo(modelId, { reasoningEffort: "none", enableReasoningEffort: false })).toBe(false) + // The model default effort ("medium") does not support temperature. + expect(opencodeGo(modelId)).toBe(false) + }) + + it("hides the control when the model info is not available yet", () => { + expect( + isTemperatureControlSupported({ + provider: providerIdentifiers.opencodeGo, + modelId: "gpt-6-luna", + modelInfo: undefined, + apiConfiguration: { reasoningEffort: "none" }, + }), + ).toBe(false) + }) + }) + + it("keeps the supportsTemperature opt-out for OpenCode Go chat-completions models", () => { + expect(opencodeGo("kimi-k2.6")).toBe(true) + expect( + isTemperatureControlSupported({ + provider: providerIdentifiers.opencodeGo, + modelId: "kimi-k2.6", + modelInfo: { ...opencodeGoModels["kimi-k2.6"], supportsTemperature: false }, + apiConfiguration: {}, + }), + ).toBe(false) + }) + + it("keeps the supportsTemperature opt-out for other providers", () => { + const base = { provider: providerIdentifiers.anthropic, modelId: "m", apiConfiguration: {} } + + expect(isTemperatureControlSupported({ ...base, modelInfo: undefined })).toBe(true) + expect( + isTemperatureControlSupported({ + ...base, + modelInfo: { contextWindow: 1, supportsPromptCache: false, supportsTemperature: false }, + }), + ).toBe(false) + // A Responses model ID on another provider does not use the Go rule. + expect( + isTemperatureControlSupported({ + ...base, + modelId: "gpt-6-luna", + modelInfo: { contextWindow: 1, supportsPromptCache: false }, + }), + ).toBe(true) + }) +}) diff --git a/webview-ui/src/components/settings/utils/temperatureSupport.ts b/webview-ui/src/components/settings/utils/temperatureSupport.ts new file mode 100644 index 0000000000..bc4b23ccd1 --- /dev/null +++ b/webview-ui/src/components/settings/utils/temperatureSupport.ts @@ -0,0 +1,54 @@ +import { + type ModelInfo, + type ProviderName, + type ProviderSettings, + type ReasoningEffortExtended, + isOpencodeGoResponsesFormatModel, + opencodeGoResponsesSupportsTemperature, + providerIdentifiers, +} from "@roo-code/types" + +import { shouldUseReasoningEffort } from "@roo/api" + +/** + * Returns the reasoning effort the request sends, matching `getModelParams`: + * the user's selection wins over the model default, and `"disable"` omits it. + */ +const getEffectiveReasoningEffort = ( + model: ModelInfo, + settings: ProviderSettings, +): ReasoningEffortExtended | undefined => { + if (!shouldUseReasoningEffort({ model, settings })) return undefined + + const effort = settings.reasoningEffort ?? model.reasoningEffort + return effort && effort !== "disable" ? effort : undefined +} + +/** + * Returns `true` when the settings UI should show the temperature control for + * the selected model and configuration. + * + * OpenCode Go Responses models use the same opt-in rule as the request + * builder, so the control is visible only when the request will send the + * value (Zoo-Code-Org/Zoo-Code#2055). The rule depends on the effective + * reasoning effort, so the result changes when the effort changes. Hiding + * the control does not clear a saved temperature. + */ +export const isTemperatureControlSupported = ({ + provider, + modelId, + modelInfo, + apiConfiguration, +}: { + provider: ProviderName | undefined + modelId: string | undefined + modelInfo: ModelInfo | undefined + apiConfiguration: ProviderSettings +}): boolean => { + if (provider === providerIdentifiers.opencodeGo && modelId && isOpencodeGoResponsesFormatModel(modelId)) { + const reasoningEffort = modelInfo ? getEffectiveReasoningEffort(modelInfo, apiConfiguration) : undefined + return opencodeGoResponsesSupportsTemperature(modelId, reasoningEffort) + } + + return modelInfo?.supportsTemperature !== false +}