feat: add Glama gateway

This commit is contained in:
Frank
2025-01-04 21:25:33 -06:00
committed by Matt Rubens
parent 5e099e2960
commit e5e700ffcb
14 changed files with 765 additions and 6 deletions

View File

@@ -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 }}>
<VSCodeOption value="openrouter">OpenRouter</VSCodeOption>
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
<VSCodeOption value="glama">Glama</VSCodeOption>
<VSCodeOption value="gemini">Google Gemini</VSCodeOption>
<VSCodeOption value="deepseek">DeepSeek</VSCodeOption>
<VSCodeOption value="openai-native">OpenAI</VSCodeOption>
@@ -193,6 +197,34 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
</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" && (
<div>
<VSCodeTextField
@@ -666,9 +698,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
</p>
)}
{selectedProvider === "glama" && showModelOptions && <GlamaModelPicker />}
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />}
{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,

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_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<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,
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)

View File

@@ -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<string, ModelInfo>
openRouterModels: Record<string, ModelInfo>
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<any>(undefined)
const [filePaths, setFilePaths] = useState<string[]>([])
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
[glamaDefaultModelId]: glamaDefaultModelInfo,
})
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[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,

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"
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<string, ModelInfo>,
openRouterModels?: Record<string, ModelInfo>,
): 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) {