diff --git a/.changeset/weak-mugs-battle.md b/.changeset/weak-mugs-battle.md new file mode 100644 index 0000000..ba2878d --- /dev/null +++ b/.changeset/weak-mugs-battle.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add the Glama provider (thanks @punkpeye!) diff --git a/README.md b/README.md index fba0aee..64b0117 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f - Language selection for Cline's communication (English, Japanese, Spanish, French, German, and more) - Support for DeepSeek V3 - Support for Amazon Nova and Meta 3, 3.1, and 3.2 models via AWS Bedrock +- Support for Glama - Support for listing models from OpenAI-compatible providers - Per-tool MCP auto-approval - Enable/disable individual MCP servers @@ -135,7 +136,7 @@ Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.ant ### Use any API and Model -Cline supports API providers like OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, and GCP Vertex. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using OpenRouter, the extension fetches their latest model list, allowing you to use the newest models as soon as they're available. +Cline supports API providers like OpenRouter, Anthropic, Glama, OpenAI, Google Gemini, AWS Bedrock, Azure, and GCP Vertex. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using OpenRouter, the extension fetches their latest model list, allowing you to use the newest models as soon as they're available. The extension also keeps track of total tokens and API usage cost for the entire task loop and individual requests, keeping you informed of spend every step of the way. diff --git a/package-lock.json b/package-lock.json index 0417b70..dd46780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", - "openai": "^4.61.0", + "openai": "^4.73.1", "os-name": "^6.0.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", diff --git a/package.json b/package.json index c872c43..264bbe7 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", - "openai": "^4.61.0", + "openai": "^4.73.1", "os-name": "^6.0.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", diff --git a/src/api/index.ts b/src/api/index.ts index 06983de..999b588 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" +import { GlamaHandler } from "./providers/glama" import { ApiConfiguration, ModelInfo } from "../shared/api" import { AnthropicHandler } from "./providers/anthropic" import { AwsBedrockHandler } from "./providers/bedrock" @@ -26,6 +27,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) + case "glama": + return new GlamaHandler(options) case "openrouter": return new OpenRouterHandler(options) case "bedrock": diff --git a/src/api/providers/glama.ts b/src/api/providers/glama.ts new file mode 100644 index 0000000..4805219 --- /dev/null +++ b/src/api/providers/glama.ts @@ -0,0 +1,131 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import axios from "axios" +import OpenAI from "openai" +import { ApiHandler } from "../" +import { ApiHandlerOptions, ModelInfo, glamaDefaultModelId, glamaDefaultModelInfo } from "../../shared/api" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream } from "../transform/stream" +import delay from "delay" + +export class GlamaHandler implements ApiHandler { + private options: ApiHandlerOptions + private client: OpenAI + + constructor(options: ApiHandlerOptions) { + this.options = options + this.client = new OpenAI({ + baseURL: "https://glama.ai/api/gateway/openai/v1", + apiKey: this.options.glamaApiKey, + }) + } + + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + // Convert Anthropic messages to OpenAI format + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // this is specifically for claude models (some models may 'support prompt caching' automatically without this) + if (this.getModel().id.startsWith("anthropic/claude-3")) { + openAiMessages[0] = { + role: "system", + content: [ + { + type: "text", + text: systemPrompt, + // @ts-ignore-next-line + cache_control: { type: "ephemeral" }, + }, + ], + } + + // Add cache_control to the last two user messages + // (note: this works because we only ever add one user message at a time, + // but if we added multiple we'd need to mark the user message before the last assistant message) + const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2) + lastTwoUserMessages.forEach((msg) => { + if (typeof msg.content === "string") { + msg.content = [{ type: "text", text: msg.content }] + } + if (Array.isArray(msg.content)) { + // NOTE: this is fine since env details will always be added at the end. + // but if it weren't there, and the user added a image_url type message, + // it would pop a text part before it and then move it after to the end. + let lastTextPart = msg.content.filter((part) => part.type === "text").pop() + + if (!lastTextPart) { + lastTextPart = { type: "text", text: "..." } + msg.content.push(lastTextPart) + } + // @ts-ignore-next-line + lastTextPart["cache_control"] = { type: "ephemeral" } + } + }) + } + + // Required by Anthropic + // Other providers default to max tokens allowed. + let maxTokens: number | undefined + + if (this.getModel().id.startsWith("anthropic/")) { + maxTokens = 8_192 + } + + const { data: completion, response } = await this.client.chat.completions.create({ + model: this.getModel().id, + max_tokens: maxTokens, + temperature: 0, + messages: openAiMessages, + stream: true, + }).withResponse(); + + const completionRequestId = response.headers.get( + 'x-completion-request-id', + ); + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } + } + + try { + const response = await axios.get(`https://glama.ai/api/gateway/v1/completion-requests/${completionRequestId}`, { + headers: { + Authorization: `Bearer ${this.options.glamaApiKey}`, + }, + }) + + const completionRequest = response.data; + + if (completionRequest.tokenUsage) { + yield { + type: "usage", + inputTokens: completionRequest.tokenUsage.promptTokens, + outputTokens: completionRequest.tokenUsage.completionTokens, + totalCost: completionRequest.totalCostUsd, + } + } + } catch (error) { + // ignore if fails + console.error("Error fetching Glama generation details:", error) + } + } + + getModel(): { id: string; info: ModelInfo } { + const modelId = this.options.glamaModelId + const modelInfo = this.options.glamaModelInfo + + if (modelId && modelInfo) { + return { id: modelId, info: modelInfo } + } + + return { id: glamaDefaultModelId, info: glamaDefaultModelInfo } + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 45f9d06..a49caee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -33,6 +33,7 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c type SecretKey = | "apiKey" + | "glamaApiKey" | "openRouterApiKey" | "awsAccessKey" | "awsSecretKey" @@ -44,6 +45,8 @@ type SecretKey = type GlobalStateKey = | "apiProvider" | "apiModelId" + | "glamaModelId" + | "glamaModelInfo" | "awsRegion" | "awsUseCrossRegionInference" | "vertexProjectId" @@ -82,6 +85,7 @@ type GlobalStateKey = export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", + glamaModels: "glama_models.json", openRouterModels: "openrouter_models.json", mcpSettings: "cline_mcp_settings.json", } @@ -385,6 +389,24 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } }) + this.readGlamaModels().then((glamaModels) => { + if (glamaModels) { + this.postMessageToWebview({ type: "glamaModels", glamaModels }) + } + }) + this.refreshGlamaModels().then(async (glamaModels) => { + if (glamaModels) { + // 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.glamaModelId) { + await this.updateGlobalState( + "glamaModelInfo", + glamaModels[apiConfiguration.glamaModelId], + ) + await this.postStateToWebview() + } + } + }) break case "newTask": // Code that should run in response to the hello message command @@ -403,6 +425,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiProvider, apiModelId, apiKey, + glamaModelId, + glamaModelInfo, + glamaApiKey, openRouterApiKey, awsAccessKey, awsSecretKey, @@ -430,6 +455,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiModelId", apiModelId) await this.storeSecret("apiKey", apiKey) + await this.updateGlobalState("glamaModelId", glamaModelId) + await this.updateGlobalState("glamaModelInfo", glamaModelInfo) + await this.storeSecret("glamaApiKey", glamaApiKey) await this.storeSecret("openRouterApiKey", openRouterApiKey) await this.storeSecret("awsAccessKey", awsAccessKey) await this.storeSecret("awsSecretKey", awsSecretKey) @@ -525,6 +553,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { const lmStudioModels = await this.getLmStudioModels(message.text) this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels }) break + case "refreshGlamaModels": + await this.refreshGlamaModels() + break case "refreshOpenRouterModels": await this.refreshOpenRouterModels() break @@ -831,6 +862,94 @@ export class ClineProvider implements vscode.WebviewViewProvider { return cacheDir } + async readGlamaModels(): Promise | undefined> { + const glamaModelsFilePath = path.join( + await this.ensureCacheDirectoryExists(), + GlobalFileNames.glamaModels, + ) + const fileExists = await fileExistsAtPath(glamaModelsFilePath) + if (fileExists) { + const fileContents = await fs.readFile(glamaModelsFilePath, "utf8") + return JSON.parse(fileContents) + } + return undefined + } + + async refreshGlamaModels() { + const glamaModelsFilePath = path.join( + await this.ensureCacheDirectoryExists(), + GlobalFileNames.glamaModels, + ) + + let models: Record = {} + try { + const response = await axios.get("https://glama.ai/api/gateway/v1/models") + /* + { + "added": "2024-12-24T15:12:49.324Z", + "capabilities": [ + "adjustable_safety_settings", + "caching", + "code_execution", + "function_calling", + "json_mode", + "json_schema", + "system_instructions", + "tuning", + "input:audio", + "input:image", + "input:text", + "input:video", + "output:text" + ], + "id": "google-vertex/gemini-1.5-flash-002", + "maxTokensInput": 1048576, + "maxTokensOutput": 8192, + "pricePerToken": { + "cacheRead": null, + "cacheWrite": null, + "input": "0.000000075", + "output": "0.0000003" + } + } + */ + if (response.data) { + const rawModels = response.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.maxTokensOutput, + contextWindow: rawModel.maxTokensInput, + supportsImages: rawModel.capabilities?.includes("input:image"), + supportsComputerUse: rawModel.capabilities?.includes("computer_use"), + supportsPromptCache: rawModel.capabilities?.includes("caching"), + inputPrice: parsePrice(rawModel.pricePerToken?.input), + outputPrice: parsePrice(rawModel.pricePerToken?.output), + description: undefined, + cacheWritesPrice: parsePrice(rawModel.pricePerToken?.cacheWrite), + cacheReadsPrice: parsePrice(rawModel.pricePerToken?.cacheRead), + } + + models[rawModel.id] = modelInfo + } + } else { + console.error("Invalid response from Glama API") + } + await fs.writeFile(glamaModelsFilePath, JSON.stringify(models)) + console.log("Glama models fetched and saved", models) + } catch (error) { + console.error("Error fetching Glama models:", error) + } + + await this.postMessageToWebview({ type: "glamaModels", glamaModels: models }) + return models + } + async readOpenRouterModels(): Promise | undefined> { const openRouterModelsFilePath = path.join( await this.ensureCacheDirectoryExists(), @@ -1153,6 +1272,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { storedApiProvider, apiModelId, apiKey, + glamaApiKey, + glamaModelId, + glamaModelInfo, openRouterApiKey, awsAccessKey, awsSecretKey, @@ -1200,6 +1322,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, this.getSecret("apiKey") as Promise, + this.getSecret("glamaApiKey") as Promise, + this.getGlobalState("glamaModelId") as Promise, + this.getGlobalState("glamaModelInfo") as Promise, this.getSecret("openRouterApiKey") as Promise, this.getSecret("awsAccessKey") as Promise, this.getSecret("awsSecretKey") as Promise, @@ -1264,6 +1389,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiProvider, apiModelId, apiKey, + glamaApiKey, + glamaModelId, + glamaModelInfo, openRouterApiKey, awsAccessKey, awsSecretKey, @@ -1402,6 +1530,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } const secretKeys: SecretKey[] = [ "apiKey", + "glamaApiKey", "openRouterApiKey", "awsAccessKey", "awsSecretKey", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index c00fa6c..887945f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -16,6 +16,7 @@ export interface ExtensionMessage { | "workspaceUpdated" | "invoke" | "partialMessage" + | "glamaModels" | "openRouterModels" | "openAiModels" | "mcpServers" @@ -34,6 +35,7 @@ export interface ExtensionMessage { lmStudioModels?: string[] filePaths?: string[] partialMessage?: ClineMessage + glamaModels?: Record openRouterModels?: Record openAiModels?: string[] mcpServers?: McpServer[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index ee602ed..111faac 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -27,6 +27,7 @@ export interface WebviewMessage { | "openFile" | "openMention" | "cancelTask" + | "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshOpenAiModels" | "alwaysAllowBrowser" diff --git a/src/shared/api.ts b/src/shared/api.ts index 2759a26..7675237 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,5 +1,6 @@ export type ApiProvider = | "anthropic" + | "glama" | "openrouter" | "bedrock" | "vertex" @@ -14,6 +15,9 @@ export interface ApiHandlerOptions { apiModelId?: string apiKey?: string // anthropic anthropicBaseUrl?: string + glamaModelId?: string + glamaModelInfo?: ModelInfo + glamaApiKey?: string openRouterApiKey?: string openRouterModelId?: string openRouterModelInfo?: ModelInfo @@ -309,6 +313,23 @@ export const bedrockModels = { }, } as const satisfies Record +// Glama +// https://glama.ai/models +export const glamaDefaultModelId = "anthropic/claude-3-5-sonnet" // will always exist in openRouterModels +export const glamaDefaultModelInfo: 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 export const openRouterDefaultModelId = "anthropic/claude-3.5-sonnet:beta" // will always exist in openRouterModels diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c72342e..ebeab8d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -21,6 +21,8 @@ import { deepSeekModels, geminiDefaultModelId, geminiModels, + glamaDefaultModelId, + glamaDefaultModelInfo, openAiModelInfoSaneDefaults, openAiNativeDefaultModelId, openAiNativeModels, @@ -38,6 +40,7 @@ import OpenRouterModelPicker, { OPENROUTER_MODEL_PICKER_Z_INDEX, } from "./OpenRouterModelPicker" import OpenAiModelPicker from "./OpenAiModelPicker" +import GlamaModelPicker from "./GlamaModelPicker" interface ApiOptionsProps { showModelOptions: boolean @@ -137,6 +140,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: OpenAI Compatible GCP Vertex AI AWS Bedrock + Glama LM Studio Ollama @@ -193,6 +197,34 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: )} + {selectedProvider === "glama" && ( +
+ + Glama API Key + + {!apiConfiguration?.glamaApiKey && ( + + You can get an Glama API key by signing up here. + + )} +

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

+
+ )} + {selectedProvider === "openai-native" && (
)} + {selectedProvider === "glama" && showModelOptions && } + {selectedProvider === "openrouter" && showModelOptions && } - {selectedProvider !== "openrouter" && + {selectedProvider !== "glama" && + selectedProvider !== "openrouter" && selectedProvider !== "openai" && selectedProvider !== "ollama" && selectedProvider !== "lmstudio" && @@ -872,6 +907,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { return getProviderData(deepSeekModels, deepSeekDefaultModelId) case "openai-native": return getProviderData(openAiNativeModels, openAiNativeDefaultModelId) + case "glama": + return { + selectedProvider: provider, + selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId, + selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo, + } case "openrouter": return { selectedProvider: provider, diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx new file mode 100644 index 0000000..6823cc0 --- /dev/null +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -0,0 +1,396 @@ +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import Fuse from "fuse.js" +import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" +import { useRemark } from "react-remark" +import { useMount } from "react-use" +import styled from "styled-components" +import { glamaDefaultModelId } from "../../../../src/shared/api" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { vscode } from "../../utils/vscode" +import { highlight } from "../history/HistoryView" +import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" + +const GlamaModelPicker: React.FC = () => { + const { apiConfiguration, setApiConfiguration, glamaModels } = useExtensionState() + const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId) + const [isDropdownVisible, setIsDropdownVisible] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const dropdownRef = useRef(null) + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const dropdownListRef = useRef(null) + + const handleModelChange = (newModelId: string) => { + // could be setting invalid model id/undefined info but validation will catch it + setApiConfiguration({ + ...apiConfiguration, + glamaModelId: newModelId, + glamaModelInfo: glamaModels[newModelId], + }) + setSearchTerm(newModelId) + } + + const { selectedModelId, selectedModelInfo } = useMemo(() => { + return normalizeApiConfiguration(apiConfiguration) + }, [apiConfiguration]) + + useMount(() => { + vscode.postMessage({ type: "refreshGlamaModels" }) + }) + + 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(glamaModels).sort((a, b) => a.localeCompare(b)) + }, [glamaModels]) + + const searchableItems = useMemo(() => { + return modelIds.map((id) => ({ + id, + html: id, + })) + }, [modelIds]) + + const fuse = useMemo(() => { + return new Fuse(searchableItems, { + keys: ["html"], // highlight function will update this + threshold: 0.6, + shouldSort: true, + isCaseSensitive: false, + ignoreLocation: false, + includeMatches: true, + minMatchCharLength: 1, + }) + }, [searchableItems]) + + const modelSearchResults = useMemo(() => { + let results: { id: string; html: string }[] = searchTerm + ? highlight(fuse.search(searchTerm), "model-item-highlight") + : searchableItems + // results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched + return results + }, [searchableItems, searchTerm, fuse]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (!isDropdownVisible) return + + switch (event.key) { + case "ArrowDown": + event.preventDefault() + setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev)) + break + case "ArrowUp": + event.preventDefault() + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)) + break + case "Enter": + event.preventDefault() + if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) { + handleModelChange(modelSearchResults[selectedIndex].id) + setIsDropdownVisible(false) + } + break + case "Escape": + setIsDropdownVisible(false) + setSelectedIndex(-1) + break + } + } + + const hasInfo = useMemo(() => { + return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) + }, [modelIds, searchTerm]) + + useEffect(() => { + setSelectedIndex(-1) + if (dropdownListRef.current) { + dropdownListRef.current.scrollTop = 0 + } + }, [searchTerm]) + + useEffect(() => { + if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) { + itemRefs.current[selectedIndex]?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + }, [selectedIndex]) + + return ( + <> + +
+ + + { + handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) + setIsDropdownVisible(true) + }} + onFocus={() => setIsDropdownVisible(true)} + onKeyDown={handleKeyDown} + style={{ width: "100%", zIndex: GLAMA_MODEL_PICKER_Z_INDEX, position: "relative" }}> + {searchTerm && ( +
{ + handleModelChange("") + setIsDropdownVisible(true) + }} + slot="end" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + height: "100%", + }} + /> + )} + + {isDropdownVisible && ( + + {modelSearchResults.map((item, index) => ( + (itemRefs.current[index] = el)} + isSelected={index === selectedIndex} + onMouseEnter={() => setSelectedIndex(index)} + onClick={() => { + handleModelChange(item.id) + setIsDropdownVisible(false) + }} + dangerouslySetInnerHTML={{ + __html: item.html, + }} + /> + ))} + + )} + +
+ + {hasInfo ? ( + + ) : ( +

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

+ )} + + ) +} + +export default GlamaModelPicker + +// Dropdown + +const DropdownWrapper = styled.div` + position: relative; + width: 100%; +` + +export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000 + +const DropdownList = styled.div` + position: absolute; + top: calc(100% - 3px); + left: 0; + width: calc(100% - 2px); + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-list-activeSelectionBackground); + z-index: ${GLAMA_MODEL_PICKER_Z_INDEX - 1}; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +` + +const DropdownItem = styled.div<{ isSelected: boolean }>` + padding: 5px 10px; + cursor: pointer; + word-break: break-all; + white-space: normal; + + background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; + + &:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } +` + +// Markdown + +const StyledMarkdown = styled.div` + font-family: + var(--vscode-font-family), + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + font-size: 12px; + color: var(--vscode-descriptionForeground); + + p, + li, + ol, + ul { + line-height: 1.25; + margin: 0; + } + + ol, + ul { + padding-left: 1.5em; + margin-left: 0; + } + + p { + white-space: pre-wrap; + } + + a { + text-decoration: none; + } + a { + &:hover { + text-decoration: underline; + } + } +` + +export const ModelDescriptionMarkdown = memo( + ({ + markdown, + key, + isExpanded, + setIsExpanded, + }: { + markdown?: string + key: string + isExpanded: boolean + setIsExpanded: (isExpanded: boolean) => void + }) => { + const [reactContent, setMarkdown] = useRemark() + const [showSeeMore, setShowSeeMore] = useState(false) + const textContainerRef = useRef(null) + const textRef = useRef(null) + + useEffect(() => { + setMarkdown(markdown || "") + }, [markdown, setMarkdown]) + + useEffect(() => { + if (textRef.current && textContainerRef.current) { + const { scrollHeight } = textRef.current + const { clientHeight } = textContainerRef.current + const isOverflowing = scrollHeight > clientHeight + setShowSeeMore(isOverflowing) + } + }, [reactContent, setIsExpanded]) + + return ( + +
+
+ {reactContent} +
+ {!isExpanded && showSeeMore && ( +
+
+ setIsExpanded(true)}> + See more + +
+ )} +
+ + ) + }, +) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 645a260..5ae0858 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -37,6 +37,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { browserViewportSize, setBrowserViewportSize, openRouterModels, + glamaModels, setAllowedCommands, allowedCommands, fuzzyMatchThreshold, @@ -56,7 +57,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { const [commandInput, setCommandInput] = useState("") const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) - const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) + const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) setApiErrorMessage(apiValidationResult) setModelIdErrorMessage(modelIdValidationResult) @@ -94,10 +95,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { // Initial validation on mount useEffect(() => { const apiValidationResult = validateApiConfiguration(apiConfiguration) - const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) + const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) setApiErrorMessage(apiValidationResult) setModelIdErrorMessage(modelIdValidationResult) - }, [apiConfiguration, openRouterModels]) + }, [apiConfiguration, glamaModels, openRouterModels]) const handleResetState = () => { vscode.postMessage({ type: "resetState" }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 45e0614..8572b79 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -4,6 +4,8 @@ import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionM import { ApiConfiguration, ModelInfo, + glamaDefaultModelId, + glamaDefaultModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo, } from "../../../src/shared/api" @@ -16,6 +18,7 @@ export interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean showWelcome: boolean theme: any + glamaModels: Record openRouterModels: Record openAiModels: string[], mcpServers: McpServer[] @@ -69,6 +72,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [showWelcome, setShowWelcome] = useState(false) const [theme, setTheme] = useState(undefined) const [filePaths, setFilePaths] = useState([]) + const [glamaModels, setGlamaModels] = useState>({ + [glamaDefaultModelId]: glamaDefaultModelInfo, + }) const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, }) @@ -85,6 +91,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const hasKey = config ? [ config.apiKey, + config.glamaApiKey, config.openRouterApiKey, config.awsRegion, config.vertexProjectId, @@ -123,6 +130,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }) break } + case "glamaModels": { + const updatedModels = message.glamaModels ?? {} + setGlamaModels({ + [glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model + ...updatedModels, + }) + break + } case "openRouterModels": { const updatedModels = message.openRouterModels ?? {} setOpenRouterModels({ @@ -154,6 +169,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode didHydrateState, showWelcome, theme, + glamaModels, openRouterModels, openAiModels, mcpServers, diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 437e126..2ddc46d 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -1,4 +1,4 @@ -import { ApiConfiguration, openRouterDefaultModelId } from "../../../src/shared/api" +import { ApiConfiguration, glamaDefaultModelId, openRouterDefaultModelId } from "../../../src/shared/api" import { ModelInfo } from "../../../src/shared/api" export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined { if (apiConfiguration) { @@ -8,6 +8,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s return "You must provide a valid API key or choose a different provider." } break + case "glama": + if (!apiConfiguration.glamaApiKey) { + return "You must provide a valid API key or choose a different provider." + } + break case "bedrock": if (!apiConfiguration.awsRegion) { return "You must choose a region to use with AWS Bedrock." @@ -59,10 +64,21 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s export function validateModelId( apiConfiguration?: ApiConfiguration, + glamaModels?: Record, openRouterModels?: Record, ): string | undefined { if (apiConfiguration) { switch (apiConfiguration.apiProvider) { + case "glama": + const glamaModelId = apiConfiguration.glamaModelId || glamaDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default + if (!glamaModelId) { + return "You must provide a model ID." + } + if (glamaModels && !Object.keys(glamaModels).includes(glamaModelId)) { + // even if the model list endpoint failed, extensionstatecontext will always have the default model info + return "The model ID you provided is not available. Please choose a different model." + } + break case "openrouter": const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default if (!modelId) {