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
2 changes: 2 additions & 0 deletions .changeset/add-glm-5-2-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
---

Add GLM-5.2 support with High/Max `reasoning_effort` tiers. The default effort is High (deep reasoning stays opt-in), Max is selected only when the user explicitly picks it, and the parameter is omitted entirely when reasoning is disabled.

Also refines the Opencode Go provider per review: bill MiniMax M3 cache writes (`cacheWritesPrice`), expose the max-output slider for DeepSeek V4 models (`supportsMaxTokens`), wrap pre-stream Anthropic-format errors with the provider prefix, and type the Anthropic streaming path's model info as `ModelInfo` so cost calculation can no longer silently return `$0`.
144 changes: 144 additions & 0 deletions packages/types/src/__tests__/opencode-go.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
opencodeGoDefaultModelId,
opencodeGoDefaultModelInfo,
opencodeGoModels,
OPENCODE_GO_DEFAULT_TEMPERATURE,
OPENCODE_GO_ANTHROPIC_FORMAT_MODELS,
isOpencodeGoAnthropicFormatModel,
getOpencodeGoModelInfo,
} from "../providers/opencode-go.js"

describe("opencode-go registry", () => {
const anthropicFormatModels = [
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
]
const openaiFormatModels = [
"glm-5",
"glm-5.1",
"glm-5.2",
"kimi-k2.5",
"kimi-k2.6",
"mimo-v2.5",
"mimo-v2.5-pro",
"deepseek-v4-pro",
"deepseek-v4-flash",
]

describe("isOpencodeGoAnthropicFormatModel", () => {
it("classifies Qwen and MiniMax models as Anthropic-format", () => {
for (const id of anthropicFormatModels) {
expect(isOpencodeGoAnthropicFormatModel(id)).toBe(true)
}
})

it("classifies GLM/Kimi/MiMo/DeepSeek models as OpenAI-compatible", () => {
for (const id of openaiFormatModels) {
expect(isOpencodeGoAnthropicFormatModel(id)).toBe(false)
}
})

it("defaults unknown model IDs to the OpenAI-compatible format", () => {
expect(isOpencodeGoAnthropicFormatModel("some-future-model")).toBe(false)
expect(isOpencodeGoAnthropicFormatModel("")).toBe(false)
})
})

describe("getOpencodeGoModelInfo", () => {
it("returns the native ModelInfo for a curated model", () => {
const info = getOpencodeGoModelInfo("qwen3.7-max")
expect(info).toBeDefined()
expect(info?.maxTokens).toBe(65_536)
expect(info?.contextWindow).toBe(1_000_000)
expect(info?.supportsPromptCache).toBe(true)
})

it("returns undefined for an unknown model ID", () => {
expect(getOpencodeGoModelInfo("not-a-real-go-model")).toBeUndefined()
})
})

describe("OPENCODE_GO_ANTHROPIC_FORMAT_MODELS", () => {
it("contains exactly the Qwen and MiniMax models", () => {
expect([...OPENCODE_GO_ANTHROPIC_FORMAT_MODELS].sort()).toEqual([...anthropicFormatModels].sort())
})

// The PR description calls out that the format-routing set must stay in
// sync with the Go model table — every routed model must have a native
// registry entry so capability flags and pricing resolve correctly.
it("every Anthropic-format model has a native registry entry", () => {
for (const id of OPENCODE_GO_ANTHROPIC_FORMAT_MODELS) {
expect(opencodeGoModels[id]).toBeDefined()
}
})
})

describe("opencodeGoModels registry invariants", () => {
it("every entry has a positive maxTokens and contextWindow", () => {
for (const [id, info] of Object.entries(opencodeGoModels)) {
expect(info.maxTokens).toBeGreaterThan(0)
expect(info.contextWindow).toBeGreaterThan(0)
// Sanity: max output must not exceed the context window.
expect(info.maxTokens).toBeLessThanOrEqual(info.contextWindow)
void id
}
})

it("every entry declares supportsImages", () => {
for (const info of Object.values(opencodeGoModels)) {
expect(typeof info.supportsImages).toBe("boolean")
}
})

it("models with an array supportsReasoningEffort expose a non-empty allow-list", () => {
for (const info of Object.values(opencodeGoModels)) {
if (Array.isArray(info.supportsReasoningEffort)) {
expect(info.supportsReasoningEffort.length).toBeGreaterThan(0)
}
}
})

it("every Anthropic-format model with prompt-cache injection declares a cacheWritesPrice", () => {
// MiniMax/Qwen route through /v1/messages with client-side
// cache_control breakpoints, so cache_creation_input_tokens are
// reported and billed — each must carry a cacheWritesPrice or the
// write cost is silently reported as $0.
for (const id of OPENCODE_GO_ANTHROPIC_FORMAT_MODELS) {
const info = getOpencodeGoModelInfo(id)
expect(info).toBeDefined()
if (info?.supportsPromptCache) {
expect(info.cacheWritesPrice).toBeDefined()
expect(info.cacheReadsPrice).toBeDefined()
}
}
})

it("DeepSeek entries expose supportsMaxTokens so the max-output slider is available", () => {
expect(getOpencodeGoModelInfo("deepseek-v4-pro")?.supportsMaxTokens).toBe(true)
expect(getOpencodeGoModelInfo("deepseek-v4-flash")?.supportsMaxTokens).toBe(true)
})
})

describe("defaults", () => {
it("the default model id is a curated OpenAI-compatible model", () => {
expect(opencodeGoDefaultModelId).toBe("glm-5.2")
expect(opencodeGoModels[opencodeGoDefaultModelId]).toBeDefined()
expect(isOpencodeGoAnthropicFormatModel(opencodeGoDefaultModelId)).toBe(false)
})

it("exposes a fully-populated default ModelInfo fallback", () => {
expect(opencodeGoDefaultModelInfo.maxTokens).toBeGreaterThan(0)
expect(opencodeGoDefaultModelInfo.contextWindow).toBeGreaterThan(0)
expect(opencodeGoDefaultModelInfo.supportsPromptCache).toBe(false)
expect(opencodeGoDefaultModelInfo.description).toBeTruthy()
})

it("exposes a deterministic default temperature", () => {
expect(OPENCODE_GO_DEFAULT_TEMPERATURE).toBe(0)
})
})
})
26 changes: 26 additions & 0 deletions packages/types/src/__tests__/provider-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ describe("getApiProtocol", () => {
})
})

describe("Opencode Go provider", () => {
it("should return 'anthropic' for opencode-go Anthropic-format models (Qwen/MiniMax)", () => {
expect(getApiProtocol("opencode-go", "qwen3.7-max")).toBe("anthropic")
expect(getApiProtocol("opencode-go", "qwen3.7-plus")).toBe("anthropic")
expect(getApiProtocol("opencode-go", "qwen3.6-plus")).toBe("anthropic")
expect(getApiProtocol("opencode-go", "minimax-m3")).toBe("anthropic")
expect(getApiProtocol("opencode-go", "minimax-m2.7")).toBe("anthropic")
expect(getApiProtocol("opencode-go", "minimax-m2.5")).toBe("anthropic")
})

it("should return 'openai' for opencode-go OpenAI-format models (GLM/DeepSeek/etc.)", () => {
expect(getApiProtocol("opencode-go", "glm-5.2")).toBe("openai")
expect(getApiProtocol("opencode-go", "deepseek-v4-pro")).toBe("openai")
expect(getApiProtocol("opencode-go", "kimi-k2.5")).toBe("openai")
expect(getApiProtocol("opencode-go", "mimo-v2.5")).toBe("openai")
})

it("should return 'openai' for opencode-go without a model", () => {
expect(getApiProtocol("opencode-go")).toBe("openai")
})

it("should return 'openai' for opencode-go with an unknown model id", () => {
expect(getApiProtocol("opencode-go", "some-future-model")).toBe("openai")
})
})

describe("Other providers", () => {
it("should return 'openai' for non-anthropic providers regardless of model", () => {
expect(getApiProtocol("openrouter", "claude-3-opus")).toBe("openai")
Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
internationalZAiModels,
minimaxModels,
mimoModels,
isOpencodeGoAnthropicFormatModel,
} from "./providers/index.js"

/**
Expand Down Expand Up @@ -595,6 +596,17 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
return "anthropic"
}

// Opencode Go routes a subset of its models (Qwen, MiniMax) through the
// Anthropic Messages wire format (`/v1/messages`), which reports usage in
// Anthropic style: `input_tokens` excludes cache tokens, with separate
// `cache_creation_input_tokens` / `cache_read_input_tokens` fields. These
// models must use the anthropic protocol so token/cost aggregation adds the
// cache tokens back into the input total — otherwise the cached prefix is
// dropped from `contextTokens`, undercounting context-window usage.
if (provider && provider === "opencode-go" && modelId && isOpencodeGoAnthropicFormatModel(modelId)) {
return "anthropic"
}

return "openai"
}

Expand Down
Loading
Loading