Merge pull request #273 from RooVetGit/glama

feat: add Glama gateway
This commit is contained in:
Matt Rubens
2025-01-05 16:46:55 -05:00
committed by GitHub
15 changed files with 771 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add the Glama provider (thanks @punkpeye!)

View File

@@ -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) - Language selection for Cline's communication (English, Japanese, Spanish, French, German, and more)
- Support for DeepSeek V3 - Support for DeepSeek V3
- Support for Amazon Nova and Meta 3, 3.1, and 3.2 models via AWS Bedrock - 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 - Support for listing models from OpenAI-compatible providers
- Per-tool MCP auto-approval - Per-tool MCP auto-approval
- Enable/disable individual MCP servers - 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 ### 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. 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.

2
package-lock.json generated
View File

@@ -31,7 +31,7 @@
"isbinaryfile": "^5.0.2", "isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7", "monaco-vscode-textmate-theme-converter": "^0.1.7",
"openai": "^4.61.0", "openai": "^4.73.1",
"os-name": "^6.0.0", "os-name": "^6.0.0",
"p-wait-for": "^5.0.2", "p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",

View File

@@ -214,7 +214,7 @@
"isbinaryfile": "^5.0.2", "isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7", "monaco-vscode-textmate-theme-converter": "^0.1.7",
"openai": "^4.61.0", "openai": "^4.73.1",
"os-name": "^6.0.0", "os-name": "^6.0.0",
"p-wait-for": "^5.0.2", "p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",

View File

@@ -1,4 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk" import { Anthropic } from "@anthropic-ai/sdk"
import { GlamaHandler } from "./providers/glama"
import { ApiConfiguration, ModelInfo } from "../shared/api" import { ApiConfiguration, ModelInfo } from "../shared/api"
import { AnthropicHandler } from "./providers/anthropic" import { AnthropicHandler } from "./providers/anthropic"
import { AwsBedrockHandler } from "./providers/bedrock" import { AwsBedrockHandler } from "./providers/bedrock"
@@ -26,6 +27,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
switch (apiProvider) { switch (apiProvider) {
case "anthropic": case "anthropic":
return new AnthropicHandler(options) return new AnthropicHandler(options)
case "glama":
return new GlamaHandler(options)
case "openrouter": case "openrouter":
return new OpenRouterHandler(options) return new OpenRouterHandler(options)
case "bedrock": case "bedrock":

131
src/api/providers/glama.ts Normal file
View File

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

View File

@@ -33,6 +33,7 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
type SecretKey = type SecretKey =
| "apiKey" | "apiKey"
| "glamaApiKey"
| "openRouterApiKey" | "openRouterApiKey"
| "awsAccessKey" | "awsAccessKey"
| "awsSecretKey" | "awsSecretKey"
@@ -44,6 +45,8 @@ type SecretKey =
type GlobalStateKey = type GlobalStateKey =
| "apiProvider" | "apiProvider"
| "apiModelId" | "apiModelId"
| "glamaModelId"
| "glamaModelInfo"
| "awsRegion" | "awsRegion"
| "awsUseCrossRegionInference" | "awsUseCrossRegionInference"
| "vertexProjectId" | "vertexProjectId"
@@ -82,6 +85,7 @@ type GlobalStateKey =
export const GlobalFileNames = { export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json", apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json", uiMessages: "ui_messages.json",
glamaModels: "glama_models.json",
openRouterModels: "openrouter_models.json", openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.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 break
case "newTask": case "newTask":
// Code that should run in response to the hello message command // Code that should run in response to the hello message command
@@ -403,6 +425,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiProvider, apiProvider,
apiModelId, apiModelId,
apiKey, apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey, openRouterApiKey,
awsAccessKey, awsAccessKey,
awsSecretKey, awsSecretKey,
@@ -430,6 +455,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId) await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey) 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("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey) await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey) await this.storeSecret("awsSecretKey", awsSecretKey)
@@ -525,6 +553,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const lmStudioModels = await this.getLmStudioModels(message.text) const lmStudioModels = await this.getLmStudioModels(message.text)
this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels }) this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
break break
case "refreshGlamaModels":
await this.refreshGlamaModels()
break
case "refreshOpenRouterModels": case "refreshOpenRouterModels":
await this.refreshOpenRouterModels() await this.refreshOpenRouterModels()
break break
@@ -831,6 +862,94 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return cacheDir return cacheDir
} }
async readGlamaModels(): Promise<Record<string, ModelInfo> | 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<string, ModelInfo> = {}
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<Record<string, ModelInfo> | undefined> { async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join( const openRouterModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(), await this.ensureCacheDirectoryExists(),
@@ -1153,6 +1272,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
storedApiProvider, storedApiProvider,
apiModelId, apiModelId,
apiKey, apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey, openRouterApiKey,
awsAccessKey, awsAccessKey,
awsSecretKey, awsSecretKey,
@@ -1200,6 +1322,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>, this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("glamaApiKey") as Promise<string | undefined>,
this.getGlobalState("glamaModelId") as Promise<string | undefined>,
this.getGlobalState("glamaModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>, this.getSecret("openRouterApiKey") as Promise<string | undefined>,
this.getSecret("awsAccessKey") as Promise<string | undefined>, this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>, this.getSecret("awsSecretKey") as Promise<string | undefined>,
@@ -1264,6 +1389,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiProvider, apiProvider,
apiModelId, apiModelId,
apiKey, apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey, openRouterApiKey,
awsAccessKey, awsAccessKey,
awsSecretKey, awsSecretKey,
@@ -1402,6 +1530,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
} }
const secretKeys: SecretKey[] = [ const secretKeys: SecretKey[] = [
"apiKey", "apiKey",
"glamaApiKey",
"openRouterApiKey", "openRouterApiKey",
"awsAccessKey", "awsAccessKey",
"awsSecretKey", "awsSecretKey",

View File

@@ -16,6 +16,7 @@ export interface ExtensionMessage {
| "workspaceUpdated" | "workspaceUpdated"
| "invoke" | "invoke"
| "partialMessage" | "partialMessage"
| "glamaModels"
| "openRouterModels" | "openRouterModels"
| "openAiModels" | "openAiModels"
| "mcpServers" | "mcpServers"
@@ -34,6 +35,7 @@ export interface ExtensionMessage {
lmStudioModels?: string[] lmStudioModels?: string[]
filePaths?: string[] filePaths?: string[]
partialMessage?: ClineMessage partialMessage?: ClineMessage
glamaModels?: Record<string, ModelInfo>
openRouterModels?: Record<string, ModelInfo> openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[] openAiModels?: string[]
mcpServers?: McpServer[] mcpServers?: McpServer[]

View File

@@ -27,6 +27,7 @@ export interface WebviewMessage {
| "openFile" | "openFile"
| "openMention" | "openMention"
| "cancelTask" | "cancelTask"
| "refreshGlamaModels"
| "refreshOpenRouterModels" | "refreshOpenRouterModels"
| "refreshOpenAiModels" | "refreshOpenAiModels"
| "alwaysAllowBrowser" | "alwaysAllowBrowser"

View File

@@ -1,5 +1,6 @@
export type ApiProvider = export type ApiProvider =
| "anthropic" | "anthropic"
| "glama"
| "openrouter" | "openrouter"
| "bedrock" | "bedrock"
| "vertex" | "vertex"
@@ -14,6 +15,9 @@ export interface ApiHandlerOptions {
apiModelId?: string apiModelId?: string
apiKey?: string // anthropic apiKey?: string // anthropic
anthropicBaseUrl?: string anthropicBaseUrl?: string
glamaModelId?: string
glamaModelInfo?: ModelInfo
glamaApiKey?: string
openRouterApiKey?: string openRouterApiKey?: string
openRouterModelId?: string openRouterModelId?: string
openRouterModelInfo?: ModelInfo openRouterModelInfo?: ModelInfo
@@ -309,6 +313,23 @@ export const bedrockModels = {
}, },
} as const satisfies Record<string, ModelInfo> } as const satisfies Record<string, ModelInfo>
// 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 // OpenRouter
// https://openrouter.ai/models?order=newest&supported_parameters=tools // https://openrouter.ai/models?order=newest&supported_parameters=tools
export const openRouterDefaultModelId = "anthropic/claude-3.5-sonnet:beta" // will always exist in openRouterModels export const openRouterDefaultModelId = "anthropic/claude-3.5-sonnet:beta" // will always exist in openRouterModels

View File

@@ -21,6 +21,8 @@ import {
deepSeekModels, deepSeekModels,
geminiDefaultModelId, geminiDefaultModelId,
geminiModels, geminiModels,
glamaDefaultModelId,
glamaDefaultModelInfo,
openAiModelInfoSaneDefaults, openAiModelInfoSaneDefaults,
openAiNativeDefaultModelId, openAiNativeDefaultModelId,
openAiNativeModels, openAiNativeModels,
@@ -38,6 +40,7 @@ import OpenRouterModelPicker, {
OPENROUTER_MODEL_PICKER_Z_INDEX, OPENROUTER_MODEL_PICKER_Z_INDEX,
} from "./OpenRouterModelPicker" } from "./OpenRouterModelPicker"
import OpenAiModelPicker from "./OpenAiModelPicker" import OpenAiModelPicker from "./OpenAiModelPicker"
import GlamaModelPicker from "./GlamaModelPicker"
interface ApiOptionsProps { interface ApiOptionsProps {
showModelOptions: boolean showModelOptions: boolean
@@ -137,6 +140,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
<VSCodeOption value="openai">OpenAI Compatible</VSCodeOption> <VSCodeOption value="openai">OpenAI Compatible</VSCodeOption>
<VSCodeOption value="vertex">GCP Vertex AI</VSCodeOption> <VSCodeOption value="vertex">GCP Vertex AI</VSCodeOption>
<VSCodeOption value="bedrock">AWS Bedrock</VSCodeOption> <VSCodeOption value="bedrock">AWS Bedrock</VSCodeOption>
<VSCodeOption value="glama">Glama</VSCodeOption>
<VSCodeOption value="lmstudio">LM Studio</VSCodeOption> <VSCodeOption value="lmstudio">LM Studio</VSCodeOption>
<VSCodeOption value="ollama">Ollama</VSCodeOption> <VSCodeOption value="ollama">Ollama</VSCodeOption>
</VSCodeDropdown> </VSCodeDropdown>
@@ -193,6 +197,34 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
</div> </div>
)} )}
{selectedProvider === "glama" && (
<div>
<VSCodeTextField
value={apiConfiguration?.glamaApiKey || ""}
style={{ width: "100%" }}
type="password"
onInput={handleInputChange("glamaApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Glama API Key</span>
</VSCodeTextField>
{!apiConfiguration?.glamaApiKey && (
<VSCodeLink
href="https://glama.ai/settings/api-keys"
style={{ display: "inline", fontSize: "inherit" }}>
You can get an Glama API key by signing up here.
</VSCodeLink>
)}
<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" && ( {selectedProvider === "openai-native" && (
<div> <div>
<VSCodeTextField <VSCodeTextField
@@ -666,9 +698,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
</p> </p>
)} )}
{selectedProvider === "glama" && showModelOptions && <GlamaModelPicker />}
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />} {selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />}
{selectedProvider !== "openrouter" && {selectedProvider !== "glama" &&
selectedProvider !== "openrouter" &&
selectedProvider !== "openai" && selectedProvider !== "openai" &&
selectedProvider !== "ollama" && selectedProvider !== "ollama" &&
selectedProvider !== "lmstudio" && selectedProvider !== "lmstudio" &&
@@ -872,6 +907,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
return getProviderData(deepSeekModels, deepSeekDefaultModelId) return getProviderData(deepSeekModels, deepSeekDefaultModelId)
case "openai-native": case "openai-native":
return getProviderData(openAiNativeModels, openAiNativeDefaultModelId) return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
case "glama":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo,
}
case "openrouter": case "openrouter":
return { return {
selectedProvider: provider, selectedProvider: provider,

View File

@@ -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<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
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<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://glama.ai/models">
Glama.
</VSCodeLink>
If you're unsure which model to choose, Cline 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 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<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>
)
},
)

View File

@@ -37,6 +37,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
browserViewportSize, browserViewportSize,
setBrowserViewportSize, setBrowserViewportSize,
openRouterModels, openRouterModels,
glamaModels,
setAllowedCommands, setAllowedCommands,
allowedCommands, allowedCommands,
fuzzyMatchThreshold, fuzzyMatchThreshold,
@@ -56,7 +57,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
const [commandInput, setCommandInput] = useState("") const [commandInput, setCommandInput] = useState("")
const handleSubmit = () => { const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration) const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
setApiErrorMessage(apiValidationResult) setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult) setModelIdErrorMessage(modelIdValidationResult)
@@ -94,10 +95,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
// Initial validation on mount // Initial validation on mount
useEffect(() => { useEffect(() => {
const apiValidationResult = validateApiConfiguration(apiConfiguration) const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
setApiErrorMessage(apiValidationResult) setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult) setModelIdErrorMessage(modelIdValidationResult)
}, [apiConfiguration, openRouterModels]) }, [apiConfiguration, glamaModels, openRouterModels])
const handleResetState = () => { const handleResetState = () => {
vscode.postMessage({ type: "resetState" }) vscode.postMessage({ type: "resetState" })

View File

@@ -4,6 +4,8 @@ import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionM
import { import {
ApiConfiguration, ApiConfiguration,
ModelInfo, ModelInfo,
glamaDefaultModelId,
glamaDefaultModelInfo,
openRouterDefaultModelId, openRouterDefaultModelId,
openRouterDefaultModelInfo, openRouterDefaultModelInfo,
} from "../../../src/shared/api" } from "../../../src/shared/api"
@@ -16,6 +18,7 @@ export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
showWelcome: boolean showWelcome: boolean
theme: any theme: any
glamaModels: Record<string, ModelInfo>
openRouterModels: Record<string, ModelInfo> openRouterModels: Record<string, ModelInfo>
openAiModels: string[], openAiModels: string[],
mcpServers: McpServer[] mcpServers: McpServer[]
@@ -69,6 +72,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [showWelcome, setShowWelcome] = useState(false) const [showWelcome, setShowWelcome] = useState(false)
const [theme, setTheme] = useState<any>(undefined) const [theme, setTheme] = useState<any>(undefined)
const [filePaths, setFilePaths] = useState<string[]>([]) const [filePaths, setFilePaths] = useState<string[]>([])
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
[glamaDefaultModelId]: glamaDefaultModelInfo,
})
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({ const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo, [openRouterDefaultModelId]: openRouterDefaultModelInfo,
}) })
@@ -85,6 +91,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const hasKey = config const hasKey = config
? [ ? [
config.apiKey, config.apiKey,
config.glamaApiKey,
config.openRouterApiKey, config.openRouterApiKey,
config.awsRegion, config.awsRegion,
config.vertexProjectId, config.vertexProjectId,
@@ -123,6 +130,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}) })
break 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": { case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {} const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({ setOpenRouterModels({
@@ -154,6 +169,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
didHydrateState, didHydrateState,
showWelcome, showWelcome,
theme, theme,
glamaModels,
openRouterModels, openRouterModels,
openAiModels, openAiModels,
mcpServers, mcpServers,

View File

@@ -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" import { ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined { export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) { 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." return "You must provide a valid API key or choose a different provider."
} }
break break
case "glama":
if (!apiConfiguration.glamaApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "bedrock": case "bedrock":
if (!apiConfiguration.awsRegion) { if (!apiConfiguration.awsRegion) {
return "You must choose a region to use with AWS Bedrock." return "You must choose a region to use with AWS Bedrock."
@@ -59,10 +64,21 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
export function validateModelId( export function validateModelId(
apiConfiguration?: ApiConfiguration, apiConfiguration?: ApiConfiguration,
glamaModels?: Record<string, ModelInfo>,
openRouterModels?: Record<string, ModelInfo>, openRouterModels?: Record<string, ModelInfo>,
): string | undefined { ): string | undefined {
if (apiConfiguration) { if (apiConfiguration) {
switch (apiConfiguration.apiProvider) { 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": case "openrouter":
const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!modelId) { if (!modelId) {