From e5e700ffcb6f19ef08ea7461c1af68418a93008b Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 4 Jan 2025 21:25:33 -0600 Subject: [PATCH 1/8] feat: add Glama gateway --- README.md | 2 +- package-lock.json | 2 +- package.json | 2 +- src/api/index.ts | 3 + src/api/providers/glama.ts | 134 ++++++ src/core/webview/ClineProvider.ts | 128 ++++++ src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 1 + src/shared/api.ts | 21 + .../src/components/settings/ApiOptions.tsx | 43 +- .../components/settings/GlamaModelPicker.tsx | 396 ++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 3 +- .../src/context/ExtensionStateContext.tsx | 16 + webview-ui/src/utils/validate.ts | 18 +- 14 files changed, 765 insertions(+), 6 deletions(-) create mode 100644 src/api/providers/glama.ts create mode 100644 webview-ui/src/components/settings/GlamaModelPicker.tsx diff --git a/README.md b/README.md index fba0aee..7c9471f 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,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..4be1291 --- /dev/null +++ b/src/api/providers/glama.ts @@ -0,0 +1,134 @@ +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 completionRequestUuid = response.headers.get( + 'x-completion-request-uuid', + ); + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } + } + + // The usage information is only available after a few moments after the completion + await delay(1000) + + try { + const response = await axios.get(`https://glama.ai/api/gateway/v1/completion-requests/${completionRequestUuid}`, { + 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..8c71324 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,93 @@ 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"), + 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 +1271,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { storedApiProvider, apiModelId, apiKey, + glamaApiKey, + glamaModelId, + glamaModelInfo, openRouterApiKey, awsAccessKey, awsSecretKey, @@ -1200,6 +1321,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 +1388,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiProvider, apiModelId, apiKey, + glamaApiKey, + glamaModelId, + glamaModelInfo, openRouterApiKey, awsAccessKey, awsSecretKey, @@ -1402,6 +1529,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..2621a8c 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 @@ -131,6 +134,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic + Glama Google Gemini DeepSeek OpenAI @@ -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..e7af3c5 --- /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_001 + +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..3ac195a 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) 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) { From eb78332d4ede55a98a55986eb303aae82eeca138 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 4 Jan 2025 22:26:58 -0600 Subject: [PATCH 2/8] fix: dynamically set computer use value --- src/core/webview/ClineProvider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8c71324..a49caee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -926,6 +926,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { 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), From 919fb5b91307be4702c5482f19aaa9d701a63c49 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 5 Jan 2025 01:00:13 -0500 Subject: [PATCH 3/8] fix: validation logic --- webview-ui/src/components/settings/SettingsView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 3ac195a..5ae0858 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -95,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" }) From 67ba60db6e606fda8847c7ce92a98d5a178046f1 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 5 Jan 2025 01:07:56 -0500 Subject: [PATCH 4/8] fix: z-index --- webview-ui/src/components/settings/GlamaModelPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index e7af3c5..6823cc0 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -234,7 +234,7 @@ const DropdownWrapper = styled.div` width: 100%; ` -export const GLAMA_MODEL_PICKER_Z_INDEX = 1_001 +export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000 const DropdownList = styled.div` position: absolute; From 8725f5ae2c43993f29641aea1ac6e32a59d3d590 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 5 Jan 2025 01:08:14 -0500 Subject: [PATCH 5/8] fix: adjust order --- webview-ui/src/components/settings/ApiOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2621a8c..ebeab8d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -134,13 +134,13 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic - Glama Google Gemini DeepSeek OpenAI OpenAI Compatible GCP Vertex AI AWS Bedrock + Glama LM Studio Ollama From a966ddb2ee2771a2b719077420543ec7c7f308eb Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 5 Jan 2025 01:09:11 -0500 Subject: [PATCH 6/8] Release --- .changeset/weak-mugs-battle.md | 5 +++++ README.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/weak-mugs-battle.md 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 7c9471f..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 From 6b048923c6f979a162bac195690b941b6b85bf5e Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 14:24:51 -0600 Subject: [PATCH 7/8] fix: remove unnecessary delay --- src/api/providers/glama.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/api/providers/glama.ts b/src/api/providers/glama.ts index 4be1291..381c3bf 100644 --- a/src/api/providers/glama.ts +++ b/src/api/providers/glama.ts @@ -95,9 +95,6 @@ export class GlamaHandler implements ApiHandler { } } - // The usage information is only available after a few moments after the completion - await delay(1000) - try { const response = await axios.get(`https://glama.ai/api/gateway/v1/completion-requests/${completionRequestUuid}`, { headers: { From 8284efc64b1269e029238d8cc7700389c2133ca2 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 13:54:18 -0600 Subject: [PATCH 8/8] fix: use x-completion-request-id header --- src/api/providers/glama.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/providers/glama.ts b/src/api/providers/glama.ts index 381c3bf..4805219 100644 --- a/src/api/providers/glama.ts +++ b/src/api/providers/glama.ts @@ -80,8 +80,8 @@ export class GlamaHandler implements ApiHandler { stream: true, }).withResponse(); - const completionRequestUuid = response.headers.get( - 'x-completion-request-uuid', + const completionRequestId = response.headers.get( + 'x-completion-request-id', ); for await (const chunk of completion) { @@ -96,7 +96,7 @@ export class GlamaHandler implements ApiHandler { } try { - const response = await axios.get(`https://glama.ai/api/gateway/v1/completion-requests/${completionRequestUuid}`, { + const response = await axios.get(`https://glama.ai/api/gateway/v1/completion-requests/${completionRequestId}`, { headers: { Authorization: `Bearer ${this.options.glamaApiKey}`, },