mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-23 00:43:14 +00:00
Gemma 4 31B uses <thought>...</thought> tags for its reasoning process, but Roo Code only recognized <think>...</think> tags. This change: - Updates TagMatcher to accept multiple tag names (string | string[]) - Adds "thought" as an additional recognized reasoning tag in all 4 providers (openai, base-openai-compatible, lm-studio, native-ollama) - Strips <thought> tags in presentAssistantMessage alongside <thinking> - Adds comprehensive test coverage for multi-tag matching Fixes #12093
260 lines
7.8 KiB
TypeScript
260 lines
7.8 KiB
TypeScript
import { Anthropic } from "@anthropic-ai/sdk"
|
|
import OpenAI from "openai"
|
|
|
|
import type { ModelInfo } from "@roo-code/types"
|
|
|
|
import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api"
|
|
import { TagMatcher } from "../../utils/tag-matcher"
|
|
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
|
import { convertToOpenAiMessages } from "../transform/openai-format"
|
|
|
|
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
|
import { DEFAULT_HEADERS } from "./constants"
|
|
import { BaseProvider } from "./base-provider"
|
|
import { handleOpenAIError } from "./utils/openai-error-handler"
|
|
import { calculateApiCostOpenAI } from "../../shared/cost"
|
|
import { getApiRequestTimeout } from "./utils/timeout-config"
|
|
|
|
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
|
|
providerName: string
|
|
baseURL: string
|
|
defaultProviderModelId: ModelName
|
|
providerModels: Record<ModelName, ModelInfo>
|
|
defaultTemperature?: number
|
|
}
|
|
|
|
export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|
extends BaseProvider
|
|
implements SingleCompletionHandler
|
|
{
|
|
protected readonly providerName: string
|
|
protected readonly baseURL: string
|
|
protected readonly defaultTemperature: number
|
|
protected readonly defaultProviderModelId: ModelName
|
|
protected readonly providerModels: Record<ModelName, ModelInfo>
|
|
|
|
protected readonly options: ApiHandlerOptions
|
|
|
|
protected client: OpenAI
|
|
|
|
constructor({
|
|
providerName,
|
|
baseURL,
|
|
defaultProviderModelId,
|
|
providerModels,
|
|
defaultTemperature,
|
|
...options
|
|
}: BaseOpenAiCompatibleProviderOptions<ModelName>) {
|
|
super()
|
|
|
|
this.providerName = providerName
|
|
this.baseURL = baseURL
|
|
this.defaultProviderModelId = defaultProviderModelId
|
|
this.providerModels = providerModels
|
|
this.defaultTemperature = defaultTemperature ?? 0
|
|
|
|
this.options = options
|
|
|
|
if (!this.options.apiKey) {
|
|
throw new Error("API key is required")
|
|
}
|
|
|
|
this.client = new OpenAI({
|
|
baseURL,
|
|
apiKey: this.options.apiKey,
|
|
defaultHeaders: DEFAULT_HEADERS,
|
|
timeout: getApiRequestTimeout(),
|
|
})
|
|
}
|
|
|
|
protected createStream(
|
|
systemPrompt: string,
|
|
messages: Anthropic.Messages.MessageParam[],
|
|
metadata?: ApiHandlerCreateMessageMetadata,
|
|
requestOptions?: OpenAI.RequestOptions,
|
|
) {
|
|
const { id: model, info } = this.getModel()
|
|
|
|
// Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply)
|
|
const max_tokens =
|
|
getModelMaxOutputTokens({
|
|
modelId: model,
|
|
model: info,
|
|
settings: this.options,
|
|
format: "openai",
|
|
}) ?? undefined
|
|
|
|
const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature
|
|
|
|
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
|
model,
|
|
max_tokens,
|
|
temperature,
|
|
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
tools: this.convertToolsForOpenAI(metadata?.tools),
|
|
tool_choice: metadata?.tool_choice,
|
|
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
|
}
|
|
|
|
// Add thinking parameter if reasoning is enabled and model supports it
|
|
if (this.options.enableReasoningEffort && info.supportsReasoningBinary) {
|
|
;(params as any).thinking = { type: "enabled" }
|
|
}
|
|
|
|
try {
|
|
return this.client.chat.completions.create(params, requestOptions)
|
|
} catch (error) {
|
|
throw handleOpenAIError(error, this.providerName)
|
|
}
|
|
}
|
|
|
|
override async *createMessage(
|
|
systemPrompt: string,
|
|
messages: Anthropic.Messages.MessageParam[],
|
|
metadata?: ApiHandlerCreateMessageMetadata,
|
|
): ApiStream {
|
|
const stream = await this.createStream(systemPrompt, messages, metadata)
|
|
|
|
const matcher = new TagMatcher(
|
|
["think", "thought"],
|
|
(chunk) =>
|
|
({
|
|
type: chunk.matched ? "reasoning" : "text",
|
|
text: chunk.data,
|
|
}) as const,
|
|
)
|
|
|
|
let lastUsage: OpenAI.CompletionUsage | undefined
|
|
const activeToolCallIds = new Set<string>()
|
|
|
|
for await (const chunk of stream) {
|
|
// Check for provider-specific error responses (e.g., MiniMax base_resp)
|
|
const chunkAny = chunk as any
|
|
if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) {
|
|
throw new Error(
|
|
`${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`,
|
|
)
|
|
}
|
|
|
|
const delta = chunk.choices?.[0]?.delta
|
|
const finishReason = chunk.choices?.[0]?.finish_reason
|
|
|
|
if (delta?.content) {
|
|
for (const processedChunk of matcher.update(delta.content)) {
|
|
yield processedChunk
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit raw tool call chunks - NativeToolCallParser handles state management
|
|
if (delta?.tool_calls) {
|
|
for (const toolCall of delta.tool_calls) {
|
|
if (toolCall.id) {
|
|
activeToolCallIds.add(toolCall.id)
|
|
}
|
|
yield {
|
|
type: "tool_call_partial",
|
|
index: toolCall.index,
|
|
id: toolCall.id,
|
|
name: toolCall.function?.name,
|
|
arguments: toolCall.function?.arguments,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit tool_call_end events when finish_reason is "tool_calls"
|
|
// This ensures tool calls are finalized even if the stream doesn't properly close
|
|
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
|
|
for (const id of activeToolCallIds) {
|
|
yield { type: "tool_call_end", id }
|
|
}
|
|
activeToolCallIds.clear()
|
|
}
|
|
|
|
if (chunk.usage) {
|
|
lastUsage = chunk.usage
|
|
}
|
|
}
|
|
|
|
if (lastUsage) {
|
|
yield this.processUsageMetrics(lastUsage, this.getModel().info)
|
|
}
|
|
|
|
// Process any remaining content
|
|
for (const processedChunk of matcher.final()) {
|
|
yield processedChunk
|
|
}
|
|
}
|
|
|
|
protected processUsageMetrics(usage: any, modelInfo?: any): ApiStreamUsageChunk {
|
|
const inputTokens = usage?.prompt_tokens || 0
|
|
const outputTokens = usage?.completion_tokens || 0
|
|
const cacheWriteTokens = usage?.prompt_tokens_details?.cache_write_tokens || 0
|
|
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
|
|
|
|
const { totalCost } = modelInfo
|
|
? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
|
: { totalCost: 0 }
|
|
|
|
return {
|
|
type: "usage",
|
|
inputTokens,
|
|
outputTokens,
|
|
cacheWriteTokens: cacheWriteTokens || undefined,
|
|
cacheReadTokens: cacheReadTokens || undefined,
|
|
totalCost,
|
|
}
|
|
}
|
|
|
|
async completePrompt(prompt: string): Promise<string> {
|
|
const { id: modelId, info: modelInfo } = this.getModel()
|
|
|
|
const params: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
|
|
model: modelId,
|
|
messages: [{ role: "user", content: prompt }],
|
|
}
|
|
|
|
// Add thinking parameter if reasoning is enabled and model supports it
|
|
if (this.options.enableReasoningEffort && modelInfo.supportsReasoningBinary) {
|
|
;(params as any).thinking = { type: "enabled" }
|
|
}
|
|
|
|
try {
|
|
const response = await this.client.chat.completions.create(params)
|
|
|
|
// Check for provider-specific error responses (e.g., MiniMax base_resp)
|
|
const responseAny = response as any
|
|
if (responseAny.base_resp?.status_code && responseAny.base_resp.status_code !== 0) {
|
|
throw new Error(
|
|
`${this.providerName} API Error (${responseAny.base_resp.status_code}): ${responseAny.base_resp.status_msg || "Unknown error"}`,
|
|
)
|
|
}
|
|
|
|
return response.choices?.[0]?.message.content || ""
|
|
} catch (error) {
|
|
throw handleOpenAIError(error, this.providerName)
|
|
}
|
|
}
|
|
|
|
override getModel() {
|
|
const id =
|
|
this.options.apiModelId && this.options.apiModelId in this.providerModels
|
|
? (this.options.apiModelId as ModelName)
|
|
: this.defaultProviderModelId
|
|
|
|
return { id, info: this.providerModels[id] }
|
|
}
|
|
}
|