mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
Merge remote-tracking branch 'origin/main' into baseten-provider
This commit is contained in:
commit
ac1e922917
11 changed files with 743 additions and 127 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -1,5 +1,23 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## [3.33.3] - 2025-11-20
|
||||
|
||||

|
||||
|
||||
- Add Google Gemini 3 Pro Image Preview to image generation models (PR #9440 by @app/roomote)
|
||||
- Add support for Minimax as Anthropic-compatible provider (PR #9455 by @daniel-lxs)
|
||||
- Store reasoning in conversation history for all providers (PR #9451 by @daniel-lxs)
|
||||
- Fix: Improve preserveReasoning flag to control API reasoning inclusion (PR #9453 by @daniel-lxs)
|
||||
- Fix: Prevent OpenAI Native parallel tool calls for native tool calling (PR #9433 by @hannesrudolph)
|
||||
- Fix: Improve search and replace symbol parsing (PR #9456 by @daniel-lxs)
|
||||
- Fix: Send tool_result blocks for skipped tools in native protocol (PR #9457 by @daniel-lxs)
|
||||
- Fix: Improve markdown formatting and add reasoning support (PR #9458 by @daniel-lxs)
|
||||
- Fix: Prevent duplicate environment_details when resuming cancelled tasks (PR #9442 by @daniel-lxs)
|
||||
- Improve read_file tool description with examples (PR #9422 by @daniel-lxs)
|
||||
- Update glob dependency to ^11.1.0 (PR #9449 by @jr)
|
||||
- Update tar-fs to 3.1.1 via pnpm override (PR #9450 by @app/roomote)
|
||||
|
||||
|
||||
## [3.33.2] - 2025-11-19
|
||||
|
||||
- Enable native tool calling for Gemini provider (PR #9343 by @hannesrudolph)
|
||||
|
|
|
|||
|
|
@ -610,7 +610,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
|
|||
*/
|
||||
|
||||
// Providers that use Anthropic-style API protocol.
|
||||
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock"]
|
||||
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock", "minimax"]
|
||||
|
||||
export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => {
|
||||
if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ export const minimaxModels = {
|
|||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsNativeTools: true,
|
||||
preserveReasoning: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.03,
|
||||
preserveReasoning: true,
|
||||
description:
|
||||
"MiniMax M2, a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
|
||||
},
|
||||
|
|
@ -26,14 +27,18 @@ export const minimaxModels = {
|
|||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsNativeTools: true,
|
||||
preserveReasoning: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.03,
|
||||
preserveReasoning: true,
|
||||
description:
|
||||
"MiniMax M2 Stable (High Concurrency, Commercial Use), a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const minimaxDefaultModelInfo: ModelInfo = minimaxModels[minimaxDefaultModelId]
|
||||
|
||||
export const MINIMAX_DEFAULT_MAX_TOKENS = 16_384
|
||||
export const MINIMAX_DEFAULT_TEMPERATURE = 1.0
|
||||
|
|
|
|||
BIN
releases/3.33.3-release.png
Normal file
BIN
releases/3.33.3-release.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -8,27 +8,35 @@ vitest.mock("vscode", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
|
||||
import { MiniMaxHandler } from "../minimax"
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const createMock = vitest.fn()
|
||||
vitest.mock("@anthropic-ai/sdk", () => {
|
||||
const mockCreate = vitest.fn()
|
||||
const mockCountTokens = vitest.fn()
|
||||
return {
|
||||
default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })),
|
||||
Anthropic: vitest.fn(() => ({
|
||||
messages: {
|
||||
create: mockCreate,
|
||||
countTokens: mockCountTokens,
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe("MiniMaxHandler", () => {
|
||||
let handler: MiniMaxHandler
|
||||
let mockCreate: any
|
||||
let mockCountTokens: any
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
mockCreate = (OpenAI as unknown as any)().chat.completions.create
|
||||
const anthropicInstance = (Anthropic as unknown as any)()
|
||||
mockCreate = anthropicInstance.messages.create
|
||||
mockCountTokens = anthropicInstance.messages.countTokens
|
||||
})
|
||||
|
||||
describe("International MiniMax (default)", () => {
|
||||
|
|
@ -41,9 +49,21 @@ describe("MiniMaxHandler", () => {
|
|||
|
||||
it("should use the correct international MiniMax base URL by default", () => {
|
||||
new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/v1",
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert /v1 endpoint to /anthropic endpoint", () => {
|
||||
new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimax.io/v1",
|
||||
})
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -51,7 +71,7 @@ describe("MiniMaxHandler", () => {
|
|||
it("should use the provided API key", () => {
|
||||
const minimaxApiKey = "test-minimax-api-key"
|
||||
new MiniMaxHandler({ minimaxApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
|
|
@ -117,13 +137,25 @@ describe("MiniMaxHandler", () => {
|
|||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimaxi.com/v1",
|
||||
})
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.minimaxi.com/v1" }))
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://api.minimaxi.com/anthropic" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert China /v1 endpoint to /anthropic endpoint", () => {
|
||||
new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimaxi.com/v1",
|
||||
})
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://api.minimaxi.com/anthropic" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key for China", () => {
|
||||
const minimaxApiKey = "test-minimax-api-key"
|
||||
new MiniMaxHandler({ minimaxApiKey, minimaxBaseUrl: "https://api.minimaxi.com/v1" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
|
|
@ -136,9 +168,9 @@ describe("MiniMaxHandler", () => {
|
|||
describe("Default behavior", () => {
|
||||
it("should default to international base URL when none is specified", () => {
|
||||
const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect(Anthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/v1",
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -161,7 +193,9 @@ describe("MiniMaxHandler", () => {
|
|||
|
||||
it("completePrompt method should return text from MiniMax API", async () => {
|
||||
const expectedResponse = "This is a test response from MiniMax"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: "text", text: expectedResponse }],
|
||||
})
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
|
@ -175,18 +209,20 @@ describe("MiniMaxHandler", () => {
|
|||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from MiniMax stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: testContent },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
|
|
@ -197,21 +233,24 @@ describe("MiniMaxHandler", () => {
|
|||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
|
|
@ -229,14 +268,12 @@ describe("MiniMaxHandler", () => {
|
|||
minimaxApiKey: "test-minimax-api-key",
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const systemPrompt = "Test system prompt for MiniMax"
|
||||
|
|
@ -250,23 +287,20 @@ describe("MiniMaxHandler", () => {
|
|||
model: modelId,
|
||||
max_tokens: Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)),
|
||||
temperature: 1,
|
||||
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
|
||||
system: expect.any(Array),
|
||||
messages: expect.any(Array),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should use temperature 1 by default", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test", [])
|
||||
|
|
@ -276,36 +310,74 @@ describe("MiniMaxHandler", () => {
|
|||
expect.objectContaining({
|
||||
temperature: 1,
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle streaming chunks with null choices array", async () => {
|
||||
const testContent = "Content after null choices"
|
||||
it("should handle thinking blocks in stream", async () => {
|
||||
const thinkingContent = "Let me think about this..."
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: null },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: thinkingContent },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
expect(firstChunk.value).toEqual({ type: "reasoning", text: thinkingContent })
|
||||
})
|
||||
|
||||
it("should handle tool calls in stream", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tool-123",
|
||||
name: "get_weather",
|
||||
input: { city: "London" },
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
type: "content_block_stop",
|
||||
index: 0,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "tool_call",
|
||||
id: "tool-123",
|
||||
name: "get_weather",
|
||||
arguments: JSON.stringify({ city: "London" }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,343 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
|
||||
import { CacheControlEphemeral } from "@anthropic-ai/sdk/resources"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { calculateApiCostAnthropic } from "../../shared/cost"
|
||||
import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters"
|
||||
|
||||
/**
|
||||
* Converts OpenAI tool_choice to Anthropic ToolChoice format
|
||||
*/
|
||||
function convertOpenAIToolChoice(
|
||||
toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"],
|
||||
): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined {
|
||||
if (!toolChoice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof toolChoice === "string") {
|
||||
switch (toolChoice) {
|
||||
case "none":
|
||||
return undefined // Anthropic doesn't have "none", just omit tools
|
||||
case "auto":
|
||||
return { type: "auto" }
|
||||
case "required":
|
||||
return { type: "any" }
|
||||
default:
|
||||
return { type: "auto" }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle object form { type: "function", function: { name: string } }
|
||||
if (typeof toolChoice === "object" && "function" in toolChoice) {
|
||||
return {
|
||||
type: "tool",
|
||||
name: toolChoice.function.name,
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "auto" }
|
||||
}
|
||||
|
||||
export class MiniMaxHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Anthropic
|
||||
|
||||
export class MiniMaxHandler extends BaseOpenAiCompatibleProvider<MinimaxModelId> {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "MiniMax",
|
||||
baseURL: options.minimaxBaseUrl ?? "https://api.minimax.io/v1",
|
||||
super()
|
||||
this.options = options
|
||||
|
||||
// Use Anthropic-compatible endpoint
|
||||
// Default to international endpoint: https://api.minimax.io/anthropic
|
||||
// China endpoint: https://api.minimaxi.com/anthropic
|
||||
let baseURL = options.minimaxBaseUrl || "https://api.minimax.io/anthropic"
|
||||
|
||||
// If user provided a /v1 endpoint, convert to /anthropic
|
||||
if (baseURL.endsWith("/v1")) {
|
||||
baseURL = baseURL.replace(/\/v1$/, "/anthropic")
|
||||
} else if (!baseURL.endsWith("/anthropic")) {
|
||||
baseURL = `${baseURL.replace(/\/$/, "")}/anthropic`
|
||||
}
|
||||
|
||||
this.client = new Anthropic({
|
||||
baseURL,
|
||||
apiKey: options.minimaxApiKey,
|
||||
defaultProviderModelId: minimaxDefaultModelId,
|
||||
providerModels: minimaxModels,
|
||||
defaultTemperature: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
|
||||
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
|
||||
const { id: modelId, info, maxTokens, temperature } = this.getModel()
|
||||
|
||||
// MiniMax M2 models support prompt caching
|
||||
const supportsPromptCache = info.supportsPromptCache ?? false
|
||||
|
||||
// Prepare request parameters
|
||||
const requestParams: Anthropic.Messages.MessageCreateParams = {
|
||||
model: modelId,
|
||||
max_tokens: maxTokens ?? 16_384,
|
||||
temperature: temperature ?? 1.0,
|
||||
system: supportsPromptCache
|
||||
? [{ text: systemPrompt, type: "text", cache_control: cacheControl }]
|
||||
: [{ text: systemPrompt, type: "text" }],
|
||||
messages: supportsPromptCache ? this.addCacheControl(messages, cacheControl) : messages,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
// Add tool support if provided - convert OpenAI format to Anthropic format
|
||||
// Only include native tools when toolProtocol is not 'xml'
|
||||
if (metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml") {
|
||||
requestParams.tools = convertOpenAIToolsToAnthropic(metadata.tools)
|
||||
|
||||
// Only add tool_choice if tools are present
|
||||
if (metadata?.tool_choice) {
|
||||
const convertedChoice = convertOpenAIToolChoice(metadata.tool_choice)
|
||||
if (convertedChoice) {
|
||||
requestParams.tool_choice = convertedChoice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream = await this.client.messages.create(requestParams)
|
||||
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cacheWriteTokens = 0
|
||||
let cacheReadTokens = 0
|
||||
|
||||
// Track tool calls being accumulated via streaming
|
||||
const toolCallAccumulator = new Map<number, { id: string; name: string; input: string }>()
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start": {
|
||||
// Tells us cache reads/writes/input/output.
|
||||
const {
|
||||
input_tokens = 0,
|
||||
output_tokens = 0,
|
||||
cache_creation_input_tokens,
|
||||
cache_read_input_tokens,
|
||||
} = chunk.message.usage
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: input_tokens,
|
||||
outputTokens: output_tokens,
|
||||
cacheWriteTokens: cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: cache_read_input_tokens || undefined,
|
||||
}
|
||||
|
||||
inputTokens += input_tokens
|
||||
outputTokens += output_tokens
|
||||
cacheWriteTokens += cache_creation_input_tokens || 0
|
||||
cacheReadTokens += cache_read_input_tokens || 0
|
||||
|
||||
break
|
||||
}
|
||||
case "message_delta":
|
||||
// Tells us stop_reason, stop_sequence, and output tokens
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
|
||||
break
|
||||
case "message_stop":
|
||||
// No usage data, just an indicator that the message is done.
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
// Yield thinking/reasoning content
|
||||
if (chunk.index > 0) {
|
||||
yield { type: "reasoning", text: "\n" }
|
||||
}
|
||||
|
||||
yield { type: "reasoning", text: chunk.content_block.thinking }
|
||||
break
|
||||
case "text":
|
||||
// We may receive multiple text blocks
|
||||
if (chunk.index > 0) {
|
||||
yield { type: "text", text: "\n" }
|
||||
}
|
||||
|
||||
yield { type: "text", text: chunk.content_block.text }
|
||||
break
|
||||
case "tool_use": {
|
||||
// Tool use block started - store initial data
|
||||
// If input is empty ({}), start with empty string as deltas will build it
|
||||
// Otherwise, stringify the initial input as a base for potential deltas
|
||||
const initialInput = chunk.content_block.input || {}
|
||||
const hasInitialContent = Object.keys(initialInput).length > 0
|
||||
toolCallAccumulator.set(chunk.index, {
|
||||
id: chunk.content_block.id,
|
||||
name: chunk.content_block.name,
|
||||
input: hasInitialContent ? JSON.stringify(initialInput) : "",
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield { type: "reasoning", text: chunk.delta.thinking }
|
||||
break
|
||||
case "text_delta":
|
||||
yield { type: "text", text: chunk.delta.text }
|
||||
break
|
||||
case "input_json_delta": {
|
||||
// Accumulate tool input JSON as it streams
|
||||
const existingToolCall = toolCallAccumulator.get(chunk.index)
|
||||
if (existingToolCall) {
|
||||
existingToolCall.input += chunk.delta.partial_json
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
case "content_block_stop": {
|
||||
// Block is complete - yield tool call if this was a tool_use block
|
||||
const completedToolCall = toolCallAccumulator.get(chunk.index)
|
||||
if (completedToolCall) {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
id: completedToolCall.id,
|
||||
name: completedToolCall.name,
|
||||
arguments: completedToolCall.input,
|
||||
}
|
||||
// Remove from accumulator after yielding
|
||||
toolCallAccumulator.delete(chunk.index)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate and yield final cost
|
||||
if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) {
|
||||
const { totalCost } = calculateApiCostAnthropic(
|
||||
this.getModel().info,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cache control to the last two user messages for prompt caching
|
||||
*/
|
||||
private addCacheControl(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
cacheControl: CacheControlEphemeral,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
return messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text", text: message.content, cache_control: cacheControl }]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? { ...content, cache_control: cacheControl }
|
||||
: content,
|
||||
),
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
getModel() {
|
||||
const modelId = this.options.apiModelId
|
||||
const id = modelId && modelId in minimaxModels ? (modelId as MinimaxModelId) : minimaxDefaultModelId
|
||||
const info = minimaxModels[id]
|
||||
|
||||
const params = getModelParams({
|
||||
format: "anthropic",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 1.0,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
info,
|
||||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string) {
|
||||
const { id: model, temperature } = this.getModel()
|
||||
|
||||
const message = await this.client.messages.create({
|
||||
model,
|
||||
max_tokens: 16_384,
|
||||
temperature: temperature ?? 1.0,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
})
|
||||
|
||||
const content = message.content.find(({ type }) => type === "text")
|
||||
return content?.type === "text" ? content.text : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts tokens for the given content using Anthropic's token counting
|
||||
* Falls back to base provider's tiktoken estimation if counting fails
|
||||
*/
|
||||
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
|
||||
try {
|
||||
const { id: model } = this.getModel()
|
||||
|
||||
const response = await this.client.messages.countTokens({
|
||||
model,
|
||||
messages: [{ role: "user", content: content }],
|
||||
})
|
||||
|
||||
return response.input_tokens
|
||||
} catch (error) {
|
||||
// Log error but fallback to tiktoken estimation
|
||||
console.warn("MiniMax token counting failed, using fallback", error)
|
||||
|
||||
// Use the base provider's implementation as fallback
|
||||
return super.countTokens(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,4 +202,159 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
|
|||
// Should have fallback text
|
||||
expect(toolResult.content).toBeTruthy()
|
||||
})
|
||||
|
||||
describe("Multiple tool calls handling", () => {
|
||||
it("should send tool_result with is_error for skipped tools in native protocol when didRejectTool is true", async () => {
|
||||
// Simulate multiple tool calls with native protocol (all have IDs)
|
||||
const toolCallId1 = "tool_call_001"
|
||||
const toolCallId2 = "tool_call_002"
|
||||
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId1,
|
||||
name: "read_file",
|
||||
params: { path: "test.txt" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId2,
|
||||
name: "write_to_file",
|
||||
params: { path: "output.txt", content: "test" },
|
||||
},
|
||||
]
|
||||
|
||||
// First tool is rejected
|
||||
mockTask.didRejectTool = true
|
||||
|
||||
// Process the second tool (should be skipped)
|
||||
mockTask.currentStreamingContentIndex = 1
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
// Find the tool_result for the second tool
|
||||
const toolResult = mockTask.userMessageContent.find(
|
||||
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId2,
|
||||
)
|
||||
|
||||
// Verify that a tool_result block was created (not a text block)
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult.tool_use_id).toBe(toolCallId2)
|
||||
expect(toolResult.is_error).toBe(true)
|
||||
expect(toolResult.content).toContain("due to user rejecting a previous tool")
|
||||
|
||||
// Ensure no text blocks were added for this rejection
|
||||
const textBlocks = mockTask.userMessageContent.filter(
|
||||
(item: any) => item.type === "text" && item.text.includes("due to user rejecting"),
|
||||
)
|
||||
expect(textBlocks.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should send tool_result with is_error for skipped tools in native protocol when didAlreadyUseTool is true", async () => {
|
||||
// Simulate multiple tool calls with native protocol
|
||||
const toolCallId1 = "tool_call_003"
|
||||
const toolCallId2 = "tool_call_004"
|
||||
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId1,
|
||||
name: "read_file",
|
||||
params: { path: "test.txt" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId2,
|
||||
name: "write_to_file",
|
||||
params: { path: "output.txt", content: "test" },
|
||||
},
|
||||
]
|
||||
|
||||
// First tool was already used
|
||||
mockTask.didAlreadyUseTool = true
|
||||
|
||||
// Process the second tool (should be skipped)
|
||||
mockTask.currentStreamingContentIndex = 1
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
// Find the tool_result for the second tool
|
||||
const toolResult = mockTask.userMessageContent.find(
|
||||
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId2,
|
||||
)
|
||||
|
||||
// Verify that a tool_result block was created (not a text block)
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult.tool_use_id).toBe(toolCallId2)
|
||||
expect(toolResult.is_error).toBe(true)
|
||||
expect(toolResult.content).toContain("was not executed because a tool has already been used")
|
||||
|
||||
// Ensure no text blocks were added for this rejection
|
||||
const textBlocks = mockTask.userMessageContent.filter(
|
||||
(item: any) => item.type === "text" && item.text.includes("was not executed because"),
|
||||
)
|
||||
expect(textBlocks.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should send text blocks for skipped tools in XML protocol (no tool IDs)", async () => {
|
||||
// Simulate multiple tool calls with XML protocol (no IDs)
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
// No ID = XML protocol
|
||||
name: "read_file",
|
||||
params: { path: "test.txt" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
// No ID = XML protocol
|
||||
name: "write_to_file",
|
||||
params: { path: "output.txt", content: "test" },
|
||||
},
|
||||
]
|
||||
|
||||
// First tool is rejected
|
||||
mockTask.didRejectTool = true
|
||||
|
||||
// Process the second tool (should be skipped)
|
||||
mockTask.currentStreamingContentIndex = 1
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
// For XML protocol, should add text block (not tool_result)
|
||||
const textBlocks = mockTask.userMessageContent.filter(
|
||||
(item: any) => item.type === "text" && item.text.includes("due to user rejecting"),
|
||||
)
|
||||
expect(textBlocks.length).toBeGreaterThan(0)
|
||||
|
||||
// Ensure no tool_result blocks were added
|
||||
const toolResults = mockTask.userMessageContent.filter((item: any) => item.type === "tool_result")
|
||||
expect(toolResults.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle partial tool blocks when didRejectTool is true in native protocol", async () => {
|
||||
const toolCallId = "tool_call_005"
|
||||
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "write_to_file",
|
||||
params: { path: "output.txt", content: "test" },
|
||||
partial: true, // Partial tool block
|
||||
},
|
||||
]
|
||||
|
||||
mockTask.didRejectTool = true
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
// Find the tool_result
|
||||
const toolResult = mockTask.userMessageContent.find(
|
||||
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
|
||||
)
|
||||
|
||||
// Verify tool_result was created for partial block
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult.is_error).toBe(true)
|
||||
expect(toolResult.content).toContain("was interrupted and not executed")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -252,16 +252,25 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
|
||||
if (cline.didRejectTool) {
|
||||
// Ignore any tool content after user has rejected tool once.
|
||||
if (!block.partial) {
|
||||
// For native protocol, we must send a tool_result for every tool_use to avoid API errors
|
||||
const toolCallId = block.id
|
||||
const errorMessage = !block.partial
|
||||
? `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`
|
||||
: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`
|
||||
|
||||
if (toolCallId) {
|
||||
// Native protocol: MUST send tool_result for every tool_use
|
||||
cline.userMessageContent.push({
|
||||
type: "text",
|
||||
text: `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`,
|
||||
})
|
||||
type: "tool_result",
|
||||
tool_use_id: toolCallId,
|
||||
content: errorMessage,
|
||||
is_error: true,
|
||||
} as Anthropic.ToolResultBlockParam)
|
||||
} else {
|
||||
// Partial tool after user rejected a previous tool.
|
||||
// XML protocol: send as text
|
||||
cline.userMessageContent.push({
|
||||
type: "text",
|
||||
text: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`,
|
||||
text: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -270,10 +279,25 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
|
||||
if (cline.didAlreadyUseTool) {
|
||||
// Ignore any content after a tool has already been used.
|
||||
cline.userMessageContent.push({
|
||||
type: "text",
|
||||
text: `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
|
||||
})
|
||||
// For native protocol, we must send a tool_result for every tool_use to avoid API errors
|
||||
const toolCallId = block.id
|
||||
const errorMessage = `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`
|
||||
|
||||
if (toolCallId) {
|
||||
// Native protocol: MUST send tool_result for every tool_use
|
||||
cline.userMessageContent.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: toolCallId,
|
||||
content: errorMessage,
|
||||
is_error: true,
|
||||
} as Anthropic.ToolResultBlockParam)
|
||||
} else {
|
||||
// XML protocol: send as text
|
||||
cline.userMessageContent.push({
|
||||
type: "text",
|
||||
text: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,14 +251,16 @@ Each file requires its own path, start_line, and diff elements.
|
|||
|
||||
const state = { current: State.START, line: 0 }
|
||||
|
||||
// Pattern allows optional '>' after SEARCH to handle AI-generated diffs
|
||||
// (e.g., Sonnet 4 sometimes adds an extra '>')
|
||||
const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/
|
||||
const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display
|
||||
// Pattern allows optional extra '<' or '>' for SEARCH to handle AI-generated diffs
|
||||
// (e.g., Sonnet 4 sometimes adds extra markers)
|
||||
const SEARCH_PATTERN = /^<{7,8} SEARCH>?$/
|
||||
const SEARCH = "<<<<<<< SEARCH" // Simplified for display
|
||||
const SEP = "======="
|
||||
const REPLACE = ">>>>>>> REPLACE"
|
||||
const SEARCH_PREFIX = "<<<<<<< "
|
||||
const REPLACE_PREFIX = ">>>>>>> "
|
||||
// Pattern allows optional extra '>' or '<' for REPLACE
|
||||
const REPLACE_PATTERN = /^>{7,8} REPLACE<?$/
|
||||
const REPLACE = ">>>>>>> REPLACE" // Simplified for display
|
||||
const SEARCH_PREFIX_PATTERN = /^<{7,8} /
|
||||
const REPLACE_PREFIX_PATTERN = /^>{7,8} /
|
||||
|
||||
const reportMergeConflictError = (found: string, _expected: string) => ({
|
||||
success: false,
|
||||
|
|
@ -326,7 +328,7 @@ Each file requires its own path, start_line, and diff elements.
|
|||
const lines = diffContent.split("\n")
|
||||
const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length
|
||||
const sepCount = lines.filter((l) => l.trim() === SEP).length
|
||||
const replaceCount = lines.filter((l) => l.trim() === REPLACE).length
|
||||
const replaceCount = lines.filter((l) => REPLACE_PATTERN.test(l.trim())).length
|
||||
|
||||
const likelyBadStructure = searchCount !== replaceCount || sepCount < searchCount
|
||||
|
||||
|
|
@ -350,29 +352,29 @@ Each file requires its own path, start_line, and diff elements.
|
|||
return likelyBadStructure
|
||||
? reportInvalidDiffError(SEP, SEARCH)
|
||||
: reportMergeConflictError(SEP, SEARCH)
|
||||
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH)
|
||||
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
|
||||
if (REPLACE_PATTERN.test(marker)) return reportInvalidDiffError(REPLACE, SEARCH)
|
||||
if (REPLACE_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
|
||||
if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH
|
||||
else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
|
||||
else if (SEARCH_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
|
||||
break
|
||||
|
||||
case State.AFTER_SEARCH:
|
||||
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP)
|
||||
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
|
||||
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP)
|
||||
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
|
||||
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH, SEP)
|
||||
if (SEARCH_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
|
||||
if (REPLACE_PATTERN.test(marker)) return reportInvalidDiffError(REPLACE, SEP)
|
||||
if (REPLACE_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
|
||||
if (marker === SEP) state.current = State.AFTER_SEPARATOR
|
||||
break
|
||||
|
||||
case State.AFTER_SEPARATOR:
|
||||
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE)
|
||||
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE)
|
||||
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH, REPLACE)
|
||||
if (SEARCH_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, REPLACE)
|
||||
if (marker === SEP)
|
||||
return likelyBadStructure
|
||||
? reportInvalidDiffError(SEP, REPLACE)
|
||||
: reportMergeConflictError(SEP, REPLACE)
|
||||
if (marker === REPLACE) state.current = State.START
|
||||
else if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, REPLACE)
|
||||
if (REPLACE_PATTERN.test(marker)) state.current = State.START
|
||||
else if (REPLACE_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, REPLACE)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -451,18 +453,18 @@ Each file requires its own path, start_line, and diff elements.
|
|||
|
||||
/* Regex parts:
|
||||
1. (?:^|\n) Ensures the first marker starts at the beginning of the file or right after a newline.
|
||||
2. (?<!\\)<<<<<<< SEARCH>?\s*\n Matches the line "<<<<<<< SEARCH" with optional '>' (ignoring any trailing spaces) – the negative lookbehind makes sure it isn't escaped.
|
||||
2. (?<!\\)<{7,8} SEARCH>?\s*\n Matches "<<<<<<< SEARCH" or "<<<<<<< SEARCH>" or "<<<<<<<<" with 7-8 '<' chars (ignoring any trailing spaces) – the negative lookbehind makes sure it isn't escaped.
|
||||
3. ((?:\:start_line:\s*(\d+)\s*\n))? Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2.
|
||||
4. ((?:\:end_line:\s*(\d+)\s*\n))? Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits.
|
||||
5. ((?<!\\)-------\s*\n)? Optionally matches the "-------" marker line (group 5).
|
||||
6. ([\s\S]*?)(?:\n)? Non‐greedy match for the "search content" (group 6) up to the next marker.
|
||||
7. (?:(?<=\n)(?<!\\)=======\s*\n) Matches the "=======" marker on its own line.
|
||||
8. ([\s\S]*?)(?:\n)? Non‐greedy match for the "replace content" (group 7).
|
||||
9. (?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$) Matches the final ">>>>>>> REPLACE" marker on its own line (and requires a following newline or the end of file).
|
||||
9. (?:(?<=\n)(?<!\\)>{7,8} REPLACE<?)(?=\n|$) Matches ">>>>>>> REPLACE" or ">>>>>>> REPLACE<" or ">>>>>>>>" with 7-8 '>' chars on its own line (and requires a following newline or the end of file).
|
||||
*/
|
||||
let matches = [
|
||||
...diffContent.matchAll(
|
||||
/(?:^|\n)(?<!\\)<<<<<<< SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
|
||||
/(?:^|\n)(?<!\\)<{7,8} SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>{7,8} REPLACE<?)(?=\n|$)/g,
|
||||
),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ import os from "os"
|
|||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// Extended content block types to support new Anthropic API features
|
||||
interface ReasoningBlock {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
type ExtendedContentBlock = Anthropic.Messages.ContentBlockParam | ReasoningBlock
|
||||
|
||||
export async function downloadTask(dateTs: number, conversationHistory: Anthropic.MessageParam[]) {
|
||||
// File name
|
||||
const date = new Date(dateTs)
|
||||
|
|
@ -22,7 +30,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
|
|||
.map((message) => {
|
||||
const role = message.role === "user" ? "**User:**" : "**Assistant:**"
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content.map((block) => formatContentBlockToMarkdown(block)).join("\n")
|
||||
? message.content.map((block) => formatContentBlockToMarkdown(block as ExtendedContentBlock)).join("\n")
|
||||
: message.content
|
||||
return `${role}\n\n${content}\n\n`
|
||||
})
|
||||
|
|
@ -41,7 +49,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
|
|||
}
|
||||
}
|
||||
|
||||
export function formatContentBlockToMarkdown(block: Anthropic.Messages.ContentBlockParam): string {
|
||||
export function formatContentBlockToMarkdown(block: ExtendedContentBlock): string {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text
|
||||
|
|
@ -51,7 +59,13 @@ export function formatContentBlockToMarkdown(block: Anthropic.Messages.ContentBl
|
|||
let input: string
|
||||
if (typeof block.input === "object" && block.input !== null) {
|
||||
input = Object.entries(block.input)
|
||||
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
|
||||
.map(([key, value]) => {
|
||||
const formattedKey = key.charAt(0).toUpperCase() + key.slice(1)
|
||||
// Handle nested objects/arrays by JSON stringifying them
|
||||
const formattedValue =
|
||||
typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value)
|
||||
return `${formattedKey}: ${formattedValue}`
|
||||
})
|
||||
.join("\n")
|
||||
} else {
|
||||
input = String(block.input)
|
||||
|
|
@ -72,8 +86,10 @@ export function formatContentBlockToMarkdown(block: Anthropic.Messages.ContentBl
|
|||
return `[${toolName}${block.is_error ? " (Error)" : ""}]`
|
||||
}
|
||||
}
|
||||
case "reasoning":
|
||||
return `[Reasoning]\n${block.text}`
|
||||
default:
|
||||
return "[Unexpected content type]"
|
||||
return `[Unexpected content type: ${block.type}]`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"displayName": "%extension.displayName%",
|
||||
"description": "%extension.description%",
|
||||
"publisher": "RooVeterinaryInc",
|
||||
"version": "3.33.2",
|
||||
"version": "3.33.3",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue