mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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
This commit is contained in:
parent
60ea3c25c0
commit
91fe764cb8
12 changed files with 1016 additions and 0 deletions
|
|
@ -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
src/api/providers/__tests__/requesty.test.ts
Normal file
247
src/api/providers/__tests__/requesty.test.ts
Normal 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}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
129
src/api/providers/requesty.ts
Normal file
129
src/api/providers/requesty.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface WebviewMessage {
|
|||
| "refreshOpenRouterModels"
|
||||
| "refreshOpenAiModels"
|
||||
| "refreshUnboundModels"
|
||||
| "refreshRequestyModels"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "alwaysAllowModeSwitch"
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ describe("checkExistKey", () => {
|
|||
deepSeekApiKey: undefined,
|
||||
mistralApiKey: undefined,
|
||||
vsCodeLmModelSelector: undefined,
|
||||
requestyApiKey: undefined,
|
||||
unboundApiKey: undefined,
|
||||
}
|
||||
expect(checkExistKey(config)).toBe(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
webview-ui/src/components/settings/RequestyModelPicker.tsx
Normal file
423
webview-ui/src/components/settings/RequestyModelPicker.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue