Merge pull request #658 from samhvw8/feat/roo-code-requesty-provider

feat: add Requesty API provider support
This commit is contained in:
Matt Rubens 2025-02-10 10:58:45 -05:00 committed by GitHub
commit c78fbed319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 545 additions and 21 deletions

View file

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

View file

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

View file

@ -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",

View file

@ -11,16 +11,20 @@ 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<string, string>
}
export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.6
const OPENAI_DEFAULT_TEMPERATURE = 0
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"
@ -44,7 +48,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 })
}
}
@ -103,11 +107,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 {
@ -130,11 +130,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,
}
}

View file

@ -0,0 +1,40 @@
import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
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",
},
})
}
override getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.requestyModelId ?? requestyDefaultModelId
return {
id: modelId,
info: this.options.requestyModelInfo ?? requestyModelInfoSaneDefaults,
}
}
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,
}
}
}

View file

@ -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"
| "modelTemperature"
@ -130,6 +133,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",
}
@ -686,6 +690,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) => {
@ -848,6 +871,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
@ -1588,6 +1617,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
@ -1630,6 +1662,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)
await this.updateGlobalState("modelTemperature", modelTemperature)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
@ -1773,6 +1808,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) {
@ -2391,6 +2513,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
@ -2468,6 +2593,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>,
this.getGlobalState("modelTemperature") as Promise<number | undefined>,
])
@ -2527,6 +2655,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
},
lastShownAnnouncementId,
@ -2681,6 +2812,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
"deepSeekApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
]
for (const key of secretKeys) {
await this.storeSecret(key, undefined)

View file

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

View file

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

View file

@ -51,6 +51,8 @@ describe("checkExistKey", () => {
deepSeekApiKey: undefined,
mistralApiKey: undefined,
vsCodeLmModelSelector: undefined,
requestyApiKey: undefined,
unboundApiKey: undefined,
}
expect(checkExistKey(config)).toBe(false)
})

View file

@ -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
modelTemperature?: number
}
@ -354,6 +358,21 @@ export const glamaDefaultModelInfo: ModelInfo = {
"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._",
}
export const requestyDefaultModelId = "anthropic/claude-3-5-sonnet"
// OpenRouter
// https://openrouter.ai/models?order=newest&supported_parameters=tools
export const openRouterDefaultModelId = "anthropic/claude-3.5-sonnet:beta" // will always exist in openRouterModels
@ -428,6 +447,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

View file

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

View file

@ -30,6 +30,8 @@ import {
vertexModels,
unboundDefaultModelId,
unboundDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
} from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
@ -41,6 +43,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
@ -154,6 +157,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{ value: "lmstudio", label: "LM Studio" },
{ value: "ollama", label: "Ollama" },
{ value: "unbound", label: "Unbound" },
{ value: "requesty", label: "Requesty" },
]}
/>
</div>
@ -241,6 +245,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
@ -1334,9 +1359,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" &&
@ -1478,6 +1505,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)
}

View file

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

View file

@ -0,0 +1,15 @@
import { ModelPicker } from "./ModelPicker"
import { requestyDefaultModelId } from "../../../../src/shared/api"
export const RequestyModelPicker = () => (
<ModelPicker
defaultModelId={requestyDefaultModelId}
modelsKey="requestyModels"
configKey="requestyModelId"
infoKey="requestyModelInfo"
refreshMessageType="refreshRequestyModels"
serviceName="Requesty"
serviceUrl="https://requesty.ai"
recommendedModel="anthropic/claude-3-5-sonnet-latest"
/>
)

View file

@ -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,