mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: improve GLM-4.6 thinking token support for better compatibility
- Make thinking parameter optional and disabled by default for ik_llama.cpp compatibility - Always parse reasoning_content from response for endpoints that provide it - Always parse XML-wrapped thinking tokens regardless of endpoint - Add comprehensive tests for all scenarios - Only enable thinking parameter when explicitly configured This ensures GLM-4.6 works with various OpenAI-compatible endpoints including ik_llama.cpp which may not support the thinking parameter but might provide thinking tokens through other means like reasoning_content or XML tags. Fixes #8547
This commit is contained in:
parent
6b8c21f873
commit
d9e20b2dc8
2 changed files with 481 additions and 3 deletions
|
|
@ -0,0 +1,411 @@
|
|||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"
|
||||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import type { ModelInfo } from "@roo-code/types"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
|
||||
|
||||
// Mock OpenAI module
|
||||
vi.mock("openai", () => {
|
||||
const mockCreate = vi.fn()
|
||||
const MockOpenAI = vi.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
}))
|
||||
return { default: MockOpenAI }
|
||||
})
|
||||
|
||||
// Create a concrete implementation for testing
|
||||
class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model" | "glm-4.6"> {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "TestProvider",
|
||||
baseURL: options.openAiBaseUrl || "https://api.test.com/v1",
|
||||
defaultProviderModelId: "test-model",
|
||||
providerModels: {
|
||||
"test-model": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.01,
|
||||
outputPrice: 0.02,
|
||||
},
|
||||
"glm-4.6": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.015,
|
||||
outputPrice: 0.03,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("BaseOpenAiCompatibleProvider", () => {
|
||||
let provider: TestOpenAiCompatibleProvider
|
||||
let mockOpenAIInstance: any
|
||||
let mockCreate: Mock
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOpenAIInstance = new (OpenAI as any)()
|
||||
mockCreate = mockOpenAIInstance.chat.completions.create
|
||||
})
|
||||
|
||||
describe("GLM-4.6 thinking token support", () => {
|
||||
it("should detect GLM-4.6 model correctly", () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
// Test the isGLM46Model method
|
||||
expect((provider as any).isGLM46Model("glm-4.6")).toBe(true)
|
||||
expect((provider as any).isGLM46Model("GLM-4.6")).toBe(true)
|
||||
expect((provider as any).isGLM46Model("glm-4-6")).toBe(true)
|
||||
expect((provider as any).isGLM46Model("GLM-4-6")).toBe(true)
|
||||
expect((provider as any).isGLM46Model("test-model")).toBe(false)
|
||||
expect((provider as any).isGLM46Model("gpt-4")).toBe(false)
|
||||
})
|
||||
|
||||
it("should NOT add thinking parameter by default for GLM-4.6 model (for ik_llama.cpp compatibility)", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
// Mock the stream response
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that the create method was called WITHOUT thinking parameter by default
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
thinking: expect.anything(),
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should add thinking parameter only when explicitly enabled", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
// @ts-ignore
|
||||
openAiEnableThinkingParameter: true, // Explicitly enable thinking parameter
|
||||
})
|
||||
|
||||
// Mock the stream response
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Now it should include the thinking parameter
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "glm-4.6",
|
||||
thinking: { type: "enabled" },
|
||||
stream: true,
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should not add thinking parameter for non-GLM-4.6 models even if enabled", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "test-model",
|
||||
// @ts-ignore
|
||||
openAiEnableThinkingParameter: true,
|
||||
})
|
||||
|
||||
// Mock the stream response
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that the create method was called without thinking parameter
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
thinking: expect.anything(),
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should parse thinking tokens from GLM-4.6 response using XML tags", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
// Mock the stream response with thinking tokens
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { choices: [{ delta: { content: "<think>" } }], usage: null }
|
||||
yield { choices: [{ delta: { content: "Let me analyze this problem..." } }], usage: null }
|
||||
yield { choices: [{ delta: { content: "</think>" } }], usage: null }
|
||||
yield { choices: [{ delta: { content: "The answer is 42." } }], usage: null }
|
||||
yield { choices: [], usage: { prompt_tokens: 10, completion_tokens: 20 } }
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "What is the meaning of life?" },
|
||||
]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that thinking tokens were parsed correctly
|
||||
const reasoningChunks = results.filter((r) => r.type === "reasoning")
|
||||
const textChunks = results.filter((r) => r.type === "text")
|
||||
|
||||
expect(reasoningChunks.length).toBeGreaterThan(0)
|
||||
expect(reasoningChunks.some((c) => c.text?.includes("Let me analyze this problem"))).toBe(true)
|
||||
expect(textChunks.some((c) => c.text === "The answer is 42.")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle reasoning_content in delta for models that support it (ik_llama.cpp compatibility)", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
// Mock the stream response with reasoning_content (as ik_llama.cpp might provide)
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { choices: [{ delta: { reasoning_content: "Thinking about the problem..." } }], usage: null }
|
||||
yield { choices: [{ delta: { content: "The solution is simple." } }], usage: null }
|
||||
yield { choices: [], usage: { prompt_tokens: 10, completion_tokens: 15 } }
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Solve this problem" }]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that reasoning_content was handled correctly
|
||||
const reasoningChunks = results.filter((r) => r.type === "reasoning")
|
||||
const textChunks = results.filter((r) => r.type === "text")
|
||||
|
||||
expect(reasoningChunks.some((c) => c.text === "Thinking about the problem...")).toBe(true)
|
||||
expect(textChunks.some((c) => c.text === "The solution is simple.")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle mixed reasoning formats (both XML and reasoning_content)", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
// Mock the stream response with both formats
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// First some reasoning_content
|
||||
yield { choices: [{ delta: { reasoning_content: "Initial thoughts..." } }], usage: null }
|
||||
// Then XML-wrapped thinking
|
||||
yield { choices: [{ delta: { content: "<think>Deep analysis</think>" } }], usage: null }
|
||||
// Finally the actual response
|
||||
yield { choices: [{ delta: { content: "Here's the answer." } }], usage: null }
|
||||
yield { choices: [], usage: { prompt_tokens: 10, completion_tokens: 20 } }
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Complex question" }]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that both types of reasoning were captured
|
||||
const reasoningChunks = results.filter((r) => r.type === "reasoning")
|
||||
const textChunks = results.filter((r) => r.type === "text")
|
||||
|
||||
expect(reasoningChunks.some((c) => c.text === "Initial thoughts...")).toBe(true)
|
||||
expect(reasoningChunks.some((c) => c.text === "Deep analysis")).toBe(true)
|
||||
expect(textChunks.some((c) => c.text === "Here's the answer.")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle non-GLM-4.6 models without XML parsing", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "test-model",
|
||||
})
|
||||
|
||||
// Mock the stream response with XML-like content that shouldn't be parsed
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { choices: [{ delta: { content: "<think>This is not parsed</think>" } }], usage: null }
|
||||
yield { choices: [{ delta: { content: "Regular response" } }], usage: null }
|
||||
yield { choices: [], usage: { prompt_tokens: 10, completion_tokens: 15 } }
|
||||
},
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
// Create a message
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test" }]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// For non-GLM-4.6, XML should not be parsed as reasoning
|
||||
const reasoningChunks = results.filter((r) => r.type === "reasoning")
|
||||
const textChunks = results.filter((r) => r.type === "text")
|
||||
|
||||
expect(reasoningChunks.length).toBe(0)
|
||||
expect(textChunks.some((c) => c.text === "<think>This is not parsed</think>")).toBe(true)
|
||||
expect(textChunks.some((c) => c.text === "Regular response")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "test-model",
|
||||
})
|
||||
|
||||
const mockResponse = {
|
||||
choices: [{ message: { content: "Completed response" } }],
|
||||
}
|
||||
mockCreate.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await provider.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Completed response")
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
model: "test-model",
|
||||
messages: [{ role: "user", content: "Test prompt" }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return correct model info", () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
const model = provider.getModel()
|
||||
|
||||
expect(model.id).toBe("glm-4.6")
|
||||
expect(model.info.maxTokens).toBe(8192)
|
||||
expect(model.info.contextWindow).toBe(128000)
|
||||
})
|
||||
|
||||
it("should use default model when apiModelId is not provided", () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
})
|
||||
|
||||
const model = provider.getModel()
|
||||
|
||||
expect(model.id).toBe("test-model")
|
||||
expect(model.info.maxTokens).toBe(4096)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldAddThinkingParameter", () => {
|
||||
it("should return false by default for compatibility", () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
})
|
||||
|
||||
expect((provider as any).shouldAddThinkingParameter()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when explicitly enabled", () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
apiModelId: "glm-4.6",
|
||||
// @ts-ignore
|
||||
openAiEnableThinkingParameter: true,
|
||||
})
|
||||
|
||||
expect((provider as any).shouldAddThinkingParameter()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import type { ModelInfo } from "@roo-code/types"
|
|||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { XmlMatcher } from "../../utils/xml-matcher"
|
||||
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
|
|
@ -85,6 +86,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
stream_options: { include_usage: true },
|
||||
}
|
||||
|
||||
// Add thinking parameter for GLM-4.6 model only if explicitly enabled
|
||||
// This allows for compatibility with endpoints that don't support this parameter
|
||||
if (this.isGLM46Model(model) && this.shouldAddThinkingParameter()) {
|
||||
// @ts-ignore - GLM-4.6 specific parameter
|
||||
params.thinking = { type: "enabled" }
|
||||
}
|
||||
|
||||
try {
|
||||
return this.client.chat.completions.create(params, requestOptions)
|
||||
} catch (error) {
|
||||
|
|
@ -98,14 +106,45 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const stream = await this.createStream(systemPrompt, messages, metadata)
|
||||
const { id: model } = this.getModel()
|
||||
const isGLM46 = this.isGLM46Model(model)
|
||||
|
||||
// Use XmlMatcher for GLM-4.6 to parse thinking tokens from content
|
||||
// This works regardless of whether the endpoint supports the thinking parameter
|
||||
const matcher = isGLM46
|
||||
? new XmlMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
: null
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
if (isGLM46 && matcher) {
|
||||
// Parse thinking tokens for GLM-4.6 from content
|
||||
for (const parsedChunk of matcher.update(delta.content)) {
|
||||
yield parsedChunk
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning_content if present (for models/endpoints that support it directly)
|
||||
// This ensures compatibility with various implementations including ik_llama.cpp
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +156,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize any remaining content from the matcher
|
||||
if (isGLM46 && matcher) {
|
||||
for (const parsedChunk of matcher.final()) {
|
||||
yield parsedChunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
|
|
@ -142,4 +188,25 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
|
||||
return { id, info: this.providerModels[id] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is GLM-4.6 which may require special handling for thinking tokens
|
||||
*/
|
||||
protected isGLM46Model(modelId: string): boolean {
|
||||
// Check for various GLM-4.6 model naming patterns
|
||||
const lowerModel = modelId.toLowerCase()
|
||||
return lowerModel.includes("glm-4.6") || lowerModel.includes("glm-4-6") || lowerModel === "glm-4.6"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to add the thinking parameter to the request
|
||||
* This can be overridden by subclasses or configured via options
|
||||
* Default is conservative (false) to ensure compatibility with endpoints like ik_llama.cpp
|
||||
*/
|
||||
protected shouldAddThinkingParameter(): boolean {
|
||||
// Only add thinking parameter if explicitly enabled via configuration
|
||||
// This ensures compatibility with endpoints that don't support this parameter
|
||||
// @ts-ignore - Check for future configuration option
|
||||
return this.options.openAiEnableThinkingParameter === true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue