Просмотр исходного кода

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
sam hoang 11 месяцев назад
Родитель
Сommit
91fe764cb8

+ 3 - 0
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<string>
@@ -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)
 	}

+ 247 - 0
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<typeof OpenAI>).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}`,
+			)
+		})
+	})
+})

+ 129 - 0
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<string> {
+		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
+		}
+	}
+}

+ 132 - 0
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<Record<string, ModelInfo> | 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<string, ModelInfo> = {}
+		try {
+			const config: Record<string, any> = {}
+			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<ApiProvider | undefined>,
 			this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2464,6 +2589,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getSecret("unboundApiKey") as Promise<string | undefined>,
 			this.getGlobalState("unboundModelId") as Promise<string | undefined>,
 			this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
+			this.getSecret("requestyApiKey") as Promise<string | undefined>,
+			this.getGlobalState("requestyModelId") as Promise<string | undefined>,
+			this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
 		])
 
 		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)

+ 2 - 0
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<string, ModelInfo>
+	requestyModels?: Record<string, ModelInfo>
 	openRouterModels?: Record<string, ModelInfo>
 	openAiModels?: string[]
 	unboundModels?: Record<string, ModelInfo>

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -43,6 +43,7 @@ export interface WebviewMessage {
 		| "refreshOpenRouterModels"
 		| "refreshOpenAiModels"
 		| "refreshUnboundModels"
+		| "refreshRequestyModels"
 		| "alwaysAllowBrowser"
 		| "alwaysAllowMcp"
 		| "alwaysAllowModeSwitch"

+ 2 - 0
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)
 	})

+ 27 - 0
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

+ 2 - 0
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
 }

+ 33 - 0
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" },
 					]}
 				/>
 			</div>
@@ -240,6 +244,27 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 				</div>
 			)}
 
+			{selectedProvider === "requesty" && (
+				<div>
+					<VSCodeTextField
+						value={apiConfiguration?.requestyApiKey || ""}
+						style={{ width: "100%" }}
+						type="password"
+						onInput={handleInputChange("requestyApiKey")}
+						placeholder="Enter API Key...">
+						<span style={{ fontWeight: 500 }}>Requesty API Key</span>
+					</VSCodeTextField>
+					<p
+						style={{
+							fontSize: "12px",
+							marginTop: "5px",
+							color: "var(--vscode-descriptionForeground)",
+						}}>
+						This key is stored locally and only used to make API requests from this extension.
+					</p>
+				</div>
+			)}
+
 			{selectedProvider === "openai-native" && (
 				<div>
 					<VSCodeTextField
@@ -1333,9 +1358,11 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 			{selectedProvider === "glama" && <GlamaModelPicker />}
 
 			{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
+			{selectedProvider === "requesty" && <RequestyModelPicker />}
 
 			{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)
 	}

+ 423 - 0
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<HTMLDivElement>(null)
+	const itemRefs = useRef<(HTMLDivElement | null)[]>([])
+	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
+	const dropdownListRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
+		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 (
+		<>
+			<style>
+				{`
+				.model-item-highlight {
+					background-color: var(--vscode-editor-findMatchHighlightBackground);
+					color: inherit;
+				}
+				`}
+			</style>
+			<div>
+				<label htmlFor="model-search">
+					<span style={{ fontWeight: 500 }}>Model</span>
+				</label>
+				<DropdownWrapper ref={dropdownRef}>
+					<VSCodeTextField
+						id="model-search"
+						placeholder="Search and select a model..."
+						value={searchTerm}
+						onInput={(e) => {
+							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 && (
+							<div
+								className="input-icon-button codicon codicon-close"
+								aria-label="Clear search"
+								onClick={() => {
+									handleModelChange("")
+									setIsDropdownVisible(true)
+								}}
+								slot="end"
+								style={{
+									display: "flex",
+									justifyContent: "center",
+									alignItems: "center",
+									height: "100%",
+								}}
+							/>
+						)}
+					</VSCodeTextField>
+					{isDropdownVisible && (
+						<DropdownList ref={dropdownListRef}>
+							{modelSearchResults.map((item, index) => (
+								<DropdownItem
+									key={item.id}
+									ref={(el) => (itemRefs.current[index] = el)}
+									isSelected={index === selectedIndex}
+									onMouseEnter={() => setSelectedIndex(index)}
+									onClick={() => {
+										handleModelChange(item.id)
+										setIsDropdownVisible(false)
+									}}
+									dangerouslySetInnerHTML={{
+										__html: item.html,
+									}}
+								/>
+							))}
+						</DropdownList>
+					)}
+				</DropdownWrapper>
+			</div>
+
+			{hasInfo ? (
+				<ModelInfoView
+					selectedModelId={selectedModelId}
+					modelInfo={selectedModelInfo}
+					isDescriptionExpanded={isDescriptionExpanded}
+					setIsDescriptionExpanded={setIsDescriptionExpanded}
+				/>
+			) : (
+				<p
+					style={{
+						fontSize: "12px",
+						marginTop: 0,
+						color: "var(--vscode-descriptionForeground)",
+					}}>
+					The extension automatically fetches the latest list of models available on{" "}
+					<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://requesty.ai/models">
+						Requesty.
+					</VSCodeLink>
+					If you're unsure which model to choose, Roo Code works best with{" "}
+					<VSCodeLink
+						style={{ display: "inline", fontSize: "inherit" }}
+						onClick={() => handleModelChange("anthropic/claude-3.5-sonnet")}>
+						anthropic/claude-3.5-sonnet.
+					</VSCodeLink>
+					You can also try searching "free" for no-cost options currently available.
+				</p>
+			)}
+		</>
+	)
+}
+
+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<HTMLDivElement>(null)
+		const textRef = useRef<HTMLDivElement>(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 (
+			<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
+				<div
+					ref={textContainerRef}
+					style={{
+						overflowY: isExpanded ? "auto" : "hidden",
+						position: "relative",
+						wordBreak: "break-word",
+						overflowWrap: "anywhere",
+					}}>
+					<div
+						ref={textRef}
+						style={{
+							display: "-webkit-box",
+							WebkitLineClamp: isExpanded ? "unset" : 3,
+							WebkitBoxOrient: "vertical",
+							overflow: "hidden",
+						}}>
+						{reactContent}
+					</div>
+					{!isExpanded && showSeeMore && (
+						<div
+							style={{
+								position: "absolute",
+								right: 0,
+								bottom: 0,
+								display: "flex",
+								alignItems: "center",
+							}}>
+							<div
+								style={{
+									width: 30,
+									height: "1.2em",
+									background:
+										"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
+								}}
+							/>
+							<VSCodeLink
+								style={{
+									fontSize: "inherit",
+									paddingRight: 0,
+									paddingLeft: 3,
+									backgroundColor: "var(--vscode-sideBar-background)",
+								}}
+								onClick={() => setIsExpanded(true)}>
+								See more
+							</VSCodeLink>
+						</div>
+					)}
+				</div>
+			</StyledMarkdown>
+		)
+	},
+)

+ 15 - 0
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<string, ModelInfo>
+	requestyModels: Record<string, ModelInfo>
 	openRouterModels: Record<string, ModelInfo>
 	unboundModels: Record<string, ModelInfo>
 	openAiModels: string[]
@@ -130,6 +133,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 	const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
 		[unboundDefaultModelId]: unboundDefaultModelInfo,
 	})
+	const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
+		[requestyDefaultModelId]: requestyDefaultModelInfo,
+	})
 
 	const [openAiModels, setOpenAiModels] = useState<string[]>([])
 	const [mcpServers, setMcpServers] = useState<McpServer[]>([])
@@ -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,