diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 267a41bfffc..148bf31310e 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,24 +1,158 @@ -import { OpenAiHandler, OpenAiHandlerOptions } from "./openai" -import { ModelInfo } from "../../shared/api" -import { deepSeekModels, deepSeekDefaultModelId } from "../../shared/api" - -export class DeepSeekHandler extends OpenAiHandler { - constructor(options: OpenAiHandlerOptions) { - super({ - ...options, - openAiApiKey: options.deepSeekApiKey ?? "not-provided", - openAiModelId: options.apiModelId ?? deepSeekDefaultModelId, - openAiBaseUrl: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1", - openAiStreamingEnabled: true, - includeMaxTokens: true, +import { Anthropic } from "@anthropic-ai/sdk" +import { ApiHandlerOptions, ModelInfo, deepSeekModels, deepSeekDefaultModelId } from "../../shared/api" +import { ApiHandler, SingleCompletionHandler } from "../index" +import { convertToR1Format } from "../transform/r1-format" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream } from "../transform/stream" + +interface DeepSeekUsage { + prompt_tokens: number + completion_tokens: number + prompt_cache_miss_tokens?: number + prompt_cache_hit_tokens?: number +} + +export class DeepSeekHandler implements ApiHandler, SingleCompletionHandler { + private options: ApiHandlerOptions + + constructor(options: ApiHandlerOptions) { + if (!options.deepSeekApiKey) { + throw new Error("DeepSeek API key is required. Please provide it in the settings.") + } + this.options = options + } + + private get baseUrl(): string { + return this.options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1" + } + + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const modelInfo = this.getModel().info + const modelId = this.options.apiModelId ?? deepSeekDefaultModelId + const isReasoner = modelId.includes("deepseek-reasoner") + + const systemMessage = { role: "system", content: systemPrompt } + const formattedMessages = isReasoner + ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + : [systemMessage, ...convertToOpenAiMessages(messages)] + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.options.deepSeekApiKey}`, + }, + body: JSON.stringify({ + model: modelId, + messages: formattedMessages, + temperature: 0, + stream: true, + max_tokens: modelInfo.maxTokens, + }), }) + + if (!response.ok) { + throw new Error(`DeepSeek API error: ${response.statusText}`) + } + + if (!response.body) { + throw new Error("No response body received from DeepSeek API") + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + if (line.trim() === "") continue + if (!line.startsWith("data: ")) continue + + const data = line.slice(6) + if (data === "[DONE]") continue + + try { + const chunk = JSON.parse(data) + 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, + } + } + + if (chunk.usage) { + const usage = chunk.usage as DeepSeekUsage + let inputTokens = (usage.prompt_tokens || 0) - (usage.prompt_cache_hit_tokens || 0) + yield { + type: "usage", + inputTokens: inputTokens, + outputTokens: usage.completion_tokens || 0, + cacheReadTokens: usage.prompt_cache_hit_tokens || 0, + cacheWriteTokens: usage.prompt_cache_miss_tokens || 0, + } + } + } catch (error) { + console.error("Error parsing DeepSeek response:", error) + } + } + } + } finally { + reader.releaseLock() + } } - override getModel(): { id: string; info: ModelInfo } { + getModel(): { id: string; info: ModelInfo } { const modelId = this.options.apiModelId ?? deepSeekDefaultModelId return { id: modelId, info: deepSeekModels[modelId as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId], } } + + async completePrompt(prompt: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.options.deepSeekApiKey}`, + }, + body: JSON.stringify({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false, + }), + }) + + if (!response.ok) { + throw new Error(`DeepSeek API error: ${response.statusText}`) + } + + const data = await response.json() + return data.choices[0]?.message?.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`DeepSeek completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/pearai.ts b/src/api/providers/pearai.ts index ccd9e5fd281..95581244a69 100644 --- a/src/api/providers/pearai.ts +++ b/src/api/providers/pearai.ts @@ -1,36 +1,99 @@ -import { OpenAiHandler } from "./openai" import * as vscode from "vscode" -import { AnthropicModelId, ApiHandlerOptions, ModelInfo, PEARAI_URL } from "../../shared/api" +import { ApiHandlerOptions, PEARAI_URL, ModelInfo } from "../../shared/api" import { AnthropicHandler } from "./anthropic" +import { DeepSeekHandler } from "./deepseek" + +interface PearAiModelsResponse { + models: { + [key: string]: { + underlyingModel?: string + [key: string]: any + } + } + defaultModelId: string +} + +export class PearAiHandler { + private handler!: AnthropicHandler | DeepSeekHandler -export class PearAiHandler extends AnthropicHandler { constructor(options: ApiHandlerOptions) { if (!options.pearaiApiKey) { vscode.window.showErrorMessage("PearAI API key not found.", "Login to PearAI").then(async (selection) => { if (selection === "Login to PearAI") { const extensionUrl = `${vscode.env.uriScheme}://pearai.pearai/auth` const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(extensionUrl)) - vscode.env.openExternal( await vscode.env.asExternalUri( - vscode.Uri.parse( - `https://trypear.ai/signin?callback=${callbackUri.toString()}`, // Change to localhost if running locally - ), + vscode.Uri.parse(`https://trypear.ai/signin?callback=${callbackUri.toString()}`), ), ) } }) throw new Error("PearAI API key not found. Please login to PearAI.") } - super({ - ...options, - apiKey: options.pearaiApiKey, - anthropicBaseUrl: PEARAI_URL, + + this.initializeHandler(options).catch((error) => { + console.error("Failed to initialize PearAI handler:", error) + throw error }) } - override getModel(): { id: AnthropicModelId; info: ModelInfo } { - const baseModel = super.getModel() + private async initializeHandler(options: ApiHandlerOptions): Promise { + const modelId = options.apiModelId || "" + + if (modelId === "pearai-model") { + try { + const response = await fetch(`${PEARAI_URL}/getPearAIAgentModels`) + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.statusText}`) + } + const data = (await response.json()) as PearAiModelsResponse + const underlyingModel = data.models[modelId]?.underlyingModel || "claude-3-5-sonnet-20241022" + if (underlyingModel.startsWith("deepseek")) { + this.handler = new DeepSeekHandler({ + ...options, + deepSeekApiKey: options.pearaiApiKey, + deepSeekBaseUrl: PEARAI_URL, + apiModelId: underlyingModel, + }) + } else { + // Default to Claude + this.handler = new AnthropicHandler({ + ...options, + apiKey: options.pearaiApiKey, + anthropicBaseUrl: PEARAI_URL, + apiModelId: underlyingModel, + }) + } + } catch (error) { + console.error("Error fetching PearAI models:", error) + // Default to Claude if there's an error + this.handler = new AnthropicHandler({ + ...options, + apiKey: options.pearaiApiKey, + anthropicBaseUrl: PEARAI_URL, + apiModelId: "claude-3-5-sonnet-20241022", + }) + } + } else if (modelId.startsWith("claude")) { + this.handler = new AnthropicHandler({ + ...options, + apiKey: options.pearaiApiKey, + anthropicBaseUrl: PEARAI_URL, + }) + } else if (modelId.startsWith("deepseek")) { + this.handler = new DeepSeekHandler({ + ...options, + deepSeekApiKey: options.pearaiApiKey, + deepSeekBaseUrl: PEARAI_URL, + }) + } else { + throw new Error(`Unsupported model: ${modelId}`) + } + } + + getModel(): { id: string; info: ModelInfo } { + const baseModel = this.handler.getModel() return { id: baseModel.id, info: { @@ -42,4 +105,13 @@ export class PearAiHandler extends AnthropicHandler { }, } } + + async *createMessage(systemPrompt: string, messages: any[]): AsyncGenerator { + const generator = this.handler.createMessage(systemPrompt, messages) + yield* generator + } + + async completePrompt(prompt: string): Promise { + return this.handler.completePrompt(prompt) + } } diff --git a/src/shared/api.ts b/src/shared/api.ts index 3173d4a3a8d..5f6feea186e 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -698,18 +698,22 @@ export const deepSeekModels = { maxTokens: 8192, contextWindow: 64_000, supportsImages: false, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 0.014, // $0.014 per million tokens outputPrice: 0.28, // $0.28 per million tokens + cacheWritesPrice: 0.27, // $0.27 per million tokens (cache miss) + cacheReadsPrice: 0.07, // $0.07 per million tokens (cache hit) description: `DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.`, }, "deepseek-reasoner": { maxTokens: 8192, contextWindow: 64_000, supportsImages: false, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 0.55, // $0.55 per million tokens outputPrice: 2.19, // $2.19 per million tokens + cacheWritesPrice: 0.55, // $0.55 per million tokens (cache miss) + cacheReadsPrice: 0.14, // $0.14 per million tokens (cache hit) description: `DeepSeek-R1 achieves performance comparable to OpenAI-o1 across math, code, and reasoning tasks.`, }, } as const satisfies Record @@ -786,8 +790,56 @@ export const unboundDefaultModelInfo: ModelInfo = { cacheWritesPrice: 3.75, cacheReadsPrice: 0.3, } + +// PearAI Models +export type PearAiModelId = keyof typeof pearAiModels +export let pearAiDefaultModelId: PearAiModelId = "pearai-model" +const defaultPearAiModels = { + "pearai-model": { + ...anthropicModels["claude-3-5-sonnet-20241022"], + }, +} as const satisfies Record +export let pearAiModels: Record = defaultPearAiModels + // CHANGE AS NEEDED FOR TESTING // PROD: -export const PEARAI_URL = "https://stingray-app-gb2an.ondigitalocean.app/pearai-server-api2/integrations/cline" +// export const PEARAI_URL = "https://stingray-app-gb2an.ondigitalocean.app/pearai-server-api2/integrations/cline" // DEV: -// export const PEARAI_URL = "http://localhost:8000/integrations/cline" +export const PEARAI_URL = "http://localhost:8000/integrations/cline" + +// Promise to track initialization status +export let modelsInitialized: Promise> + +// Immediately invoked function to initialize models +modelsInitialized = (async () => { + try { + const res = await fetch(`${PEARAI_URL}/getPearAIAgentModels`) + if (!res.ok) throw new Error("Failed to fetch models") + const config = await res.json() + if (config.models && Object.keys(config.models).length > 0) { + pearAiModels = config.models + pearAiDefaultModelId = config.defaultModelId || "pearai-model" + console.log("Models successfully loaded from server") + return pearAiModels + } else { + console.log("Using default models (no models returned from server)") + pearAiModels = defaultPearAiModels + return defaultPearAiModels + } + } catch (error) { + console.error("Error fetching PearAI models:", error) + pearAiModels = defaultPearAiModels + return defaultPearAiModels + } +})() + +// Helper function to ensure models are loaded before operations that depend on them +export const ensureModelsLoaded = async () => { + return await modelsInitialized +} + +// This will log after models are initialized +modelsInitialized.then(() => { + console.log("Models initialization complete") + console.dir(pearAiModels) +}) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 9a0b01f4c55..740ceb11acc 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -67,9 +67,7 @@ const App = () => { // Do not conditionally load ChatView, it's expensive and there's state we // don't want to lose (user input, disableInput, askResponse promise, etc.) - return showWelcome ? ( - - ) : ( + return ( <> {tab === "settings" && setTab("chat")} />} {tab === "history" && switchTab("chat")} />} diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 79afd743191..9afb4b2524d 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1011,7 +1011,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flexDirection: "column-reverse", paddingBottom: "10px", }}> - {showAnnouncement && } {messages.length === 0 && ( <>
@@ -1037,17 +1036,17 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)} - {/* + {/* // Flex layout explanation: // 1. Content div above uses flex: "1 1 0" to: - // - Grow to fill available space (flex-grow: 1) + // - Grow to fill available space (flex-grow: 1) // - Shrink when AutoApproveMenu needs space (flex-shrink: 1) // - Start from zero size (flex-basis: 0) to ensure proper distribution // minHeight: 0 allows it to shrink below its content height // // 2. AutoApproveMenu uses flex: "0 1 auto" to: // - Not grow beyond its content (flex-grow: 0) - // - Shrink when viewport is small (flex-shrink: 1) + // - Shrink when viewport is small (flex-shrink: 1) // - Use its content size as basis (flex-basis: auto) // This ensures it takes its natural height when there's space // but becomes scrollable when the viewport is too small diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index e79de3440d5..93a9460c397 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -38,6 +38,9 @@ import { unboundDefaultModelInfo, requestyDefaultModelId, requestyDefaultModelInfo, + pearAiModels, + pearAiDefaultModelId, + PearAiModelId, } from "../../../../src/shared/api" import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" @@ -50,16 +53,6 @@ import { validateApiConfiguration, validateModelId } from "@/utils/validate" import { ApiErrorMessage } from "./ApiErrorMessage" import { ThinkingBudget } from "./ThinkingBudget" -const modelsByProvider: Record> = { - anthropic: anthropicModels, - bedrock: bedrockModels, - vertex: vertexModels, - gemini: geminiModels, - "openai-native": openAiNativeModels, - deepseek: deepSeekModels, - mistral: mistralModels, -} - interface ApiOptionsProps { uriScheme: string | undefined apiConfiguration: ApiConfiguration @@ -97,6 +90,8 @@ const ApiOptions = ({ [requestyDefaultModelId]: requestyDefaultModelInfo, }) + const [pearAiModelsState, setPearAiModelsState] = useState>(pearAiModels) + const [openAiModels, setOpenAiModels] = useState | null>(null) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) @@ -121,8 +116,8 @@ const ApiOptions = ({ ) const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo( - () => normalizeApiConfiguration(apiConfiguration), - [apiConfiguration], + () => normalizeApiConfiguration(apiConfiguration, pearAiModelsState), + [apiConfiguration, pearAiModelsState], ) // Debounced refresh model updates, only executed 250ms after the user @@ -219,11 +214,63 @@ const ApiOptions = ({ setVsCodeLmModels(newModels) } break + case "state": + if (message.state?.apiConfiguration?.pearaiApiKey) { + setPearAiModelsState(pearAiModels) + } + break } }, []) + // Update PearAI models when API key changes + useEffect(() => { + if (apiConfiguration?.pearaiApiKey) { + setPearAiModelsState(pearAiModels) + } + }, [apiConfiguration?.pearaiApiKey]) + useEvent("message", onMessage) + type ApiProvider = + | "anthropic" + | "bedrock" + | "vertex" + | "gemini" + | "openai-native" + | "deepseek" + | "mistral" + | "pearai" + | "openrouter" + | "glama" + | "unbound" + | "requesty" + | "openai" + | "ollama" + | "lmstudio" + | "vscode-lm" + + const modelsByProvider = useMemo>>( + () => ({ + anthropic: anthropicModels, + bedrock: bedrockModels, + vertex: vertexModels, + gemini: geminiModels, + "openai-native": openAiNativeModels, + deepseek: deepSeekModels, + mistral: mistralModels, + pearai: pearAiModelsState, + openrouter: openRouterModels, + glama: glamaModels, + unbound: unboundModels, + requesty: requestyModels, + openai: openAiModels || {}, + ollama: {}, + lmstudio: {}, + "vscode-lm": {}, + }), + [pearAiModelsState, openRouterModels, glamaModels, unboundModels, requestyModels, openAiModels], + ) + const selectedProviderModelOptions: DropdownOption[] = useMemo( () => modelsByProvider[selectedProvider] @@ -235,7 +282,7 @@ const ApiOptions = ({ })), ] : [], - [selectedProvider], + [selectedProvider, modelsByProvider], ) return ( @@ -1456,7 +1503,10 @@ export function getOpenRouterAuthUrl(uriScheme?: string) { return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://rooveterinaryinc.roo-cline/openrouter` } -export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { +export function normalizeApiConfiguration( + apiConfiguration?: ApiConfiguration, + pearAiModelsState?: Record, +) { const provider = apiConfiguration?.apiProvider || "anthropic" const modelId = apiConfiguration?.apiModelId @@ -1543,22 +1593,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { supportsImages: false, // VSCode LM API currently doesn't support images. }, } - case "pearai": { - // Get the base Anthropic model info - const baseModelInfo = anthropicModels[anthropicDefaultModelId] - const pearaiModelInfo: ModelInfo = { - ...baseModelInfo, - inputPrice: baseModelInfo.inputPrice, - outputPrice: baseModelInfo.outputPrice, - cacheWritesPrice: baseModelInfo.cacheWritesPrice ? baseModelInfo.cacheWritesPrice : undefined, - cacheReadsPrice: baseModelInfo.cacheWritesPrice ? baseModelInfo.cacheReadsPrice : undefined, - } - return { - selectedProvider: provider, - selectedModelId: apiConfiguration?.pearaiModelId || "pearai_model", - selectedModelInfo: pearaiModelInfo, - } - } + case "pearai": + return getProviderData(pearAiModelsState || pearAiModels, pearAiDefaultModelId) default: return getProviderData(anthropicModels, anthropicDefaultModelId) }