Skip to content
Open
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
85 changes: 84 additions & 1 deletion src/lib/llm-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ import {
supportsImageInput,
type ChatMessage,
type ContentBlock,
type RequestOverrides,
} from "./llm-providers"
import type { LlmConfig } from "@/stores/wiki-store"
import type { LlmConfig, ReasoningConfig, ReasoningMode } from "@/stores/wiki-store"

const TINY_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg=="
Expand Down Expand Up @@ -673,6 +674,88 @@ describe("reasoning controls", () => {
expect(body.max_completion_tokens).toBe(4096)
})

function glmBody(model: string, reasoning: ReasoningConfig, temperature?: number): Record<string, unknown> {
const cfg = mkConfig({
provider: "custom",
model,
customEndpoint: "https://open.bigmodel.cn/api/paas/v4",
apiMode: "chat_completions",
})
const overrides: RequestOverrides = { reasoning }
if (temperature !== undefined) overrides.temperature = temperature
return getProviderConfig(cfg)
.buildBody([{ role: "user", content: "hi" }], overrides) as Record<string, unknown>
}

it("maps GLM-4.5..5.2 reasoning off to thinking disabled on the BigModel endpoint", () => {
for (const model of ["glm-4.7-flash", "glm-4.5", "glm-5.1", "glm-5v-turbo"]) {
const body = glmBody(model, { mode: "off" }, 0.1)
expect(body.thinking).toEqual({ type: "disabled" })
expect(body.reasoning_effort).toBeUndefined()
expect(body.temperature).toBe(0.1)
}
})

it("maps always-thinking GLM-5.3+ reasoning off to reasoning_effort low, never thinking disabled", () => {
// GLM-5.3 rejects `thinking.type=disabled` with error 1210; the lowest
// effort level is the only way to stop reasoning-only empty responses.
for (const model of ["glm-5.3-flash", "GLM-5.3", "glm-5.10"]) {
const body = glmBody(model, { mode: "off" })
expect(body.reasoning_effort).toBe("low")
expect(body.thinking).toBeUndefined()
}
})

it("maps GLM-5.2+ effort levels to thinking enabled plus reasoning_effort", () => {
const effortModes: ReasoningMode[] = ["low", "high", "max"]
for (const mode of effortModes) {
const body = glmBody("glm-5.3-flash", { mode })
expect(body.thinking).toEqual({ type: "enabled" })
expect(body.reasoning_effort).toBe(mode)
}
})

it("leaves GLM requests untouched on auto and on unrepresentable modes", () => {
const normalized: ReasoningConfig[] = [
{ mode: "auto" },
{ mode: "medium" },
{ mode: "custom", budgetTokens: 2048 },
]
for (const reasoning of normalized) {
const body = glmBody("glm-5.3-flash", reasoning)
expect(body.thinking).toBeUndefined()
expect(body.reasoning_effort).toBeUndefined()
}
// Toggle-only generations normalize effort levels away instead of sending them.
const toggleOnly = glmBody("glm-4.7", { mode: "high" })
expect(toggleOnly.thinking).toBeUndefined()
expect(toggleOnly.reasoning_effort).toBeUndefined()
})

it("does not send thinking controls to pre-4.5 GLM models or non-GLM ids on BigModel", () => {
for (const model of ["glm-4-flash-250414", "glm-4v-plus", "glm-z1-flash"]) {
const body = glmBody(model, { mode: "off" })
expect(body.thinking).toBeUndefined()
expect(body.reasoning_effort).toBeUndefined()
}
})

it("does not apply GLM thinking controls to GLM model ids on a generic gateway", () => {
const cfg = mkConfig({
provider: "custom",
model: "glm-5.3-flash",
customEndpoint: "https://gateway.example/v1",
apiMode: "chat_completions",
})
const body = getProviderConfig(cfg).buildBody(
[{ role: "user", content: "hi" }],
{ reasoning: { mode: "off" } },
) as Record<string, unknown>

expect(body.thinking).toBeUndefined()
expect(body.reasoning_effort).toBeUndefined()
})

it("uses Bearer auth for Xiaomi MiMo Token Plan Anthropic wire", () => {
const cfg = mkConfig({
provider: "custom",
Expand Down
50 changes: 44 additions & 6 deletions src/lib/llm-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import {
} from "@/lib/azure-openai"
import {
isAdaptiveAnthropicModel,
isBigModelEndpoint,
isGeminiThinkingLevelModel,
isGlmAtLeast,
isOpenRouterEndpoint,
normalizeReasoningForProvider,
parseGlmVersion,
} from "@/lib/reasoning-capabilities"

/**
Expand Down Expand Up @@ -365,10 +368,6 @@ function isXiaomiMimoEndpoint(config: LlmConfig): boolean {
return /\.?xiaomimimo\.com(?::|\/|$)/i.test(config.customEndpoint)
}

function isBigModelEndpoint(config: LlmConfig): boolean {
return /(?:^|\/\/)open\.bigmodel\.cn(?:[:/]|$)/i.test(config.customEndpoint)
}

function isGlmVisionModel(model: string): boolean {
const normalized = model.trim().toLowerCase()
return /(?:^|[-_.])glm[-_.]5v[-_.]turbo(?:[-_.]|$)/i.test(normalized)
Expand Down Expand Up @@ -447,6 +446,44 @@ function adaptXiaomiMimoBody(
}
}

function adaptBigModelBody(
config: LlmConfig,
body: Record<string, unknown>,
reasoning: ReasoningConfig,
): void {
if (!isBigModelEndpoint(config.customEndpoint)) return
const version = parseGlmVersion(config.model)
// Pre-4.5 GLM models (and non-GLM ids routed through BigModel) predate
// the thinking controls; the capability layer already narrows them to
// auto, so there is nothing to translate.
if (!version || !isGlmAtLeast(version, 4, 5)) return

if (reasoning.mode === "off") {
// GLM-5.3+ thinks unconditionally and rejects `thinking.type=disabled`
// with error 1210 ("该模型始终思考,不支持关闭思考;请使用 low、high 或
// max"). `reasoning_effort=low` is the floor those models expose, and it
// is what keeps structured ingest from spending the whole budget on
// `reasoning_content` and returning empty `content`.
if (isGlmAtLeast(version, 5, 3)) {
body.reasoning_effort = "low"
} else {
body.thinking = { type: "disabled" }
}
return
}

// `reasoning_effort` is a GLM-5.2+ field. Earlier generations only have
// the on/off toggle, which the capability layer reflects by never handing
// an effort level to this adapter for them.
if (
isGlmAtLeast(version, 5, 2)
&& (reasoning.mode === "low" || reasoning.mode === "high" || reasoning.mode === "max")
) {
body.thinking = { type: "enabled" }
body.reasoning_effort = reasoning.mode
}
}

function buildOpenAiCompatibleBody(
config: LlmConfig,
messages: ChatMessage[],
Expand All @@ -463,6 +500,7 @@ function buildOpenAiCompatibleBody(
adaptOpenAiStrictCompletionBody(config, body)
adaptKimiBody(config, body)
adaptXiaomiMimoBody(config, body, reasoning)
adaptBigModelBody(config, body, reasoning)

if (config.provider === "custom" && isOpenRouterEndpoint(config.customEndpoint)) {
if (reasoning.mode === "custom" && reasoning.budgetTokens !== undefined) {
Expand Down Expand Up @@ -751,7 +789,7 @@ function assertMiniMaxImageSupport(url: string, model: string, messages: ChatMes
}

function assertBigModelImageSupport(config: LlmConfig, messages: ChatMessage[]): void {
if (!isBigModelEndpoint(config) || !hasImageContent(messages) || isGlmVisionModel(config.model)) return
if (!isBigModelEndpoint(config.customEndpoint) || !hasImageContent(messages) || isGlmVisionModel(config.model)) return
throw new Error(
"Zhipu BigModel image input is supported only by GLM vision models. Switch to glm-5v-turbo, glm-4.6v, glm-4.5v, or glm-4v-plus.",
)
Expand All @@ -760,7 +798,7 @@ function assertBigModelImageSupport(config: LlmConfig, messages: ChatMessage[]):
export function supportsImageInput(config: LlmConfig): boolean {
if (config.provider === "codex-cli") return false
if (config.provider === "minimax") return isMiniMaxM3Model(config.model)
if (isBigModelEndpoint(config)) return isGlmVisionModel(config.model)
if (isBigModelEndpoint(config.customEndpoint)) return isGlmVisionModel(config.model)
if ((config.provider === "custom") && (config.apiMode ?? "chat_completions") === "anthropic_messages") {
const url = buildAnthropicUrl(config.customEndpoint)
return !isOfficialMiniMaxAnthropicUrl(url) || isMiniMaxM3Model(config.model)
Expand Down
39 changes: 38 additions & 1 deletion src/lib/reasoning-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"
import type { LlmConfig } from "@/stores/wiki-store"
import { normalizeReasoningForProvider, resolveReasoningCapabilities } from "./reasoning-capabilities"
import {
normalizeReasoningForProvider,
parseGlmVersion,
resolveReasoningCapabilities,
} from "./reasoning-capabilities"

function config(provider: LlmConfig["provider"], model: string): LlmConfig {
return {
Expand Down Expand Up @@ -40,6 +44,39 @@ describe("reasoning capabilities", () => {
.not.toContain("custom")
})

it("offers Zhipu GLM thinking controls by generation on the BigModel endpoint", () => {
const glm = (model: string) => ({
...config("custom", model),
customEndpoint: "https://open.bigmodel.cn/api/paas/v4",
})

// Pre-4.5 models predate `thinking` entirely.
expect(resolveReasoningCapabilities(glm("glm-4-flash-250414")).modes).toEqual(["auto"])
expect(resolveReasoningCapabilities(glm("glm-z1-flash")).modes).toEqual(["auto"])
// GLM-4.5 .. 5.1 only have the on/off toggle.
expect(resolveReasoningCapabilities(glm("glm-4.7-flash")).modes).toEqual(["auto", "off"])
expect(resolveReasoningCapabilities(glm("glm-5.1")).modes).toEqual(["auto", "off"])
// GLM-5.2+ adds low/high/max; medium and custom budgets never exist.
expect(resolveReasoningCapabilities(glm("glm-5.3-flash")).modes)
.toEqual(["auto", "off", "low", "high", "max"])
expect(normalizeReasoningForProvider(glm("glm-5.3-flash"), { mode: "medium" }))
.toEqual({ mode: "auto" })
expect(normalizeReasoningForProvider(glm("glm-4.7"), { mode: "high" }))
.toEqual({ mode: "auto" })
// The same model ids through a generic gateway stay auto-only.
expect(resolveReasoningCapabilities(config("custom", "glm-5.3-flash")).modes).toEqual(["auto"])
})

it("parses GLM generations out of model ids", () => {
expect(parseGlmVersion("glm-4.7-flash")).toEqual({ major: 4, minor: 7 })
expect(parseGlmVersion("GLM-5.3-Flash")).toEqual({ major: 5, minor: 3 })
expect(parseGlmVersion("glm-5v-turbo")).toEqual({ major: 5, minor: 0 })
expect(parseGlmVersion("glm-4.5v")).toEqual({ major: 4, minor: 5 })
expect(parseGlmVersion("zhipu/glm-4.6")).toEqual({ major: 4, minor: 6 })
expect(parseGlmVersion("glm-z1-flash")).toBeNull()
expect(parseGlmVersion("deepseek-v4-flash")).toBeNull()
})

it("limits OpenAI reasoning models to representable effort levels", () => {
expect(resolveReasoningCapabilities(config("openai", "gpt-5.4")).modes)
.toEqual(["auto", "low", "medium", "high"])
Expand Down
43 changes: 43 additions & 0 deletions src/lib/reasoning-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const THINKING_REQUIRED_LEVELS = ["auto", "low", "medium", "high", "max"] as con
const OLLAMA_LEVELS = ["auto", "off", "low", "medium", "high", "max"] as const
const TOGGLE_LEVELS = ["auto", "off"] as const
const DEEPSEEK_LEVELS = ["auto", "off", "high", "max"] as const
const GLM_EFFORT_LEVELS = ["auto", "off", "low", "high", "max"] as const

function capabilities(
modes: readonly ReasoningMode[],
Expand Down Expand Up @@ -63,6 +64,45 @@ export function isOpenRouterEndpoint(endpoint: string): boolean {
}
}

export function isBigModelEndpoint(endpoint: string): boolean {
return /(?:^|\/\/)open\.bigmodel\.cn(?:[:/]|$)/i.test(endpoint)
}

export interface GlmVersion {
major: number
minor: number
}

/**
* Generation of a Zhipu GLM model id: "glm-4.7-flash" → 4.7, "glm-5v-turbo"
* → 5.0, "GLM-5.3-Flash" → 5.3. Lines without a generation number (glm-z1-*)
* and non-GLM ids return null.
*/
export function parseGlmVersion(model: string): GlmVersion | null {
const match = /(?:^|[-_./])glm[-_.]?(\d+)(?:\.(\d+))?/i.exec(model.trim())
if (!match) return null
return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 }
}

export function isGlmAtLeast(version: GlmVersion, major: number, minor: number): boolean {
return version.major > major || (version.major === major && version.minor >= minor)
}

/**
* Zhipu's thinking controls arrived generation by generation: `thinking.type`
* ("enabled" | "disabled") with GLM-4.5, `reasoning_effort` ("low" | "high" |
* "max") with GLM-5.2, and from GLM-5.3 thinking is always on and can only be
* dialed down. "off" stays offered on every 4.5+ model — the wire layer maps
* it to whatever the generation permits — while "medium" and custom budgets
* are not representable anywhere in the lineup.
* See docs.bigmodel.cn/cn/guide/capabilities/thinking.
*/
function glmReasoningModes(model: string): readonly ReasoningMode[] {
const version = parseGlmVersion(model)
if (!version || !isGlmAtLeast(version, 4, 5)) return AUTO_ONLY
return isGlmAtLeast(version, 5, 2) ? GLM_EFFORT_LEVELS : TOGGLE_LEVELS
}

/**
* Resolve only capabilities that are part of the selected wire contract.
* Generic custom gateways deliberately stay Auto-only: a vendor-looking
Expand Down Expand Up @@ -100,6 +140,9 @@ export function resolveReasoningCapabilities(config: LlmConfig): ReasoningCapabi
if (/xiaomimimo\.com(?:[:/]|$)/.test(endpoint)) {
return capabilities(TOGGLE_LEVELS)
}
if (isBigModelEndpoint(endpoint)) {
return capabilities(glmReasoningModes(config.model))
}
// Anthropic-compatible custom endpoints are not necessarily Anthropic
// itself (MiniMax, Kimi and enterprise proxies differ), so omission is the
// only portable default. Users can select a first-party preset when they
Expand Down