From 91fe764cb8df0ee261584f6f0aa0b768380fb71a Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sat, 1 Feb 2025 20:12:54 +0700 Subject: [PATCH 1/3] feat: add Requesty API provider support - Add RequestyHandler implementation for API integration - Add RequestyModelPicker component for model selection - Update shared types and messages for Requesty support - Update API options to include Requesty provider --- src/api/index.ts | 3 + src/api/providers/__tests__/requesty.test.ts | 247 ++++++++++ src/api/providers/requesty.ts | 129 ++++++ src/core/webview/ClineProvider.ts | 132 ++++++ src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 1 + .../__tests__/checkExistApiConfig.test.ts | 2 + src/shared/api.ts | 27 ++ src/shared/checkExistApiConfig.ts | 2 + .../src/components/settings/ApiOptions.tsx | 33 ++ .../settings/RequestyModelPicker.tsx | 423 ++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 15 + 12 files changed, 1016 insertions(+) create mode 100644 src/api/providers/__tests__/requesty.test.ts create mode 100644 src/api/providers/requesty.ts create mode 100644 webview-ui/src/components/settings/RequestyModelPicker.tsx diff --git a/src/api/index.ts b/src/api/index.ts index b3927b4c13..f68c9acd1f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -15,6 +15,7 @@ import { MistralHandler } from "./providers/mistral" import { VsCodeLmHandler } from "./providers/vscode-lm" import { ApiStream } from "./transform/stream" import { UnboundHandler } from "./providers/unbound" +import { RequestyHandler } from "./providers/requesty" export interface SingleCompletionHandler { completePrompt(prompt: string): Promise @@ -56,6 +57,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new MistralHandler(options) case "unbound": return new UnboundHandler(options) + case "requesty": + return new RequestyHandler(options) default: return new AnthropicHandler(options) } diff --git a/src/api/providers/__tests__/requesty.test.ts b/src/api/providers/__tests__/requesty.test.ts new file mode 100644 index 0000000000..7867b15ebc --- /dev/null +++ b/src/api/providers/__tests__/requesty.test.ts @@ -0,0 +1,247 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import { ApiHandlerOptions, ModelInfo, requestyModelInfoSaneDefaults } from "../../../shared/api" +import { RequestyHandler } from "../requesty" +import { convertToOpenAiMessages } from "../../transform/openai-format" +import { convertToR1Format } from "../../transform/r1-format" + +// Mock OpenAI and transform functions +jest.mock("openai") +jest.mock("../../transform/openai-format") +jest.mock("../../transform/r1-format") + +describe("RequestyHandler", () => { + let handler: RequestyHandler + let mockCreate: jest.Mock + + const defaultOptions: ApiHandlerOptions = { + requestyApiKey: "test-key", + requestyModelId: "test-model", + requestyModelInfo: { + maxTokens: 1000, + contextWindow: 4000, + supportsPromptCache: false, + supportsImages: true, + inputPrice: 0, + outputPrice: 0, + }, + openAiStreamingEnabled: true, + includeMaxTokens: true, // Add this to match the implementation + } + + beforeEach(() => { + // Clear mocks + jest.clearAllMocks() + + // Setup mock create function + mockCreate = jest.fn() + + // Mock OpenAI constructor + ;(OpenAI as jest.MockedClass).mockImplementation( + () => + ({ + chat: { + completions: { + create: mockCreate, + }, + }, + }) as unknown as OpenAI, + ) + + // Mock transform functions + ;(convertToOpenAiMessages as jest.Mock).mockImplementation((messages) => messages) + ;(convertToR1Format as jest.Mock).mockImplementation((messages) => messages) + + // Create handler instance + handler = new RequestyHandler(defaultOptions) + }) + + describe("constructor", () => { + it("should initialize with correct options", () => { + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: "https://router.requesty.ai/v1", + apiKey: defaultOptions.requestyApiKey, + defaultHeaders: { + "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", + "X-Title": "Roo Code", + }, + }) + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + + describe("with streaming enabled", () => { + beforeEach(() => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hello" } }], + } + yield { + choices: [{ delta: { content: " world" } }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + }, + } + }, + } + mockCreate.mockResolvedValue(stream) + }) + + it("should handle streaming response correctly", async () => { + const stream = handler.createMessage(systemPrompt, messages) + const results = [] + + for await (const chunk of stream) { + results.push(chunk) + } + + expect(results).toEqual([ + { type: "text", text: "Hello" }, + { type: "text", text: " world" }, + { + type: "usage", + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: undefined, + cacheReadTokens: undefined, + }, + ]) + + expect(mockCreate).toHaveBeenCalledWith({ + model: defaultOptions.requestyModelId, + temperature: 0, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "Hello" }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: defaultOptions.requestyModelInfo?.maxTokens, + }) + }) + + it("should not include max_tokens when includeMaxTokens is false", async () => { + handler = new RequestyHandler({ + ...defaultOptions, + includeMaxTokens: false, + }) + + await handler.createMessage(systemPrompt, messages).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ + max_tokens: expect.any(Number), + }), + ) + }) + + it("should handle deepseek-reasoner model format", async () => { + handler = new RequestyHandler({ + ...defaultOptions, + requestyModelId: "deepseek-reasoner", + }) + + await handler.createMessage(systemPrompt, messages).next() + + expect(convertToR1Format).toHaveBeenCalledWith([{ role: "user", content: systemPrompt }, ...messages]) + }) + }) + + describe("with streaming disabled", () => { + beforeEach(() => { + handler = new RequestyHandler({ + ...defaultOptions, + openAiStreamingEnabled: false, + }) + + mockCreate.mockResolvedValue({ + choices: [{ message: { content: "Hello world" } }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + }, + }) + }) + + it("should handle non-streaming response correctly", async () => { + const stream = handler.createMessage(systemPrompt, messages) + const results = [] + + for await (const chunk of stream) { + results.push(chunk) + } + + expect(results).toEqual([ + { type: "text", text: "Hello world" }, + { + type: "usage", + inputTokens: 10, + outputTokens: 5, + }, + ]) + + expect(mockCreate).toHaveBeenCalledWith({ + model: defaultOptions.requestyModelId, + messages: [ + { role: "user", content: systemPrompt }, + { role: "user", content: "Hello" }, + ], + }) + }) + }) + }) + + describe("getModel", () => { + it("should return correct model information", () => { + const result = handler.getModel() + expect(result).toEqual({ + id: defaultOptions.requestyModelId, + info: defaultOptions.requestyModelInfo, + }) + }) + + it("should use sane defaults when no model info provided", () => { + handler = new RequestyHandler({ + ...defaultOptions, + requestyModelInfo: undefined, + }) + + const result = handler.getModel() + expect(result).toEqual({ + id: defaultOptions.requestyModelId, + info: requestyModelInfoSaneDefaults, + }) + }) + }) + + describe("completePrompt", () => { + beforeEach(() => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: "Completed response" } }], + }) + }) + + it("should complete prompt successfully", async () => { + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Completed response") + expect(mockCreate).toHaveBeenCalledWith({ + model: defaultOptions.requestyModelId, + messages: [{ role: "user", content: "Test prompt" }], + }) + }) + + it("should handle errors correctly", async () => { + const errorMessage = "API error" + mockCreate.mockRejectedValue(new Error(errorMessage)) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow( + `OpenAI completion error: ${errorMessage}`, + ) + }) + }) +}) diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts new file mode 100644 index 0000000000..01396b8a2b --- /dev/null +++ b/src/api/providers/requesty.ts @@ -0,0 +1,129 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { ApiHandlerOptions, ModelInfo, requestyModelInfoSaneDefaults } from "../../shared/api" +import { ApiHandler, SingleCompletionHandler } from "../index" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToR1Format } from "../transform/r1-format" +import { ApiStream } from "../transform/stream" + +export class RequestyHandler implements ApiHandler, SingleCompletionHandler { + protected options: ApiHandlerOptions + private client: OpenAI + + constructor(options: ApiHandlerOptions) { + this.options = options + this.client = new OpenAI({ + baseURL: "https://router.requesty.ai/v1", + apiKey: this.options.requestyApiKey, + defaultHeaders: { + "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", + "X-Title": "Roo Code", + }, + }) + } + + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const modelInfo = this.getModel().info + const modelId = this.options.requestyModelId ?? "" + + const deepseekReasoner = modelId.includes("deepseek-reasoner") + + if (this.options.openAiStreamingEnabled ?? true) { + const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { + role: "system", + content: systemPrompt, + } + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: modelId, + temperature: 0, + messages: deepseekReasoner + ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + : [systemMessage, ...convertToOpenAiMessages(messages)], + stream: true as const, + stream_options: { include_usage: true }, + } + if (this.options.includeMaxTokens) { + requestOptions.max_tokens = modelInfo.maxTokens + } + + const stream = await this.client.chat.completions.create(requestOptions) + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta ?? {} + + if (delta.content) { + yield { + type: "text", + text: delta.content, + } + } + + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + text: (delta.reasoning_content as string | undefined) || "", + } + } + if (chunk.usage) { + yield { + type: "usage", + inputTokens: chunk.usage.prompt_tokens || 0, + outputTokens: chunk.usage.completion_tokens || 0, + cacheWriteTokens: (chunk.usage as any).cache_creation_input_tokens || undefined, + cacheReadTokens: (chunk.usage as any).cache_read_input_tokens || undefined, + } + } + } + } else { + // o1 for instance doesnt support streaming, non-1 temp, or system prompt + const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = { + role: "user", + content: systemPrompt, + } + + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: modelId, + messages: deepseekReasoner + ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + : [systemMessage, ...convertToOpenAiMessages(messages)], + } + + const response = await this.client.chat.completions.create(requestOptions) + + yield { + type: "text", + text: response.choices[0]?.message.content || "", + } + yield { + type: "usage", + inputTokens: response.usage?.prompt_tokens || 0, + outputTokens: response.usage?.completion_tokens || 0, + } + } + } + + getModel(): { id: string; info: ModelInfo } { + return { + id: this.options.requestyModelId ?? "", + info: this.options.requestyModelInfo ?? requestyModelInfoSaneDefaults, + } + } + + async completePrompt(prompt: string): Promise { + try { + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + } + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI completion error: ${error.message}`) + } + throw error + } + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 74e797e4a1..9f18b74480 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -59,6 +59,7 @@ type SecretKey = | "deepSeekApiKey" | "mistralApiKey" | "unboundApiKey" + | "requestyApiKey" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -122,6 +123,8 @@ type GlobalStateKey = | "autoApprovalEnabled" | "customModes" // Array of custom modes | "unboundModelId" + | "requestyModelId" + | "requestyModelInfo" | "unboundModelInfo" export const GlobalFileNames = { @@ -129,6 +132,7 @@ export const GlobalFileNames = { uiMessages: "ui_messages.json", glamaModels: "glama_models.json", openRouterModels: "openrouter_models.json", + requestyModels: "requesty_models.json", mcpSettings: "cline_mcp_settings.json", unboundModels: "unbound_models.json", } @@ -685,6 +689,25 @@ export class ClineProvider implements vscode.WebviewViewProvider { } }) + this.readRequestyModels().then((requestyModels) => { + if (requestyModels) { + this.postMessageToWebview({ type: "requestyModels", requestyModels }) + } + }) + this.refreshRequestyModels().then(async (requestyModels) => { + if (requestyModels) { + // update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there) + const { apiConfiguration } = await this.getState() + if (apiConfiguration.requestyModelId) { + await this.updateGlobalState( + "requestyModelInfo", + requestyModels[apiConfiguration.requestyModelId], + ) + await this.postStateToWebview() + } + } + }) + this.configManager .listConfig() .then(async (listApiConfig) => { @@ -847,6 +870,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "refreshUnboundModels": await this.refreshUnboundModels() break + case "refreshRequestyModels": + if (message?.values?.apiKey) { + const requestyModels = await this.refreshRequestyModels(message?.values?.apiKey) + this.postMessageToWebview({ type: "requestyModels", requestyModels: requestyModels }) + } + break case "openImage": openImage(message.text!) break @@ -1587,6 +1616,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { unboundApiKey, unboundModelId, unboundModelInfo, + requestyApiKey, + requestyModelId, + requestyModelInfo, } = apiConfiguration await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiModelId", apiModelId) @@ -1628,6 +1660,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("unboundApiKey", unboundApiKey) await this.updateGlobalState("unboundModelId", unboundModelId) await this.updateGlobalState("unboundModelInfo", unboundModelInfo) + await this.storeSecret("requestyApiKey", requestyApiKey) + await this.updateGlobalState("requestyModelId", requestyModelId) + await this.updateGlobalState("requestyModelInfo", requestyModelInfo) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) } @@ -1770,6 +1805,93 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } + // Requesty + async readRequestyModels(): Promise | undefined> { + const requestyModelsFilePath = path.join( + await this.ensureCacheDirectoryExists(), + GlobalFileNames.requestyModels, + ) + const fileExists = await fileExistsAtPath(requestyModelsFilePath) + if (fileExists) { + const fileContents = await fs.readFile(requestyModelsFilePath, "utf8") + return JSON.parse(fileContents) + } + return undefined + } + + async refreshRequestyModels(apiKey?: string) { + const requestyModelsFilePath = path.join( + await this.ensureCacheDirectoryExists(), + GlobalFileNames.requestyModels, + ) + + const models: Record = {} + try { + const config: Record = {} + if (!apiKey) { + apiKey = (await this.getSecret("requestyApiKey")) as string + } + if (apiKey) { + config["headers"] = { Authorization: `Bearer ${apiKey}` } + } + + const response = await axios.get("https://router.requesty.ai/v1/models", config) + /* + { + "id": "anthropic/claude-3-5-sonnet-20240620", + "object": "model", + "created": 1738243330, + "owned_by": "system", + "input_price": 0.000003, + "caching_price": 0.00000375, + "cached_price": 3E-7, + "output_price": 0.000015, + "max_output_tokens": 8192, + "context_window": 200000, + "supports_caching": true, + "description": "Anthropic's most intelligent model. Highest level of intelligence and capability" + }, + } + */ + if (response.data) { + const rawModels = response.data.data + const parsePrice = (price: any) => { + if (price) { + return parseFloat(price) * 1_000_000 + } + return undefined + } + for (const rawModel of rawModels) { + const modelInfo: ModelInfo = { + maxTokens: rawModel.max_output_tokens, + contextWindow: rawModel.context_window, + supportsImages: rawModel.support_image, + supportsComputerUse: rawModel.support_computer_use, + supportsPromptCache: rawModel.supports_caching, + inputPrice: parsePrice(rawModel.input_price), + outputPrice: parsePrice(rawModel.output_price), + description: rawModel.description, + cacheWritesPrice: parsePrice(rawModel.caching_price), + cacheReadsPrice: parsePrice(rawModel.cached_price), + } + + models[rawModel.id] = modelInfo + } + } else { + this.outputChannel.appendLine("Invalid response from Requesty API") + } + await fs.writeFile(requestyModelsFilePath, JSON.stringify(models)) + this.outputChannel.appendLine(`Requesty models fetched and saved: ${JSON.stringify(models, null, 2)}`) + } catch (error) { + this.outputChannel.appendLine( + `Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + + await this.postMessageToWebview({ type: "requestyModels", requestyModels: models }) + return models + } + // OpenRouter async handleOpenRouterCallback(code: string) { @@ -2388,6 +2510,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { unboundApiKey, unboundModelId, unboundModelInfo, + requestyApiKey, + requestyModelId, + requestyModelInfo, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -2464,6 +2589,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getSecret("unboundApiKey") as Promise, this.getGlobalState("unboundModelId") as Promise, this.getGlobalState("unboundModelInfo") as Promise, + this.getSecret("requestyApiKey") as Promise, + this.getGlobalState("requestyModelId") as Promise, + this.getGlobalState("requestyModelInfo") as Promise, ]) let apiProvider: ApiProvider @@ -2522,6 +2650,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { unboundApiKey, unboundModelId, unboundModelInfo, + requestyApiKey, + requestyModelId, + requestyModelInfo, }, lastShownAnnouncementId, customInstructions, @@ -2675,6 +2806,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "deepSeekApiKey", "mistralApiKey", "unboundApiKey", + "requestyApiKey", ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index f22ad958a0..33a5767f32 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -30,6 +30,7 @@ export interface ExtensionMessage { | "glamaModels" | "openRouterModels" | "openAiModels" + | "requestyModels" | "mcpServers" | "enhancedPrompt" | "commitSearchResults" @@ -67,6 +68,7 @@ export interface ExtensionMessage { }> partialMessage?: ClineMessage glamaModels?: Record + requestyModels?: Record openRouterModels?: Record openAiModels?: string[] unboundModels?: Record diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9d9c00872c..96488854b1 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -43,6 +43,7 @@ export interface WebviewMessage { | "refreshOpenRouterModels" | "refreshOpenAiModels" | "refreshUnboundModels" + | "refreshRequestyModels" | "alwaysAllowBrowser" | "alwaysAllowMcp" | "alwaysAllowModeSwitch" diff --git a/src/shared/__tests__/checkExistApiConfig.test.ts b/src/shared/__tests__/checkExistApiConfig.test.ts index 25c967c68e..914f4933d6 100644 --- a/src/shared/__tests__/checkExistApiConfig.test.ts +++ b/src/shared/__tests__/checkExistApiConfig.test.ts @@ -51,6 +51,8 @@ describe("checkExistKey", () => { deepSeekApiKey: undefined, mistralApiKey: undefined, vsCodeLmModelSelector: undefined, + requestyApiKey: undefined, + unboundApiKey: undefined, } expect(checkExistKey(config)).toBe(false) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 77c73a8c36..f0afb333a6 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -15,6 +15,7 @@ export type ApiProvider = | "vscode-lm" | "mistral" | "unbound" + | "requesty" export interface ApiHandlerOptions { apiModelId?: string @@ -61,6 +62,9 @@ export interface ApiHandlerOptions { unboundApiKey?: string unboundModelId?: string unboundModelInfo?: ModelInfo + requestyApiKey?: string + requestyModelId?: string + requestyModelInfo?: ModelInfo } export type ApiConfiguration = ApiHandlerOptions & { @@ -339,6 +343,7 @@ export const bedrockModels = { // Glama // https://glama.ai/models export const glamaDefaultModelId = "anthropic/claude-3-5-sonnet" +export const requestyDefaultModelId = "anthropic/claude-3-5-sonnet" export const glamaDefaultModelInfo: ModelInfo = { maxTokens: 8192, contextWindow: 200_000, @@ -352,6 +357,19 @@ export const glamaDefaultModelInfo: ModelInfo = { description: "The new Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: New Sonnet scores ~49% on SWE-Bench Verified, higher than the last best score, and without any fancy prompt scaffolding\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal\n\n_This is a faster endpoint, made available in collaboration with Anthropic, that is self-moderated: response moderation happens on the provider's side instead of OpenRouter's. For requests that pass moderation, it's identical to the [Standard](/anthropic/claude-3.5-sonnet) variant._", } +export const requestyDefaultModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsComputerUse: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: + "The new Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: New Sonnet scores ~49% on SWE-Bench Verified, higher than the last best score, and without any fancy prompt scaffolding\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal\n\n_This is a faster endpoint, made available in collaboration with Anthropic, that is self-moderated: response moderation happens on the provider's side instead of OpenRouter's. For requests that pass moderation, it's identical to the [Standard](/anthropic/claude-3.5-sonnet) variant._", +} // OpenRouter // https://openrouter.ai/models?order=newest&supported_parameters=tools @@ -427,6 +445,15 @@ export const openAiModelInfoSaneDefaults: ModelInfo = { outputPrice: 0, } +export const requestyModelInfoSaneDefaults: ModelInfo = { + maxTokens: -1, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, +} + // Gemini // https://ai.google.dev/gemini-api/docs/models/gemini export type GeminiModelId = keyof typeof geminiModels diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 8cb8055cc8..0570f6118a 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -16,6 +16,8 @@ export function checkExistKey(config: ApiConfiguration | undefined) { config.deepSeekApiKey, config.mistralApiKey, config.vsCodeLmModelSelector, + config.requestyApiKey, + config.unboundApiKey, ].some((key) => key !== undefined) : false } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3f5dd7698b..1d8ca04b77 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -29,6 +29,8 @@ import { vertexModels, unboundDefaultModelId, unboundDefaultModelInfo, + requestyDefaultModelId, + requestyDefaultModelInfo, } from "../../../../src/shared/api" import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" @@ -40,6 +42,7 @@ import { GlamaModelPicker } from "./GlamaModelPicker" import { UnboundModelPicker } from "./UnboundModelPicker" import { ModelInfoView } from "./ModelInfoView" import { DROPDOWN_Z_INDEX } from "./styles" +import RequestyModelPicker from "./RequestyModelPicker" interface ApiOptionsProps { apiErrorMessage?: string @@ -153,6 +156,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = { value: "lmstudio", label: "LM Studio" }, { value: "ollama", label: "Ollama" }, { value: "unbound", label: "Unbound" }, + { value: "requesty", label: "Requesty" }, ]} /> @@ -240,6 +244,27 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = )} + {selectedProvider === "requesty" && ( +
+ + Requesty API Key + +

+ This key is stored locally and only used to make API requests from this extension. +

+
+ )} + {selectedProvider === "openai-native" && (
} {selectedProvider === "openrouter" && } + {selectedProvider === "requesty" && } {selectedProvider !== "glama" && selectedProvider !== "openrouter" && + selectedProvider !== "requesty" && selectedProvider !== "openai" && selectedProvider !== "ollama" && selectedProvider !== "lmstudio" && @@ -1465,6 +1492,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId, selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo, } + case "requesty": + return { + selectedProvider: provider, + selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId, + selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo, + } default: return getProviderData(anthropicModels, anthropicDefaultModelId) } diff --git a/webview-ui/src/components/settings/RequestyModelPicker.tsx b/webview-ui/src/components/settings/RequestyModelPicker.tsx new file mode 100644 index 0000000000..899ffe62a8 --- /dev/null +++ b/webview-ui/src/components/settings/RequestyModelPicker.tsx @@ -0,0 +1,423 @@ +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import debounce from "debounce" +import { Fzf } from "fzf" +import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" +import { useRemark } from "react-remark" +import styled from "styled-components" +import { requestyDefaultModelId } from "../../../../src/shared/api" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { vscode } from "../../utils/vscode" +import { highlightFzfMatch } from "../../utils/highlight" +import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" + +const RequestyModelPicker: React.FC = () => { + const { apiConfiguration, setApiConfiguration, requestyModels, onUpdateApiConfig } = useExtensionState() + const [searchTerm, setSearchTerm] = useState(apiConfiguration?.requestyModelId || requestyDefaultModelId) + const [isDropdownVisible, setIsDropdownVisible] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const dropdownRef = useRef(null) + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const dropdownListRef = useRef(null) + + const handleModelChange = (newModelId: string) => { + // could be setting invalid model id/undefined info but validation will catch it + const apiConfig = { + ...apiConfiguration, + requestyModelId: newModelId, + requestyModelInfo: requestyModels[newModelId], + } + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) + + setSearchTerm(newModelId) + } + + const { selectedModelId, selectedModelInfo } = useMemo(() => { + return normalizeApiConfiguration(apiConfiguration) + }, [apiConfiguration]) + + useEffect(() => { + if (apiConfiguration?.requestyModelId && apiConfiguration?.requestyModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.requestyModelId) + } + }, [apiConfiguration, searchTerm]) + + const debouncedRefreshModels = useMemo( + () => + debounce((apiKey: string) => { + vscode.postMessage({ + type: "refreshRequestyModels", + values: { + apiKey, + }, + }) + }, 50), + [], + ) + + useEffect(() => { + if (!apiConfiguration?.requestyApiKey) { + return + } + + debouncedRefreshModels(apiConfiguration.requestyApiKey) + + // Cleanup debounced function + return () => { + debouncedRefreshModels.clear() + } + }, [apiConfiguration?.requestyApiKey, debouncedRefreshModels]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownVisible(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, []) + + const modelIds = useMemo(() => { + return Object.keys(requestyModels).sort((a, b) => a.localeCompare(b)) + }, [requestyModels]) + + const searchableItems = useMemo(() => { + return modelIds.map((id) => ({ + id, + html: id, + })) + }, [modelIds]) + + const fzf = useMemo(() => { + return new Fzf(searchableItems, { + selector: (item) => item.html, + }) + }, [searchableItems]) + + const modelSearchResults = useMemo(() => { + if (!searchTerm) return searchableItems + + const searchResults = fzf.find(searchTerm) + return searchResults.map((result) => ({ + ...result.item, + html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"), + })) + }, [searchableItems, searchTerm, fzf]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (!isDropdownVisible) return + + switch (event.key) { + case "ArrowDown": + event.preventDefault() + setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev)) + break + case "ArrowUp": + event.preventDefault() + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)) + break + case "Enter": + event.preventDefault() + if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) { + handleModelChange(modelSearchResults[selectedIndex].id) + setIsDropdownVisible(false) + } + break + case "Escape": + setIsDropdownVisible(false) + setSelectedIndex(-1) + break + } + } + + const hasInfo = useMemo(() => { + return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) + }, [modelIds, searchTerm]) + + useEffect(() => { + setSelectedIndex(-1) + if (dropdownListRef.current) { + dropdownListRef.current.scrollTop = 0 + } + }, [searchTerm]) + + useEffect(() => { + if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) { + itemRefs.current[selectedIndex]?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + }, [selectedIndex]) + + return ( + <> + +
+ + + { + handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) + setIsDropdownVisible(true) + }} + onFocus={() => setIsDropdownVisible(true)} + onKeyDown={handleKeyDown} + style={{ width: "100%", zIndex: GLAMA_MODEL_PICKER_Z_INDEX, position: "relative" }}> + {searchTerm && ( +
{ + handleModelChange("") + setIsDropdownVisible(true) + }} + slot="end" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + height: "100%", + }} + /> + )} + + {isDropdownVisible && ( + + {modelSearchResults.map((item, index) => ( + (itemRefs.current[index] = el)} + isSelected={index === selectedIndex} + onMouseEnter={() => setSelectedIndex(index)} + onClick={() => { + handleModelChange(item.id) + setIsDropdownVisible(false) + }} + dangerouslySetInnerHTML={{ + __html: item.html, + }} + /> + ))} + + )} + +
+ + {hasInfo ? ( + + ) : ( +

+ The extension automatically fetches the latest list of models available on{" "} + + Requesty. + + If you're unsure which model to choose, Roo Code works best with{" "} + handleModelChange("anthropic/claude-3.5-sonnet")}> + anthropic/claude-3.5-sonnet. + + You can also try searching "free" for no-cost options currently available. +

+ )} + + ) +} + +export default RequestyModelPicker + +// Dropdown + +const DropdownWrapper = styled.div` + position: relative; + width: 100%; +` + +export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000 + +const DropdownList = styled.div` + position: absolute; + top: calc(100% - 3px); + left: 0; + width: calc(100% - 2px); + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-list-activeSelectionBackground); + z-index: ${GLAMA_MODEL_PICKER_Z_INDEX - 1}; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +` + +const DropdownItem = styled.div<{ isSelected: boolean }>` + padding: 5px 10px; + cursor: pointer; + word-break: break-all; + white-space: normal; + + background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; + + &:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } +` + +// Markdown + +const StyledMarkdown = styled.div` + font-family: + var(--vscode-font-family), + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + font-size: 12px; + color: var(--vscode-descriptionForeground); + + p, + li, + ol, + ul { + line-height: 1.25; + margin: 0; + } + + ol, + ul { + padding-left: 1.5em; + margin-left: 0; + } + + p { + white-space: pre-wrap; + } + + a { + text-decoration: none; + } + a { + &:hover { + text-decoration: underline; + } + } +` + +export const ModelDescriptionMarkdown = memo( + ({ + markdown, + key, + isExpanded, + setIsExpanded, + }: { + markdown?: string + key: string + isExpanded: boolean + setIsExpanded: (isExpanded: boolean) => void + }) => { + const [reactContent, setMarkdown] = useRemark() + const [showSeeMore, setShowSeeMore] = useState(false) + const textContainerRef = useRef(null) + const textRef = useRef(null) + + useEffect(() => { + setMarkdown(markdown || "") + }, [markdown, setMarkdown]) + + useEffect(() => { + if (textRef.current && textContainerRef.current) { + const { scrollHeight } = textRef.current + const { clientHeight } = textContainerRef.current + const isOverflowing = scrollHeight > clientHeight + setShowSeeMore(isOverflowing) + } + }, [reactContent, setIsExpanded]) + + return ( + +
+
+ {reactContent} +
+ {!isExpanded && showSeeMore && ( +
+
+ setIsExpanded(true)}> + See more + +
+ )} +
+ + ) + }, +) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 79edcc6a35..6f4a196d2a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -10,6 +10,8 @@ import { openRouterDefaultModelInfo, unboundDefaultModelId, unboundDefaultModelInfo, + requestyDefaultModelId, + requestyDefaultModelInfo, } from "../../../src/shared/api" import { vscode } from "../utils/vscode" import { convertTextMateToHljs } from "../utils/textMateToHljs" @@ -25,6 +27,7 @@ export interface ExtensionStateContextType extends ExtensionState { showWelcome: boolean theme: any glamaModels: Record + requestyModels: Record openRouterModels: Record unboundModels: Record openAiModels: string[] @@ -130,6 +133,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [unboundModels, setUnboundModels] = useState>({ [unboundDefaultModelId]: unboundDefaultModelInfo, }) + const [requestyModels, setRequestyModels] = useState>({ + [requestyDefaultModelId]: requestyDefaultModelInfo, + }) const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) @@ -250,6 +256,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setUnboundModels(updatedModels) break } + case "requestyModels": { + const updatedModels = message.requestyModels ?? {} + setRequestyModels({ + [requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model + ...updatedModels, + }) + break + } case "mcpServers": { setMcpServers(message.mcpServers ?? []) break @@ -279,6 +293,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showWelcome, theme, glamaModels, + requestyModels, openRouterModels, openAiModels, unboundModels, From 41fcf85c484fbee6fb4f75c13c4a7d0118151030 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 5 Feb 2025 19:40:24 +0700 Subject: [PATCH 2/3] refactor(api): improve OpenAI handler inheritance - Add OpenAiHandlerOptions interface for configuration - Extract processUsageMetrics to base class for reuse - Update RequestyHandler to extend OpenAiHandler - Add proper type safety for metrics handling - Clean up code duplication across handlers --- src/api/providers/deepseek.ts | 6 +- src/api/providers/openai.ts | 32 ++++---- src/api/providers/requesty.ts | 137 ++++++---------------------------- 3 files changed, 45 insertions(+), 130 deletions(-) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 1c7186d48c..267a41bfff 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,9 +1,9 @@ -import { OpenAiHandler } from "./openai" -import { ApiHandlerOptions, ModelInfo } from "../../shared/api" +import { OpenAiHandler, OpenAiHandlerOptions } from "./openai" +import { ModelInfo } from "../../shared/api" import { deepSeekModels, deepSeekDefaultModelId } from "../../shared/api" export class DeepSeekHandler extends OpenAiHandler { - constructor(options: ApiHandlerOptions) { + constructor(options: OpenAiHandlerOptions) { super({ ...options, openAiApiKey: options.deepSeekApiKey ?? "not-provided", diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 203eb44040..44421a831d 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -11,13 +11,17 @@ import { ApiHandler, SingleCompletionHandler } from "../index" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" import { convertToSimpleMessages } from "../transform/simple-format" -import { ApiStream } from "../transform/stream" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" + +export interface OpenAiHandlerOptions extends ApiHandlerOptions { + defaultHeaders?: Record +} export class OpenAiHandler implements ApiHandler, SingleCompletionHandler { - protected options: ApiHandlerOptions + protected options: OpenAiHandlerOptions private client: OpenAI - constructor(options: ApiHandlerOptions) { + constructor(options: OpenAiHandlerOptions) { this.options = options const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" @@ -41,7 +45,7 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler { apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, }) } else { - this.client = new OpenAI({ baseURL, apiKey }) + this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: this.options.defaultHeaders }) } } @@ -98,11 +102,7 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler { } } if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } + yield this.processUsageMetrics(chunk.usage) } } } else { @@ -125,11 +125,15 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler { type: "text", text: response.choices[0]?.message.content || "", } - yield { - type: "usage", - inputTokens: response.usage?.prompt_tokens || 0, - outputTokens: response.usage?.completion_tokens || 0, - } + yield this.processUsageMetrics(response.usage) + } + } + + protected processUsageMetrics(usage: any): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage?.prompt_tokens || 0, + outputTokens: usage?.completion_tokens || 0, } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 01396b8a2b..67f43aabc5 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,21 +1,18 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { OpenAiHandler, OpenAiHandlerOptions } from "./openai" +import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { ApiHandlerOptions, ModelInfo, requestyModelInfoSaneDefaults } from "../../shared/api" -import { ApiHandler, SingleCompletionHandler } from "../index" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { convertToR1Format } from "../transform/r1-format" -import { ApiStream } from "../transform/stream" - -export class RequestyHandler implements ApiHandler, SingleCompletionHandler { - protected options: ApiHandlerOptions - private client: OpenAI - - constructor(options: ApiHandlerOptions) { - this.options = options - this.client = new OpenAI({ - baseURL: "https://router.requesty.ai/v1", - apiKey: this.options.requestyApiKey, +export class RequestyHandler extends OpenAiHandler { + constructor(options: OpenAiHandlerOptions) { + if (!options.requestyApiKey) { + throw new Error("Requesty API key is required. Please provide it in the settings.") + } + super({ + ...options, + openAiApiKey: options.requestyApiKey, + openAiModelId: options.requestyModelId ?? requestyDefaultModelId, + openAiBaseUrl: "https://router.requesty.ai/v1", + openAiCustomModelInfo: options.requestyModelInfo ?? requestyModelInfoSaneDefaults, defaultHeaders: { "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", "X-Title": "Roo Code", @@ -23,107 +20,21 @@ export class RequestyHandler implements ApiHandler, SingleCompletionHandler { }) } - async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const modelInfo = this.getModel().info - const modelId = this.options.requestyModelId ?? "" - - const deepseekReasoner = modelId.includes("deepseek-reasoner") - - if (this.options.openAiStreamingEnabled ?? true) { - const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { - role: "system", - content: systemPrompt, - } - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - temperature: 0, - messages: deepseekReasoner - ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - : [systemMessage, ...convertToOpenAiMessages(messages)], - stream: true as const, - stream_options: { include_usage: true }, - } - if (this.options.includeMaxTokens) { - requestOptions.max_tokens = modelInfo.maxTokens - } - - const stream = await this.client.chat.completions.create(requestOptions) - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta ?? {} - - if (delta.content) { - yield { - type: "text", - text: delta.content, - } - } - - if ("reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: (delta.reasoning_content as string | undefined) || "", - } - } - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - cacheWriteTokens: (chunk.usage as any).cache_creation_input_tokens || undefined, - cacheReadTokens: (chunk.usage as any).cache_read_input_tokens || undefined, - } - } - } - } else { - // o1 for instance doesnt support streaming, non-1 temp, or system prompt - const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = { - role: "user", - content: systemPrompt, - } - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: deepseekReasoner - ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - : [systemMessage, ...convertToOpenAiMessages(messages)], - } - - const response = await this.client.chat.completions.create(requestOptions) - - yield { - type: "text", - text: response.choices[0]?.message.content || "", - } - yield { - type: "usage", - inputTokens: response.usage?.prompt_tokens || 0, - outputTokens: response.usage?.completion_tokens || 0, - } - } - } - - getModel(): { id: string; info: ModelInfo } { + override getModel(): { id: string; info: ModelInfo } { + const modelId = this.options.requestyModelId ?? requestyDefaultModelId return { - id: this.options.requestyModelId ?? "", + id: modelId, info: this.options.requestyModelInfo ?? requestyModelInfoSaneDefaults, } } - async completePrompt(prompt: string): Promise { - try { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: this.getModel().id, - messages: [{ role: "user", content: prompt }], - } - - const response = await this.client.chat.completions.create(requestOptions) - return response.choices[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`OpenAI completion error: ${error.message}`) - } - throw error + protected override processUsageMetrics(usage: any): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage?.prompt_tokens || 0, + outputTokens: usage?.completion_tokens || 0, + cacheWriteTokens: usage?.cache_creation_input_tokens, + cacheReadTokens: usage?.cache_read_input_tokens, } } } From d6e55afb518cc7c97923e2cfdc339b4bf50354a3 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Mon, 10 Feb 2025 20:11:29 +0700 Subject: [PATCH 3/3] using ModelPicker for Requesty Model Picker --- src/shared/api.ts | 3 +- .../src/components/settings/ApiOptions.tsx | 2 +- .../src/components/settings/ModelPicker.tsx | 8 +- .../settings/RequestyModelPicker.tsx | 432 +----------------- 4 files changed, 19 insertions(+), 426 deletions(-) diff --git a/src/shared/api.ts b/src/shared/api.ts index f0afb333a6..93ebaed9bc 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -343,7 +343,6 @@ export const bedrockModels = { // Glama // https://glama.ai/models export const glamaDefaultModelId = "anthropic/claude-3-5-sonnet" -export const requestyDefaultModelId = "anthropic/claude-3-5-sonnet" export const glamaDefaultModelInfo: ModelInfo = { maxTokens: 8192, contextWindow: 200_000, @@ -357,6 +356,7 @@ export const glamaDefaultModelInfo: ModelInfo = { description: "The new Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: New Sonnet scores ~49% on SWE-Bench Verified, higher than the last best score, and without any fancy prompt scaffolding\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal\n\n_This is a faster endpoint, made available in collaboration with Anthropic, that is self-moderated: response moderation happens on the provider's side instead of OpenRouter's. For requests that pass moderation, it's identical to the [Standard](/anthropic/claude-3.5-sonnet) variant._", } + export const requestyDefaultModelInfo: ModelInfo = { maxTokens: 8192, contextWindow: 200_000, @@ -370,6 +370,7 @@ export const requestyDefaultModelInfo: ModelInfo = { description: "The new Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: New Sonnet scores ~49% on SWE-Bench Verified, higher than the last best score, and without any fancy prompt scaffolding\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal\n\n_This is a faster endpoint, made available in collaboration with Anthropic, that is self-moderated: response moderation happens on the provider's side instead of OpenRouter's. For requests that pass moderation, it's identical to the [Standard](/anthropic/claude-3.5-sonnet) variant._", } +export const requestyDefaultModelId = "anthropic/claude-3-5-sonnet" // OpenRouter // https://openrouter.ai/models?order=newest&supported_parameters=tools diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 1d8ca04b77..2848fb7fd5 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -42,7 +42,7 @@ import { GlamaModelPicker } from "./GlamaModelPicker" import { UnboundModelPicker } from "./UnboundModelPicker" import { ModelInfoView } from "./ModelInfoView" import { DROPDOWN_Z_INDEX } from "./styles" -import RequestyModelPicker from "./RequestyModelPicker" +import { RequestyModelPicker } from "./RequestyModelPicker" interface ApiOptionsProps { apiErrorMessage?: string diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index 0b609466bb..f45f857d9d 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -25,10 +25,10 @@ import { ModelInfoView } from "./ModelInfoView" interface ModelPickerProps { defaultModelId: string - modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" - configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" - infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" - refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels" + modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" | "requestyModels" + configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId" + infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo" + refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels" | "refreshRequestyModels" serviceName: string serviceUrl: string recommendedModel: string diff --git a/webview-ui/src/components/settings/RequestyModelPicker.tsx b/webview-ui/src/components/settings/RequestyModelPicker.tsx index 899ffe62a8..bdc2db0744 100644 --- a/webview-ui/src/components/settings/RequestyModelPicker.tsx +++ b/webview-ui/src/components/settings/RequestyModelPicker.tsx @@ -1,423 +1,15 @@ -import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import debounce from "debounce" -import { Fzf } from "fzf" -import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" -import { useRemark } from "react-remark" -import styled from "styled-components" +import { ModelPicker } from "./ModelPicker" import { requestyDefaultModelId } from "../../../../src/shared/api" -import { useExtensionState } from "../../context/ExtensionStateContext" -import { vscode } from "../../utils/vscode" -import { highlightFzfMatch } from "../../utils/highlight" -import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" -const RequestyModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, requestyModels, onUpdateApiConfig } = useExtensionState() - const [searchTerm, setSearchTerm] = useState(apiConfiguration?.requestyModelId || requestyDefaultModelId) - const [isDropdownVisible, setIsDropdownVisible] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(-1) - const dropdownRef = useRef(null) - const itemRefs = useRef<(HTMLDivElement | null)[]>([]) - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) - const dropdownListRef = useRef(null) - - const handleModelChange = (newModelId: string) => { - // could be setting invalid model id/undefined info but validation will catch it - const apiConfig = { - ...apiConfiguration, - requestyModelId: newModelId, - requestyModelInfo: requestyModels[newModelId], - } - setApiConfiguration(apiConfig) - onUpdateApiConfig(apiConfig) - - setSearchTerm(newModelId) - } - - const { selectedModelId, selectedModelInfo } = useMemo(() => { - return normalizeApiConfiguration(apiConfiguration) - }, [apiConfiguration]) - - useEffect(() => { - if (apiConfiguration?.requestyModelId && apiConfiguration?.requestyModelId !== searchTerm) { - setSearchTerm(apiConfiguration?.requestyModelId) - } - }, [apiConfiguration, searchTerm]) - - const debouncedRefreshModels = useMemo( - () => - debounce((apiKey: string) => { - vscode.postMessage({ - type: "refreshRequestyModels", - values: { - apiKey, - }, - }) - }, 50), - [], - ) - - useEffect(() => { - if (!apiConfiguration?.requestyApiKey) { - return - } - - debouncedRefreshModels(apiConfiguration.requestyApiKey) - - // Cleanup debounced function - return () => { - debouncedRefreshModels.clear() - } - }, [apiConfiguration?.requestyApiKey, debouncedRefreshModels]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownVisible(false) - } - } - - document.addEventListener("mousedown", handleClickOutside) - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, []) - - const modelIds = useMemo(() => { - return Object.keys(requestyModels).sort((a, b) => a.localeCompare(b)) - }, [requestyModels]) - - const searchableItems = useMemo(() => { - return modelIds.map((id) => ({ - id, - html: id, - })) - }, [modelIds]) - - const fzf = useMemo(() => { - return new Fzf(searchableItems, { - selector: (item) => item.html, - }) - }, [searchableItems]) - - const modelSearchResults = useMemo(() => { - if (!searchTerm) return searchableItems - - const searchResults = fzf.find(searchTerm) - return searchResults.map((result) => ({ - ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"), - })) - }, [searchableItems, searchTerm, fzf]) - - const handleKeyDown = (event: KeyboardEvent) => { - if (!isDropdownVisible) return - - switch (event.key) { - case "ArrowDown": - event.preventDefault() - setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev)) - break - case "ArrowUp": - event.preventDefault() - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)) - break - case "Enter": - event.preventDefault() - if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) { - handleModelChange(modelSearchResults[selectedIndex].id) - setIsDropdownVisible(false) - } - break - case "Escape": - setIsDropdownVisible(false) - setSelectedIndex(-1) - break - } - } - - const hasInfo = useMemo(() => { - return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) - }, [modelIds, searchTerm]) - - useEffect(() => { - setSelectedIndex(-1) - if (dropdownListRef.current) { - dropdownListRef.current.scrollTop = 0 - } - }, [searchTerm]) - - useEffect(() => { - if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) { - itemRefs.current[selectedIndex]?.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }) - } - }, [selectedIndex]) - - return ( - <> - -
- - - { - handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) - setIsDropdownVisible(true) - }} - onFocus={() => setIsDropdownVisible(true)} - onKeyDown={handleKeyDown} - style={{ width: "100%", zIndex: GLAMA_MODEL_PICKER_Z_INDEX, position: "relative" }}> - {searchTerm && ( -
{ - handleModelChange("") - setIsDropdownVisible(true) - }} - slot="end" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - height: "100%", - }} - /> - )} - - {isDropdownVisible && ( - - {modelSearchResults.map((item, index) => ( - (itemRefs.current[index] = el)} - isSelected={index === selectedIndex} - onMouseEnter={() => setSelectedIndex(index)} - onClick={() => { - handleModelChange(item.id) - setIsDropdownVisible(false) - }} - dangerouslySetInnerHTML={{ - __html: item.html, - }} - /> - ))} - - )} - -
- - {hasInfo ? ( - - ) : ( -

- The extension automatically fetches the latest list of models available on{" "} - - Requesty. - - If you're unsure which model to choose, Roo Code works best with{" "} - handleModelChange("anthropic/claude-3.5-sonnet")}> - anthropic/claude-3.5-sonnet. - - You can also try searching "free" for no-cost options currently available. -

- )} - - ) -} - -export default RequestyModelPicker - -// Dropdown - -const DropdownWrapper = styled.div` - position: relative; - width: 100%; -` - -export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000 - -const DropdownList = styled.div` - position: absolute; - top: calc(100% - 3px); - left: 0; - width: calc(100% - 2px); - max-height: 200px; - overflow-y: auto; - background-color: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-list-activeSelectionBackground); - z-index: ${GLAMA_MODEL_PICKER_Z_INDEX - 1}; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -` - -const DropdownItem = styled.div<{ isSelected: boolean }>` - padding: 5px 10px; - cursor: pointer; - word-break: break-all; - white-space: normal; - - background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; - - &:hover { - background-color: var(--vscode-list-activeSelectionBackground); - } -` - -// Markdown - -const StyledMarkdown = styled.div` - font-family: - var(--vscode-font-family), - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Oxygen, - Ubuntu, - Cantarell, - "Open Sans", - "Helvetica Neue", - sans-serif; - font-size: 12px; - color: var(--vscode-descriptionForeground); - - p, - li, - ol, - ul { - line-height: 1.25; - margin: 0; - } - - ol, - ul { - padding-left: 1.5em; - margin-left: 0; - } - - p { - white-space: pre-wrap; - } - - a { - text-decoration: none; - } - a { - &:hover { - text-decoration: underline; - } - } -` - -export const ModelDescriptionMarkdown = memo( - ({ - markdown, - key, - isExpanded, - setIsExpanded, - }: { - markdown?: string - key: string - isExpanded: boolean - setIsExpanded: (isExpanded: boolean) => void - }) => { - const [reactContent, setMarkdown] = useRemark() - const [showSeeMore, setShowSeeMore] = useState(false) - const textContainerRef = useRef(null) - const textRef = useRef(null) - - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown]) - - useEffect(() => { - if (textRef.current && textContainerRef.current) { - const { scrollHeight } = textRef.current - const { clientHeight } = textContainerRef.current - const isOverflowing = scrollHeight > clientHeight - setShowSeeMore(isOverflowing) - } - }, [reactContent, setIsExpanded]) - - return ( - -
-
- {reactContent} -
- {!isExpanded && showSeeMore && ( -
-
- setIsExpanded(true)}> - See more - -
- )} -
- - ) - }, +export const RequestyModelPicker = () => ( + )