From 1e790b0d39e0fd1737a52feaabb1fafc5a38e351 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:30:07 -0500 Subject: [PATCH 01/65] fix(code-index): remove deprecated text-embedding-004 and migrate to gemini-embedding-001 (#11038) Co-authored-by: Roo Code Co-authored-by: Hannes Rudolph --- .../__tests__/service-factory.spec.ts | 21 +++- .../embedders/__tests__/gemini.spec.ts | 27 +++++- src/services/code-index/embedders/gemini.ts | 29 +++++- src/shared/__tests__/embeddingModels.spec.ts | 95 +++++++++++++++++++ src/shared/embeddingModels.ts | 4 +- 5 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 src/shared/__tests__/embeddingModels.spec.ts diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 1d8f7ba478..3e943ebd82 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -286,7 +286,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testConfig = { embedderProvider: "gemini", - modelId: "text-embedding-004", + modelId: "gemini-embedding-001", geminiOptions: { apiKey: "test-gemini-api-key", }, @@ -297,6 +297,25 @@ describe("CodeIndexServiceFactory", () => { factory.createEmbedder() // Assert + expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "gemini-embedding-001") + }) + + it("should pass deprecated text-embedding-004 modelId to GeminiEmbedder (migration happens inside GeminiEmbedder)", () => { + // Arrange - service-factory passes the config modelId directly; + // GeminiEmbedder handles the migration internally + const testConfig = { + embedderProvider: "gemini", + modelId: "text-embedding-004", + geminiOptions: { + apiKey: "test-gemini-api-key", + }, + } + mockConfigManager.getConfig.mockReturnValue(testConfig as any) + + // Act + factory.createEmbedder() + + // Assert - factory passes the original modelId; GeminiEmbedder migrates it internally expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004") }) diff --git a/src/services/code-index/embedders/__tests__/gemini.spec.ts b/src/services/code-index/embedders/__tests__/gemini.spec.ts index d41a4dc1e9..d84dcd8abc 100644 --- a/src/services/code-index/embedders/__tests__/gemini.spec.ts +++ b/src/services/code-index/embedders/__tests__/gemini.spec.ts @@ -44,7 +44,7 @@ describe("GeminiEmbedder", () => { it("should create an instance with specified model", () => { // Arrange const apiKey = "test-gemini-api-key" - const modelId = "text-embedding-004" + const modelId = "gemini-embedding-001" // Act embedder = new GeminiEmbedder(apiKey, modelId) @@ -53,7 +53,24 @@ describe("GeminiEmbedder", () => { expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( "https://generativelanguage.googleapis.com/v1beta/openai/", apiKey, - "text-embedding-004", + "gemini-embedding-001", + 2048, + ) + }) + + it("should migrate deprecated text-embedding-004 to gemini-embedding-001", () => { + // Arrange + const apiKey = "test-gemini-api-key" + const deprecatedModelId = "text-embedding-004" + + // Act + embedder = new GeminiEmbedder(apiKey, deprecatedModelId) + + // Assert - should be migrated to gemini-embedding-001 + expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/openai/", + apiKey, + "gemini-embedding-001", 2048, ) }) @@ -109,8 +126,8 @@ describe("GeminiEmbedder", () => { }) it("should use provided model parameter when specified", async () => { - // Arrange - embedder = new GeminiEmbedder("test-api-key", "text-embedding-004") + // Arrange - even with deprecated model in constructor, the runtime parameter takes precedence + embedder = new GeminiEmbedder("test-api-key", "gemini-embedding-001") const texts = ["test text 1", "test text 2"] const mockResponse = { embeddings: [ @@ -120,7 +137,7 @@ describe("GeminiEmbedder", () => { } mockCreateEmbeddings.mockResolvedValue(mockResponse) - // Act + // Act - specify a different model at runtime const result = await embedder.createEmbeddings(texts, "gemini-embedding-001") // Assert diff --git a/src/services/code-index/embedders/gemini.ts b/src/services/code-index/embedders/gemini.ts index 7e795875c9..03bfc35aae 100644 --- a/src/services/code-index/embedders/gemini.ts +++ b/src/services/code-index/embedders/gemini.ts @@ -10,15 +10,33 @@ import { TelemetryService } from "@roo-code/telemetry" * with configuration for Google's Gemini embedding API. * * Supported models: - * - text-embedding-004 (dimension: 768) - * - gemini-embedding-001 (dimension: 2048) + * - gemini-embedding-001 (dimension: 3072) + * + * Note: text-embedding-004 has been deprecated and is automatically + * migrated to gemini-embedding-001 for backward compatibility. */ export class GeminiEmbedder implements IEmbedder { private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder private static readonly GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/" private static readonly DEFAULT_MODEL = "gemini-embedding-001" + /** + * Deprecated models that are automatically migrated to their replacements. + * Users with these models configured will be silently migrated without interruption. + */ + private static readonly DEPRECATED_MODEL_MIGRATIONS: Record = { + "text-embedding-004": "gemini-embedding-001", + } private readonly modelId: string + /** + * Migrates deprecated model IDs to their replacements. + * @param modelId The model ID to potentially migrate + * @returns The migrated model ID, or the original if no migration is needed + */ + private static migrateModelId(modelId: string): string { + return GeminiEmbedder.DEPRECATED_MODEL_MIGRATIONS[modelId] ?? modelId + } + /** * Creates a new Gemini embedder * @param apiKey The Gemini API key for authentication @@ -29,8 +47,11 @@ export class GeminiEmbedder implements IEmbedder { throw new Error(t("embeddings:validation.apiKeyRequired")) } - // Use provided model or default - this.modelId = modelId || GeminiEmbedder.DEFAULT_MODEL + // Migrate deprecated models to their replacements silently + const migratedModelId = modelId ? GeminiEmbedder.migrateModelId(modelId) : undefined + + // Use provided model (after migration) or default + this.modelId = migratedModelId || GeminiEmbedder.DEFAULT_MODEL // Create an OpenAI Compatible embedder with Gemini's configuration this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder( diff --git a/src/shared/__tests__/embeddingModels.spec.ts b/src/shared/__tests__/embeddingModels.spec.ts new file mode 100644 index 0000000000..16aa019c7f --- /dev/null +++ b/src/shared/__tests__/embeddingModels.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest" +import { + getModelDimension, + getModelScoreThreshold, + getDefaultModelId, + EMBEDDING_MODEL_PROFILES, +} from "../embeddingModels" + +describe("embeddingModels", () => { + describe("EMBEDDING_MODEL_PROFILES", () => { + it("should have gemini provider with gemini-embedding-001 model", () => { + const geminiProfiles = EMBEDDING_MODEL_PROFILES.gemini + expect(geminiProfiles).toBeDefined() + expect(geminiProfiles!["gemini-embedding-001"]).toBeDefined() + expect(geminiProfiles!["gemini-embedding-001"].dimension).toBe(3072) + }) + + it("should have deprecated text-embedding-004 in gemini profiles for backward compatibility", () => { + // This is critical for backward compatibility: + // Users with text-embedding-004 configured need dimension lookup to work + // even though the model is migrated to gemini-embedding-001 in GeminiEmbedder + const geminiProfiles = EMBEDDING_MODEL_PROFILES.gemini + expect(geminiProfiles).toBeDefined() + expect(geminiProfiles!["text-embedding-004"]).toBeDefined() + expect(geminiProfiles!["text-embedding-004"].dimension).toBe(3072) + }) + }) + + describe("getModelDimension", () => { + it("should return dimension for gemini-embedding-001", () => { + const dimension = getModelDimension("gemini", "gemini-embedding-001") + expect(dimension).toBe(3072) + }) + + it("should return dimension for deprecated text-embedding-004", () => { + // This ensures createVectorStore() works for users with text-embedding-004 configured + // The dimension should be 3072 (matching gemini-embedding-001) because: + // 1. GeminiEmbedder migrates text-embedding-004 to gemini-embedding-001 + // 2. gemini-embedding-001 produces 3072-dimensional embeddings + // 3. Vector store dimension must match the actual embedding dimension + const dimension = getModelDimension("gemini", "text-embedding-004") + expect(dimension).toBe(3072) + }) + + it("should return undefined for unknown model", () => { + const dimension = getModelDimension("gemini", "unknown-model") + expect(dimension).toBeUndefined() + }) + + it("should return undefined for unknown provider", () => { + const dimension = getModelDimension("unknown-provider" as any, "some-model") + expect(dimension).toBeUndefined() + }) + + it("should return correct dimensions for openai models", () => { + expect(getModelDimension("openai", "text-embedding-3-small")).toBe(1536) + expect(getModelDimension("openai", "text-embedding-3-large")).toBe(3072) + expect(getModelDimension("openai", "text-embedding-ada-002")).toBe(1536) + }) + }) + + describe("getModelScoreThreshold", () => { + it("should return score threshold for gemini-embedding-001", () => { + const threshold = getModelScoreThreshold("gemini", "gemini-embedding-001") + expect(threshold).toBe(0.4) + }) + + it("should return score threshold for deprecated text-embedding-004", () => { + const threshold = getModelScoreThreshold("gemini", "text-embedding-004") + expect(threshold).toBe(0.4) + }) + + it("should return undefined for unknown model", () => { + const threshold = getModelScoreThreshold("gemini", "unknown-model") + expect(threshold).toBeUndefined() + }) + }) + + describe("getDefaultModelId", () => { + it("should return gemini-embedding-001 for gemini provider", () => { + const defaultModel = getDefaultModelId("gemini") + expect(defaultModel).toBe("gemini-embedding-001") + }) + + it("should return text-embedding-3-small for openai provider", () => { + const defaultModel = getDefaultModelId("openai") + expect(defaultModel).toBe("text-embedding-3-small") + }) + + it("should return codestral-embed-2505 for mistral provider", () => { + const defaultModel = getDefaultModelId("mistral") + expect(defaultModel).toBe("codestral-embed-2505") + }) + }) +}) diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index a4c5217a9d..0b59c5b4b2 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -34,8 +34,10 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { }, }, gemini: { - "text-embedding-004": { dimension: 768 }, "gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.4 }, + // Deprecated: text-embedding-004 is migrated to gemini-embedding-001 in GeminiEmbedder + // Kept here for backward-compatible dimension lookup in createVectorStore() + "text-embedding-004": { dimension: 3072, scoreThreshold: 0.4 }, }, mistral: { "codestral-embed-2505": { dimension: 1536, scoreThreshold: 0.4 }, From b4b8cef859933c12b412fab7211276af1906c03e Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:31:09 -0500 Subject: [PATCH 02/65] fix: transform tool blocks to text before condensing (EXT-624) (#10975) --- src/core/condense/__tests__/index.spec.ts | 307 ++++++++++++++++++++++ src/core/condense/index.ts | 104 +++++++- 2 files changed, 409 insertions(+), 2 deletions(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 75190985db..10092f71dc 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -15,6 +15,10 @@ import { cleanupAfterTruncation, extractCommandBlocks, injectSyntheticToolResults, + toolUseToText, + toolResultToText, + convertToolBlocksToText, + transformMessagesForCondensing, } from "../index" vi.mock("../../../api/transform/image-cleaning", () => ({ @@ -1282,3 +1286,306 @@ describe("summarizeConversation with custom settings", () => { ) }) }) + +describe("toolUseToText", () => { + it("should convert tool_use block with object input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts", encoding: "utf-8" }, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: read_file]\npath: test.ts\nencoding: utf-8") + }) + + it("should convert tool_use block with nested object input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-456", + name: "write_file", + input: { + path: "output.json", + content: { key: "value", nested: { a: 1 } }, + }, + } + + const result = toolUseToText(block) + + expect(result).toContain("[Tool Use: write_file]") + expect(result).toContain("path: output.json") + expect(result).toContain("content:") + expect(result).toContain('"key"') + expect(result).toContain('"value"') + }) + + it("should convert tool_use block with string input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-789", + name: "execute_command", + input: "ls -la" as unknown as Record, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: execute_command]\nls -la") + }) + + it("should handle empty object input", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-empty", + name: "some_tool", + input: {}, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: some_tool]\n") + }) +}) + +describe("toolResultToText", () => { + it("should convert tool_result with string content to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents here", + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nFile contents here") + }) + + it("should convert tool_result with error flag to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-456", + content: "File not found", + is_error: true, + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result (Error)]\nFile not found") + }) + + it("should convert tool_result with array content to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-789", + content: [ + { type: "text", text: "First line" }, + { type: "text", text: "Second line" }, + ], + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nFirst line\nSecond line") + }) + + it("should handle tool_result with image in array content", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-img", + content: [ + { type: "text", text: "Screenshot:" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } }, + ], + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nScreenshot:\n[Image]") + }) + + it("should handle tool_result with no content", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-empty", + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]") + }) +}) + +describe("convertToolBlocksToText", () => { + it("should return string content unchanged", () => { + const content = "Simple text content" + + const result = convertToolBlocksToText(content) + + expect(result).toBe("Simple text content") + }) + + it("should convert tool_use blocks to text blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Use: read_file]") + }) + + it("should convert tool_result blocks to text blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents", + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Result]") + }) + + it("should preserve non-tool blocks unchanged", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: "Hello" }, + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + { type: "text", text: "World" }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + const resultArray = result as Anthropic.Messages.ContentBlockParam[] + expect(resultArray).toHaveLength(3) + expect(resultArray[0]).toEqual({ type: "text", text: "Hello" }) + expect(resultArray[1].type).toBe("text") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect(resultArray[2]).toEqual({ type: "text", text: "World" }) + }) + + it("should handle mixed content with multiple tool blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "a.ts" }, + }, + { + type: "tool_result", + tool_use_id: "tool-1", + content: "contents of a.ts", + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + const resultArray = result as Anthropic.Messages.ContentBlockParam[] + expect(resultArray).toHaveLength(2) + expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts") + }) +}) + +describe("transformMessagesForCondensing", () => { + it("should transform all messages with tool blocks to text", () => { + const messages = [ + { role: "user" as const, content: "Hello" }, + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "tool-1", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + tool_use_id: "tool-1", + content: "file contents", + }, + ], + }, + ] + + const result = transformMessagesForCondensing(messages) + + expect(result).toHaveLength(3) + expect(result[0].content).toBe("Hello") + expect(Array.isArray(result[1].content)).toBe(true) + expect((result[1].content as any[])[0].type).toBe("text") + expect((result[1].content as any[])[0].text).toContain("[Tool Use: read_file]") + expect(Array.isArray(result[2].content)).toBe(true) + expect((result[2].content as any[])[0].type).toBe("text") + expect((result[2].content as any[])[0].text).toContain("[Tool Result]") + }) + + it("should preserve message role and other properties", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "tool-1", + name: "execute", + input: { cmd: "ls" }, + }, + ], + }, + ] + + const result = transformMessagesForCondensing(messages) + + expect(result[0].role).toBe("assistant") + }) + + it("should handle empty messages array", () => { + const result = transformMessagesForCondensing([]) + + expect(result).toEqual([]) + }) + + it("should not mutate original messages", () => { + const originalContent = [ + { + type: "tool_use" as const, + id: "tool-1", + name: "read_file", + input: { path: "test.ts" }, + }, + ] + const messages = [{ role: "assistant" as const, content: originalContent }] + + transformMessagesForCondensing(messages) + + // Original should still have tool_use type + expect(messages[0].content[0].type).toBe("tool_use") + }) +}) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 5a65f0a96f..0438bf6bcb 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -14,6 +14,100 @@ import { generateFoldedFileContext } from "./foldedFileContext" export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext" +/** + * Converts a tool_use block to a text representation. + * This allows the conversation to be summarized without requiring the tools parameter. + */ +export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string { + let input: string + if (typeof block.input === "object" && block.input !== null) { + input = Object.entries(block.input) + .map(([key, value]) => { + const formattedValue = + typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) + return `${key}: ${formattedValue}` + }) + .join("\n") + } else { + input = String(block.input) + } + return `[Tool Use: ${block.name}]\n${input}` +} + +/** + * Converts a tool_result block to a text representation. + * This allows the conversation to be summarized without requiring the tools parameter. + */ +export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string { + const errorSuffix = block.is_error ? " (Error)" : "" + if (typeof block.content === "string") { + return `[Tool Result${errorSuffix}]\n${block.content}` + } else if (Array.isArray(block.content)) { + const contentText = block.content + .map((contentBlock) => { + if (contentBlock.type === "text") { + return contentBlock.text + } + if (contentBlock.type === "image") { + return "[Image]" + } + // Handle any other content block types + return `[${(contentBlock as { type: string }).type}]` + }) + .join("\n") + return `[Tool Result${errorSuffix}]\n${contentText}` + } + return `[Tool Result${errorSuffix}]` +} + +/** + * Converts all tool_use and tool_result blocks in a message's content to text representations. + * This is necessary for providers like Bedrock that require the tools parameter when tool blocks are present. + * By converting to text, we can send the conversation for summarization without the tools parameter. + * + * @param content - The message content (string or array of content blocks) + * @returns The transformed content with tool blocks converted to text blocks + */ +export function convertToolBlocksToText( + content: string | Anthropic.Messages.ContentBlockParam[], +): string | Anthropic.Messages.ContentBlockParam[] { + if (typeof content === "string") { + return content + } + + return content.map((block) => { + if (block.type === "tool_use") { + return { + type: "text" as const, + text: toolUseToText(block), + } + } + if (block.type === "tool_result") { + return { + type: "text" as const, + text: toolResultToText(block), + } + } + return block + }) +} + +/** + * Transforms all messages by converting tool_use and tool_result blocks to text representations. + * This ensures the conversation can be sent for summarization without requiring the tools parameter. + * + * @param messages - The messages to transform + * @returns The transformed messages with tool blocks converted to text + */ +export function transformMessagesForCondensing< + T extends { role: string; content: string | Anthropic.Messages.ContentBlockParam[] }, +>(messages: T[]): T[] { + return messages.map((msg) => ({ + ...msg, + content: convertToolBlocksToText(msg.content), + })) +} + export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing @@ -213,10 +307,16 @@ export async function summarizeConversation(options: SummarizeConversationOption // (e.g., when user triggers condense after receiving attempt_completion but before responding) const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize) - const requestMessages = maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler).map( - ({ role, content }) => ({ role, content }), + // Transform tool_use and tool_result blocks to text representations. + // This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter + // when tool blocks are present. By converting them to text, we can send the conversation for + // summarization without needing to pass the tools parameter. + const messagesWithTextToolBlocks = transformMessagesForCondensing( + maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler), ) + const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content })) + // Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt const promptToUse = SUMMARY_PROMPT From c5874fc7642df6aedc2c3361a885cc8569df87e0 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:02:32 -0500 Subject: [PATCH 03/65] feat: migrate SambaNova provider to AI SDK (#11153) Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- pnpm-lock.yaml | 90 ++- src/api/providers/__tests__/sambanova.spec.ts | 718 +++++++++++++++--- src/api/providers/sambanova.ts | 181 ++++- src/api/transform/__tests__/ai-sdk.spec.ts | 174 +++++ src/api/transform/ai-sdk.ts | 92 ++- src/package.json | 1 + 6 files changed, 1117 insertions(+), 139 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41db99fb0e..4cf3a3627e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -760,7 +760,7 @@ importers: version: 3.0.19(zod@3.25.76) '@ai-sdk/mistral': specifier: ^3.0.0 - version: 3.0.16(zod@3.25.76) + version: 3.0.18(zod@3.25.76) '@anthropic-ai/bedrock-sdk': specifier: ^0.10.2 version: 0.10.4 @@ -938,6 +938,9 @@ importers: safe-stable-stringify: specifier: ^2.5.0 version: 2.5.0 + sambanova-ai-provider: + specifier: ^1.2.2 + version: 1.2.2(zod@3.25.76) sanitize-filename: specifier: ^1.6.3 version: 1.6.3 @@ -1435,8 +1438,14 @@ packages: peerDependencies: zod: 3.25.76 - '@ai-sdk/mistral@3.0.16': - resolution: {integrity: sha512-8I/gxXJwghaDLbQQHMBwd61WxYz/PaFUFlG8I38daNYj5qRTMmQ5V10Idi6GJJC0wWEqQkal31lidm9+Y+u6TQ==} + '@ai-sdk/mistral@3.0.18': + resolution: {integrity: sha512-k8nCBBVGOzBigNwBO5kREzsP/e+C3npcL7jt19ZdicIbZ6rvmnSIRI90iENyS9T10vM7sjrXoCpgZSYgJB2pJQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai-compatible@1.0.11': + resolution: {integrity: sha512-eRD6dZviy31KYz4YvxAR/c6UEYx3p4pCiWZeDdYdAHj0rn8xZlGVxtQRs1qynhz6IYGOo4aLBf9zVW5w0tI/Uw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -1459,6 +1468,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.5': + resolution: {integrity: sha512-HliwB/yzufw3iwczbFVE2Fiwf1XqROB/I6ng8EKUsPM5+2wnIa8f4VbljZcDx+grhFrPV+PnRZH7zBqi8WZM7Q==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.10': resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==} engines: {node: '>=18'} @@ -1471,6 +1486,16 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.13': + resolution: {integrity: sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + engines: {node: '>=18'} + '@ai-sdk/provider@2.0.1': resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} engines: {node: '>=18'} @@ -1483,6 +1508,10 @@ packages: resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.7': + resolution: {integrity: sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.3': resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==} engines: {node: '>=18'} @@ -6066,6 +6095,10 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + dotenv@16.5.0: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} @@ -9520,6 +9553,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sambanova-ai-provider@1.2.2: + resolution: {integrity: sha512-MU/D+9GCg6me0guDRPw/x0N8cnpkOkv03FR7QXdrcinX0hprS7bsZXXTYEz81Svc+oVwXDZwh0v+Sd5pUxV3mg==} + sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} @@ -11086,10 +11122,16 @@ snapshots: '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/mistral@3.0.16(zod@3.25.76)': + '@ai-sdk/mistral@3.0.18(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/openai-compatible@1.0.11(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) zod: 3.25.76 '@ai-sdk/openai-compatible@1.0.31(zod@3.25.76)': @@ -11111,6 +11153,14 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.5(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) + '@ai-sdk/provider-utils@4.0.10(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.5 @@ -11125,6 +11175,17 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.13(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.7 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@2.0.0': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@2.0.1': dependencies: json-schema: 0.4.0 @@ -11137,6 +11198,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.7': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.3': dependencies: ansi-styles: 6.2.3 @@ -15113,7 +15178,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -16469,6 +16534,8 @@ snapshots: dotenv@16.0.3: {} + dotenv@16.4.5: {} + dotenv@16.5.0: {} drizzle-kit@0.31.4: @@ -20622,6 +20689,15 @@ snapshots: safer-buffer@2.1.2: {} + sambanova-ai-provider@1.2.2(zod@3.25.76): + dependencies: + '@ai-sdk/openai-compatible': 1.0.11(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) + dotenv: 16.4.5 + transitivePeerDependencies: + - zod + sanitize-filename@1.6.3: dependencies: truncate-utf8-bytes: 1.0.2 diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 685cedf34c..51bc256b76 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -1,152 +1,628 @@ // npx vitest run src/api/providers/__tests__/sambanova.spec.ts -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from "@roo-code/types" - -import { SambaNovaHandler } from "../sambanova" - -vitest.mock("openai", () => { - const createMock = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("sambanova-ai-provider", () => ({ + createSambaNova: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "Meta-Llama-3.3-70B-Instruct", + provider: "sambanova", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { sambaNovaDefaultModelId, sambaNovaModels, type SambaNovaModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { SambaNovaHandler } from "../sambanova" + describe("SambaNovaHandler", () => { let handler: SambaNovaHandler - let mockCreate: any + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - mockCreate = (OpenAI as unknown as any)().chat.completions.create - handler = new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" }) - }) - - it("should use the correct SambaNova base URL", () => { - new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.sambanova.ai/v1" })) - }) - - it("should use the provided API key", () => { - const sambaNovaApiKey = "test-sambanova-api-key" - new SambaNovaHandler({ sambaNovaApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: sambaNovaApiKey })) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(sambaNovaDefaultModelId) - expect(model.info).toEqual(sambaNovaModels[sambaNovaDefaultModelId]) - }) - - it("should return specified model when valid model is provided", () => { - const testModelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct" - const handlerWithModel = new SambaNovaHandler({ - apiModelId: testModelId, + mockOptions = { sambaNovaApiKey: "test-sambanova-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(sambaNovaModels[testModelId]) + apiModelId: "Meta-Llama-3.3-70B-Instruct", + } + handler = new SambaNovaHandler(mockOptions) + vi.clearAllMocks() }) - it("completePrompt method should return text from SambaNova API", async () => { - const expectedResponse = "This is a test response from SambaNova" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "SambaNova API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `SambaNova completion error: ${errorMessage}`, - ) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from SambaNova stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(SambaNovaHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new SambaNovaHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(sambaNovaDefaultModelId) + }) }) - 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 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new SambaNovaHandler({ + sambaNovaApiKey: "test-sambanova-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(sambaNovaDefaultModelId) + expect(model.info).toEqual(sambaNovaModels[sambaNovaDefaultModelId]) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should return specified model when valid model is provided", () => { + const testModelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct" + const handlerWithModel = new SambaNovaHandler({ + apiModelId: testModelId, + sambaNovaApiKey: "test-sambanova-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(sambaNovaModels[testModelId]) + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) + it("should return Meta-Llama-3.1-8B-Instruct model with correct configuration", () => { + const testModelId: SambaNovaModelId = "Meta-Llama-3.1-8B-Instruct" + const handlerWithModel = new SambaNovaHandler({ + apiModelId: testModelId, + sambaNovaApiKey: "test-sambanova-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBeDefined() + expect(model.info.contextWindow).toBeDefined() + }) + + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new SambaNovaHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") + expect(model.info).toBeDefined() + // Should use default model info + expect(model.info).toBe(sambaNovaModels[sambaNovaDefaultModelId]) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) - it("createMessage should pass correct parameters to SambaNova client", async () => { - const modelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct" - const modelInfo = sambaNovaModels[modelId] - const handlerWithModel = new SambaNovaHandler({ - apiModelId: modelId, - sambaNovaApiKey: "test-sambanova-api-key", - }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", }, - }), + ], + }, + ] + + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from SambaNova" } } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from SambaNova") }) - const systemPrompt = "Test system prompt for SambaNova" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for SambaNova" }] + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0.7, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) + }) + + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) + + // SambaNova provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + sambanova: { + promptCacheHitTokens: 30, + promptCacheMissTokens: 70, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].cacheWriteTokens).toBe(70) + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0.7 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new SambaNovaHandler({ + sambaNovaApiKey: "test-key", + apiModelId: "Meta-Llama-3.3-70B-Instruct", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should use user-specified temperature over model and provider defaults", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithCustomTemp = new SambaNovaHandler({ + sambaNovaApiKey: "test-key", + apiModelId: "Meta-Llama-3.3-70B-Instruct", + modelTemperature: 0.7, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + // User-specified temperature should take precedence over everything + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from SambaNova", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from SambaNova") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestSambaNovaHandler extends SambaNovaHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestSambaNovaHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + sambanova: { + promptCacheHitTokens: 20, + promptCacheMissTokens: 80, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) + }) + + it("should handle missing cache metrics gracefully", () => { + class TestSambaNovaHandler extends SambaNovaHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestSambaNovaHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should include reasoning tokens when provided", () => { + class TestSambaNovaHandler extends SambaNovaHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestSambaNovaHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.reasoningTokens).toBe(30) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events should be ignored (only tool-input-start/delta/end are processed) + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolCallChunks.length).toBe(0) + }) + }) + + describe("error handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle AI SDK errors with handleAiSdkError", async () => { + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw new Error("API Error") + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("SambaNova: API Error") + }) + + it("should preserve status codes in error handling", async () => { + const apiError = new Error("Rate limit exceeded") + ;(apiError as any).status = 429 + + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw apiError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + try { + for await (const _ of stream) { + // consume stream + } + expect.fail("Should have thrown an error") + } catch (error: any) { + expect(error.message).toContain("SambaNova") + expect(error.status).toBe(429) + } + }) }) }) diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index a15bc12577..1e68dae33f 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -1,19 +1,180 @@ -import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { createSambaNova } from "sambanova-ai-provider" +import { streamText, generateText, ToolSet } from "ai" + +import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, + flattenAiSdkMessagesToStringContent, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +const SAMBANOVA_DEFAULT_TEMPERATURE = 0.7 + +/** + * SambaNova provider using the dedicated sambanova-ai-provider package. + * Provides native support for various models including Llama models. + */ +export class SambaNovaHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType -export class SambaNovaHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "SambaNova", + super() + this.options = options + + // Create the SambaNova provider using AI SDK + this.provider = createSambaNova({ baseURL: "https://api.sambanova.ai/v1", - apiKey: options.sambaNovaApiKey, - defaultProviderModelId: sambaNovaDefaultModelId, - providerModels: sambaNovaModels, - defaultTemperature: 0.7, + apiKey: options.sambaNovaApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, }) } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.apiModelId ?? sambaNovaDefaultModelId + const info = sambaNovaModels[id as keyof typeof sambaNovaModels] || sambaNovaModels[sambaNovaDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: SAMBANOVA_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + sambanova?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from SambaNova's providerMetadata if available + const cacheReadTokens = providerMetadata?.sambanova?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.sambanova?.promptCacheMissTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { temperature, info } = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + // For models that don't support multi-part content (like DeepSeek), flatten messages to string content + // SambaNova's DeepSeek models expect string content, not array content + const aiSdkMessages = convertToAiSdkMessages(messages, { + transform: info.supportsImages ? undefined : flattenAiSdkMessagesToStringContent, + }) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + // Use streamText for streaming responses + const result = streamText(requestOptions) + + try { + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } + } catch (error) { + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "SambaNova") + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE, + }) + + return text + } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index bd87fd8eeb..fb4e3b9e2f 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -7,6 +7,7 @@ import { mapToolChoice, extractAiSdkErrorMessage, handleAiSdkError, + flattenAiSdkMessagesToStringContent, } from "../ai-sdk" vitest.mock("ai", () => ({ @@ -644,4 +645,177 @@ describe("AI SDK conversion utilities", () => { expect((result as any).cause).toBe(originalError) }) }) + + describe("flattenAiSdkMessagesToStringContent", () => { + it("should return messages unchanged if content is already a string", () => { + const messages = [ + { role: "user" as const, content: "Hello" }, + { role: "assistant" as const, content: "Hi there" }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should flatten user messages with only text parts to string", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "Hello" }, + { type: "text" as const, text: "World" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toBe("Hello\nWorld") + }) + + it("should flatten assistant messages with only text parts to string", () => { + const messages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "I am an assistant" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("assistant") + expect(result[0].content).toBe("I am an assistant") + }) + + it("should not flatten user messages with image parts", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "Look at this" }, + { type: "image" as const, image: "data:image/png;base64,abc123" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should not flatten assistant messages with tool calls", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Let me use a tool" }, + { + type: "tool-call" as const, + toolCallId: "123", + toolName: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should not flatten tool role messages", () => { + const messages = [ + { + role: "tool" as const, + content: [ + { + type: "tool-result" as const, + toolCallId: "123", + toolName: "test", + output: { type: "text" as const, value: "result" }, + }, + ], + }, + ] as any + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should respect flattenUserMessages option", () => { + const messages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: "Hello" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages, { flattenUserMessages: false }) + + expect(result).toEqual(messages) + }) + + it("should respect flattenAssistantMessages option", () => { + const messages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Hi" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages, { flattenAssistantMessages: false }) + + expect(result).toEqual(messages) + }) + + it("should handle mixed message types correctly", () => { + const messages = [ + { role: "user" as const, content: "Simple string" }, + { + role: "user" as const, + content: [{ type: "text" as const, text: "Text parts" }], + }, + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Assistant text" }], + }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "With tool" }, + { type: "tool-call" as const, toolCallId: "456", toolName: "test", input: {} }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result[0].content).toBe("Simple string") // unchanged + expect(result[1].content).toBe("Text parts") // flattened + expect(result[2].content).toBe("Assistant text") // flattened + expect(result[3]).toEqual(messages[3]) // unchanged (has tool call) + }) + + it("should handle empty text parts", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "" }, + { type: "text" as const, text: "Hello" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result[0].content).toBe("\nHello") + }) + }) }) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index ebbf1a8661..c6f37be694 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -8,14 +8,29 @@ import OpenAI from "openai" import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai" import type { ApiStreamChunk } from "./stream" +/** + * Options for converting Anthropic messages to AI SDK format. + */ +export interface ConvertToAiSdkMessagesOptions { + /** + * Optional function to transform the converted messages. + * Useful for transformations like flattening message content for models that require string content. + */ + transform?: (messages: ModelMessage[]) => ModelMessage[] +} + /** * Convert Anthropic messages to AI SDK ModelMessage format. * Handles text, images, tool uses, and tool results. * * @param messages - Array of Anthropic message parameters + * @param options - Optional conversion options including post-processing function * @returns Array of AI SDK ModelMessage objects */ -export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam[]): ModelMessage[] { +export function convertToAiSdkMessages( + messages: Anthropic.Messages.MessageParam[], + options?: ConvertToAiSdkMessagesOptions, +): ModelMessage[] { const modelMessages: ModelMessage[] = [] // First pass: build a map of tool call IDs to tool names from assistant messages @@ -149,9 +164,84 @@ export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam } } + // Apply transform if provided + if (options?.transform) { + return options.transform(modelMessages) + } + return modelMessages } +/** + * Options for flattening AI SDK messages. + */ +export interface FlattenMessagesOptions { + /** + * If true, flattens user messages with only text parts to string content. + * Default: true + */ + flattenUserMessages?: boolean + /** + * If true, flattens assistant messages with only text (no tool calls) to string content. + * Default: true + */ + flattenAssistantMessages?: boolean +} + +/** + * Flatten AI SDK messages to use string content where possible. + * Some models (like DeepSeek on SambaNova) require string content instead of array content. + * This function converts messages that contain only text parts to use simple string content. + * + * @param messages - Array of AI SDK ModelMessage objects + * @param options - Options for controlling which message types to flatten + * @returns Array of AI SDK ModelMessage objects with flattened content where applicable + */ +export function flattenAiSdkMessagesToStringContent( + messages: ModelMessage[], + options: FlattenMessagesOptions = {}, +): ModelMessage[] { + const { flattenUserMessages = true, flattenAssistantMessages = true } = options + + return messages.map((message) => { + // Skip if content is already a string + if (typeof message.content === "string") { + return message + } + + // Handle user messages + if (message.role === "user" && flattenUserMessages && Array.isArray(message.content)) { + const parts = message.content as Array<{ type: string; text?: string }> + // Only flatten if all parts are text + const allText = parts.every((part) => part.type === "text") + if (allText && parts.length > 0) { + const textContent = parts.map((part) => part.text || "").join("\n") + return { + ...message, + content: textContent, + } + } + } + + // Handle assistant messages + if (message.role === "assistant" && flattenAssistantMessages && Array.isArray(message.content)) { + const parts = message.content as Array<{ type: string; text?: string }> + // Only flatten if all parts are text (no tool calls) + const allText = parts.every((part) => part.type === "text") + if (allText && parts.length > 0) { + const textContent = parts.map((part) => part.text || "").join("\n") + return { + ...message, + content: textContent, + } + } + } + + // Return unchanged for tool role and messages with non-text content + return message + }) +} + /** * Convert OpenAI-style function tool definitions to AI SDK tool format. * diff --git a/src/package.json b/src/package.json index 98bd1d1b3e..6292fd1594 100644 --- a/src/package.json +++ b/src/package.json @@ -455,6 +455,7 @@ "@ai-sdk/fireworks": "^2.0.26", "@ai-sdk/groq": "^3.0.19", "@ai-sdk/mistral": "^3.0.0", + "sambanova-ai-provider": "^1.2.2", "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0", From 67fb1507271ac96bfc429093f0dd7bc2eb6589d0 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:03:47 -0500 Subject: [PATCH 04/65] feat: use custom Base URL for OpenRouter model list fetch (#11154) Co-authored-by: Roo Code --- src/api/providers/fetchers/modelCache.ts | 2 +- src/core/webview/webviewMessageHandler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 51ca19e2bc..cb5cb09414 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -67,7 +67,7 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise Date: Mon, 2 Feb 2026 23:56:07 -0500 Subject: [PATCH 05/65] feat: migrate xAI provider to use dedicated @ai-sdk/xai package (#11158) Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- packages/types/src/providers/xai.ts | 4 + pnpm-lock.yaml | 36 +- src/api/providers/__tests__/xai.spec.ts | 1176 +++++++++++++---------- src/api/providers/xai.ts | 246 ++--- src/package.json | 1 + 5 files changed, 829 insertions(+), 634 deletions(-) diff --git a/packages/types/src/providers/xai.ts b/packages/types/src/providers/xai.ts index 37e0f2d12e..2954888d73 100644 --- a/packages/types/src/providers/xai.ts +++ b/packages/types/src/providers/xai.ts @@ -30,6 +30,8 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4.1 Fast model with 2M context window, optimized for high-performance agentic tool calling with reasoning", + supportsReasoningEffort: ["low", "high"], + reasoningEffort: "low", includedTools: ["search_replace"], excludedTools: ["apply_diff"], }, @@ -58,6 +60,8 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4 Fast model with 2M context window, optimized for high-performance agentic tool calling with reasoning", + supportsReasoningEffort: ["low", "high"], + reasoningEffort: "low", includedTools: ["search_replace"], excludedTools: ["apply_diff"], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cf3a3627e..417e69a07a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,6 +761,9 @@ importers: '@ai-sdk/mistral': specifier: ^3.0.0 version: 3.0.18(zod@3.25.76) + '@ai-sdk/xai': + specifier: ^3.0.46 + version: 3.0.46(zod@3.25.76) '@anthropic-ai/bedrock-sdk': specifier: ^0.10.2 version: 0.10.4 @@ -1462,6 +1465,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/openai-compatible@2.0.26': + resolution: {integrity: sha512-l6jdFjI1C2eDAEm7oo+dnRn0oG1EkcyqfbEZ7ozT0TnYrah6amX2JkftYMP1GRzNtAeCB3WNN8XspXdmi6ZNlQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.20': resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==} engines: {node: '>=18'} @@ -1512,6 +1521,12 @@ packages: resolution: {integrity: sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==} engines: {node: '>=18'} + '@ai-sdk/xai@3.0.46': + resolution: {integrity: sha512-26qM/jYcFhF5krTM7bQT1CiZcdz22EQmA+r5me1hKYFM/yM20sSUMHnAcUzvzuuG9oQVKF0tziU2IcC0HX5huQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@alcalzone/ansi-tokenize@0.2.3': resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==} engines: {node: '>=18'} @@ -6530,10 +6545,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.2: - resolution: {integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -11146,6 +11157,12 @@ snapshots: '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai-compatible@2.0.26(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -11202,6 +11219,13 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/xai@3.0.46(zod@3.25.76)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.26(zod@3.25.76) + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + zod: 3.25.76 + '@alcalzone/ansi-tokenize@0.2.3': dependencies: ansi-styles: 6.2.3 @@ -17027,13 +17051,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.2: {} - eventsource-parser@3.0.6: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.2 + eventsource-parser: 3.0.6 exceljs@4.4.0: dependencies: diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index c622c9d4fc..27e0a25f5c 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -1,587 +1,731 @@ -// npx vitest api/providers/__tests__/xai.spec.ts +// npx vitest run api/providers/__tests__/xai.spec.ts -// Mock TelemetryService - must come before other imports -const mockCaptureException = vitest.hoisted(() => vitest.fn()) -vitest.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureException: mockCaptureException, - }, - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -const mockCreate = vitest.fn() - -vitest.mock("openai", () => { - const mockConstructor = vitest.fn() - +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: mockConstructor.mockImplementation(() => ({ chat: { completions: { create: mockCreate } } })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import OpenAI from "openai" +vi.mock("@ai-sdk/xai", () => ({ + createXai: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "grok-code-fast-1", + provider: "xai", + })) + }), +})) + import type { Anthropic } from "@anthropic-ai/sdk" -import { xaiDefaultModelId, xaiModels } from "@roo-code/types" +import { xaiDefaultModelId, xaiModels, type XAIModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" import { XAIHandler } from "../xai" describe("XAIHandler", () => { let handler: XAIHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { - // Reset all mocks + mockOptions = { + xaiApiKey: "test-xai-api-key", + apiModelId: "grok-code-fast-1", + } + handler = new XAIHandler(mockOptions) vi.clearAllMocks() - mockCreate.mockClear() - mockCaptureException.mockClear() - - // Create handler with mock - handler = new XAIHandler({}) }) - it("should use the correct X.AI base URL", () => { - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.x.ai/v1", - }), - ) - }) - - it("should use the provided API key", () => { - // Clear mocks before this specific test - vi.clearAllMocks() - - // Create a handler with our API key - const xaiApiKey = "test-api-key" - new XAIHandler({ xaiApiKey }) - - // Verify the OpenAI constructor was called with our API key - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: xaiApiKey, - }), - ) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(xaiDefaultModelId) - expect(model.info).toEqual(xaiModels[xaiDefaultModelId]) - }) - - test("should return specified model when valid model is provided", () => { - const testModelId = "grok-3" - const handlerWithModel = new XAIHandler({ apiModelId: testModelId }) - const model = handlerWithModel.getModel() - - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(xaiModels[testModelId]) - }) - - it("should include reasoning_effort parameter for mini models", async () => { - const miniModelHandler = new XAIHandler({ - apiModelId: "grok-3-mini", - reasoningEffort: "high", + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(XAIHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new XAIHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(xaiDefaultModelId) + }) + }) + + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new XAIHandler({ + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(xaiDefaultModelId) + expect(model.info).toEqual(xaiModels[xaiDefaultModelId]) + }) + + it("should return specified model when valid model is provided", () => { + const testModelId: XAIModelId = "grok-3" + const handlerWithModel = new XAIHandler({ + apiModelId: testModelId, + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(xaiModels[testModelId]) + }) + + it("should return grok-3-mini model with correct configuration", () => { + const testModelId: XAIModelId = "grok-3-mini" + const handlerWithModel = new XAIHandler({ + apiModelId: testModelId, + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 131072, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.3, + outputPrice: 0.5, }), - } + ) }) - // Start generating a message - const messageGenerator = miniModelHandler.createMessage("test prompt", []) - await messageGenerator.next() // Start the generator - - // Check that reasoning_effort was included - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning_effort: "high", - }), - ) - }) - - it("should not include reasoning_effort parameter for non-mini models", async () => { - const regularModelHandler = new XAIHandler({ - apiModelId: "grok-3", - reasoningEffort: "high", - }) - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, + it("should return grok-4-0709 model with correct configuration", () => { + const testModelId: XAIModelId = "grok-4-0709" + const handlerWithModel = new XAIHandler({ + apiModelId: testModelId, + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 256_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, }), - } + ) }) - // Start generating a message - const messageGenerator = regularModelHandler.createMessage("test prompt", []) - await messageGenerator.next() // Start the generator - - // Check call args for reasoning_effort - const calls = mockCreate.mock.calls - const lastCall = calls[calls.length - 1][0] - expect(lastCall).not.toHaveProperty("reasoning_effort") - }) - - it("completePrompt method should return text from OpenAI API", async () => { - const expectedResponse = "This is a test response" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`xAI completion error: ${errorMessage}`) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content" - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: { content: testContent } }], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + it("should fall back to default model for invalid model ID", () => { + const handlerWithInvalidModel = new XAIHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe(xaiDefaultModelId) + expect(model.info).toBe(xaiModels[xaiDefaultModelId]) }) - // Create and consume the stream - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - // Verify the content - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "text", - text: testContent, + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) }) - it("createMessage should yield reasoning content from stream", async () => { - const testReasoning = "Test reasoning content" - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: { reasoning_content: testReasoning } }], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - // Create and consume the stream - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - // Verify the reasoning content - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "reasoning", - text: testReasoning, - }) - }) - - it("createMessage should yield usage data from stream", async () => { - // Setup mock for streaming response that includes usage data - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], // Needs to have choices array to avoid error - usage: { - prompt_tokens: 10, - completion_tokens: 20, - cache_read_input_tokens: 5, - cache_creation_input_tokens: 15, - }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - // Create and consume the stream - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - // Verify the usage data - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheReadTokens: 5, - cacheWriteTokens: 15, - }) - }) - - it("createMessage should pass correct parameters to OpenAI client", async () => { - // Setup a handler with specific model - const modelId = "grok-3" - const modelInfo = xaiModels[modelId] - const handlerWithModel = new XAIHandler({ apiModelId: modelId }) - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - // System prompt and messages - const systemPrompt = "Test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] - - // Start generating a message - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() // Start the generator - - // Check that all parameters were passed correctly - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - ) - }) - - describe("Native Tool Calling", () => { - const testTools = [ + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string", description: "First argument" }, - }, - required: ["arg1"], + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", }, - }, + ], }, ] - it("should include tools in request when model supports native tools and tools are provided (native is default)", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from xAI" } + } - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - await messageGenerator.next() + const mockProviderMetadata = Promise.resolve({}) - expect(mockCreate).toHaveBeenCalledWith( + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from xAI") + }) + + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) + }) + + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) + + // xAI provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + xai: { + cachedPromptTokens: 30, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new XAIHandler({ + xaiApiKey: "test-key", + apiModelId: "grok-code-fast-1", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - parallel_tool_calls: true, + temperature: 0, }), ) }) - it("should include tool_choice when provided", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) + it("should use user-specified temperature over default", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), }) - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", + const handlerWithCustomTemp = new XAIHandler({ + xaiApiKey: "test-key", + apiModelId: "grok-3", + modelTemperature: 0.7, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + // User-specified temperature should take precedence over everything + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) + + it("should handle reasoning content from stream", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "Let me think about this..." } + yield { type: "text-delta", text: "Here is my answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here is my answer") + }) + + it("should handle errors during streaming", async () => { + const mockError = new Error("API error") + ;(mockError as any).name = "AI_APICallError" + ;(mockError as any).status = 500 + + async function* mockFullStream(): AsyncGenerator { + // This yield is unreachable but needed to satisfy the require-yield lint rule + yield undefined as never + throw mockError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("xAI") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from xAI", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from xAI") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0, + }), + ) + }) + + it("should handle errors in completePrompt", async () => { + const mockError = new Error("API error") + ;(mockError as any).name = "AI_APICallError" + mockGenerateText.mockRejectedValue(mockError) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("xAI") + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestXAIHandler extends XAIHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestXAIHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + xai: { + cachedPromptTokens: 20, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheReadTokens).toBe(20) + // xAI doesn't report cache write tokens separately + expect(result.cacheWriteTokens).toBeUndefined() + }) + + it("should handle missing cache metrics gracefully", () => { + class TestXAIHandler extends XAIHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestXAIHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should include reasoning tokens when provided", () => { + class TestXAIHandler extends XAIHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestXAIHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.reasoningTokens).toBe(30) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events should be ignored (only tool-input-start/delta/end are processed) + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolCallChunks.length).toBe(0) + }) + + it("should pass tools to streamText when provided", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string", description: "First argument" }, + }, + required: ["arg1"], + }, + }, + }, + ] + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", tools: testTools, tool_choice: "auto", }) - await messageGenerator.next() - expect(mockCreate).toHaveBeenCalledWith( + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tool_choice: "auto", + tools: expect.any(Object), + toolChoice: "auto", }), ) }) + }) - it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - }) - await messageGenerator.next() - - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should yield tool_call_partial chunks during streaming", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) + describe("reasoning effort (mini models)", () => { + it("should include reasoning effort for grok-3-mini model", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg1":', + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), }) - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - }) - - it("should set parallel_tool_calls based on metadata", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + const miniModelHandler = new XAIHandler({ + xaiApiKey: "test-key", + apiModelId: "grok-3-mini", + reasoningEffort: "high", }) - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, - }) - await messageGenerator.next() + const stream = miniModelHandler.createMessage("test prompt", []) + for await (const _ of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( + // Check that provider options are passed for reasoning + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - parallel_tool_calls: true, + providerOptions: expect.any(Object), }), ) }) - - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - // Import NativeToolCallParser to set up state - const { NativeToolCallParser } = await import("../../../core/assistant-message/NativeToolCallParser") - - // Clear any previous state - NativeToolCallParser.clearRawChunkState() - - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_xai_test", - function: { - name: "test_tool", - arguments: '{"arg1":"value"}', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - - const chunks = [] - for await (const chunk of stream) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } - chunks.push(chunk) - } - - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_xai_test") - }) }) }) diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 8df9cc66ec..238dbeaf2d 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -1,166 +1,190 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createXai } from "@ai-sdk/xai" +import { streamText, generateText, ToolSet } from "ai" -import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" +import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@roo-code/types" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" import type { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { handleOpenAIError } from "./utils/openai-error-handler" const XAI_DEFAULT_TEMPERATURE = 0 +/** + * xAI provider using the dedicated @ai-sdk/xai package. + * Provides native support for Grok models including reasoning models. + */ export class XAIHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI - private readonly providerName = "xAI" + protected provider: ReturnType constructor(options: ApiHandlerOptions) { super() this.options = options - const apiKey = this.options.xaiApiKey ?? "not-provided" - - this.client = new OpenAI({ + // Create the xAI provider using AI SDK + this.provider = createXai({ baseURL: "https://api.x.ai/v1", - apiKey: apiKey, - defaultHeaders: DEFAULT_HEADERS, + apiKey: options.xaiApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, }) } - override getModel() { + override getModel(): { + id: XAIModelId + info: ModelInfo + maxTokens?: number + temperature?: number + reasoning?: any + } { const id = this.options.apiModelId && this.options.apiModelId in xaiModels ? (this.options.apiModelId as XAIModelId) : xaiDefaultModelId const info = xaiModels[id] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: XAI_DEFAULT_TEMPERATURE, + }) return { id, info, ...params } } + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + xai?: { + cachedPromptTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from xAI's providerMetadata if available + // xAI supports prompt caching through prompt_tokens_details.cached_tokens + const cacheReadTokens = providerMetadata?.xai?.cachedPromptTokens ?? usage.details?.cachedInputTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens: undefined, // xAI doesn't report cache write tokens separately + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id: modelId, info: modelInfo, reasoning } = this.getModel() + const { temperature, reasoning } = this.getModel() + const languageModel = this.getLanguageModel() - // Use the OpenAI-compatible API. - const requestOptions = { - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: this.options.modelTemperature ?? XAI_DEFAULT_TEMPERATURE, - messages: [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] as OpenAI.Chat.ChatCompletionMessageParam[], - stream: true as const, - stream_options: { include_usage: true }, - ...(reasoning && reasoning), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? XAI_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + ...(reasoning && { providerOptions: { xai: reasoning } }), } - let stream + // Use streamText for streaming responses + const result = streamText(requestOptions) + try { - stream = await this.client.chat.completions.create(requestOptions) + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta?.content) { - yield { - type: "text", - text: delta.content, - } - } - - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: delta.reasoning_content as string, - } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Process finish_reason to emit tool_call_end events - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } - } - - if (chunk.usage) { - // Extract detailed token information if available - // First check for prompt_tokens_details structure (real API response) - const promptDetails = "prompt_tokens_details" in chunk.usage ? chunk.usage.prompt_tokens_details : null - const cachedTokens = promptDetails && "cached_tokens" in promptDetails ? promptDetails.cached_tokens : 0 - - // Fall back to direct fields in usage (used in test mocks) - const readTokens = - cachedTokens || - ("cache_read_input_tokens" in chunk.usage ? (chunk.usage as any).cache_read_input_tokens : 0) - const writeTokens = - "cache_creation_input_tokens" in chunk.usage ? (chunk.usage as any).cache_creation_input_tokens : 0 - - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - cacheReadTokens: readTokens, - cacheWriteTokens: writeTokens, - } - } + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "xAI") } } + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string): Promise { - const { id: modelId, reasoning } = this.getModel() + const { temperature, reasoning } = this.getModel() + const languageModel = this.getLanguageModel() try { - const response = await this.client.chat.completions.create({ - model: modelId, - messages: [{ role: "user", content: prompt }], - ...(reasoning && reasoning), + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? XAI_DEFAULT_TEMPERATURE, + ...(reasoning && { providerOptions: { xai: reasoning } }), }) - return response.choices[0]?.message.content || "" + return text } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) + throw handleAiSdkError(error, "xAI") } } } diff --git a/src/package.json b/src/package.json index 6292fd1594..04402de28a 100644 --- a/src/package.json +++ b/src/package.json @@ -455,6 +455,7 @@ "@ai-sdk/fireworks": "^2.0.26", "@ai-sdk/groq": "^3.0.19", "@ai-sdk/mistral": "^3.0.0", + "@ai-sdk/xai": "^3.0.46", "sambanova-ai-provider": "^1.2.2", "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", From 45921336243328c81858a707babd968f6cd765e2 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 2 Feb 2026 21:00:52 -0800 Subject: [PATCH 06/65] Add cli support for linux (#11167) --- .github/workflows/cli-release.yml | 397 +++++++++++++++++ apps/cli/README.md | 72 +-- apps/cli/package.json | 2 +- apps/cli/scripts/build.sh | 343 ++++++++++++++ apps/cli/scripts/release.sh | 711 ------------------------------ 5 files changed, 786 insertions(+), 739 deletions(-) create mode 100644 .github/workflows/cli-release.yml create mode 100755 apps/cli/scripts/build.sh delete mode 100755 apps/cli/scripts/release.sh diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000000..a9e63a049c --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,397 @@ +name: CLI Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.' + required: false + type: string + dry_run: + description: 'Dry run (build and test but do not create release).' + required: false + type: boolean + default: false + +jobs: + # Build CLI for each platform. + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin-arm64 + runs-on: macos-latest + - os: macos-13 + platform: darwin-x64 + runs-on: macos-13 + - os: ubuntu-latest + platform: linux-x64 + runs-on: ubuntu-latest + + runs-on: ${{ matrix.runs-on }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" + + - name: Build extension bundle + run: pnpm bundle + + - name: Build CLI + run: pnpm --filter @roo-code/cli build + + - name: Create release tarball + id: tarball + env: + VERSION: ${{ steps.version.outputs.version }} + PLATFORM: ${{ matrix.platform }} + run: | + RELEASE_DIR="roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build. + rm -rf "$RELEASE_DIR" + rm -f "$TARBALL" + + # Create directory structure. + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files. + echo "Copying CLI files..." + cp -r apps/cli/dist/* "$RELEASE_DIR/lib/" + + # Create package.json for npm install. + echo "Creating package.json..." + node -e " + const pkg = require('./apps/cli/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle. + echo "Copying extension bundle..." + cp -r src/dist/* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS. + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary. + echo "Looking for ripgrep binary..." + RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + echo "Found ripgrep at: $RIPGREP_PATH" + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + echo "Warning: ripgrep binary not found" + fi + + # Create the wrapper script + echo "Creating wrapper script..." + cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_CLI_ROOT = join(__dirname, '..'); +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file. + touch "$RELEASE_DIR/.env" + + # Create tarball. + echo "Creating tarball..." + tar -czvf "$TARBALL" "$RELEASE_DIR" + + # Clean up release directory. + rm -rf "$RELEASE_DIR" + + # Create checksum. + if command -v sha256sum &> /dev/null; then + sha256sum "$TARBALL" > "${TARBALL}.sha256" + elif command -v shasum &> /dev/null; then + shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" + fi + + echo "tarball=$TARBALL" >> $GITHUB_OUTPUT + echo "Created: $TARBALL" + ls -la "$TARBALL" + + - name: Verify tarball + env: + PLATFORM: ${{ matrix.platform }} + run: | + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Create temp directory for verification. + VERIFY_DIR=$(mktemp -d) + + # Extract and verify structure. + tar -xzf "$TARBALL" -C "$VERIFY_DIR" + + echo "Verifying tarball contents..." + ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/" + + # Check required files exist. + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; } + test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; } + + echo "Tarball verification passed!" + + # Cleanup. + rm -rf "$VERIFY_DIR" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.platform }} + path: | + roo-cli-${{ matrix.platform }}.tar.gz + roo-cli-${{ matrix.platform }}.tar.gz.sha256 + retention-days: 7 + + # Create GitHub release with all platform artifacts. + release: + needs: build + runs-on: ubuntu-latest + if: ${{ !inputs.dry_run }} + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -name "*.tar.gz" -exec cp {} release/ \; + find artifacts -name "*.sha256" -exec cp {} release/ \; + ls -la release/ + + - name: Extract changelog + id: changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CHANGELOG_FILE="apps/cli/CHANGELOG.md" + + if [ -f "$CHANGELOG_FILE" ]; then + # Extract content between version headers. + CONTENT=$(awk -v version="$VERSION" ' + BEGIN { found = 0; content = ""; target = "[" version "]" } + /^## \[/ { + if (found) { exit } + if (index($0, target) > 0) { found = 1; next } + } + found { content = content $0 "\n" } + END { print content } + ' "$CHANGELOG_FILE") + + if [ -n "$CONTENT" ]; then + echo "Found changelog content" + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "No changelog content found for version $VERSION" + echo "content=" >> $GITHUB_OUTPUT + fi + else + echo "No changelog file found" + echo "content=" >> $GITHUB_OUTPUT + fi + + - name: Generate checksums summary + id: checksums + run: | + echo "checksums<> $GITHUB_OUTPUT + cat release/*.sha256 >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check for existing release + id: check_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if gh release view "$TAG" &> /dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Delete existing release + if: steps.check_release.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "Deleting existing release $TAG..." + gh release delete "$TAG" --yes || true + git push origin ":refs/tags/$TAG" || true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} + CHECKSUMS: ${{ steps.checksums.outputs.checksums }} + run: | + WHATS_NEW="" + if [ -n "$CHANGELOG_CONTENT" ]; then + WHATS_NEW="## What's New + +$CHANGELOG_CONTENT + +" + fi + + RELEASE_NOTES=$(cat << EOF +${WHATS_NEW}## Installation + +\`\`\`bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +\`\`\` + +Or install a specific version: +\`\`\`bash +ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +\`\`\` + +## Requirements + +- Node.js 20 or higher +- macOS (Intel or Apple Silicon) or Linux x64 + +## Usage + +\`\`\`bash +# Run a task +roo "What is this project?" + +# See all options +roo --help +\`\`\` + +## Platform Support + +This release includes binaries for: +- \`roo-cli-darwin-arm64.tar.gz\` - macOS Apple Silicon (M1/M2/M3) +- \`roo-cli-darwin-x64.tar.gz\` - macOS Intel +- \`roo-cli-linux-x64.tar.gz\` - Linux x64 + +## Checksums + +\`\`\` +${CHECKSUMS} +\`\`\` +EOF + ) + + gh release create "$TAG" \ + --title "Roo Code CLI v$VERSION" \ + --notes "$RELEASE_NOTES" \ + --prerelease \ + release/* + + echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG" + + # Summary job for dry runs + summary: + needs: build + runs-on: ubuntu-latest + if: ${{ inputs.dry_run }} + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Show build summary + run: | + echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + find artifacts -name "*.tar.gz" | while read f; do + SIZE=$(ls -lh "$f" | awk '{print $5}') + echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Checksums" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/apps/cli/README.md b/apps/cli/README.md index 8814c68702..b18cb77ccf 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -19,7 +19,7 @@ curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/i **Requirements:** - Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) +- macOS (Intel or Apple Silicon) or Linux x64 **Custom installation directory:** @@ -77,7 +77,7 @@ roo "What is this project?" -w ~/Documents/my-project You can also run without a prompt and enter it interactively in TUI mode: ```bash -roo ~/Documents/my-project +roo -w ~/Documents/my-project ``` In interactive mode: @@ -147,21 +147,23 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Options -| Option | Description | Default | -| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- | -| `[prompt]` | Your prompt (positional argument, optional) | None | -| `-w, --workspace ` | Workspace path to operate in | Current directory | -| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | -| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | -| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` | -| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | -| `-k, --api-key ` | API key for the LLM provider | From env var | -| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | -| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | -| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | -| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | -| `--no-tui` | Disable TUI, use plain text output | `false` | +| Option | Description | Default | +| ------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | +| `[prompt]` | Your prompt (positional argument, optional) | None | +| `--prompt-file ` | Read prompt from a file instead of command line argument | None | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-y, --yes, --dangerously-skip-permissions` | Auto-approve all actions (use with caution) | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.5` | +| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | +| `--oneshot` | Exit upon task completion | `false` | +| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | ## Auth Commands @@ -246,17 +248,33 @@ pnpm lint ## Releasing -To create a new release, execute the /cli-release slash command: +Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`. -```bash -roo "/cli-release" -w ~/Documents/Roo-Code -y -``` +To trigger a release: + +1. Go to **Actions** → **CLI Release** +2. Click **Run workflow** +3. Optionally specify a version (defaults to `package.json` version) +4. Click **Run workflow** The workflow will: -1. Bump the version -2. Update the CHANGELOG -3. Build the extension and CLI -4. Create a platform-specific tarball (for your current OS/architecture) -5. Test the install script -6. Create a GitHub release with the tarball attached +1. Build the CLI on all platforms (macOS Intel, macOS ARM, Linux x64) +2. Create platform-specific tarballs with bundled ripgrep +3. Verify each tarball +4. Create a GitHub release with all tarballs attached + +### Local Builds + +For local development and testing, use the build script: + +```bash +# Build tarball for your current platform +./apps/cli/scripts/build.sh + +# Build and install locally +./apps/cli/scripts/build.sh --install + +# Fast build (skip verification) +./apps/cli/scripts/build.sh --skip-verify +``` diff --git a/apps/cli/package.json b/apps/cli/package.json index 6348bbe020..abea4771e0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,7 +19,7 @@ "dev": "tsup --watch", "start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js", "start:production": "node dist/index.js", - "release": "scripts/release.sh", + "build:local": "scripts/build.sh", "clean": "rimraf dist .turbo" }, "dependencies": { diff --git a/apps/cli/scripts/build.sh b/apps/cli/scripts/build.sh new file mode 100755 index 0000000000..97a33c384c --- /dev/null +++ b/apps/cli/scripts/build.sh @@ -0,0 +1,343 @@ +#!/bin/bash +# Roo Code CLI Local Build Script +# +# Usage: +# ./apps/cli/scripts/build.sh [options] +# +# Options: +# --install Install locally after building +# --skip-verify Skip end-to-end verification tests (faster builds) +# +# Examples: +# ./apps/cli/scripts/build.sh # Build for local testing +# ./apps/cli/scripts/build.sh --install # Build and install locally +# ./apps/cli/scripts/build.sh --skip-verify # Fast local build +# +# This script builds the CLI for your current platform. For official releases +# with multi-platform support, use the GitHub Actions workflow instead: +# .github/workflows/cli-release.yml +# +# Prerequisites: +# - pnpm installed +# - Run from the monorepo root directory + +set -e + +# Parse arguments +LOCAL_INSTALL=false +SKIP_VERIFY=false + +while [[ $# -gt 0 ]]; do + case $1 in + --install) + LOCAL_INSTALL=true + shift + ;; + --skip-verify) + SKIP_VERIFY=true + shift + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + shift + ;; + esac +done + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } +step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } + +# Get script directory and repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CLI_DIR="$REPO_ROOT/apps/cli" + +# Detect current platform +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" +} + +# Check prerequisites +check_prerequisites() { + step "1/6" "Checking prerequisites..." + + if ! command -v pnpm &> /dev/null; then + error "pnpm is not installed." + fi + + if ! command -v node &> /dev/null; then + error "Node.js is not installed." + fi + + info "Prerequisites OK" +} + +# Get version +get_version() { + VERSION=$(node -p "require('$CLI_DIR/package.json').version") + GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + VERSION="${VERSION}-local.${GIT_SHORT_HASH}" + + info "Version: $VERSION" +} + +# Build everything +build() { + step "2/6" "Building extension bundle..." + cd "$REPO_ROOT" + pnpm bundle + + step "3/6" "Building CLI..." + pnpm --filter @roo-code/cli build + + info "Build complete" +} + +# Create release tarball +create_tarball() { + step "4/6" "Creating release tarball for $PLATFORM..." + + RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build + rm -rf "$RELEASE_DIR" + rm -f "$REPO_ROOT/$TARBALL" + + # Create directory structure + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files + info "Copying CLI files..." + cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" + + # Create package.json for npm install + info "Creating package.json..." + node -e " + const pkg = require('$CLI_DIR/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle + info "Copying extension bundle..." + cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary + info "Looking for ripgrep binary..." + RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + info "Found ripgrep at: $RIPGREP_PATH" + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + warn "ripgrep binary not found - users will need ripgrep installed" + fi + + # Create the wrapper script + info "Creating wrapper script..." + cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_CLI_ROOT = join(__dirname, '..'); +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file + touch "$RELEASE_DIR/.env" + + # Create tarball + info "Creating tarball..." + cd "$REPO_ROOT" + tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + + # Clean up release directory + rm -rf "$RELEASE_DIR" + + # Show size + TARBALL_PATH="$REPO_ROOT/$TARBALL" + TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') + info "Created: $TARBALL ($TARBALL_SIZE)" +} + +# Verify local installation +verify_local_install() { + if [ "$SKIP_VERIFY" = true ]; then + step "5/6" "Skipping verification (--skip-verify)" + return + fi + + step "5/6" "Verifying installation..." + + VERIFY_DIR="$REPO_ROOT/.verify-release" + VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" + VERIFY_BIN_DIR="$VERIFY_DIR/bin" + + rm -rf "$VERIFY_DIR" + mkdir -p "$VERIFY_DIR" + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ + ROO_BIN_DIR="$VERIFY_BIN_DIR" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + rm -rf "$VERIFY_DIR" + error "Installation verification failed!" + } + + # Test --help + if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --help check failed!" + fi + info "CLI --help check passed" + + # Test --version + if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --version check failed!" + fi + info "CLI --version check passed" + + cd "$REPO_ROOT" + rm -rf "$VERIFY_DIR" + + info "Verification passed!" +} + +# Install locally +install_local() { + if [ "$LOCAL_INSTALL" = false ]; then + step "6/6" "Skipping install (use --install to auto-install)" + return + fi + + step "6/6" "Installing locally..." + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + error "Local installation failed!" + } + + info "Local installation complete!" +} + +# Print summary +print_summary() { + echo "" + printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" + echo "" + echo " Tarball: $REPO_ROOT/$TARBALL" + echo "" + + if [ "$LOCAL_INSTALL" = true ]; then + echo " Installed to: ~/.roo/cli" + echo " Binary: ~/.local/bin/roo" + echo "" + echo " Test it out:" + echo " roo --version" + echo " roo --help" + else + echo " To install manually:" + echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" + echo "" + echo " Or re-run with --install:" + echo " ./apps/cli/scripts/build.sh --install" + fi + echo "" + echo " For official multi-platform releases, use the GitHub Actions workflow:" + echo " .github/workflows/cli-release.yml" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Local Build │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + detect_platform + check_prerequisites + get_version + build + create_tarball + verify_local_install + install_local + print_summary +} + +main diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh deleted file mode 100755 index 7e736db3db..0000000000 --- a/apps/cli/scripts/release.sh +++ /dev/null @@ -1,711 +0,0 @@ -#!/bin/bash -# Roo Code CLI Release Script -# -# Usage: -# ./apps/cli/scripts/release.sh [options] [version] -# -# Options: -# --dry-run Run all steps except creating the GitHub release -# --local Build for local testing only (no GitHub checks, no changelog prompts) -# --install Install locally after building (only with --local) -# --skip-verify Skip end-to-end verification tests (faster local builds) -# -# Examples: -# ./apps/cli/scripts/release.sh # Use version from package.json -# ./apps/cli/scripts/release.sh 0.1.0 # Specify version -# ./apps/cli/scripts/release.sh --dry-run # Test the release flow without pushing -# ./apps/cli/scripts/release.sh --dry-run 0.1.0 # Dry run with specific version -# ./apps/cli/scripts/release.sh --local # Build for local testing -# ./apps/cli/scripts/release.sh --local --install # Build and install locally -# ./apps/cli/scripts/release.sh --local --skip-verify # Fast local build -# -# This script: -# 1. Builds the extension and CLI -# 2. Creates a tarball for the current platform -# 3. Creates a GitHub release and uploads the tarball (unless --dry-run or --local) -# -# Prerequisites: -# - GitHub CLI (gh) installed and authenticated (not needed for --local) -# - pnpm installed -# - Run from the monorepo root directory - -set -e - -# Parse arguments -DRY_RUN=false -LOCAL_BUILD=false -LOCAL_INSTALL=false -SKIP_VERIFY=false -VERSION_ARG="" - -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) - DRY_RUN=true - shift - ;; - --local) - LOCAL_BUILD=true - shift - ;; - --install) - LOCAL_INSTALL=true - shift - ;; - --skip-verify) - SKIP_VERIFY=true - shift - ;; - -*) - echo "Unknown option: $1" >&2 - exit 1 - ;; - *) - VERSION_ARG="$1" - shift - ;; - esac -done - -# Validate option combinations -if [ "$LOCAL_INSTALL" = true ] && [ "$LOCAL_BUILD" = false ]; then - echo "Error: --install can only be used with --local" >&2 - exit 1 -fi - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -BOLD='\033[1m' -NC='\033[0m' - -info() { printf "${GREEN}==>${NC} %s\n" "$1"; } -warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } -error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } -step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } - -# Get script directory and repo root -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -CLI_DIR="$REPO_ROOT/apps/cli" - -# Detect current platform -detect_platform() { - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m) - - case "$OS" in - darwin) OS="darwin" ;; - linux) OS="linux" ;; - *) error "Unsupported OS: $OS" ;; - esac - - case "$ARCH" in - x86_64|amd64) ARCH="x64" ;; - arm64|aarch64) ARCH="arm64" ;; - *) error "Unsupported architecture: $ARCH" ;; - esac - - PLATFORM="${OS}-${ARCH}" -} - -# Check prerequisites -check_prerequisites() { - step "1/8" "Checking prerequisites..." - - # Skip GitHub CLI checks for local builds - if [ "$LOCAL_BUILD" = false ]; then - if ! command -v gh &> /dev/null; then - error "GitHub CLI (gh) is not installed. Install it with: brew install gh" - fi - - if ! gh auth status &> /dev/null; then - error "GitHub CLI is not authenticated. Run: gh auth login" - fi - fi - - if ! command -v pnpm &> /dev/null; then - error "pnpm is not installed." - fi - - if ! command -v node &> /dev/null; then - error "Node.js is not installed." - fi - - info "Prerequisites OK" -} - -# Get version -get_version() { - if [ -n "$VERSION_ARG" ]; then - VERSION="$VERSION_ARG" - else - VERSION=$(node -p "require('$CLI_DIR/package.json').version") - fi - - # For local builds, append a local suffix with git short hash - # This creates versions like: 0.1.0-local.abc1234 - if [ "$LOCAL_BUILD" = true ]; then - GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") - # Only append suffix if not already a local version - if ! echo "$VERSION" | grep -qE '\-local\.'; then - VERSION="${VERSION}-local.${GIT_SHORT_HASH}" - fi - fi - - # Validate semver format (allow -local.hash suffix) - if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then - error "Invalid version format: $VERSION (expected semver like 0.1.0)" - fi - - TAG="cli-v$VERSION" - info "Version: $VERSION (tag: $TAG)" -} - -# Extract changelog content for a specific version -# Returns the content between the version header and the next version header (or EOF) -get_changelog_content() { - CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md" - - if [ ! -f "$CHANGELOG_FILE" ]; then - warn "No CHANGELOG.md found at $CHANGELOG_FILE" - CHANGELOG_CONTENT="" - return - fi - - # Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats) - # Also handles "Unreleased" marker - VERSION_PATTERN="^\#\# \[${VERSION}\]" - - # Check if the version exists in the changelog - if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then - warn "No changelog entry found for version $VERSION" - # Skip prompts for local builds - if [ "$LOCAL_BUILD" = true ]; then - info "Skipping changelog prompt for local build" - CHANGELOG_CONTENT="" - return - fi - warn "Please add an entry to $CHANGELOG_FILE before releasing" - echo "" - echo "Expected format:" - echo " ## [$VERSION] - $(date +%Y-%m-%d)" - echo " " - echo " ### Added" - echo " - Your changes here" - echo "" - read -p "Continue without changelog content? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - error "Aborted. Please add a changelog entry and try again." - fi - CHANGELOG_CONTENT="" - return - fi - - # Extract content between this version and the next version header (or EOF) - # Uses awk to capture everything between ## [VERSION] and the next ## [ - # Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10) - CHANGELOG_CONTENT=$(awk -v version="$VERSION" ' - BEGIN { found = 0; content = ""; target = "[" version "]" } - /^## \[/ { - if (found) { exit } - if (index($0, target) > 0) { found = 1; next } - } - found { content = content $0 "\n" } - END { print content } - ' "$CHANGELOG_FILE") - - # Trim leading/trailing whitespace - CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - if [ -n "$CHANGELOG_CONTENT" ]; then - info "Found changelog content for version $VERSION" - else - warn "Changelog entry for $VERSION appears to be empty" - fi -} - -# Build everything -build() { - step "2/8" "Building extension bundle..." - cd "$REPO_ROOT" - pnpm bundle - - step "3/8" "Building CLI..." - pnpm --filter @roo-code/cli build - - info "Build complete" -} - -# Create release tarball -create_tarball() { - step "4/8" "Creating release tarball for $PLATFORM..." - - RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" - TARBALL="roo-cli-${PLATFORM}.tar.gz" - - # Clean up any previous build - rm -rf "$RELEASE_DIR" - rm -f "$REPO_ROOT/$TARBALL" - - # Create directory structure - mkdir -p "$RELEASE_DIR/bin" - mkdir -p "$RELEASE_DIR/lib" - mkdir -p "$RELEASE_DIR/extension" - - # Copy CLI dist files - info "Copying CLI files..." - cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" - - # Create package.json for npm install (runtime dependencies that can't be bundled) - info "Creating package.json..." - node -e " - const pkg = require('$CLI_DIR/package.json'); - const newPkg = { - name: '@roo-code/cli', - version: '$VERSION', - type: 'module', - dependencies: { - '@inkjs/ui': pkg.dependencies['@inkjs/ui'], - '@trpc/client': pkg.dependencies['@trpc/client'], - 'commander': pkg.dependencies.commander, - 'fuzzysort': pkg.dependencies.fuzzysort, - 'ink': pkg.dependencies.ink, - 'p-wait-for': pkg.dependencies['p-wait-for'], - 'react': pkg.dependencies.react, - 'superjson': pkg.dependencies.superjson, - 'zustand': pkg.dependencies.zustand - } - }; - console.log(JSON.stringify(newPkg, null, 2)); - " > "$RELEASE_DIR/package.json" - - # Copy extension bundle - info "Copying extension bundle..." - cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" - - # Add package.json to extension directory to mark it as CommonJS - # This is necessary because the main package.json has "type": "module" - # but the extension bundle is CommonJS - echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" - - # Find and copy ripgrep binary - # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg - # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there - info "Looking for ripgrep binary..." - RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) - if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then - info "Found ripgrep at: $RIPGREP_PATH" - # Create the expected directory structure for the extension to find ripgrep - mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" - cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" - chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" - # Also keep a copy in bin/ for direct access - mkdir -p "$RELEASE_DIR/bin" - cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" - chmod +x "$RELEASE_DIR/bin/rg" - else - warn "ripgrep binary not found - users will need ripgrep installed" - fi - - # Create the wrapper script - info "Creating wrapper script..." - cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' -#!/usr/bin/env node - -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Set environment variables for the CLI -// ROO_CLI_ROOT is the installed CLI package root (where node_modules/@vscode/ripgrep is) -process.env.ROO_CLI_ROOT = join(__dirname, '..'); -process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); -process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); - -// Import and run the actual CLI -await import(join(__dirname, '..', 'lib', 'index.js')); -WRAPPER_EOF - - chmod +x "$RELEASE_DIR/bin/roo" - - # Create empty .env file to suppress dotenvx warnings - touch "$RELEASE_DIR/.env" - - # Create empty .env file to suppress dotenvx warnings - touch "$RELEASE_DIR/.env" - - # Create tarball - info "Creating tarball..." - cd "$REPO_ROOT" - tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" - - # Clean up release directory - rm -rf "$RELEASE_DIR" - - # Show size - TARBALL_PATH="$REPO_ROOT/$TARBALL" - TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') - info "Created: $TARBALL ($TARBALL_SIZE)" -} - -# Verify local installation -verify_local_install() { - if [ "$SKIP_VERIFY" = true ]; then - step "5/8" "Skipping verification (--skip-verify)" - return - fi - - step "5/8" "Verifying local installation..." - - VERIFY_DIR="$REPO_ROOT/.verify-release" - VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" - VERIFY_BIN_DIR="$VERIFY_DIR/bin" - - # Clean up any previous verification directory - rm -rf "$VERIFY_DIR" - mkdir -p "$VERIFY_DIR" - - # Run the actual install script with the local tarball - info "Running install script with local tarball..." - TARBALL_PATH="$REPO_ROOT/$TARBALL" - - ROO_LOCAL_TARBALL="$TARBALL_PATH" \ - ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ - ROO_BIN_DIR="$VERIFY_BIN_DIR" \ - ROO_VERSION="$VERSION" \ - "$CLI_DIR/install.sh" || { - echo "" - warn "Install script failed. Showing tarball contents:" - tar -tzf "$TARBALL_PATH" 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "Installation verification failed! The install script could not complete successfully." - } - - # Verify the CLI runs correctly with basic commands - info "Testing installed CLI..." - - # Test --help - if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then - echo "" - warn "CLI --help output:" - "$VERIFY_BIN_DIR/roo" --help 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI --help check failed! The release tarball may have missing dependencies." - fi - info "CLI --help check passed" - - # Test --version - if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then - echo "" - warn "CLI --version output:" - "$VERIFY_BIN_DIR/roo" --version 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI --version check failed! The release tarball may have missing dependencies." - fi - info "CLI --version check passed" - - # Run a simple end-to-end test to verify the CLI actually works - info "Running end-to-end verification test..." - - # Create a temporary workspace for the test - VERIFY_WORKSPACE="$VERIFY_DIR/workspace" - mkdir -p "$VERIFY_WORKSPACE" - - # Run the CLI with a simple prompt - if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --oneshot -w "$VERIFY_WORKSPACE" "1+1=?" > "$VERIFY_DIR/test-output.log" 2>&1; then - info "End-to-end test passed" - else - EXIT_CODE=$? - echo "" - warn "End-to-end test failed (exit code: $EXIT_CODE). Output:" - cat "$VERIFY_DIR/test-output.log" 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI end-to-end test failed! The CLI may be broken." - fi - - # Clean up verification directory - cd "$REPO_ROOT" - rm -rf "$VERIFY_DIR" - - info "Local verification passed!" -} - -# Create checksum -create_checksum() { - step "6/8" "Creating checksum..." - cd "$REPO_ROOT" - - if command -v sha256sum &> /dev/null; then - sha256sum "$TARBALL" > "${TARBALL}.sha256" - elif command -v shasum &> /dev/null; then - shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" - else - warn "No sha256sum or shasum found, skipping checksum" - return - fi - - info "Checksum: $(cat "${TARBALL}.sha256")" -} - -# Check if release already exists -check_existing_release() { - step "7/8" "Checking for existing release..." - - if gh release view "$TAG" &> /dev/null; then - warn "Release $TAG already exists" - read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - info "Deleting existing release..." - gh release delete "$TAG" --yes - # Also delete the tag if it exists - git tag -d "$TAG" 2>/dev/null || true - git push origin ":refs/tags/$TAG" 2>/dev/null || true - else - error "Aborted. Use a different version or delete the existing release manually." - fi - fi -} - -# Create GitHub release -create_release() { - step "8/8" "Creating GitHub release..." - cd "$REPO_ROOT" - - # Get the current commit SHA for the release target - COMMIT_SHA=$(git rev-parse HEAD) - - # Verify the commit exists on GitHub before attempting to create the release - # This prevents the "Release.target_commitish is invalid" error - info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..." - git fetch origin 2>/dev/null || true - if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then - warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub" - echo "" - echo "The release script needs to create a release at your current commit," - echo "but this commit hasn't been pushed to GitHub yet." - echo "" - read -p "Push current branch to origin now? [Y/n] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - info "Pushing to origin..." - git push origin HEAD || error "Failed to push to origin. Please push manually and try again." - else - error "Aborted. Please push your commits to GitHub and try again." - fi - fi - info "Commit verified on GitHub" - - # Build the What's New section from changelog content - WHATS_NEW_SECTION="" - if [ -n "$CHANGELOG_CONTENT" ]; then - WHATS_NEW_SECTION="## What's New - -$CHANGELOG_CONTENT - -" - fi - - RELEASE_NOTES=$(cat << EOF -${WHATS_NEW_SECTION}## Installation - -\`\`\`bash -curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -Or install a specific version: -\`\`\`bash -ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -## Requirements - -- Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) - -## Usage - -\`\`\`bash -# Run a task -roo "What is this project?" - -# See all options -roo --help -\`\`\` - -## Platform Support - -This release includes: -- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m) - -> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue. - -## Checksum - -\`\`\` -$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A") -\`\`\` -EOF -) - - info "Creating release at commit: ${COMMIT_SHA:0:8}" - - # Create release (gh will create the tag automatically) - info "Creating release..." - RELEASE_FILES="$TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256" - fi - - gh release create "$TAG" \ - --title "Roo Code CLI v$VERSION" \ - --notes "$RELEASE_NOTES" \ - --prerelease \ - --target "$COMMIT_SHA" \ - $RELEASE_FILES - - info "Release created!" -} - -# Cleanup -cleanup() { - info "Cleaning up..." - cd "$REPO_ROOT" - rm -f "$TARBALL" "${TARBALL}.sha256" -} - -# Print summary -print_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n" - echo "" - echo " Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG" - echo "" - echo " Install with:" - echo " curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" - echo "" -} - -# Print dry-run summary -print_dry_run_summary() { - echo "" - printf "${YELLOW}${BOLD}✓ Dry run complete for v$VERSION${NC}\n" - echo "" - echo " The following artifacts were created:" - echo " - $TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - echo " - ${TARBALL}.sha256" - fi - echo "" - echo " To complete the release, run without --dry-run:" - echo " ./apps/cli/scripts/release.sh $VERSION" - echo "" - echo " Or manually upload the tarball to a new GitHub release." - echo "" -} - -# Print local build summary -print_local_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" - echo "" - echo " Tarball: $REPO_ROOT/$TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - echo " Checksum: $REPO_ROOT/${TARBALL}.sha256" - fi - echo "" - echo " To install manually:" - echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" - echo "" - echo " Or re-run with --install to install automatically:" - echo " ./apps/cli/scripts/release.sh --local --install" - echo "" -} - -# Install locally using the install script -install_local() { - step "7/8" "Installing locally..." - - TARBALL_PATH="$REPO_ROOT/$TARBALL" - - ROO_LOCAL_TARBALL="$TARBALL_PATH" \ - ROO_VERSION="$VERSION" \ - "$CLI_DIR/install.sh" || { - error "Local installation failed!" - } - - info "Local installation complete!" -} - -# Print local install summary -print_local_install_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Local build installed for v$VERSION${NC}\n" - echo "" - echo " Tarball: $REPO_ROOT/$TARBALL" - echo " Installed to: ~/.roo/cli" - echo " Binary: ~/.local/bin/roo" - echo "" - echo " Test it out:" - echo " roo --version" - echo " roo --help" - echo "" -} - -# Main -main() { - echo "" - printf "${BLUE}${BOLD}" - echo " ╭─────────────────────────────────╮" - echo " │ Roo Code CLI Release Script │" - echo " ╰─────────────────────────────────╯" - printf "${NC}" - - if [ "$DRY_RUN" = true ]; then - printf "${YELLOW} (DRY RUN MODE)${NC}\n" - elif [ "$LOCAL_BUILD" = true ]; then - printf "${YELLOW} (LOCAL BUILD MODE)${NC}\n" - fi - echo "" - - detect_platform - check_prerequisites - get_version - get_changelog_content - build - create_tarball - verify_local_install - create_checksum - - if [ "$LOCAL_BUILD" = true ]; then - step "7/8" "Skipping GitHub checks (local build)" - if [ "$LOCAL_INSTALL" = true ]; then - install_local - print_local_install_summary - else - step "8/8" "Skipping installation (use --install to auto-install)" - print_local_summary - fi - elif [ "$DRY_RUN" = true ]; then - step "7/8" "Skipping existing release check (dry run)" - step "8/8" "Skipping GitHub release creation (dry run)" - print_dry_run_summary - else - check_existing_release - create_release - cleanup - print_summary - fi -} - -main From 304b1c213cab1f776e27c3df29173393ce53b5fb Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 2 Feb 2026 22:02:57 -0800 Subject: [PATCH 07/65] fix: replace heredocs with echo statements in cli-release workflow (#11168) Co-authored-by: Claude Opus 4.5 --- .github/workflows/cli-release.yml | 127 +++++++++++++++--------------- 1 file changed, 62 insertions(+), 65 deletions(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index a9e63a049c..d285028916 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -128,23 +128,22 @@ jobs: # Create the wrapper script echo "Creating wrapper script..." - cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' -#!/usr/bin/env node - -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Set environment variables for the CLI -process.env.ROO_CLI_ROOT = join(__dirname, '..'); -process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); -process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); - -// Import and run the actual CLI -await import(join(__dirname, '..', 'lib', 'index.js')); -WRAPPER_EOF + printf '%s\n' '#!/usr/bin/env node' \ + '' \ + "import { fileURLToPath } from 'url';" \ + "import { dirname, join } from 'path';" \ + '' \ + 'const __filename = fileURLToPath(import.meta.url);' \ + 'const __dirname = dirname(__filename);' \ + '' \ + '// Set environment variables for the CLI' \ + "process.env.ROO_CLI_ROOT = join(__dirname, '..');" \ + "process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');" \ + "process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');" \ + '' \ + '// Import and run the actual CLI' \ + "await import(join(__dirname, '..', 'lib', 'index.js'));" \ + > "$RELEASE_DIR/bin/roo" chmod +x "$RELEASE_DIR/bin/roo" @@ -309,63 +308,61 @@ WRAPPER_EOF CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} CHECKSUMS: ${{ steps.checksums.outputs.checksums }} run: | - WHATS_NEW="" + NOTES_FILE=$(mktemp) + if [ -n "$CHANGELOG_CONTENT" ]; then - WHATS_NEW="## What's New - -$CHANGELOG_CONTENT - -" + echo "## What's New" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "$CHANGELOG_CONTENT" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" fi - RELEASE_NOTES=$(cat << EOF -${WHATS_NEW}## Installation - -\`\`\`bash -curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -Or install a specific version: -\`\`\`bash -ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -## Requirements - -- Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux x64 - -## Usage - -\`\`\`bash -# Run a task -roo "What is this project?" - -# See all options -roo --help -\`\`\` - -## Platform Support - -This release includes binaries for: -- \`roo-cli-darwin-arm64.tar.gz\` - macOS Apple Silicon (M1/M2/M3) -- \`roo-cli-darwin-x64.tar.gz\` - macOS Intel -- \`roo-cli-linux-x64.tar.gz\` - Linux x64 - -## Checksums - -\`\`\` -${CHECKSUMS} -\`\`\` -EOF - ) + echo "## Installation" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "Or install a specific version:" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Requirements" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "- Node.js 20 or higher" >> "$NOTES_FILE" + echo "- macOS (Intel or Apple Silicon) or Linux x64" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Usage" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "# Run a task" >> "$NOTES_FILE" + echo 'roo "What is this project?"' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "# See all options" >> "$NOTES_FILE" + echo "roo --help" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Platform Support" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "This release includes binaries for:" >> "$NOTES_FILE" + echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE" + echo '- `roo-cli-darwin-x64.tar.gz` - macOS Intel' >> "$NOTES_FILE" + echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Checksums" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "$CHECKSUMS" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" gh release create "$TAG" \ --title "Roo Code CLI v$VERSION" \ - --notes "$RELEASE_NOTES" \ + --notes-file "$NOTES_FILE" \ --prerelease \ release/* + rm -f "$NOTES_FILE" echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG" # Summary job for dry runs From 1c7ccb7b3dd56009daaf38f20efb1764a5188e74 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 2 Feb 2026 22:33:56 -0800 Subject: [PATCH 08/65] Drop MacOS-13 cli support (#11169) --- .github/workflows/cli-release.yml | 6 +----- apps/cli/README.md | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index d285028916..3bcb8995fd 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -23,9 +23,6 @@ jobs: - os: macos-latest platform: darwin-arm64 runs-on: macos-latest - - os: macos-13 - platform: darwin-x64 - runs-on: macos-13 - os: ubuntu-latest platform: linux-x64 runs-on: ubuntu-latest @@ -331,7 +328,7 @@ jobs: echo "## Requirements" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "- Node.js 20 or higher" >> "$NOTES_FILE" - echo "- macOS (Intel or Apple Silicon) or Linux x64" >> "$NOTES_FILE" + echo "- macOS Apple Silicon (M1/M2/M3/M4) or Linux x64" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "## Usage" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" @@ -347,7 +344,6 @@ jobs: echo "" >> "$NOTES_FILE" echo "This release includes binaries for:" >> "$NOTES_FILE" echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE" - echo '- `roo-cli-darwin-x64.tar.gz` - macOS Intel' >> "$NOTES_FILE" echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "## Checksums" >> "$NOTES_FILE" diff --git a/apps/cli/README.md b/apps/cli/README.md index b18cb77ccf..6165448e71 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -19,7 +19,7 @@ curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/i **Requirements:** - Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux x64 +- macOS Apple Silicon (M1/M2/M3/M4) or Linux x64 **Custom installation directory:** @@ -259,7 +259,7 @@ To trigger a release: The workflow will: -1. Build the CLI on all platforms (macOS Intel, macOS ARM, Linux x64) +1. Build the CLI on all platforms (macOS Apple Silicon, Linux x64) 2. Create platform-specific tarballs with bundled ripgrep 3. Verify each tarball 4. Create a GitHub release with all tarballs attached From 4647d0f3c51ac3bdcf34e7b1b87638c854c13c03 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 2 Feb 2026 23:04:41 -0800 Subject: [PATCH 09/65] fix(cli): correct example in install script (#11170) Co-authored-by: Claude Opus 4.5 --- apps/cli/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/install.sh b/apps/cli/install.sh index 1b01e51aa5..2576ec6cce 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -278,7 +278,7 @@ print_success() { echo "" echo " ${BOLD}Example:${NC}" echo " export OPENROUTER_API_KEY=sk-or-v1-..." - echo " roo ~/my-project -P \"What is this project?\"" + echo " cd ~/my-project && roo \"What is this project?\"" echo "" } From 957600ab8c1aa42a39594ce17f1279b9d2c6658d Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 3 Feb 2026 11:46:34 -0500 Subject: [PATCH 10/65] chore: add changeset for v3.46.2 (#11175) --- .changeset/v3.46.2.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/v3.46.2.md diff --git a/.changeset/v3.46.2.md b/.changeset/v3.46.2.md new file mode 100644 index 0000000000..98bcb1ca9e --- /dev/null +++ b/.changeset/v3.46.2.md @@ -0,0 +1,14 @@ +--- +"roo-cline": patch +--- + +- Fix: IPC improvements for task cancellation and queued message handling (PR #11162 by @cte) +- Fix: Queue messages during command execution instead of losing them (PR #11140 by @mrubens) +- Fix: Transform tool blocks to text before condensing to prevent context corruption (PR #10975 by @daniel-lxs) +- Fix: Add image content support to MCP tool responses (PR #10874 by @roomote) +- Fix: Remove deprecated text-embedding-004 and migrate code index to gemini-embedding-001 (PR #11038 by @roomote) +- Feat: Use custom Base URL for OpenRouter model list fetch (#11150 by @sebastianlang84, PR #11154 by @roomote) +- Feat: Migrate Mistral provider to AI SDK for improved performance and reliability (PR #11089 by @daniel-lxs) +- Feat: Migrate SambaNova provider to AI SDK (PR #11153 by @roomote) +- Feat: Migrate xAI provider to use dedicated @ai-sdk/xai package (PR #11158 by @roomote) +- Chore: Remove Feature Request from issue template options (PR #11141 by @roomote) From 658034323b1bd39344e6f2ecb0359c5de509dfa1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:57:43 -0500 Subject: [PATCH 11/65] Changeset version bump (#11176) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens --- .changeset/v3.46.2.md | 14 -------------- CHANGELOG.md | 13 +++++++++++++ src/package.json | 2 +- 3 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 .changeset/v3.46.2.md diff --git a/.changeset/v3.46.2.md b/.changeset/v3.46.2.md deleted file mode 100644 index 98bcb1ca9e..0000000000 --- a/.changeset/v3.46.2.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"roo-cline": patch ---- - -- Fix: IPC improvements for task cancellation and queued message handling (PR #11162 by @cte) -- Fix: Queue messages during command execution instead of losing them (PR #11140 by @mrubens) -- Fix: Transform tool blocks to text before condensing to prevent context corruption (PR #10975 by @daniel-lxs) -- Fix: Add image content support to MCP tool responses (PR #10874 by @roomote) -- Fix: Remove deprecated text-embedding-004 and migrate code index to gemini-embedding-001 (PR #11038 by @roomote) -- Feat: Use custom Base URL for OpenRouter model list fetch (#11150 by @sebastianlang84, PR #11154 by @roomote) -- Feat: Migrate Mistral provider to AI SDK for improved performance and reliability (PR #11089 by @daniel-lxs) -- Feat: Migrate SambaNova provider to AI SDK (PR #11153 by @roomote) -- Feat: Migrate xAI provider to use dedicated @ai-sdk/xai package (PR #11158 by @roomote) -- Chore: Remove Feature Request from issue template options (PR #11141 by @roomote) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d523b897..02ceac20f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Roo Code Changelog +## [3.46.2] - 2026-02-03 + +- Fix: Queue messages during command execution instead of losing them (PR #11140 by @mrubens) +- Fix: Transform tool blocks to text before condensing to prevent context corruption (PR #10975 by @daniel-lxs) +- Fix: Add image content support to MCP tool responses (PR #10874 by @roomote) +- Fix: Remove deprecated text-embedding-004 and migrate code index to gemini-embedding-001 (PR #11038 by @roomote) +- Feat: Use custom Base URL for OpenRouter model list fetch (#11150 by @sebastianlang84, PR #11154 by @roomote) +- Feat: Migrate Mistral provider to AI SDK (PR #11089 by @daniel-lxs) +- Feat: Migrate SambaNova provider to AI SDK (PR #11153 by @roomote) +- Feat: Migrate xAI provider to AI SDK (PR #11158 by @roomote) +- Chore: Remove Feature Request from issue template options (PR #11141 by @roomote) +- Fix: IPC improvements for task cancellation and queued message handling (PR #11162 by @cte) + ## [3.46.1] - 2026-01-30 - Fix: Sanitize tool_use_id in tool_result blocks to match API history, preventing message format errors (PR #11131 by @daniel-lxs) diff --git a/src/package.json b/src/package.json index 04402de28a..423463bf0b 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.46.1", + "version": "3.46.2", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 460cff4c3b6879102625a0a64dade5c4d813e839 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:59:26 -0500 Subject: [PATCH 12/65] feat: migrate HuggingFace provider to AI SDK (#11156) Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- .../providers/__tests__/huggingface.spec.ts | 553 ++++++++++++++++++ src/api/providers/huggingface.ts | 234 +++++--- 2 files changed, 707 insertions(+), 80 deletions(-) create mode 100644 src/api/providers/__tests__/huggingface.spec.ts diff --git a/src/api/providers/__tests__/huggingface.spec.ts b/src/api/providers/__tests__/huggingface.spec.ts new file mode 100644 index 0000000000..e7682474c1 --- /dev/null +++ b/src/api/providers/__tests__/huggingface.spec.ts @@ -0,0 +1,553 @@ +// npx vitest run src/api/providers/__tests__/huggingface.spec.ts + +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "meta-llama/Llama-3.3-70B-Instruct", + provider: "huggingface", + })) + }), +})) + +// Mock the fetchers +vi.mock("../fetchers/huggingface", () => ({ + getHuggingFaceModels: vi.fn(() => Promise.resolve({})), + getCachedHuggingFaceModels: vi.fn(() => ({})), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { HuggingFaceHandler } from "../huggingface" + +describe("HuggingFaceHandler", () => { + let handler: HuggingFaceHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + mockOptions = { + huggingFaceApiKey: "test-huggingface-api-key", + huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct", + } + handler = new HuggingFaceHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(HuggingFaceHandler) + expect(handler.getModel().id).toBe(mockOptions.huggingFaceModelId) + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new HuggingFaceHandler({ + ...mockOptions, + huggingFaceModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe("meta-llama/Llama-3.3-70B-Instruct") + }) + + it("should throw error if API key is not provided", () => { + expect(() => { + new HuggingFaceHandler({ + ...mockOptions, + huggingFaceApiKey: undefined, + }) + }).toThrow("Hugging Face API key is required") + }) + }) + + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new HuggingFaceHandler({ + huggingFaceApiKey: "test-huggingface-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe("meta-llama/Llama-3.3-70B-Instruct") + expect(model.info).toBeDefined() + }) + + it("should return specified model when valid model is provided", () => { + const testModelId = "mistralai/Mistral-7B-Instruct-v0.3" + const handlerWithModel = new HuggingFaceHandler({ + huggingFaceModelId: testModelId, + huggingFaceApiKey: "test-huggingface-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + + it("should return fallback info when model not in cache", () => { + const model = handler.getModel() + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 131072, + supportsImages: false, + supportsPromptCache: false, + }), + ) + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from HuggingFace" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from HuggingFace") + }) + + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) + }) + + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) + + // HuggingFace provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + huggingface: { + promptCacheHitTokens: 30, + promptCacheMissTokens: 70, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].cacheWriteTokens).toBe(70) + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0.7 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new HuggingFaceHandler({ + huggingFaceApiKey: "test-key", + huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should use user-specified temperature over provider defaults", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithCustomTemp = new HuggingFaceHandler({ + huggingFaceApiKey: "test-key", + huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct", + modelTemperature: 0.7, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + // User-specified temperature should take precedence over everything + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) + + it("should handle errors with handleAiSdkError", async () => { + async function* mockFullStream(): AsyncGenerator { + yield { type: "text-delta", text: "" } // Yield something before error to satisfy lint + throw new Error("API Error") + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("HuggingFace: API Error") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from HuggingFace", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from HuggingFace") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestHuggingFaceHandler extends HuggingFaceHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestHuggingFaceHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + huggingface: { + promptCacheHitTokens: 20, + promptCacheMissTokens: 80, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) + }) + + it("should handle missing cache metrics gracefully", () => { + class TestHuggingFaceHandler extends HuggingFaceHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestHuggingFaceHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should include reasoning tokens when provided", () => { + class TestHuggingFaceHandler extends HuggingFaceHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestHuggingFaceHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.reasoningTokens).toBe(30) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + }) +}) diff --git a/src/api/providers/huggingface.ts b/src/api/providers/huggingface.ts index 21e429aaab..25d0608a33 100644 --- a/src/api/providers/huggingface.ts +++ b/src/api/providers/huggingface.ts @@ -1,22 +1,37 @@ -import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { streamText, generateText, ToolSet } from "ai" -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import { getHuggingFaceModels, getCachedHuggingFaceModels } from "./fetchers/huggingface" -import { handleOpenAIError } from "./utils/openai-error-handler" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +const HUGGINGFACE_DEFAULT_TEMPERATURE = 0.7 + +/** + * HuggingFace provider using @ai-sdk/openai-compatible for OpenAI-compatible API. + * Uses HuggingFace's OpenAI-compatible endpoint to enable tool message support. + * @see https://github.com/vercel/ai/issues/10766 - Workaround for tool messages not supported in @ai-sdk/huggingface + */ export class HuggingFaceHandler extends BaseProvider implements SingleCompletionHandler { - private client: OpenAI - private options: ApiHandlerOptions + protected options: ApiHandlerOptions + protected provider: ReturnType private modelCache: ModelRecord | null = null - private readonly providerName = "HuggingFace" constructor(options: ApiHandlerOptions) { super() @@ -26,10 +41,14 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion throw new Error("Hugging Face API key is required") } - this.client = new OpenAI({ + // Create an OpenAI-compatible provider pointing to HuggingFace's /v1 endpoint + // This fixes "tool messages not supported" error - the HuggingFace SDK doesn't + // properly handle function_call_output format, but OpenAI SDK does + this.provider = createOpenAICompatible({ + name: "huggingface", baseURL: "https://router.huggingface.co/v1", apiKey: this.options.huggingFaceApiKey, - defaultHeaders: DEFAULT_HEADERS, + headers: DEFAULT_HEADERS, }) // Try to get cached models first @@ -47,91 +66,146 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion } } + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" + + // Try to get model info from cache + const cachedInfo = this.modelCache?.[id] + + const info: ModelInfo = cachedInfo || { + maxTokens: 8192, + contextWindow: 131072, + supportsImages: false, + supportsPromptCache: false, + } + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: HUGGINGFACE_DEFAULT_TEMPERATURE, + }) + + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + huggingface?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from HuggingFace's providerMetadata if available + const cacheReadTokens = providerMetadata?.huggingface?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.huggingface?.promptCacheMissTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" - const temperature = this.options.modelTemperature ?? 0.7 + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? HUGGINGFACE_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), } - // Add max_tokens if specified - if (this.options.includeMaxTokens && this.options.modelMaxTokens) { - params.max_tokens = this.options.modelMaxTokens - } + // Use streamText for streaming responses + const result = streamText(requestOptions) - let stream try { - stream = await this.client.chat.completions.create(params) + // Process the full stream to get all events + for await (const part of result.fullStream) { + // Use the processAiSdkStreamPart utility to convert stream parts + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - yield { - type: "text", - text: delta.content, - } - } - - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "HuggingFace") } } + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string): Promise { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() - try { - const response = await this.client.chat.completions.create({ - model: modelId, - messages: [{ role: "user", content: prompt }], - }) + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? HUGGINGFACE_DEFAULT_TEMPERATURE, + }) - return response.choices[0]?.message.content || "" - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - } - - override getModel() { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" - - // Try to get model info from cache - const modelInfo = this.modelCache?.[modelId] - - if (modelInfo) { - return { - id: modelId, - info: modelInfo, - } - } - - // Fallback to default values if model not found in cache - return { - id: modelId, - info: { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - }, - } + return text } } From 54ea34e2c1dc4e837a9f1c7047c8f67562da45d1 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Tue, 3 Feb 2026 17:11:58 +0000 Subject: [PATCH 13/65] ux: improve Skills and Slash Commands settings UI with multi-mode support (#11157) Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> Co-authored-by: Roo Code --- packages/types/src/skills.ts | 12 +- packages/types/src/vscode-extension-host.ts | 7 + .../__tests__/skillsMessageHandler.spec.ts | 14 +- src/core/webview/skillsMessageHandler.ts | 52 +- src/core/webview/webviewMessageHandler.ts | 5 + src/i18n/locales/ca/skills.json | 1 + src/i18n/locales/de/skills.json | 1 + src/i18n/locales/en/skills.json | 1 + src/i18n/locales/es/skills.json | 1 + src/i18n/locales/fr/skills.json | 1 + src/i18n/locales/hi/skills.json | 1 + src/i18n/locales/id/skills.json | 1 + src/i18n/locales/it/skills.json | 1 + src/i18n/locales/ja/skills.json | 1 + src/i18n/locales/ko/skills.json | 1 + src/i18n/locales/nl/skills.json | 1 + src/i18n/locales/pl/skills.json | 1 + src/i18n/locales/pt-BR/skills.json | 1 + src/i18n/locales/ru/skills.json | 1 + src/i18n/locales/tr/skills.json | 1 + src/i18n/locales/vi/skills.json | 1 + src/i18n/locales/zh-CN/skills.json | 1 + src/i18n/locales/zh-TW/skills.json | 1 + src/services/skills/SkillsManager.ts | 135 ++++- .../skills/__tests__/SkillsManager.spec.ts | 12 +- src/shared/skills.ts | 12 +- .../src/components/chat/SlashCommandItem.tsx | 84 ---- .../components/settings/CreateSkillDialog.tsx | 131 +++-- .../settings/CreateSlashCommandDialog.tsx | 156 ++++++ .../src/components/settings/SettingsView.tsx | 8 +- .../components/settings/SkillsSettings.tsx | 349 +++++++++---- .../settings/SlashCommandsSettings.tsx | 321 ++++++------ .../__tests__/CreateSkillDialog.spec.tsx | 130 ++++- .../SettingsView.change-detection.spec.tsx | 31 ++ .../settings/__tests__/SettingsView.spec.tsx | 20 + .../SettingsView.unsaved-changes.spec.tsx | 31 ++ .../__tests__/SkillsSettings.spec.tsx | 198 ++++---- .../__tests__/SlashCommandsSettings.spec.tsx | 467 +++++++----------- webview-ui/src/components/ui/checkbox.tsx | 2 +- webview-ui/src/components/ui/input.tsx | 2 +- webview-ui/src/components/ui/textarea.tsx | 2 +- webview-ui/src/i18n/locales/ca/settings.json | 62 ++- webview-ui/src/i18n/locales/de/settings.json | 80 ++- webview-ui/src/i18n/locales/en/settings.json | 66 ++- webview-ui/src/i18n/locales/es/settings.json | 64 ++- webview-ui/src/i18n/locales/fr/settings.json | 58 ++- webview-ui/src/i18n/locales/hi/settings.json | 61 ++- webview-ui/src/i18n/locales/id/settings.json | 90 ++-- webview-ui/src/i18n/locales/it/settings.json | 59 ++- webview-ui/src/i18n/locales/ja/settings.json | 59 ++- webview-ui/src/i18n/locales/ko/settings.json | 59 ++- webview-ui/src/i18n/locales/nl/settings.json | 63 ++- webview-ui/src/i18n/locales/pl/settings.json | 59 ++- .../src/i18n/locales/pt-BR/settings.json | 59 ++- webview-ui/src/i18n/locales/ru/settings.json | 59 ++- webview-ui/src/i18n/locales/tr/settings.json | 59 ++- webview-ui/src/i18n/locales/vi/settings.json | 59 ++- .../src/i18n/locales/zh-CN/settings.json | 59 ++- .../src/i18n/locales/zh-TW/settings.json | 66 ++- 59 files changed, 2248 insertions(+), 1092 deletions(-) delete mode 100644 webview-ui/src/components/chat/SlashCommandItem.tsx create mode 100644 webview-ui/src/components/settings/CreateSlashCommandDialog.tsx diff --git a/packages/types/src/skills.ts b/packages/types/src/skills.ts index b50b4e6d47..3e856612bc 100644 --- a/packages/types/src/skills.ts +++ b/packages/types/src/skills.ts @@ -7,7 +7,17 @@ export interface SkillMetadata { description: string // Required: when to use this skill path: string // Absolute path to SKILL.md (or "" for built-in skills) source: "global" | "project" | "built-in" // Where the skill was discovered - mode?: string // If set, skill is only available in this mode + /** + * @deprecated Use modeSlugs instead. Kept for backward compatibility. + * If set, skill is only available in this mode. + */ + mode?: string + /** + * Mode slugs where this skill is available. + * - undefined or empty array means the skill is available in all modes ("Any mode"). + * - An array with one or more mode slugs restricts the skill to those modes. + */ + modeSlugs?: string[] } /** diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 21bc59092a..fa2f04c0e5 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -605,6 +605,7 @@ export interface WebviewMessage { | "createSkill" | "deleteSkill" | "moveSkill" + | "updateSkillModes" | "openSkillFile" text?: string editedMessageContent?: string @@ -641,9 +642,15 @@ export interface WebviewMessage { payload?: WebViewMessagePayload source?: "global" | "project" | "built-in" skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile) + /** @deprecated Use skillModeSlugs instead */ skillMode?: string // For skill operations (current mode restriction) + /** @deprecated Use newSkillModeSlugs instead */ newSkillMode?: string // For moveSkill (target mode) skillDescription?: string // For createSkill (skill description) + /** Mode slugs for skill operations. undefined/empty = any mode */ + skillModeSlugs?: string[] // For skill operations (mode restrictions) + /** Target mode slugs for updateSkillModes */ + newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions) requestId?: string ids?: string[] hasSystemPromptOverride?: boolean diff --git a/src/core/webview/__tests__/skillsMessageHandler.spec.ts b/src/core/webview/__tests__/skillsMessageHandler.spec.ts index f26194ee81..cdc571282f 100644 --- a/src/core/webview/__tests__/skillsMessageHandler.spec.ts +++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts @@ -52,6 +52,7 @@ describe("skillsMessageHandler", () => { const mockDeleteSkill = vi.fn() const mockMoveSkill = vi.fn() const mockGetSkill = vi.fn() + const mockFindSkillByNameAndSource = vi.fn() const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => { const skillsManager = hasSkillsManager @@ -61,6 +62,7 @@ describe("skillsMessageHandler", () => { deleteSkill: mockDeleteSkill, moveSkill: mockMoveSkill, getSkill: mockGetSkill, + findSkillByNameAndSource: mockFindSkillByNameAndSource, } : undefined @@ -158,7 +160,7 @@ describe("skillsMessageHandler", () => { } as WebviewMessage) expect(result).toEqual(mockSkills) - expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", "code") + expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", ["code"]) }) it("returns undefined when required fields are missing", async () => { @@ -355,7 +357,7 @@ describe("skillsMessageHandler", () => { describe("handleOpenSkillFile", () => { it("opens a skill file successfully", async () => { const provider = createMockProvider(true) - mockGetSkill.mockReturnValue(mockSkills[0]) + mockFindSkillByNameAndSource.mockReturnValue(mockSkills[0]) await handleOpenSkillFile(provider, { type: "openSkillFile", @@ -363,13 +365,13 @@ describe("skillsMessageHandler", () => { source: "global", } as WebviewMessage) - expect(mockGetSkill).toHaveBeenCalledWith("test-skill", "global", undefined) + expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("test-skill", "global") expect(openFile).toHaveBeenCalledWith("/path/to/test-skill/SKILL.md") }) it("opens a skill file with mode restriction", async () => { const provider = createMockProvider(true) - mockGetSkill.mockReturnValue(mockSkills[1]) + mockFindSkillByNameAndSource.mockReturnValue(mockSkills[1]) await handleOpenSkillFile(provider, { type: "openSkillFile", @@ -378,7 +380,7 @@ describe("skillsMessageHandler", () => { skillMode: "code", } as WebviewMessage) - expect(mockGetSkill).toHaveBeenCalledWith("project-skill", "project", "code") + expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("project-skill", "project") expect(openFile).toHaveBeenCalledWith("/project/.roo/skills/project-skill/SKILL.md") }) @@ -416,7 +418,7 @@ describe("skillsMessageHandler", () => { it("shows error when skill is not found", async () => { const provider = createMockProvider(true) - mockGetSkill.mockReturnValue(undefined) + mockFindSkillByNameAndSource.mockReturnValue(undefined) await handleOpenSkillFile(provider, { type: "openSkillFile", diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts index f09f22f58c..f5db0473fb 100644 --- a/src/core/webview/skillsMessageHandler.ts +++ b/src/core/webview/skillsMessageHandler.ts @@ -38,7 +38,8 @@ export async function handleCreateSkill( const skillName = message.skillName const source = message.source const skillDescription = message.skillDescription - const skillMode = message.skillMode + // Support new modeSlugs array or fall back to legacy skillMode + const modeSlugs = message.skillModeSlugs ?? (message.skillMode ? [message.skillMode] : undefined) if (!skillName || !source || !skillDescription) { throw new Error(t("skills:errors.missing_create_fields")) @@ -54,7 +55,7 @@ export async function handleCreateSkill( throw new Error(t("skills:errors.manager_unavailable")) } - const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode) + const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, modeSlugs) // Open the created file in the editor openFile(createdPath) @@ -81,7 +82,8 @@ export async function handleDeleteSkill( try { const skillName = message.skillName const source = message.source - const skillMode = message.skillMode + // Support new skillModeSlugs array or fall back to legacy skillMode + const skillMode = message.skillModeSlugs?.[0] ?? message.skillMode if (!skillName || !source) { throw new Error(t("skills:errors.missing_delete_fields")) @@ -152,6 +154,46 @@ export async function handleMoveSkill( } } +/** + * Handles the updateSkillModes message - updates the mode associations for a skill + */ +export async function handleUpdateSkillModes( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source + const newModeSlugs = message.newSkillModeSlugs + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_update_modes_fields")) + } + + // Built-in skills cannot be modified + if (source === "built-in") { + throw new Error(t("skills:errors.cannot_modify_builtin")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.updateSkillModes(skillName, source, newModeSlugs) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error updating skill modes: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to update skill modes: ${errorMessage}`) + return undefined + } +} + /** * Handles the openSkillFile message - opens a skill file in the editor */ @@ -159,7 +201,6 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv try { const skillName = message.skillName const source = message.source - const skillMode = message.skillMode if (!skillName || !source) { throw new Error(t("skills:errors.missing_delete_fields")) @@ -175,7 +216,8 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv throw new Error(t("skills:errors.manager_unavailable")) } - const skill = skillsManager.getSkill(skillName, source, skillMode) + // Find skill by name and source (skills may have modeSlugs arrays now) + const skill = skillsManager.findSkillByNameAndSource(skillName, source) if (!skill) { throw new Error(t("skills:errors.skill_not_found", { name: skillName })) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ea4a556726..77d1e02233 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -37,6 +37,7 @@ import { handleCreateSkill, handleDeleteSkill, handleMoveSkill, + handleUpdateSkillModes, handleOpenSkillFile, } from "./skillsMessageHandler" import { changeLanguage, t } from "../../i18n" @@ -2992,6 +2993,10 @@ export const webviewMessageHandler = async ( await handleMoveSkill(provider, message) break } + case "updateSkillModes": { + await handleUpdateSkillModes(provider, message) + break + } case "openSkillFile": { await handleOpenSkillFile(provider, message) break diff --git a/src/i18n/locales/ca/skills.json b/src/i18n/locales/ca/skills.json index 74d8cba039..47b5993889 100644 --- a/src/i18n/locales/ca/skills.json +++ b/src/i18n/locales/ca/skills.json @@ -8,6 +8,7 @@ "not_found": "No s'ha trobat l'habilitat \"{{name}}\" a {{source}}{{modeInfo}}", "missing_create_fields": "Falten camps obligatoris: skillName, source o skillDescription", "missing_move_fields": "Falten camps obligatoris: skillName o source", + "missing_update_modes_fields": "Falten camps obligatoris: skillName o source", "manager_unavailable": "El gestor d'habilitats no està disponible", "missing_delete_fields": "Falten camps obligatoris: skillName o source", "skill_not_found": "No s'ha trobat l'habilitat \"{{name}}\"", diff --git a/src/i18n/locales/de/skills.json b/src/i18n/locales/de/skills.json index 5aad37950f..fe05128895 100644 --- a/src/i18n/locales/de/skills.json +++ b/src/i18n/locales/de/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" nicht gefunden in {{source}}{{modeInfo}}", "missing_create_fields": "Erforderliche Felder fehlen: skillName, source oder skillDescription", "missing_move_fields": "Erforderliche Felder fehlen: skillName oder source", + "missing_update_modes_fields": "Erforderliche Felder fehlen: skillName oder source", "manager_unavailable": "Skill-Manager nicht verfügbar", "missing_delete_fields": "Erforderliche Felder fehlen: skillName oder source", "skill_not_found": "Skill \"{{name}}\" nicht gefunden", diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index ef4d7e68e3..5b6dde45b9 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}", "missing_create_fields": "Missing required fields: skillName, source, or skillDescription", "missing_move_fields": "Missing required fields: skillName or source", + "missing_update_modes_fields": "Missing required fields: skillName or source", "manager_unavailable": "Skills manager not available", "missing_delete_fields": "Missing required fields: skillName or source", "skill_not_found": "Skill \"{{name}}\" not found", diff --git a/src/i18n/locales/es/skills.json b/src/i18n/locales/es/skills.json index 6534581518..84ab35b6d1 100644 --- a/src/i18n/locales/es/skills.json +++ b/src/i18n/locales/es/skills.json @@ -8,6 +8,7 @@ "not_found": "No se encontró la habilidad \"{{name}}\" en {{source}}{{modeInfo}}", "missing_create_fields": "Faltan campos obligatorios: skillName, source o skillDescription", "missing_move_fields": "Faltan campos obligatorios: skillName o source", + "missing_update_modes_fields": "Faltan campos obligatorios: skillName o source", "manager_unavailable": "El gestor de habilidades no está disponible", "missing_delete_fields": "Faltan campos obligatorios: skillName o source", "skill_not_found": "No se encontró la habilidad \"{{name}}\"", diff --git a/src/i18n/locales/fr/skills.json b/src/i18n/locales/fr/skills.json index 5c4cb1f5ae..6320a4f55d 100644 --- a/src/i18n/locales/fr/skills.json +++ b/src/i18n/locales/fr/skills.json @@ -8,6 +8,7 @@ "not_found": "Compétence \"{{name}}\" introuvable dans {{source}}{{modeInfo}}", "missing_create_fields": "Champs obligatoires manquants : skillName, source ou skillDescription", "missing_move_fields": "Champs obligatoires manquants : skillName ou source", + "missing_update_modes_fields": "Champs obligatoires manquants : skillName ou source", "manager_unavailable": "Le gestionnaire de compétences n'est pas disponible", "missing_delete_fields": "Champs obligatoires manquants : skillName ou source", "skill_not_found": "Compétence \"{{name}}\" introuvable", diff --git a/src/i18n/locales/hi/skills.json b/src/i18n/locales/hi/skills.json index 50929b4845..9b79cdb30f 100644 --- a/src/i18n/locales/hi/skills.json +++ b/src/i18n/locales/hi/skills.json @@ -8,6 +8,7 @@ "not_found": "स्किल \"{{name}}\" {{source}}{{modeInfo}} में नहीं मिला", "missing_create_fields": "आवश्यक फ़ील्ड गायब हैं: skillName, source, या skillDescription", "missing_move_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", + "missing_update_modes_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", "manager_unavailable": "स्किल मैनेजर उपलब्ध नहीं है", "missing_delete_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", "skill_not_found": "स्किल \"{{name}}\" नहीं मिला", diff --git a/src/i18n/locales/id/skills.json b/src/i18n/locales/id/skills.json index cfa01b3323..6559a9d6b1 100644 --- a/src/i18n/locales/id/skills.json +++ b/src/i18n/locales/id/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" tidak ditemukan di {{source}}{{modeInfo}}", "missing_create_fields": "Bidang wajib tidak ada: skillName, source, atau skillDescription", "missing_move_fields": "Bidang wajib tidak ada: skillName atau source", + "missing_update_modes_fields": "Bidang wajib tidak ada: skillName atau source", "manager_unavailable": "Manajer skill tidak tersedia", "missing_delete_fields": "Bidang wajib tidak ada: skillName atau source", "skill_not_found": "Skill \"{{name}}\" tidak ditemukan", diff --git a/src/i18n/locales/it/skills.json b/src/i18n/locales/it/skills.json index 0ddcf0f70c..fdfd82e261 100644 --- a/src/i18n/locales/it/skills.json +++ b/src/i18n/locales/it/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" non trovata in {{source}}{{modeInfo}}", "missing_create_fields": "Campi obbligatori mancanti: skillName, source o skillDescription", "missing_move_fields": "Campi obbligatori mancanti: skillName o source", + "missing_update_modes_fields": "Campi obbligatori mancanti: skillName o source", "manager_unavailable": "Il gestore delle skill non è disponibile", "missing_delete_fields": "Campi obbligatori mancanti: skillName o source", "skill_not_found": "Skill \"{{name}}\" non trovata", diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json index 16576b2be3..074baacdfd 100644 --- a/src/i18n/locales/ja/skills.json +++ b/src/i18n/locales/ja/skills.json @@ -8,6 +8,7 @@ "not_found": "スキル「{{name}}」が{{source}}{{modeInfo}}に見つかりません", "missing_create_fields": "必須フィールドが不足しています:skillName、source、またはskillDescription", "missing_move_fields": "必須フィールドが不足しています:skillNameまたはsource", + "missing_update_modes_fields": "必須フィールドが不足しています:skillNameまたはsource", "manager_unavailable": "スキルマネージャーが利用できません", "missing_delete_fields": "必須フィールドが不足しています:skillNameまたはsource", "skill_not_found": "スキル「{{name}}」が見つかりません", diff --git a/src/i18n/locales/ko/skills.json b/src/i18n/locales/ko/skills.json index c5808f3630..5386675ea6 100644 --- a/src/i18n/locales/ko/skills.json +++ b/src/i18n/locales/ko/skills.json @@ -8,6 +8,7 @@ "not_found": "{{source}}{{modeInfo}}에서 스킬 \"{{name}}\"을(를) 찾을 수 없습니다", "missing_create_fields": "필수 필드 누락: skillName, source 또는 skillDescription", "missing_move_fields": "필수 필드 누락: skillName 또는 source", + "missing_update_modes_fields": "필수 필드 누락: skillName 또는 source", "manager_unavailable": "스킬 관리자를 사용할 수 없습니다", "missing_delete_fields": "필수 필드 누락: skillName 또는 source", "skill_not_found": "스킬 \"{{name}}\"을(를) 찾을 수 없습니다", diff --git a/src/i18n/locales/nl/skills.json b/src/i18n/locales/nl/skills.json index 6c6e7e0e83..ed9caab43b 100644 --- a/src/i18n/locales/nl/skills.json +++ b/src/i18n/locales/nl/skills.json @@ -8,6 +8,7 @@ "not_found": "Vaardigheid \"{{name}}\" niet gevonden in {{source}}{{modeInfo}}", "missing_create_fields": "Vereiste velden ontbreken: skillName, source of skillDescription", "missing_move_fields": "Vereiste velden ontbreken: skillName of source", + "missing_update_modes_fields": "Vereiste velden ontbreken: skillName of source", "manager_unavailable": "Vaardigheidenbeheerder niet beschikbaar", "missing_delete_fields": "Vereiste velden ontbreken: skillName of source", "skill_not_found": "Vaardigheid \"{{name}}\" niet gevonden", diff --git a/src/i18n/locales/pl/skills.json b/src/i18n/locales/pl/skills.json index f9363e42d0..7a5e5f0ac1 100644 --- a/src/i18n/locales/pl/skills.json +++ b/src/i18n/locales/pl/skills.json @@ -8,6 +8,7 @@ "not_found": "Nie znaleziono umiejętności \"{{name}}\" w {{source}}{{modeInfo}}", "missing_create_fields": "Brakuje wymaganych pól: skillName, source lub skillDescription", "missing_move_fields": "Brakuje wymaganych pól: skillName lub source", + "missing_update_modes_fields": "Brakuje wymaganych pól: skillName lub source", "manager_unavailable": "Menedżer umiejętności niedostępny", "missing_delete_fields": "Brakuje wymaganych pól: skillName lub source", "skill_not_found": "Nie znaleziono umiejętności \"{{name}}\"", diff --git a/src/i18n/locales/pt-BR/skills.json b/src/i18n/locales/pt-BR/skills.json index 8058e9f6a3..eac683e7fe 100644 --- a/src/i18n/locales/pt-BR/skills.json +++ b/src/i18n/locales/pt-BR/skills.json @@ -8,6 +8,7 @@ "not_found": "Habilidade \"{{name}}\" não encontrada em {{source}}{{modeInfo}}", "missing_create_fields": "Campos obrigatórios ausentes: skillName, source ou skillDescription", "missing_move_fields": "Campos obrigatórios ausentes: skillName ou source", + "missing_update_modes_fields": "Campos obrigatórios ausentes: skillName ou source", "manager_unavailable": "Gerenciador de habilidades não disponível", "missing_delete_fields": "Campos obrigatórios ausentes: skillName ou source", "skill_not_found": "Habilidade \"{{name}}\" não encontrada", diff --git a/src/i18n/locales/ru/skills.json b/src/i18n/locales/ru/skills.json index 627a8fd4d6..740d813873 100644 --- a/src/i18n/locales/ru/skills.json +++ b/src/i18n/locales/ru/skills.json @@ -8,6 +8,7 @@ "not_found": "Навык \"{{name}}\" не найден в {{source}}{{modeInfo}}", "missing_create_fields": "Отсутствуют обязательные поля: skillName, source или skillDescription", "missing_move_fields": "Отсутствуют обязательные поля: skillName или source", + "missing_update_modes_fields": "Отсутствуют обязательные поля: skillName или source", "manager_unavailable": "Менеджер навыков недоступен", "missing_delete_fields": "Отсутствуют обязательные поля: skillName или source", "skill_not_found": "Навык \"{{name}}\" не найден", diff --git a/src/i18n/locales/tr/skills.json b/src/i18n/locales/tr/skills.json index e7781aa696..235b9d55fc 100644 --- a/src/i18n/locales/tr/skills.json +++ b/src/i18n/locales/tr/skills.json @@ -8,6 +8,7 @@ "not_found": "\"{{name}}\" becerisi {{source}}{{modeInfo}} içinde bulunamadı", "missing_create_fields": "Gerekli alanlar eksik: skillName, source veya skillDescription", "missing_move_fields": "Gerekli alanlar eksik: skillName veya source", + "missing_update_modes_fields": "Gerekli alanlar eksik: skillName veya source", "manager_unavailable": "Beceri yöneticisi kullanılamıyor", "missing_delete_fields": "Gerekli alanlar eksik: skillName veya source", "skill_not_found": "\"{{name}}\" becerisi bulunamadı", diff --git a/src/i18n/locales/vi/skills.json b/src/i18n/locales/vi/skills.json index f97b7ed2b0..47e433ee57 100644 --- a/src/i18n/locales/vi/skills.json +++ b/src/i18n/locales/vi/skills.json @@ -8,6 +8,7 @@ "not_found": "Không tìm thấy kỹ năng \"{{name}}\" trong {{source}}{{modeInfo}}", "missing_create_fields": "Thiếu các trường bắt buộc: skillName, source hoặc skillDescription", "missing_move_fields": "Thiếu các trường bắt buộc: skillName hoặc source", + "missing_update_modes_fields": "Thiếu các trường bắt buộc: skillName hoặc source", "manager_unavailable": "Trình quản lý kỹ năng không khả dụng", "missing_delete_fields": "Thiếu các trường bắt buộc: skillName hoặc source", "skill_not_found": "Không tìm thấy kỹ năng \"{{name}}\"", diff --git a/src/i18n/locales/zh-CN/skills.json b/src/i18n/locales/zh-CN/skills.json index 566f583fee..719bc722a5 100644 --- a/src/i18n/locales/zh-CN/skills.json +++ b/src/i18n/locales/zh-CN/skills.json @@ -8,6 +8,7 @@ "not_found": "在 {{source}}{{modeInfo}} 中未找到技能 \"{{name}}\"", "missing_create_fields": "缺少必填字段:skillName、source 或 skillDescription", "missing_move_fields": "缺少必填字段:skillName 或 source", + "missing_update_modes_fields": "缺少必填字段:skillName 或 source", "manager_unavailable": "技能管理器不可用", "missing_delete_fields": "缺少必填字段:skillName 或 source", "skill_not_found": "未找到技能 \"{{name}}\"", diff --git a/src/i18n/locales/zh-TW/skills.json b/src/i18n/locales/zh-TW/skills.json index 633bb1a6b2..2d9a52be1e 100644 --- a/src/i18n/locales/zh-TW/skills.json +++ b/src/i18n/locales/zh-TW/skills.json @@ -8,6 +8,7 @@ "not_found": "在 {{source}}{{modeInfo}} 中找不到技能「{{name}}」", "missing_create_fields": "缺少必填欄位:skillName、source 或 skillDescription", "missing_move_fields": "缺少必填欄位:skillName 或 source", + "missing_update_modes_fields": "缺少必填欄位:skillName 或 source", "manager_unavailable": "技能管理器無法使用", "missing_delete_fields": "缺少必填欄位:skillName 或 source", "skill_not_found": "找不到技能「{{name}}」", diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 05d879d975..ac1473748b 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -143,15 +143,34 @@ export class SkillsManager { return } - // Create unique key combining name, source, and mode for override resolution - const skillKey = this.getSkillKey(effectiveSkillName, source, mode) + // Parse modeSlugs from frontmatter (new format) or fall back to directory-based mode + // Priority: frontmatter.modeSlugs > frontmatter.mode > directory mode + let modeSlugs: string[] | undefined + if (Array.isArray(frontmatter.modeSlugs)) { + modeSlugs = frontmatter.modeSlugs.filter((s: unknown) => typeof s === "string" && s.length > 0) + if (modeSlugs.length === 0) { + modeSlugs = undefined // Empty array means "any mode" + } + } else if (typeof frontmatter.mode === "string" && frontmatter.mode.length > 0) { + // Legacy single mode in frontmatter + modeSlugs = [frontmatter.mode] + } else if (mode) { + // Fall back to directory-based mode (skills-{mode}/) + modeSlugs = [mode] + } + + // Create unique key combining name, source, and modeSlugs for override resolution + // For backward compatibility, use first mode slug or undefined for the key + const primaryMode = modeSlugs?.[0] + const skillKey = this.getSkillKey(effectiveSkillName, source, primaryMode) this.skills.set(skillKey, { name: effectiveSkillName, description, path: skillMdPath, source, - mode, // undefined for generic skills, string for mode-specific + mode: primaryMode, // Deprecated: kept for backward compatibility + modeSlugs, // New: array of mode slugs, undefined = any mode }) } catch (error) { console.error(`Failed to load skill at ${skillDir}:`, error) @@ -174,8 +193,11 @@ export class SkillsManager { // Then, add discovered skills (will override built-in skills with same name) for (const skill of this.skills.values()) { - // Skip mode-specific skills that don't match current mode - if (skill.mode && skill.mode !== currentMode) continue + // Check if skill is available in current mode: + // - modeSlugs undefined or empty = available in all modes ("Any mode") + // - modeSlugs array with values = available only if currentMode is in the array + const isAvailableInMode = this.isSkillAvailableInMode(skill, currentMode) + if (!isAvailableInMode) continue const existingSkill = resolvedSkills.get(skill.name) @@ -194,6 +216,20 @@ export class SkillsManager { return Array.from(resolvedSkills.values()) } + /** + * Check if a skill is available in the given mode. + * - modeSlugs undefined or empty = available in all modes ("Any mode") + * - modeSlugs with values = available only if mode is in the array + */ + private isSkillAvailableInMode(skill: SkillMetadata, currentMode: string): boolean { + // No mode restrictions = available in all modes + if (!skill.modeSlugs || skill.modeSlugs.length === 0) { + return true + } + // Check if current mode is in the allowed modes + return skill.modeSlugs.includes(currentMode) + } + /** * Determine if newSkill should override existingSkill based on priority rules. * Priority: project > global > built-in, mode-specific > generic @@ -214,8 +250,11 @@ export class SkillsManager { if (newPriority < existingPriority) return false // Same source: mode-specific overrides generic - if (newSkill.mode && !existing.mode) return true - if (!newSkill.mode && existing.mode) return false + // A skill with modeSlugs (restricted) is more specific than one without (any mode) + const existingHasModes = existing.modeSlugs && existing.modeSlugs.length > 0 + const newHasModes = newSkill.modeSlugs && newSkill.modeSlugs.length > 0 + if (newHasModes && !existingHasModes) return true + if (!newHasModes && existingHasModes) return false // Same source and same mode-specificity: keep existing (first wins) return false @@ -276,6 +315,19 @@ export class SkillsManager { return this.skills.get(skillKey) } + /** + * Find a skill by name and source (regardless of mode). + * Useful for opening/editing skills where the exact mode key may vary. + */ + findSkillByNameAndSource(name: string, source: "global" | "project"): SkillMetadata | undefined { + for (const skill of this.skills.values()) { + if (skill.name === name && skill.source === source) { + return skill + } + } + return undefined + } + /** * Validate skill name per agentskills.io spec using shared validation. * Converts error codes to user-friendly error messages. @@ -307,10 +359,15 @@ export class SkillsManager { * @param name - Skill name (must be valid per agentskills.io spec) * @param source - "global" or "project" * @param description - Skill description - * @param mode - Optional mode restriction (creates in skills-{mode}/ directory) + * @param modeSlugs - Optional mode restrictions (undefined/empty = any mode) * @returns Path to created SKILL.md file */ - async createSkill(name: string, source: "global" | "project", description: string, mode?: string): Promise { + async createSkill( + name: string, + source: "global" | "project", + description: string, + modeSlugs?: string[], + ): Promise { // Validate skill name const validation = this.validateSkillName(name) if (!validation.valid) { @@ -335,9 +392,8 @@ export class SkillsManager { baseDir = path.join(provider.cwd, ".roo") } - // Determine skills directory (with optional mode suffix) - const skillsDirName = mode ? `skills-${mode}` : "skills" - const skillsDir = path.join(baseDir, skillsDirName) + // Always use the generic skills directory (mode info stored in frontmatter now) + const skillsDir = path.join(baseDir, "skills") const skillDir = path.join(skillsDir, name) const skillMdPath = path.join(skillDir, "SKILL.md") @@ -355,9 +411,17 @@ export class SkillsManager { .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ") + // Build frontmatter with optional modeSlugs + const frontmatterLines = [`name: ${name}`, `description: ${trimmedDescription}`] + if (modeSlugs && modeSlugs.length > 0) { + frontmatterLines.push(`modeSlugs:`) + for (const slug of modeSlugs) { + frontmatterLines.push(` - ${slug}`) + } + } + const skillContent = `--- -name: ${name} -description: ${trimmedDescription} +${frontmatterLines.join("\n")} --- # ${titleName} @@ -471,6 +535,49 @@ Add your skill instructions here. await this.discoverSkills() } + /** + * Update the mode associations for a skill by modifying its SKILL.md frontmatter. + * @param name - Skill name + * @param source - Where the skill is located ("global" or "project") + * @param newModeSlugs - New mode slugs (undefined/empty = any mode) + */ + async updateSkillModes(name: string, source: "global" | "project", newModeSlugs?: string[]): Promise { + // Find any skill with this name and source (regardless of current mode) + let skill: SkillMetadata | undefined + for (const s of this.skills.values()) { + if (s.name === name && s.source === source) { + skill = s + break + } + } + + if (!skill) { + throw new Error(t("skills:errors.not_found", { name, source, modeInfo: "" })) + } + + // Read the current SKILL.md file + const fileContent = await fs.readFile(skill.path, "utf-8") + const { data: frontmatter, content: body } = matter(fileContent) + + // Update the frontmatter with new modeSlugs + if (newModeSlugs && newModeSlugs.length > 0) { + frontmatter.modeSlugs = newModeSlugs + // Remove legacy mode field if present + delete frontmatter.mode + } else { + // Empty/undefined = any mode, remove mode restrictions + delete frontmatter.modeSlugs + delete frontmatter.mode + } + + // Serialize back to SKILL.md format + const newContent = matter.stringify(body, frontmatter) + await fs.writeFile(skill.path, newContent, "utf-8") + + // Refresh skills list + await this.discoverSkills() + } + /** * Get all skills directories to scan, including mode-specific directories. */ diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 9c02769ce8..8d1e1e9113 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -1004,7 +1004,7 @@ Instructions`) expect(writeCall[1]).toContain("description: A new skill description") }) - it("should create a mode-specific skill", async () => { + it("should create a mode-specific skill with modeSlugs array", async () => { mockDirectoryExists.mockResolvedValue(false) mockRealpath.mockImplementation(async (p: string) => p) mockReaddir.mockResolvedValue([]) @@ -1012,9 +1012,15 @@ Instructions`) mockMkdir.mockResolvedValue(undefined) mockWriteFile.mockResolvedValue(undefined) - const createdPath = await skillsManager.createSkill("code-skill", "global", "A code skill", "code") + const createdPath = await skillsManager.createSkill("code-skill", "global", "A code skill", ["code"]) - expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills-code", "code-skill", "SKILL.md")) + // Skills are always created in the generic skills directory now; mode info is in frontmatter + expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills", "code-skill", "SKILL.md")) + + // Verify frontmatter contains modeSlugs + const writeCall = mockWriteFile.mock.calls[0] + expect(writeCall[1]).toContain("modeSlugs:") + expect(writeCall[1]).toContain("- code") }) it("should create a project skill", async () => { diff --git a/src/shared/skills.ts b/src/shared/skills.ts index ae35b8c387..cbcc71d7b7 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -7,7 +7,17 @@ export interface SkillMetadata { description: string // Required: when to use this skill path: string // Absolute path to SKILL.md (or "" for built-in skills) source: "global" | "project" | "built-in" // Where the skill was discovered - mode?: string // If set, skill is only available in this mode + /** + * @deprecated Use modeSlugs instead. Kept for backward compatibility. + * If set, skill is only available in this mode. + */ + mode?: string + /** + * Mode slugs where this skill is available. + * - undefined or empty array means the skill is available in all modes ("Any mode"). + * - An array with one or more mode slugs restricts the skill to those modes. + */ + modeSlugs?: string[] } /** diff --git a/webview-ui/src/components/chat/SlashCommandItem.tsx b/webview-ui/src/components/chat/SlashCommandItem.tsx deleted file mode 100644 index 04ade08bbd..0000000000 --- a/webview-ui/src/components/chat/SlashCommandItem.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from "react" -import { Edit, Trash2 } from "lucide-react" - -import type { Command } from "@roo-code/types" - -import { useAppTranslation } from "@/i18n/TranslationContext" -import { Button, StandardTooltip } from "@/components/ui" -import { vscode } from "@/utils/vscode" - -interface SlashCommandItemProps { - command: Command - onDelete: (command: Command) => void - onClick?: (command: Command) => void -} - -export const SlashCommandItem: React.FC = ({ command, onDelete, onClick }) => { - const { t } = useAppTranslation() - - // Built-in commands cannot be edited or deleted - const isBuiltIn = command.source === "built-in" - - const handleEdit = () => { - if (command.filePath) { - vscode.postMessage({ - type: "openFile", - text: command.filePath, - }) - } else { - // Fallback: request to open command file by name and source - vscode.postMessage({ - type: "openCommandFile", - text: command.name, - values: { source: command.source }, - }) - } - } - - const handleDelete = () => { - onDelete(command) - } - - return ( -
- {/* Command name - clickable */} -
onClick?.(command)}> -
- {command.name} - {command.description && ( -
- {command.description} -
- )} -
-
- - {/* Action buttons - only show for non-built-in commands */} - {!isBuiltIn && ( -
- - - - - - - -
- )} -
- ) -} diff --git a/webview-ui/src/components/settings/CreateSkillDialog.tsx b/webview-ui/src/components/settings/CreateSkillDialog.tsx index a4daa9989c..3a8def14ee 100644 --- a/webview-ui/src/components/settings/CreateSkillDialog.tsx +++ b/webview-ui/src/components/settings/CreateSkillDialog.tsx @@ -7,17 +7,20 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { useExtensionState } from "@/context/ExtensionStateContext" import { Button, + Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + Textarea, } from "@/components/ui" import { vscode } from "@/utils/vscode" @@ -65,9 +68,6 @@ const validateDescription = (description: string): string | null => { return null } -// Sentinel value for "Any mode" since Radix Select doesn't allow empty string values -const MODE_ANY = "__any__" - export const CreateSkillDialog: React.FC = ({ open, onOpenChange, @@ -80,11 +80,14 @@ export const CreateSkillDialog: React.FC = ({ const [name, setName] = useState("") const [description, setDescription] = useState("") const [source, setSource] = useState<"global" | "project">(hasWorkspace ? "project" : "global") - const [mode, setMode] = useState(MODE_ANY) const [nameError, setNameError] = useState(null) const [descriptionError, setDescriptionError] = useState(null) - // Get available modes for the dropdown (built-in + custom modes) + // Multi-mode selection state (same pattern as SkillsSettings mode dialog) + const [selectedModes, setSelectedModes] = useState([]) + const [isAnyMode, setIsAnyMode] = useState(true) + + // Get available modes for the checkboxes (built-in + custom modes) const availableModes = useMemo(() => { return getAllModes(customModes).map((m) => ({ slug: m.slug, name: m.name })) }, [customModes]) @@ -93,7 +96,8 @@ export const CreateSkillDialog: React.FC = ({ setName("") setDescription("") setSource(hasWorkspace ? "project" : "global") - setMode(MODE_ANY) + setSelectedModes([]) + setIsAnyMode(true) setNameError(null) setDescriptionError(null) }, [hasWorkspace]) @@ -114,6 +118,33 @@ export const CreateSkillDialog: React.FC = ({ setDescriptionError(null) }, []) + // Handle "Any mode" toggle - mutually exclusive with specific modes + const handleAnyModeToggle = useCallback((checked: boolean) => { + if (checked) { + setIsAnyMode(true) + setSelectedModes([]) // Clear specific modes when "Any mode" is selected + } else { + setIsAnyMode(false) + } + }, []) + + // Handle specific mode toggle - unchecks "Any mode" when a specific mode is selected + const handleModeToggle = useCallback((modeSlug: string, checked: boolean) => { + if (checked) { + setIsAnyMode(false) // Uncheck "Any mode" when selecting a specific mode + setSelectedModes((prev) => [...prev, modeSlug]) + } else { + setSelectedModes((prev) => { + const newModes = prev.filter((m) => m !== modeSlug) + // If no modes selected, default back to "Any mode" + if (newModes.length === 0) { + setIsAnyMode(true) + } + return newModes + }) + } + }, []) + const handleCreate = useCallback(() => { // Validate fields const nameValidationError = validateSkillName(name) @@ -130,73 +161,64 @@ export const CreateSkillDialog: React.FC = ({ } // Send message to create skill - // Convert MODE_ANY sentinel value to undefined for the backend + // Convert to modeSlugs: undefined for "Any mode", or array of selected modes + const modeSlugs = isAnyMode ? undefined : selectedModes.length > 0 ? selectedModes : undefined vscode.postMessage({ type: "createSkill", skillName: name, source, skillDescription: description, - skillMode: mode === MODE_ANY ? undefined : mode, + skillModeSlugs: modeSlugs, }) // Close dialog and notify parent handleClose() onSkillCreated() - }, [name, description, source, mode, handleClose, onSkillCreated]) + }, [name, description, source, isAnyMode, selectedModes, handleClose, onSkillCreated]) return ( {t("settings:skills.createDialog.title")} - {t("settings:skills.createDialog.description")} + -
+
{/* Name Input */} -
+
- - - {t("settings:skills.createDialog.nameHint")} - {nameError && {t(nameError)}}
{/* Description Input */} -
- -