Continuing work on support for OpenRouter compression (#43)

This commit is contained in:
John Stearns
2024-12-07 09:38:13 -08:00
committed by GitHub
parent 0ac3dd5753
commit 423e2af520
12 changed files with 3026 additions and 7352 deletions

View File

@@ -1,5 +1,9 @@
# Roo Cline Changelog # 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] ## [2.1.10]
- Incorporate HeavenOSK's [PR](https://github.com/cline/cline/pull/818) to add sound effects to Cline - Incorporate HeavenOSK's [PR](https://github.com/cline/cline/pull/818) to add sound effects to Cline

View File

@@ -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> <a href="https://discord.gg/cline" target="_blank"><strong>Join the Discord</strong></a>
</td> </td>
<td align="center"> <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>
<td align="center"> <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> </td>
</tbody> </tbody>
</table> </table>
@@ -112,7 +112,7 @@ Try asking Cline to "test the app", and watch as he runs a command like `npm run
## Contributing ## 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> <details>
<summary>Local Development Instructions</summary> <summary>Local Development Instructions</summary>

Binary file not shown.

1960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
}))
})
})

View File

@@ -4,9 +4,19 @@ import OpenAI from "openai"
import { ApiHandler } from "../" import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api" import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream" import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream"
import delay from "delay" 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 { export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions private options: ApiHandlerOptions
private client: OpenAI 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 // Convert Anthropic messages to OpenAI format
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
@@ -95,17 +105,21 @@ export class OpenRouterHandler implements ApiHandler {
maxTokens = 8_192 maxTokens = 8_192
break break
} }
// https://openrouter.ai/docs/transforms
let fullResponseText = "";
const stream = await this.client.chat.completions.create({ const stream = await this.client.chat.completions.create({
model: this.getModel().id, model: this.getModel().id,
max_tokens: maxTokens, max_tokens: maxTokens,
temperature: 0, temperature: 0,
messages: openAiMessages, messages: openAiMessages,
stream: true, 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 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 // openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) { if ("error" in chunk) {
const error = chunk.error as { message?: string; code?: number } const error = chunk.error as { message?: string; code?: number }
@@ -119,10 +133,11 @@ export class OpenRouterHandler implements ApiHandler {
const delta = chunk.choices[0]?.delta const delta = chunk.choices[0]?.delta
if (delta?.content) { if (delta?.content) {
fullResponseText += delta.content;
yield { yield {
type: "text", type: "text",
text: delta.content, text: delta.content,
} } as ApiStreamChunk;
} }
// if (chunk.usage) { // if (chunk.usage) {
// yield { // yield {
@@ -153,13 +168,14 @@ export class OpenRouterHandler implements ApiHandler {
inputTokens: generation?.native_tokens_prompt || 0, inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0, outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0, totalCost: generation?.total_cost || 0,
} fullResponseText
} as OpenRouterApiStreamUsageChunk;
} catch (error) { } catch (error) {
// ignore if fails // ignore if fails
console.error("Error fetching OpenRouter generation details:", error) console.error("Error fetching OpenRouter generation details:", error)
} }
}
}
getModel(): { id: string; info: ModelInfo } { getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.openRouterModelId const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo const modelInfo = this.options.openRouterModelInfo

View File

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

View File

@@ -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')
})
})

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -249,6 +249,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
</span> </span>
)} */} )} */}
</p> </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> </div>
)} )}