mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge 9e96578df4 into b867ec9145
This commit is contained in:
commit
f19ac34694
7 changed files with 748 additions and 15 deletions
|
|
@ -7,6 +7,7 @@ import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/ap
|
|||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToZAiFormat } from "../transform/zai-format"
|
||||
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
|
|
@ -14,6 +15,7 @@ import { BaseProvider } from "./base-provider"
|
|||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { calculateApiCostOpenAI } from "../../shared/cost"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { detectGlmModel, logGlmDetection, type GlmModelConfig } from "./utils/glm-model-detection"
|
||||
|
||||
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
|
||||
providerName: string
|
||||
|
|
@ -36,6 +38,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
protected readonly options: ApiHandlerOptions
|
||||
|
||||
protected client: OpenAI
|
||||
protected glmConfig: GlmModelConfig | null = null
|
||||
|
||||
constructor({
|
||||
providerName,
|
||||
|
|
@ -65,6 +68,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
defaultHeaders: DEFAULT_HEADERS,
|
||||
timeout: getApiRequestTimeout(),
|
||||
})
|
||||
|
||||
// Detect GLM model on construction if model ID is available
|
||||
const modelId = this.options.apiModelId || ""
|
||||
if (modelId) {
|
||||
this.glmConfig = detectGlmModel(modelId)
|
||||
logGlmDetection(this.providerName, modelId, this.glmConfig)
|
||||
}
|
||||
}
|
||||
|
||||
protected createStream(
|
||||
|
|
@ -75,6 +85,12 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
) {
|
||||
const { id: model, info } = this.getModel()
|
||||
|
||||
// Re-detect GLM model if not already done or if model ID changed
|
||||
if (!this.glmConfig || this.glmConfig.originalModelId !== model) {
|
||||
this.glmConfig = detectGlmModel(model)
|
||||
logGlmDetection(this.providerName, model, this.glmConfig)
|
||||
}
|
||||
|
||||
// Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply)
|
||||
const max_tokens =
|
||||
getModelMaxOutputTokens({
|
||||
|
|
@ -86,16 +102,32 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
|
||||
const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature
|
||||
|
||||
// Convert messages based on whether this is a GLM model
|
||||
// GLM models benefit from mergeToolResultText to prevent reasoning_content loss
|
||||
const convertedMessages = this.glmConfig.isGlmModel
|
||||
? convertToZAiFormat(messages, { mergeToolResultText: this.glmConfig.mergeToolResultText })
|
||||
: convertToOpenAiMessages(messages)
|
||||
|
||||
// Determine parallel_tool_calls setting
|
||||
// Disable for GLM models as they may not support it properly
|
||||
let parallelToolCalls: boolean
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.disableParallelToolCalls) {
|
||||
parallelToolCalls = false
|
||||
console.log(`[${this.providerName}] parallel_tool_calls disabled for GLM model`)
|
||||
} else {
|
||||
parallelToolCalls = metadata?.parallelToolCalls ?? true
|
||||
}
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model,
|
||||
max_tokens,
|
||||
temperature,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertedMessages],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
parallel_tool_calls: parallelToolCalls,
|
||||
}
|
||||
|
||||
// Add thinking parameter if reasoning is enabled and model supports it
|
||||
|
|
@ -103,6 +135,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
;(params as any).thinking = { type: "enabled" }
|
||||
}
|
||||
|
||||
// For GLM-4.7 models with thinking support, add thinking parameter
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.supportsThinking) {
|
||||
const useReasoning = this.options.enableReasoningEffort !== false // Default to enabled for GLM-4.7
|
||||
;(params as any).thinking = useReasoning ? { type: "enabled" } : { type: "disabled" }
|
||||
console.log(`[${this.providerName}] GLM-4.7 thinking mode: ${useReasoning ? "enabled" : "disabled"}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.client.chat.completions.create(params, requestOptions)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCal
|
|||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToZAiFormat } from "../transform/zai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
import { BaseProvider } from "./base-provider"
|
||||
|
|
@ -17,11 +18,13 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ".
|
|||
import { getModelsFromCache } from "./fetchers/modelCache"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { detectGlmModel, logGlmDetection, type GlmModelConfig } from "./utils/glm-model-detection"
|
||||
|
||||
export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private readonly providerName = "LM Studio"
|
||||
private glmConfig: GlmModelConfig | null = null
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
|
|
@ -35,6 +38,13 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
apiKey: apiKey,
|
||||
timeout: getApiRequestTimeout(),
|
||||
})
|
||||
|
||||
// Detect GLM model on construction if model ID is available
|
||||
const modelId = this.options.lmStudioModelId || ""
|
||||
if (modelId) {
|
||||
this.glmConfig = detectGlmModel(modelId)
|
||||
logGlmDetection(this.providerName, modelId, this.glmConfig)
|
||||
}
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
|
|
@ -42,9 +52,23 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
// Re-detect GLM model if not already done or if model ID changed
|
||||
if (!this.glmConfig || this.glmConfig.originalModelId !== model.id) {
|
||||
this.glmConfig = detectGlmModel(model.id)
|
||||
logGlmDetection(this.providerName, model.id, this.glmConfig)
|
||||
}
|
||||
|
||||
// Convert messages based on whether this is a GLM model
|
||||
// GLM models benefit from mergeToolResultText to prevent reasoning_content loss
|
||||
const convertedMessages = this.glmConfig.isGlmModel
|
||||
? convertToZAiFormat(messages, { mergeToolResultText: this.glmConfig.mergeToolResultText })
|
||||
: convertToOpenAiMessages(messages)
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
...convertedMessages,
|
||||
]
|
||||
|
||||
// -------------------------
|
||||
|
|
@ -83,20 +107,37 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
let assistantText = ""
|
||||
|
||||
try {
|
||||
// Determine parallel_tool_calls setting
|
||||
// Disable for GLM models as they may not support it properly
|
||||
let parallelToolCalls: boolean
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.disableParallelToolCalls) {
|
||||
parallelToolCalls = false
|
||||
console.log(`[${this.providerName}] parallel_tool_calls disabled for GLM model`)
|
||||
} else {
|
||||
parallelToolCalls = metadata?.parallelToolCalls ?? true
|
||||
}
|
||||
|
||||
const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = {
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
messages: openAiMessages,
|
||||
temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE,
|
||||
stream: true,
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
parallel_tool_calls: parallelToolCalls,
|
||||
}
|
||||
|
||||
if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) {
|
||||
params.draft_model = this.options.lmStudioDraftModelId
|
||||
}
|
||||
|
||||
// For GLM-4.7 models with thinking support, add thinking parameter
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.supportsThinking) {
|
||||
const useReasoning = this.options.enableReasoningEffort !== false // Default to enabled for GLM-4.7
|
||||
;(params as any).thinking = useReasoning ? { type: "enabled" } : { type: "disabled" }
|
||||
console.log(`[${this.providerName}] GLM-4.7 thinking mode: ${useReasoning ? "enabled" : "disabled"}`)
|
||||
}
|
||||
|
||||
let results
|
||||
try {
|
||||
results = await this.client.chat.completions.create(params)
|
||||
|
|
@ -124,6 +165,19 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
}
|
||||
}
|
||||
|
||||
// Handle reasoning_content for GLM models (similar to Z.ai)
|
||||
if (delta) {
|
||||
for (const key of ["reasoning_content", "reasoning"] as const) {
|
||||
if (key in delta) {
|
||||
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
|
||||
if (reasoning_content?.trim()) {
|
||||
yield { type: "reasoning", text: reasoning_content }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls in stream - emit partial chunks for NativeToolCallParser
|
||||
if (delta?.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
|
|||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToZAiFormat } from "../transform/zai-format"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
|
@ -24,6 +25,7 @@ import { BaseProvider } from "./base-provider"
|
|||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { detectGlmModel, logGlmDetection, type GlmModelConfig } from "./utils/glm-model-detection"
|
||||
|
||||
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
|
||||
// `OpenAINativeHandler` can subclass from this, since it's obviously
|
||||
|
|
@ -31,7 +33,8 @@ import { handleOpenAIError } from "./utils/openai-error-handler"
|
|||
export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
protected client: OpenAI
|
||||
private readonly providerName = "OpenAI"
|
||||
private readonly providerName = "OpenAI Compatible"
|
||||
private glmConfig: GlmModelConfig | null = null
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
|
|
@ -77,6 +80,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// Detect GLM model on construction if model ID is available
|
||||
const modelId = this.options.openAiModelId || ""
|
||||
if (modelId) {
|
||||
this.glmConfig = detectGlmModel(modelId)
|
||||
logGlmDetection(this.providerName, modelId, this.glmConfig)
|
||||
}
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
|
|
@ -91,6 +101,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
const isAzureAiInference = this._isAzureAiInference(modelUrl)
|
||||
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
|
||||
|
||||
// Re-detect GLM model if not already done or if model ID changed
|
||||
if (!this.glmConfig || this.glmConfig.originalModelId !== modelId) {
|
||||
this.glmConfig = detectGlmModel(modelId)
|
||||
logGlmDetection(this.providerName, modelId, this.glmConfig)
|
||||
}
|
||||
|
||||
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
|
||||
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata)
|
||||
return
|
||||
|
|
@ -106,6 +122,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
if (deepseekReasoner) {
|
||||
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
} else if (this.glmConfig.isGlmModel) {
|
||||
// GLM models benefit from mergeToolResultText to prevent reasoning_content loss
|
||||
const glmConvertedMessages = convertToZAiFormat(messages, {
|
||||
mergeToolResultText: this.glmConfig.mergeToolResultText,
|
||||
})
|
||||
convertedMessages = [systemMessage, ...glmConvertedMessages]
|
||||
} else {
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
systemMessage = {
|
||||
|
|
@ -152,6 +174,16 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
|
||||
|
||||
// Determine parallel_tool_calls setting
|
||||
// Disable for GLM models as they may not support it properly
|
||||
let parallelToolCalls: boolean
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.disableParallelToolCalls) {
|
||||
parallelToolCalls = false
|
||||
console.log(`[${this.providerName}] parallel_tool_calls disabled for GLM model`)
|
||||
} else {
|
||||
parallelToolCalls = metadata?.parallelToolCalls ?? true
|
||||
}
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
|
||||
|
|
@ -161,12 +193,19 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
...(reasoning && reasoning),
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
parallel_tool_calls: parallelToolCalls,
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
// For GLM-4.7 models with thinking support, add thinking parameter
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.supportsThinking) {
|
||||
const useReasoning = this.options.enableReasoningEffort !== false // Default to enabled for GLM-4.7
|
||||
;(requestOptions as any).thinking = useReasoning ? { type: "enabled" } : { type: "disabled" }
|
||||
console.log(`[${this.providerName}] GLM-4.7 thinking mode: ${useReasoning ? "enabled" : "disabled"}`)
|
||||
}
|
||||
|
||||
let stream
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(
|
||||
|
|
@ -221,20 +260,46 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
} else {
|
||||
// Determine message conversion based on model type
|
||||
let nonStreamingMessages
|
||||
if (deepseekReasoner) {
|
||||
nonStreamingMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
} else if (this.glmConfig.isGlmModel) {
|
||||
// GLM models benefit from mergeToolResultText to prevent reasoning_content loss
|
||||
const glmConvertedMessages = convertToZAiFormat(messages, {
|
||||
mergeToolResultText: this.glmConfig.mergeToolResultText,
|
||||
})
|
||||
nonStreamingMessages = [systemMessage, ...glmConvertedMessages]
|
||||
} else {
|
||||
nonStreamingMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
|
||||
}
|
||||
|
||||
// Determine parallel_tool_calls setting for non-streaming
|
||||
let nonStreamingParallelToolCalls: boolean
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.disableParallelToolCalls) {
|
||||
nonStreamingParallelToolCalls = false
|
||||
} else {
|
||||
nonStreamingParallelToolCalls = metadata?.parallelToolCalls ?? true
|
||||
}
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
messages: deepseekReasoner
|
||||
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
: [systemMessage, ...convertToOpenAiMessages(messages)],
|
||||
messages: nonStreamingMessages,
|
||||
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
parallel_tool_calls: nonStreamingParallelToolCalls,
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
// For GLM-4.7 models with thinking support, add thinking parameter
|
||||
if (this.glmConfig.isGlmModel && this.glmConfig.supportsThinking) {
|
||||
const useReasoning = this.options.enableReasoningEffort !== false
|
||||
;(requestOptions as any).thinking = useReasoning ? { type: "enabled" } : { type: "disabled" }
|
||||
}
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await this.client.chat.completions.create(
|
||||
|
|
|
|||
254
src/api/providers/utils/__tests__/glm-model-detection.spec.ts
Normal file
254
src/api/providers/utils/__tests__/glm-model-detection.spec.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { detectGlmModel, logGlmDetection, isGlmModel } from "../glm-model-detection"
|
||||
|
||||
describe("GLM Model Detection", () => {
|
||||
describe("detectGlmModel", () => {
|
||||
describe("non-GLM models", () => {
|
||||
it("should return isGlmModel=false for non-GLM models", () => {
|
||||
expect(detectGlmModel("gpt-4").isGlmModel).toBe(false)
|
||||
expect(detectGlmModel("claude-3-opus").isGlmModel).toBe(false)
|
||||
expect(detectGlmModel("llama-3.1").isGlmModel).toBe(false)
|
||||
expect(detectGlmModel("qwen-2.5").isGlmModel).toBe(false)
|
||||
})
|
||||
|
||||
it("should NOT enable GLM optimizations for non-GLM models", () => {
|
||||
const config = detectGlmModel("gpt-4")
|
||||
expect(config.mergeToolResultText).toBe(false)
|
||||
expect(config.disableParallelToolCalls).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GLM model detection", () => {
|
||||
it("should detect standard GLM model IDs", () => {
|
||||
expect(detectGlmModel("glm-4.5").isGlmModel).toBe(true)
|
||||
expect(detectGlmModel("glm-4.6").isGlmModel).toBe(true)
|
||||
expect(detectGlmModel("glm-4.7").isGlmModel).toBe(true)
|
||||
expect(detectGlmModel("GLM-4.5").isGlmModel).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect GLM models with various prefixes", () => {
|
||||
expect(detectGlmModel("mlx-community/GLM-4.5-4bit").isGlmModel).toBe(true)
|
||||
expect(detectGlmModel("local/glm-4.7-flash").isGlmModel).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect GGUF GLM models", () => {
|
||||
const result = detectGlmModel("GLM-4.5-UD-Q8_K_XL-00001-of-00008.gguf")
|
||||
expect(result.isGlmModel).toBe(true)
|
||||
expect(result.version).toBe("4.5")
|
||||
})
|
||||
|
||||
it("should detect ChatGLM models", () => {
|
||||
expect(detectGlmModel("chatglm-6b").isGlmModel).toBe(true)
|
||||
expect(detectGlmModel("chatglm3-6b").isGlmModel).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("version detection", () => {
|
||||
it("should detect GLM-4.5 version", () => {
|
||||
expect(detectGlmModel("glm-4.5").version).toBe("4.5")
|
||||
expect(detectGlmModel("glm-4-5-flash").version).toBe("4.5")
|
||||
expect(detectGlmModel("accounts/fireworks/models/glm-4p5").version).toBe("4.5")
|
||||
})
|
||||
|
||||
it("should detect GLM-4.6 version", () => {
|
||||
expect(detectGlmModel("glm-4.6").version).toBe("4.6")
|
||||
expect(detectGlmModel("GLM-4.6V").version).toBe("4.6")
|
||||
expect(detectGlmModel("glm-4-6-flash").version).toBe("4.6")
|
||||
})
|
||||
|
||||
it("should detect GLM-4.7 version", () => {
|
||||
expect(detectGlmModel("glm-4.7").version).toBe("4.7")
|
||||
expect(detectGlmModel("GLM-4.7-Flash").version).toBe("4.7")
|
||||
expect(detectGlmModel("glm-4-7-flashx").version).toBe("4.7")
|
||||
})
|
||||
})
|
||||
|
||||
describe("variant detection", () => {
|
||||
describe("base variant", () => {
|
||||
it("should detect base variant", () => {
|
||||
expect(detectGlmModel("glm-4.5").variant).toBe("base")
|
||||
expect(detectGlmModel("glm-4.6").variant).toBe("base")
|
||||
expect(detectGlmModel("glm-4.7").variant).toBe("base")
|
||||
})
|
||||
})
|
||||
|
||||
describe("air variants", () => {
|
||||
it("should detect air variant", () => {
|
||||
expect(detectGlmModel("glm-4.5-air").variant).toBe("air")
|
||||
expect(detectGlmModel("GLM-4.5-Air").variant).toBe("air")
|
||||
})
|
||||
|
||||
it("should detect airx variant", () => {
|
||||
expect(detectGlmModel("glm-4.5-airx").variant).toBe("airx")
|
||||
expect(detectGlmModel("GLM-4.5-AirX").variant).toBe("airx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("flash variants", () => {
|
||||
it("should detect flash variant", () => {
|
||||
expect(detectGlmModel("glm-4.5-flash").variant).toBe("flash")
|
||||
expect(detectGlmModel("glm-4.7-flash").variant).toBe("flash")
|
||||
})
|
||||
|
||||
it("should detect flashx variant", () => {
|
||||
expect(detectGlmModel("glm-4.7-flashx").variant).toBe("flashx")
|
||||
expect(detectGlmModel("GLM-4.7-FlashX").variant).toBe("flashx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("x variant", () => {
|
||||
it("should detect x variant", () => {
|
||||
expect(detectGlmModel("glm-4.5-x").variant).toBe("x")
|
||||
expect(detectGlmModel("GLM-4.5-X").variant).toBe("x")
|
||||
})
|
||||
})
|
||||
|
||||
describe("vision variants", () => {
|
||||
it("should detect v (vision) variant for 4.5", () => {
|
||||
const result = detectGlmModel("glm-4.5v")
|
||||
expect(result.variant).toBe("v")
|
||||
expect(result.supportsVision).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect v (vision) variant for 4.6", () => {
|
||||
const result = detectGlmModel("glm-4.6v")
|
||||
expect(result.variant).toBe("v")
|
||||
expect(result.supportsVision).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect v-flash variant", () => {
|
||||
const result = detectGlmModel("glm-4.6v-flash")
|
||||
expect(result.variant).toBe("v-flash")
|
||||
expect(result.supportsVision).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect v-flashx variant", () => {
|
||||
const result = detectGlmModel("glm-4.6v-flashx")
|
||||
expect(result.variant).toBe("v-flashx")
|
||||
expect(result.supportsVision).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking support detection", () => {
|
||||
it("should detect thinking support for GLM-4.7", () => {
|
||||
expect(detectGlmModel("glm-4.7").supportsThinking).toBe(true)
|
||||
expect(detectGlmModel("glm-4.7-flash").supportsThinking).toBe(true)
|
||||
expect(detectGlmModel("GLM-4.7-FlashX").supportsThinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should NOT detect thinking support for GLM-4.5 and GLM-4.6", () => {
|
||||
expect(detectGlmModel("glm-4.5").supportsThinking).toBe(false)
|
||||
expect(detectGlmModel("glm-4.6").supportsThinking).toBe(false)
|
||||
expect(detectGlmModel("glm-4.5-air").supportsThinking).toBe(false)
|
||||
expect(detectGlmModel("glm-4.6v").supportsThinking).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("configuration flags", () => {
|
||||
it("should enable mergeToolResultText for all GLM models", () => {
|
||||
expect(detectGlmModel("glm-4.5").mergeToolResultText).toBe(true)
|
||||
expect(detectGlmModel("glm-4.6").mergeToolResultText).toBe(true)
|
||||
expect(detectGlmModel("glm-4.7").mergeToolResultText).toBe(true)
|
||||
})
|
||||
|
||||
it("should disable parallel tool calls for all GLM models", () => {
|
||||
expect(detectGlmModel("glm-4.5").disableParallelToolCalls).toBe(true)
|
||||
expect(detectGlmModel("glm-4.6").disableParallelToolCalls).toBe(true)
|
||||
expect(detectGlmModel("glm-4.7").disableParallelToolCalls).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("display name generation", () => {
|
||||
it("should generate correct display names for base variants", () => {
|
||||
expect(detectGlmModel("glm-4.5").displayName).toBe("GLM-4.5")
|
||||
expect(detectGlmModel("glm-4.6").displayName).toBe("GLM-4.6")
|
||||
expect(detectGlmModel("glm-4.7").displayName).toBe("GLM-4.7")
|
||||
})
|
||||
|
||||
it("should generate correct display names for variants", () => {
|
||||
expect(detectGlmModel("glm-4.5-air").displayName).toBe("GLM-4.5 AIR")
|
||||
expect(detectGlmModel("glm-4.5-flash").displayName).toBe("GLM-4.5 FLASH")
|
||||
expect(detectGlmModel("glm-4.7-flashx").displayName).toBe("GLM-4.7 FLASHX")
|
||||
expect(detectGlmModel("glm-4.6v").displayName).toBe("GLM-4.6 V")
|
||||
expect(detectGlmModel("glm-4.6v-flash").displayName).toBe("GLM-4.6 V FLASH")
|
||||
})
|
||||
|
||||
it("should handle unknown version", () => {
|
||||
// ChatGLM doesn't have a specific version number
|
||||
const result = detectGlmModel("chatglm-6b")
|
||||
expect(result.displayName).toBe("GLM-4.x")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world model ID formats", () => {
|
||||
it("should correctly detect MLX community models", () => {
|
||||
const result = detectGlmModel("mlx-community/GLM-4.5-4bit")
|
||||
expect(result.isGlmModel).toBe(true)
|
||||
expect(result.version).toBe("4.5")
|
||||
expect(result.variant).toBe("base")
|
||||
})
|
||||
|
||||
it("should correctly detect GGUF models", () => {
|
||||
const result = detectGlmModel("GLM-4.5-UD-Q8_K_XL-00001-of-00008.gguf")
|
||||
expect(result.isGlmModel).toBe(true)
|
||||
expect(result.version).toBe("4.5")
|
||||
})
|
||||
|
||||
it("should correctly detect Fireworks models", () => {
|
||||
const result = detectGlmModel("accounts/fireworks/models/glm-4p5")
|
||||
expect(result.isGlmModel).toBe(true)
|
||||
expect(result.version).toBe("4.5")
|
||||
})
|
||||
|
||||
it("should correctly detect Fireworks air models", () => {
|
||||
const result = detectGlmModel("accounts/fireworks/models/glm-4p5-air")
|
||||
expect(result.isGlmModel).toBe(true)
|
||||
expect(result.version).toBe("4.5")
|
||||
expect(result.variant).toBe("air")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("logGlmDetection", () => {
|
||||
let consoleLogSpy: any
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should log detection results for GLM models", () => {
|
||||
const config = detectGlmModel("glm-4.5")
|
||||
logGlmDetection("LM Studio", "glm-4.5", config)
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('[LM Studio] Using model ID: "glm-4.5"')
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('[GLM Detection] ✓ GLM model detected: "glm-4.5"')
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("[GLM Detection] - Version: 4.5")
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("[GLM Detection] - Variant: base")
|
||||
})
|
||||
|
||||
it("should log when model is NOT a GLM model", () => {
|
||||
const config = detectGlmModel("gpt-4")
|
||||
logGlmDetection("OpenAI-compatible", "gpt-4", config)
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('[OpenAI-compatible] Using model ID: "gpt-4"')
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('[GLM Detection] ✗ Not a GLM model: "gpt-4"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGlmModel", () => {
|
||||
it("should return true for GLM models", () => {
|
||||
expect(isGlmModel("glm-4.5")).toBe(true)
|
||||
expect(isGlmModel("GLM-4.7-Flash")).toBe(true)
|
||||
expect(isGlmModel("chatglm-6b")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-GLM models", () => {
|
||||
expect(isGlmModel("gpt-4")).toBe(false)
|
||||
expect(isGlmModel("claude-3-opus")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
199
src/api/providers/utils/glm-model-detection.ts
Normal file
199
src/api/providers/utils/glm-model-detection.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* GLM Model Detection Utility
|
||||
*
|
||||
* Detects GLM models from Z.ai (Zhipu AI) and returns appropriate configuration
|
||||
* for optimal interaction. This utility supports various model ID formats from
|
||||
* different providers like LM Studio and OpenAI-compatible endpoints.
|
||||
*
|
||||
* GLM Model Family:
|
||||
* - GLM-4.5: Base model with 355B parameters
|
||||
* - GLM-4.5-Air: Lightweight version balancing performance and cost
|
||||
* - GLM-4.5-X: High-performance variant with ultra-fast responses
|
||||
* - GLM-4.5-AirX: Lightweight ultra-fast variant
|
||||
* - GLM-4.5-Flash: Free high-speed model
|
||||
* - GLM-4.5V: Multimodal visual model
|
||||
* - GLM-4.6: Extended 200k context window
|
||||
* - GLM-4.6V: Multimodal vision model
|
||||
* - GLM-4.6V-Flash: Free high-speed vision model
|
||||
* - GLM-4.7: Built-in thinking capabilities
|
||||
* - GLM-4.7-Flash: Free high-speed variant of GLM-4.7
|
||||
* - GLM-4.7-FlashX: Ultra-fast variant
|
||||
*/
|
||||
|
||||
/**
|
||||
* GLM model version enumeration
|
||||
*/
|
||||
export type GlmVersion = "4.5" | "4.6" | "4.7" | "unknown"
|
||||
|
||||
/**
|
||||
* GLM model variant - specific model within a version
|
||||
*/
|
||||
export type GlmVariant =
|
||||
| "base"
|
||||
| "air"
|
||||
| "x"
|
||||
| "airx"
|
||||
| "flash"
|
||||
| "flashx"
|
||||
| "v" // vision
|
||||
| "v-flash"
|
||||
| "v-flashx"
|
||||
|
||||
/**
|
||||
* Configuration options for GLM models
|
||||
*/
|
||||
export interface GlmModelConfig {
|
||||
/** Whether this is a GLM model */
|
||||
isGlmModel: boolean
|
||||
/** The detected GLM version (4.5, 4.6, 4.7) */
|
||||
version: GlmVersion
|
||||
/** The detected variant (base, air, flash, v, etc.) */
|
||||
variant: GlmVariant | "unknown"
|
||||
/** Whether this model supports vision/images */
|
||||
supportsVision: boolean
|
||||
/** Whether this model has built-in thinking/reasoning support */
|
||||
supportsThinking: boolean
|
||||
/** Whether to merge tool result text into tool messages */
|
||||
mergeToolResultText: boolean
|
||||
/** Whether to disable parallel tool calls */
|
||||
disableParallelToolCalls: boolean
|
||||
/** The original model ID */
|
||||
originalModelId: string
|
||||
/** A normalized/canonical model name for display */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a model ID represents a GLM model and returns its configuration.
|
||||
*
|
||||
* Supports various model ID formats:
|
||||
* - Standard: "glm-4.5", "glm-4.7-flash"
|
||||
* - With prefix: "mlx-community/GLM-4.5-4bit"
|
||||
* - GGUF files: "GLM-4.5-UD-Q8_K_XL-00001-of-00008.gguf"
|
||||
* - ChatGLM: "chatglm-6b", "chatglm3-6b"
|
||||
*
|
||||
* @param modelId The model identifier string
|
||||
* @returns GLM model configuration
|
||||
*/
|
||||
export function detectGlmModel(modelId: string): GlmModelConfig {
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
|
||||
// Check if this is a GLM model using case-insensitive matching
|
||||
// Match patterns: "glm-", "glm4", "chatglm", or "glm" followed by a version number
|
||||
const isGlm = /glm[-_]?4|chatglm|\/glm[-_]|^glm[-_]/i.test(modelId)
|
||||
|
||||
if (!isGlm) {
|
||||
return {
|
||||
isGlmModel: false,
|
||||
version: "unknown",
|
||||
variant: "unknown",
|
||||
supportsVision: false,
|
||||
supportsThinking: false,
|
||||
mergeToolResultText: false,
|
||||
disableParallelToolCalls: false,
|
||||
originalModelId: modelId,
|
||||
displayName: modelId,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect version (4.5, 4.6, 4.7)
|
||||
let version: GlmVersion = "unknown"
|
||||
if (/4\.7|4-7|47/i.test(lowerModelId)) {
|
||||
version = "4.7"
|
||||
} else if (/4\.6|4-6|46/i.test(lowerModelId)) {
|
||||
version = "4.6"
|
||||
} else if (/4\.5|4-5|45|4p5/i.test(lowerModelId)) {
|
||||
version = "4.5"
|
||||
}
|
||||
|
||||
// Detect variant
|
||||
let variant: GlmVariant = "base"
|
||||
let supportsVision = false
|
||||
|
||||
// Check for vision variants first (they may also have flash/etc.)
|
||||
if (/4\.5v|4-5v|45v|4p5v|glm-4\.5v/i.test(lowerModelId)) {
|
||||
variant = "v"
|
||||
supportsVision = true
|
||||
} else if (/4\.6v[-_]?flashx|4-6v[-_]?flashx/i.test(lowerModelId)) {
|
||||
variant = "v-flashx"
|
||||
supportsVision = true
|
||||
} else if (/4\.6v[-_]?flash|4-6v[-_]?flash/i.test(lowerModelId)) {
|
||||
variant = "v-flash"
|
||||
supportsVision = true
|
||||
} else if (/4\.6v|4-6v|46v/i.test(lowerModelId)) {
|
||||
variant = "v"
|
||||
supportsVision = true
|
||||
}
|
||||
// Non-vision variants
|
||||
else if (/flashx/i.test(lowerModelId)) {
|
||||
variant = "flashx"
|
||||
} else if (/flash/i.test(lowerModelId)) {
|
||||
variant = "flash"
|
||||
} else if (/airx/i.test(lowerModelId)) {
|
||||
variant = "airx"
|
||||
} else if (/air/i.test(lowerModelId)) {
|
||||
variant = "air"
|
||||
} else if (/[-_]x\b/i.test(lowerModelId)) {
|
||||
// Match "-x" or "_x" at word boundary (to avoid matching "flashx", "airx")
|
||||
variant = "x"
|
||||
}
|
||||
|
||||
// GLM-4.7 has built-in thinking support
|
||||
const supportsThinking = version === "4.7"
|
||||
|
||||
// Generate display name
|
||||
let displayName = `GLM-${version !== "unknown" ? version : "4.x"}`
|
||||
if (variant !== "base") {
|
||||
const variantName = variant.toUpperCase().replace("-", " ")
|
||||
displayName += ` ${variantName}`
|
||||
}
|
||||
|
||||
return {
|
||||
isGlmModel: true,
|
||||
version,
|
||||
variant,
|
||||
supportsVision,
|
||||
supportsThinking,
|
||||
// All GLM models benefit from mergeToolResultText to prevent reasoning_content loss
|
||||
mergeToolResultText: true,
|
||||
// Disable parallel tool calls for GLM models as they may not support it properly
|
||||
disableParallelToolCalls: true,
|
||||
originalModelId: modelId,
|
||||
displayName,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs GLM model detection results to the console for debugging.
|
||||
*
|
||||
* @param providerName The name of the provider (e.g., "LM Studio", "OpenAI-compatible")
|
||||
* @param modelId The model ID being used
|
||||
* @param config The detected GLM configuration
|
||||
*/
|
||||
export function logGlmDetection(providerName: string, modelId: string, config: GlmModelConfig): void {
|
||||
console.log(`[${providerName}] Using model ID: "${modelId}"`)
|
||||
|
||||
if (config.isGlmModel) {
|
||||
console.log(`[GLM Detection] ✓ GLM model detected: "${modelId}"`)
|
||||
console.log(`[GLM Detection] - Version: ${config.version}`)
|
||||
console.log(`[GLM Detection] - Variant: ${config.variant}`)
|
||||
console.log(`[GLM Detection] - Display name: ${config.displayName}`)
|
||||
console.log(`[GLM Detection] - Supports vision: ${config.supportsVision}`)
|
||||
console.log(`[GLM Detection] - Supports thinking: ${config.supportsThinking}`)
|
||||
console.log(`[GLM Detection] - mergeToolResultText: ${config.mergeToolResultText}`)
|
||||
console.log(`[GLM Detection] - disableParallelToolCalls: ${config.disableParallelToolCalls}`)
|
||||
} else {
|
||||
console.log(`[GLM Detection] ✗ Not a GLM model: "${modelId}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple check if a model ID is a GLM model without full configuration.
|
||||
* Use this for quick checks where you only need a boolean.
|
||||
*
|
||||
* @param modelId The model identifier string
|
||||
* @returns true if the model is a GLM model
|
||||
*/
|
||||
export function isGlmModel(modelId: string): boolean {
|
||||
return /glm[-_]?4|chatglm|\/glm[-_]|^glm[-_]/i.test(modelId)
|
||||
}
|
||||
|
|
@ -166,16 +166,35 @@ export class NativeToolCallParser {
|
|||
/**
|
||||
* Process stream finish reason.
|
||||
* Emits end events when finish_reason is 'tool_calls'.
|
||||
*
|
||||
* IMPORTANT: Only emits tool_call_end for tool calls that have actually started
|
||||
* (i.e., where tool_call_start was emitted). This prevents finalizeStreamingToolCall
|
||||
* from receiving IDs that were never registered via startStreamingToolCall, which
|
||||
* would cause tool results to be silently dropped and trigger infinite retry loops.
|
||||
*/
|
||||
public static processFinishReason(finishReason: string | null | undefined): ToolCallStreamEvent[] {
|
||||
const events: ToolCallStreamEvent[] = []
|
||||
|
||||
if (finishReason === "tool_calls" && this.rawChunkTracker.size > 0) {
|
||||
for (const [, tracked] of this.rawChunkTracker.entries()) {
|
||||
events.push({
|
||||
type: "tool_call_end",
|
||||
id: tracked.id,
|
||||
})
|
||||
// Only emit tool_call_end for tool calls that have actually started.
|
||||
// Tool calls without hasStarted=true never had a tool_call_start emitted
|
||||
// (likely due to missing tool name), so they were never registered in
|
||||
// streamingToolCalls. Emitting tool_call_end for these would cause
|
||||
// finalizeStreamingToolCall to fail, resulting in no tool_result being
|
||||
// sent to the model and triggering infinite retry loops.
|
||||
if (tracked.hasStarted) {
|
||||
events.push({
|
||||
type: "tool_call_end",
|
||||
id: tracked.id,
|
||||
})
|
||||
} else {
|
||||
// Log a warning for tool calls that were tracked but never started.
|
||||
// This helps diagnose issues with models that send malformed tool calls.
|
||||
console.warn(
|
||||
`[NativeToolCallParser] Skipping tool_call_end for unstarted tool call: ${tracked.id} (no name received)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -343,4 +343,107 @@ describe("NativeToolCallParser", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("processFinishReason", () => {
|
||||
describe("tool call tracking synchronization", () => {
|
||||
it("should emit tool_call_end only for tool calls that have started", () => {
|
||||
// Simulate a tool call with both ID and name (will start)
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: 0,
|
||||
id: "call_started_123",
|
||||
name: "read_file",
|
||||
})
|
||||
|
||||
const events = NativeToolCallParser.processFinishReason("tool_calls")
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toEqual({
|
||||
type: "tool_call_end",
|
||||
id: "call_started_123",
|
||||
})
|
||||
})
|
||||
|
||||
it("should NOT emit tool_call_end for tool calls without a name (never started)", () => {
|
||||
// Simulate a tool call with ID but NO name - this happens when models
|
||||
// send malformed tool calls or split ID/name across chunks incorrectly
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: 0,
|
||||
id: "call_no_name_456",
|
||||
// No name provided - tool_call_start will not be emitted
|
||||
})
|
||||
|
||||
// Capture console.warn to verify warning is logged
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
const events = NativeToolCallParser.processFinishReason("tool_calls")
|
||||
|
||||
// Should NOT emit tool_call_end since tool was never started
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
// Should log a warning about the unstarted tool call
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Skipping tool_call_end for unstarted tool call"),
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle mixed started and unstarted tool calls correctly", () => {
|
||||
// Tool call with ID and name (will start)
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: 0,
|
||||
id: "call_with_name",
|
||||
name: "read_file",
|
||||
})
|
||||
|
||||
// Tool call with only ID (will not start)
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: 1,
|
||||
id: "call_without_name",
|
||||
// No name
|
||||
})
|
||||
|
||||
// Another tool call with ID and name (will start)
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: 2,
|
||||
id: "call_also_with_name",
|
||||
name: "write_to_file",
|
||||
})
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
const events = NativeToolCallParser.processFinishReason("tool_calls")
|
||||
|
||||
// Should only emit tool_call_end for the two started tool calls
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((e) => e.id)).toContain("call_with_name")
|
||||
expect(events.map((e) => e.id)).toContain("call_also_with_name")
|
||||
expect(events.map((e) => e.id)).not.toContain("call_without_name")
|
||||
|
||||
// Should log warning for the unstarted tool call
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("call_without_name"))
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should return empty array when finish_reason is not tool_calls", () => {
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "read_file",
|
||||
})
|
||||
|
||||
const events = NativeToolCallParser.processFinishReason("stop")
|
||||
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return empty array when no tool calls are tracked", () => {
|
||||
const events = NativeToolCallParser.processFinishReason("tool_calls")
|
||||
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue