Browse Source

Continuing work on support for OpenRouter compression (#43)

John Stearns 1 year ago
parent
commit
423e2af520

+ 4 - 0
CHANGELOG.md

@@ -1,5 +1,9 @@
 # Roo Cline Changelog
 
+## [2.1.11]
+
+- Incorporate lloydchang's [PR](https://github.com/RooVetGit/Roo-Cline/pull/42) to add support for OpenRouter compression
+
 ## [2.1.10]
 
 - Incorporate HeavenOSK's [PR](https://github.com/cline/cline/pull/818) to add sound effects to Cline

+ 3 - 3
README.md

@@ -27,10 +27,10 @@ After installation, Roo Cline will appear in your VSCode-compatible editor's ins
 <a href="https://discord.gg/cline" target="_blank"><strong>Join the Discord</strong></a>
 </td>
 <td align="center">
-<a href="https://github.com/cline/cline/wiki" target="_blank"><strong>Docs</strong></a>
+<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
 </td>
 <td align="center">
-<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
+<a href="https://cline.bot/join-us" target="_blank"><strong>We're Hiring!</strong></a>
 </td>
 </tbody>
 </table>
@@ -112,7 +112,7 @@ Try asking Cline to "test the app", and watch as he runs a command like `npm run
 
 ## Contributing
 
-To contribute to the project, start by exploring [open issues](https://github.com/cline/cline/issues) or checking our [feature request board](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop). We'd also love to have you join our [Discord](https://discord.gg/cline) to share ideas and connect with other contributors.
+To contribute to the project, start by exploring [open issues](https://github.com/cline/cline/issues) or checking our [feature request board](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop). We'd also love to have you join our [Discord](https://discord.gg/cline) to share ideas and connect with other contributors. If you're interested in joining the team, check out our [careers page](https://cline.bot/join-us)!
 
 <details>
 <summary>Local Development Instructions</summary>

BIN
bin/roo-cline-2.1.0.vsix


File diff suppressed because it is too large
+ 25 - 144
package-lock.json


+ 1 - 2
package.json

@@ -3,7 +3,7 @@
   "displayName": "Roo Cline",
   "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
   "publisher": "RooVeterinaryInc",
-  "version": "2.1.10",
+  "version": "2.1.11",
   "icon": "assets/icons/rocket.png",
   "galleryBanner": {
     "color": "#617A91",
@@ -136,7 +136,6 @@
   },
   "scripts": {
     "vscode:prepublish": "npm run package",
-    "vsix": "vsce package --out bin",
     "compile": "npm run check-types && npm run lint && node esbuild.js",
     "watch": "npm-run-all -p watch:*",
     "watch:esbuild": "node esbuild.js --watch",

+ 121 - 0
src/api/providers/__tests__/openrouter.test.ts

@@ -0,0 +1,121 @@
+import { OpenRouterHandler } from '../openrouter'
+import { ApiHandlerOptions, ModelInfo } from '../../../shared/api'
+import OpenAI from 'openai'
+import axios from 'axios'
+import { Anthropic } from '@anthropic-ai/sdk'
+
+// Mock dependencies
+jest.mock('openai')
+jest.mock('axios')
+jest.mock('delay', () => jest.fn(() => Promise.resolve()))
+
+describe('OpenRouterHandler', () => {
+    const mockOptions: ApiHandlerOptions = {
+        openRouterApiKey: 'test-key',
+        openRouterModelId: 'test-model',
+        openRouterModelInfo: {
+            name: 'Test Model',
+            description: 'Test Description',
+            maxTokens: 1000,
+            contextWindow: 2000,
+            supportsPromptCache: true,
+            inputPrice: 0.01,
+            outputPrice: 0.02
+        } as ModelInfo
+    }
+
+    beforeEach(() => {
+        jest.clearAllMocks()
+    })
+
+    test('constructor initializes with correct options', () => {
+        const handler = new OpenRouterHandler(mockOptions)
+        expect(handler).toBeInstanceOf(OpenRouterHandler)
+        expect(OpenAI).toHaveBeenCalledWith({
+            baseURL: 'https://openrouter.ai/api/v1',
+            apiKey: mockOptions.openRouterApiKey,
+            defaultHeaders: {
+                'HTTP-Referer': 'https://cline.bot',
+                'X-Title': 'Cline',
+            },
+        })
+    })
+
+    test('getModel returns correct model info when options are provided', () => {
+        const handler = new OpenRouterHandler(mockOptions)
+        const result = handler.getModel()
+        
+        expect(result).toEqual({
+            id: mockOptions.openRouterModelId,
+            info: mockOptions.openRouterModelInfo
+        })
+    })
+
+    test('createMessage generates correct stream chunks', async () => {
+        const handler = new OpenRouterHandler(mockOptions)
+        const mockStream = {
+            async *[Symbol.asyncIterator]() {
+                yield {
+                    id: 'test-id',
+                    choices: [{
+                        delta: {
+                            content: 'test response'
+                        }
+                    }]
+                }
+            }
+        }
+
+        // Mock OpenAI chat.completions.create
+        const mockCreate = jest.fn().mockResolvedValue(mockStream)
+        ;(OpenAI as jest.MockedClass<typeof OpenAI>).prototype.chat = {
+            completions: { create: mockCreate }
+        } as any
+
+        // Mock axios.get for generation details
+        ;(axios.get as jest.Mock).mockResolvedValue({
+            data: {
+                data: {
+                    native_tokens_prompt: 10,
+                    native_tokens_completion: 20,
+                    total_cost: 0.001
+                }
+            }
+        })
+
+        const systemPrompt = 'test system prompt'
+        const messages: Anthropic.Messages.MessageParam[] = [{ role: 'user' as const, content: 'test message' }]
+
+        const generator = handler.createMessage(systemPrompt, messages)
+        const chunks = []
+        
+        for await (const chunk of generator) {
+            chunks.push(chunk)
+        }
+
+        // Verify stream chunks
+        expect(chunks).toHaveLength(2) // One text chunk and one usage chunk
+        expect(chunks[0]).toEqual({
+            type: 'text',
+            text: 'test response'
+        })
+        expect(chunks[1]).toEqual({
+            type: 'usage',
+            inputTokens: 10,
+            outputTokens: 20,
+            totalCost: 0.001,
+            fullResponseText: 'test response'
+        })
+
+        // Verify OpenAI client was called with correct parameters
+        expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
+            model: mockOptions.openRouterModelId,
+            temperature: 0,
+            messages: expect.arrayContaining([
+                { role: 'system', content: systemPrompt },
+                { role: 'user', content: 'test message' }
+            ]),
+            stream: true
+        }))
+    })
+})

+ 23 - 7
src/api/providers/openrouter.ts

@@ -4,9 +4,19 @@ import OpenAI from "openai"
 import { ApiHandler } from "../"
 import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
 import { convertToOpenAiMessages } from "../transform/openai-format"
-import { ApiStream } from "../transform/stream"
+import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream"
 import delay from "delay"
 
+// Add custom interface for OpenRouter params
+interface OpenRouterChatCompletionParams extends OpenAI.Chat.ChatCompletionCreateParamsStreaming {
+    transforms?: string[];
+}
+
+// Add custom interface for OpenRouter usage chunk
+interface OpenRouterApiStreamUsageChunk extends ApiStreamUsageChunk {
+    fullResponseText: string;
+}
+
 export class OpenRouterHandler implements ApiHandler {
 	private options: ApiHandlerOptions
 	private client: OpenAI
@@ -23,7 +33,7 @@ export class OpenRouterHandler implements ApiHandler {
 		})
 	}
 
-	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
+	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): AsyncGenerator<ApiStreamChunk> {
 		// Convert Anthropic messages to OpenAI format
 		const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
 			{ role: "system", content: systemPrompt },
@@ -95,17 +105,21 @@ export class OpenRouterHandler implements ApiHandler {
 				maxTokens = 8_192
 				break
 		}
+		// https://openrouter.ai/docs/transforms
+		let fullResponseText = "";
 		const stream = await this.client.chat.completions.create({
 			model: this.getModel().id,
 			max_tokens: maxTokens,
 			temperature: 0,
 			messages: openAiMessages,
 			stream: true,
-		})
+			// This way, the transforms field will only be included in the parameters when openRouterUseMiddleOutTransform is true.
+			...(this.options.openRouterUseMiddleOutTransform && { transforms: ["middle-out"] })
+		} as OpenRouterChatCompletionParams);
 
 		let genId: string | undefined
 
-		for await (const chunk of stream) {
+		for await (const chunk of stream as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>) {
 			// openrouter returns an error object instead of the openai sdk throwing an error
 			if ("error" in chunk) {
 				const error = chunk.error as { message?: string; code?: number }
@@ -119,10 +133,11 @@ export class OpenRouterHandler implements ApiHandler {
 
 			const delta = chunk.choices[0]?.delta
 			if (delta?.content) {
+				fullResponseText += delta.content;
 				yield {
 					type: "text",
 					text: delta.content,
-				}
+				} as ApiStreamChunk;
 			}
 			// if (chunk.usage) {
 			// 	yield {
@@ -153,13 +168,14 @@ export class OpenRouterHandler implements ApiHandler {
 				inputTokens: generation?.native_tokens_prompt || 0,
 				outputTokens: generation?.native_tokens_completion || 0,
 				totalCost: generation?.total_cost || 0,
-			}
+				fullResponseText
+			} as OpenRouterApiStreamUsageChunk;
 		} catch (error) {
 			// ignore if fails
 			console.error("Error fetching OpenRouter generation details:", error)
 		}
-	}
 
+	}
 	getModel(): { id: string; info: ModelInfo } {
 		const modelId = this.options.openRouterModelId
 		const modelInfo = this.options.openRouterModelInfo

+ 6 - 0
src/core/webview/ClineProvider.ts

@@ -61,6 +61,7 @@ type GlobalStateKey =
 	| "azureApiVersion"
 	| "openRouterModelId"
 	| "openRouterModelInfo"
+	| "openRouterUseMiddleOutTransform"
 	| "allowedCommands"
 	| "soundEnabled"
 
@@ -391,6 +392,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 								azureApiVersion,
 								openRouterModelId,
 								openRouterModelInfo,
+								openRouterUseMiddleOutTransform,
 							} = message.apiConfiguration
 							await this.updateGlobalState("apiProvider", apiProvider)
 							await this.updateGlobalState("apiModelId", apiModelId)
@@ -416,6 +418,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 							await this.updateGlobalState("azureApiVersion", azureApiVersion)
 							await this.updateGlobalState("openRouterModelId", openRouterModelId)
 							await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
+							await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
 							if (this.cline) {
 								this.cline.api = buildApiHandler(message.apiConfiguration)
 							}
@@ -943,6 +946,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			azureApiVersion,
 			openRouterModelId,
 			openRouterModelInfo,
+			openRouterUseMiddleOutTransform,
 			lastShownAnnouncementId,
 			customInstructions,
 			alwaysAllowReadOnly,
@@ -977,6 +981,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
 			this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
 			this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
+			this.getGlobalState("openRouterUseMiddleOutTransform") as Promise<boolean | undefined>,
 			this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
 			this.getGlobalState("customInstructions") as Promise<string | undefined>,
 			this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
@@ -1028,6 +1033,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				azureApiVersion,
 				openRouterModelId,
 				openRouterModelInfo,
+				openRouterUseMiddleOutTransform,
 			},
 			lastShownAnnouncementId,
 			customInstructions,

+ 234 - 0
src/core/webview/__tests__/ClineProvider.test.ts

@@ -0,0 +1,234 @@
+import { ClineProvider } from '../ClineProvider'
+import * as vscode from 'vscode'
+import { ExtensionMessage, ExtensionState } from '../../../shared/ExtensionMessage'
+
+// Mock dependencies
+jest.mock('vscode', () => ({
+    ExtensionContext: jest.fn(),
+    OutputChannel: jest.fn(),
+    WebviewView: jest.fn(),
+    Uri: {
+        joinPath: jest.fn(),
+        file: jest.fn()
+    },
+    workspace: {
+        getConfiguration: jest.fn().mockReturnValue({
+            get: jest.fn().mockReturnValue([]),
+            update: jest.fn()
+        }),
+        onDidChangeConfiguration: jest.fn().mockImplementation((callback) => ({
+            dispose: jest.fn()
+        }))
+    },
+    env: {
+        uriScheme: 'vscode'
+    }
+}))
+
+// Mock ESM modules
+jest.mock('p-wait-for', () => ({
+    __esModule: true,
+    default: jest.fn().mockResolvedValue(undefined)
+}))
+
+// Mock fs/promises
+jest.mock('fs/promises', () => ({
+    mkdir: jest.fn(),
+    writeFile: jest.fn(),
+    readFile: jest.fn(),
+    unlink: jest.fn(),
+    rmdir: jest.fn()
+}))
+
+// Mock axios
+jest.mock('axios', () => ({
+    get: jest.fn().mockResolvedValue({ data: { data: [] } }),
+    post: jest.fn()
+}))
+
+// Mock buildApiHandler
+jest.mock('../../../api', () => ({
+    buildApiHandler: jest.fn()
+}))
+
+// Mock WorkspaceTracker
+jest.mock('../../../integrations/workspace/WorkspaceTracker', () => {
+    return jest.fn().mockImplementation(() => ({
+        initializeFilePaths: jest.fn(),
+        dispose: jest.fn()
+    }))
+})
+
+// Mock Cline
+jest.mock('../../Cline', () => {
+    return {
+        Cline: jest.fn().mockImplementation(() => ({
+            abortTask: jest.fn(),
+            handleWebviewAskResponse: jest.fn(),
+            clineMessages: []
+        }))
+    }
+})
+
+// Spy on console.error and console.log to suppress expected messages
+beforeAll(() => {
+    jest.spyOn(console, 'error').mockImplementation(() => {})
+    jest.spyOn(console, 'log').mockImplementation(() => {})
+})
+
+afterAll(() => {
+    jest.restoreAllMocks()
+})
+
+describe('ClineProvider', () => {
+    let provider: ClineProvider
+    let mockContext: vscode.ExtensionContext
+    let mockOutputChannel: vscode.OutputChannel
+    let mockWebviewView: vscode.WebviewView
+    let mockPostMessage: jest.Mock
+    let visibilityChangeCallback: (e?: unknown) => void
+
+    beforeEach(() => {
+        // Reset mocks
+        jest.clearAllMocks()
+
+        // Mock context
+        mockContext = {
+            extensionPath: '/test/path',
+            extensionUri: {} as vscode.Uri,
+            globalState: {
+                get: jest.fn(),
+                update: jest.fn(),
+                keys: jest.fn().mockReturnValue([]),
+            },
+            secrets: {
+                get: jest.fn(),
+                store: jest.fn(),
+                delete: jest.fn()
+            },
+            subscriptions: [],
+            extension: {
+                packageJSON: { version: '1.0.0' }
+            },
+            globalStorageUri: {
+                fsPath: '/test/storage/path'
+            }
+        } as unknown as vscode.ExtensionContext
+
+        // Mock output channel
+        mockOutputChannel = {
+            appendLine: jest.fn(),
+            clear: jest.fn(),
+            dispose: jest.fn()
+        } as unknown as vscode.OutputChannel
+
+        // Mock webview
+        mockPostMessage = jest.fn()
+        mockWebviewView = {
+            webview: {
+                postMessage: mockPostMessage,
+                html: '',
+                options: {},
+                onDidReceiveMessage: jest.fn(),
+                asWebviewUri: jest.fn()
+            },
+            visible: true,
+            onDidDispose: jest.fn().mockImplementation((callback) => {
+                callback()
+                return { dispose: jest.fn() }
+            }),
+            onDidChangeVisibility: jest.fn().mockImplementation((callback) => {
+                visibilityChangeCallback = callback
+                return { dispose: jest.fn() }
+            })
+        } as unknown as vscode.WebviewView
+
+        provider = new ClineProvider(mockContext, mockOutputChannel)
+    })
+
+    test('constructor initializes correctly', () => {
+        expect(provider).toBeInstanceOf(ClineProvider)
+        // Since getVisibleInstance returns the last instance where view.visible is true
+        // @ts-ignore - accessing private property for testing
+        provider.view = mockWebviewView
+        expect(ClineProvider.getVisibleInstance()).toBe(provider)
+    })
+
+    test('resolveWebviewView sets up webview correctly', () => {
+        provider.resolveWebviewView(mockWebviewView)
+        
+        expect(mockWebviewView.webview.options).toEqual({
+            enableScripts: true,
+            localResourceRoots: [mockContext.extensionUri]
+        })
+        expect(mockWebviewView.webview.html).toContain('<!DOCTYPE html>')
+    })
+
+    test('postMessageToWebview sends message to webview', async () => {
+        provider.resolveWebviewView(mockWebviewView)
+        
+        const mockState: ExtensionState = {
+            version: '1.0.0',
+            clineMessages: [],
+            taskHistory: [],
+            shouldShowAnnouncement: false,
+            apiConfiguration: {
+                apiProvider: 'openrouter'
+            },
+            customInstructions: undefined,
+            alwaysAllowReadOnly: false,
+            alwaysAllowWrite: false,
+            alwaysAllowExecute: false,
+            alwaysAllowBrowser: false,
+            uriScheme: 'vscode',
+            soundEnabled: true
+        }
+        
+        const message: ExtensionMessage = { 
+            type: 'state', 
+            state: mockState
+        }
+        await provider.postMessageToWebview(message)
+        
+        expect(mockPostMessage).toHaveBeenCalledWith(message)
+    })
+
+    test('handles webviewDidLaunch message', async () => {
+        provider.resolveWebviewView(mockWebviewView)
+
+        // Get the message handler from onDidReceiveMessage
+        const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+        // Simulate webviewDidLaunch message
+        await messageHandler({ type: 'webviewDidLaunch' })
+
+        // Should post state and theme to webview
+        expect(mockPostMessage).toHaveBeenCalled()
+    })
+
+    test('clearTask aborts current task', async () => {
+        const mockAbortTask = jest.fn()
+        // @ts-ignore - accessing private property for testing
+        provider.cline = { abortTask: mockAbortTask }
+
+        await provider.clearTask()
+
+        expect(mockAbortTask).toHaveBeenCalled()
+        // @ts-ignore - accessing private property for testing
+        expect(provider.cline).toBeUndefined()
+    })
+
+    test('getState returns correct initial state', async () => {
+        const state = await provider.getState()
+        
+        expect(state).toHaveProperty('apiConfiguration')
+        expect(state.apiConfiguration).toHaveProperty('apiProvider')
+        expect(state).toHaveProperty('customInstructions')
+        expect(state).toHaveProperty('alwaysAllowReadOnly')
+        expect(state).toHaveProperty('alwaysAllowWrite')
+        expect(state).toHaveProperty('alwaysAllowExecute')
+        expect(state).toHaveProperty('alwaysAllowBrowser')
+        expect(state).toHaveProperty('taskHistory')
+        expect(state).toHaveProperty('soundEnabled')
+    })
+})

+ 1 - 0
src/shared/api.ts

@@ -33,6 +33,7 @@ export interface ApiHandlerOptions {
 	geminiApiKey?: string
 	openAiNativeApiKey?: string
 	azureApiVersion?: string
+	openRouterUseMiddleOutTransform?: boolean
 }
 
 export type ApiConfiguration = ApiHandlerOptions & {

File diff suppressed because it is too large
+ 183 - 375
webview-ui/package-lock.json


+ 9 - 0
webview-ui/src/components/settings/ApiOptions.tsx

@@ -249,6 +249,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
 							</span>
 						)} */}
 					</p>
+					<VSCodeCheckbox
+						checked={apiConfiguration?.openRouterUseMiddleOutTransform || false}
+						onChange={(e: any) => {
+							const isChecked = e.target.checked === true
+							setApiConfiguration({ ...apiConfiguration, openRouterUseMiddleOutTransform: isChecked })
+						}}>
+						Compress prompts and message chains to the context size (<a href="https://openrouter.ai/docs/transforms">OpenRouter Transforms</a>)
+					</VSCodeCheckbox>
+					<br/>
 				</div>
 			)}
 

Some files were not shown because too many files changed in this diff