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
5 changes: 5 additions & 0 deletions .changeset/zenmux-context-window-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fixed ZenMux context window detection to prevent erroneous context-condensing loops.
5 changes: 5 additions & 0 deletions .changeset/zenmux-native-tools-reliability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fixed ZenMux tool-calling reliability to avoid repeated "tool not used" loops and preserve transformed request messages.
4 changes: 4 additions & 0 deletions packages/types/src/providers/zenmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const zenmuxDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
// kilocode_change start
supportsNativeTools: true,
defaultToolProtocol: "native",
// kilocode_change end
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
Expand Down
175 changes: 175 additions & 0 deletions src/api/providers/__tests__/zenmux-native-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// kilocode_change - new file
import OpenAI from "openai"

import type { ApiHandlerCreateMessageMetadata } from "../../index"
import type { ApiHandlerOptions } from "../../../shared/api"
import { ZenMuxHandler } from "../zenmux"

vi.mock("../fetchers/modelCache", () => ({
getModels: vi.fn().mockResolvedValue({}),
}))

function createMockStream() {
return {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, cost: 0 },
}
},
}
}

async function consume(generator: AsyncGenerator<unknown>) {
for await (const _chunk of generator) {
// Consume all chunks
}
}

describe("ZenMuxHandler native tools and message pipeline", () => {
const baseOptions: ApiHandlerOptions = {
zenmuxApiKey: "test-key",
zenmuxModelId: "z-ai/glm-5",
zenmuxBaseUrl: "https://test.zenmux.ai/api/v1",
}

it("merges native tool defaults when model cache entry lacks native metadata", () => {
const handler = new ZenMuxHandler(baseOptions)
;(handler as unknown as { models: Record<string, unknown> }).models = {
"z-ai/glm-5": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "GLM 5",
},
}

const model = handler.getModel()
expect(model.info.supportsNativeTools).toBe(true)
expect(model.info.defaultToolProtocol).toBe("native")
})

it("passes tools and tool choice to stream creation when task protocol is native", async () => {
const handler = new ZenMuxHandler(baseOptions)

vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "z-ai/glm-5",
info: {
maxTokens: 8192,
contextWindow: 128000,
supportsNativeTools: true,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "GLM 5",
},
} as any)

const streamSpy = vi.spyOn(handler, "createZenMuxStream").mockResolvedValue(createMockStream() as any)

const tools: OpenAI.Chat.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "attempt_completion",
description: "Complete the task",
parameters: { type: "object", properties: {} },
},
},
]
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "task-native",
toolProtocol: "native",
tools,
tool_choice: "auto",
parallelToolCalls: true,
}

await consume(handler.createMessage("system", [{ role: "user", content: "hi" }], metadata))

expect(streamSpy).toHaveBeenCalledTimes(1)
expect(streamSpy.mock.calls[0][6]).toEqual(tools)
expect(streamSpy.mock.calls[0][7]).toBe("auto")
expect(streamSpy.mock.calls[0][8]).toBe(true)
})

it("omits tools when task protocol is xml even if tools are provided", async () => {
const handler = new ZenMuxHandler(baseOptions)

vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "z-ai/glm-5",
info: {
maxTokens: 8192,
contextWindow: 128000,
supportsNativeTools: true,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "GLM 5",
},
} as any)

const streamSpy = vi.spyOn(handler, "createZenMuxStream").mockResolvedValue(createMockStream() as any)

const tools: OpenAI.Chat.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "ask_followup_question",
description: "Ask a follow-up question",
parameters: { type: "object", properties: {} },
},
},
]

await consume(
handler.createMessage("system", [{ role: "user", content: "hi" }], {
taskId: "task-xml",
toolProtocol: "xml",
tools,
tool_choice: "auto",
parallelToolCalls: true,
}),
)

expect(streamSpy).toHaveBeenCalledTimes(1)
expect(streamSpy.mock.calls[0][6]).toBeUndefined()
expect(streamSpy.mock.calls[0][7]).toBeUndefined()
expect(streamSpy.mock.calls[0][8]).toBe(false)
})

it("passes transformed DeepSeek R1 messages into stream creation", async () => {
const handler = new ZenMuxHandler({
...baseOptions,
zenmuxModelId: "deepseek/deepseek-r1",
})

vi.spyOn(handler, "fetchModel").mockResolvedValue({
id: "deepseek/deepseek-r1",
info: {
maxTokens: 8192,
contextWindow: 128000,
supportsNativeTools: true,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1",
},
} as any)

const streamSpy = vi.spyOn(handler, "createZenMuxStream").mockResolvedValue(createMockStream() as any)

await consume(handler.createMessage("system prompt", [{ role: "user", content: "hi" }], { taskId: "task-r1" }))

expect(streamSpy).toHaveBeenCalledTimes(1)
const sentMessages = streamSpy.mock.calls[0][1] as OpenAI.Chat.ChatCompletionMessageParam[]
expect(sentMessages.some((message: any) => message.role === "system")).toBe(false)
expect((sentMessages[0] as any).role).toBe("user")
})
})
43 changes: 43 additions & 0 deletions src/api/providers/fetchers/__tests__/modelCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,49 @@ describe("getModelsFromCache disk fallback", () => {

consoleErrorSpy.mockRestore()
})

// kilocode_change start
it("rejects stale ZenMux cache entries with invalid contextWindow", () => {
const invalidZenmuxModels = {
"anthropic/claude-opus-4": {
maxTokens: 0,
contextWindow: 0,
supportsPromptCache: false,
},
}

mockCache.get.mockReturnValue(invalidZenmuxModels)

const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})

const result = getModelsFromCache("zenmux")

expect(result).toBeUndefined()
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[MODEL_CACHE] Ignoring stale ZenMux model cache with invalid contextWindow values",
)
expect(fsSync.existsSync).not.toHaveBeenCalled()

consoleWarnSpy.mockRestore()
})

it("accepts valid ZenMux cache entries", () => {
const validZenmuxModels = {
"anthropic/claude-opus-4": {
maxTokens: 0,
contextWindow: 200000,
supportsPromptCache: false,
},
}

mockCache.get.mockReturnValue(validZenmuxModels)

const result = getModelsFromCache("zenmux")

expect(result).toEqual(validZenmuxModels)
expect(fsSync.existsSync).not.toHaveBeenCalled()
})
// kilocode_change end
})

describe("empty cache protection", () => {
Expand Down
70 changes: 70 additions & 0 deletions src/api/providers/fetchers/__tests__/zenmux.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// kilocode_change - new file
import { zenmuxDefaultModelInfo } from "@roo-code/types"
import { getZenmuxModels } from "../zenmux"

describe("getZenmuxModels", () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

it("maps context_length from ZenMux model payload", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
object: "list",
data: [
{
id: "anthropic/claude-opus-4",
object: "model",
created: 1767146192,
owned_by: "anthropic",
display_name: "Claude Opus 4",
context_length: 200000,
input_modalities: ["text", "image"],
},
],
}),
})
vi.stubGlobal("fetch", fetchMock)

const models = await getZenmuxModels()

expect(models["anthropic/claude-opus-4"]).toEqual({
maxTokens: 0,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
supportsNativeTools: true,
defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "anthropic model",
displayName: "Claude Opus 4",
})
expect(models["anthropic/claude-opus-4"].contextWindow).toBeGreaterThan(0)
})

it("falls back to default context window when optional metadata is missing", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
object: "list",
data: [
{
id: "openai/gpt-5",
object: "model",
created: 1767146192,
owned_by: "openai",
},
],
}),
})
vi.stubGlobal("fetch", fetchMock)

const models = await getZenmuxModels()

expect(models["openai/gpt-5"].contextWindow).toBe(zenmuxDefaultModelInfo.contextWindow)
expect(models["openai/gpt-5"].displayName).toBe("openai/gpt-5")
expect(models["openai/gpt-5"].supportsNativeTools).toBe(true)
expect(models["openai/gpt-5"].defaultToolProtocol).toBe("native")
})
})
19 changes: 19 additions & 0 deletions src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,12 @@ export function getModelsFromCache(provider: ProviderName): ModelRecord | undefi
// Check memory cache first (fast)
const memoryModels = memoryCache.get<ModelRecord>(provider)
if (memoryModels) {
// kilocode_change start
if (provider === "zenmux" && hasInvalidZenmuxContextWindow(memoryModels)) {
console.warn("[MODEL_CACHE] Ignoring stale ZenMux model cache with invalid contextWindow values")
return undefined
}
// kilocode_change end
return memoryModels
}

Expand Down Expand Up @@ -429,6 +435,13 @@ export function getModelsFromCache(provider: ProviderName): ModelRecord | undefi
)
return undefined
}
// kilocode_change start
// Self-heal stale ZenMux cache entries from v5.7.0 where contextWindow was persisted as 0.
if (provider === "zenmux" && hasInvalidZenmuxContextWindow(validation.data)) {
console.warn("[MODEL_CACHE] Ignoring stale ZenMux model cache with invalid contextWindow values")
return undefined
}
// kilocode_change end

// Populate memory cache for future fast access
memoryCache.set(provider, validation.data)
Expand Down Expand Up @@ -459,3 +472,9 @@ function getCacheDirectoryPathSync(): string | undefined {
return undefined
}
}

// kilocode_change start
function hasInvalidZenmuxContextWindow(models: ModelRecord): boolean {
return Object.values(models).some((model) => (model.contextWindow ?? 0) <= 0)
}
// kilocode_change end
Loading
Loading