Skip to content
Merged
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
56 changes: 56 additions & 0 deletions packages/types/src/__tests__/opencode-go.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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([
{
Expand Down
60 changes: 57 additions & 3 deletions packages/types/src/providers/opencode-go.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -574,6 +574,9 @@
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,
Expand All @@ -596,8 +599,8 @@
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",
Expand Down Expand Up @@ -640,6 +643,7 @@
supportsImages: true,
supportsPromptCache: true,
supportsMaxTokens: true,
supportsTemperature: true,
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
reasoningEffort: "high",
inputPrice: 2.0,
Expand All @@ -660,6 +664,7 @@
supportsImages: true,
supportsPromptCache: true,
supportsMaxTokens: true,
supportsTemperature: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh"],
reasoningEffort: "medium",
inputPrice: 0.1,
Expand All @@ -674,6 +679,7 @@
supportsImages: true,
supportsPromptCache: true,
supportsMaxTokens: true,
supportsTemperature: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh"],
reasoningEffort: "medium",
inputPrice: 0.1,
Expand Down Expand Up @@ -760,6 +766,54 @@
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<Record<string, readonly ReasoningEffortExtended[]>> = {
"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)

Check warning on line 811 in packages/types/src/providers/opencode-go.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/providers/opencode-go.ts:811: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}

return getOpencodeGoModelInfo(modelId)?.supportsTemperature === true

Check warning on line 814 in packages/types/src/providers/opencode-go.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/providers/opencode-go.ts:814: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}

/**
* 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
Expand Down
143 changes: 142 additions & 1 deletion src/api/providers/__tests__/opencode-go.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<string, unknown>
}

const completeRequest = async (options: ApiHandlerOptions) => {
mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" })
await new OpencodeGoHandler(options).completePrompt("ping")
return mockResponsesCreate.mock.calls[0][0] as Record<string, unknown>
}

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)
Expand Down
26 changes: 23 additions & 3 deletions src/api/providers/fetchers/__tests__/opencode-go.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] } })

Expand All @@ -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",
Expand Down
Loading
Loading