Advanced configuration for OpenAI Compatible Providers (#1737)

* feat: advanced configuration for OpenAI Compatible Providers

* Update .changeset/thirty-eyes-appear.md

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update webview-ui/src/components/settings/ApiOptions.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* dropdown menu

* Show pricing if user entered model info

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
brownrw8 2025-02-12 12:09:55 -10:00 committed by GitHub
parent e534c3dbc7
commit 0434b5c772
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 207 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Advanced Configuration for OpenAI Compatible Providers

View file

@ -78,7 +78,7 @@ export class OpenAiHandler implements ApiHandler {
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.openAiModelId ?? "",
info: openAiModelInfoSaneDefaults,
info: this.options.openAiModelInfo ?? openAiModelInfoSaneDefaults,
}
}
}

View file

@ -66,6 +66,7 @@ type GlobalStateKey =
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiModelInfo"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
@ -443,6 +444,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
@ -482,6 +484,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("openAiModelInfo", openAiModelInfo)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
@ -561,6 +564,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "openai":
await this.updateGlobalState("previousModeModelId", apiConfiguration.openAiModelId)
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openAiModelInfo)
break
case "ollama":
await this.updateGlobalState("previousModeModelId", apiConfiguration.ollamaModelId)
@ -592,6 +596,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "openai":
await this.updateGlobalState("openAiModelId", newModelId)
await this.updateGlobalState("openAiModelInfo", newModelInfo)
break
case "ollama":
await this.updateGlobalState("ollamaModelId", newModelId)
@ -1387,6 +1392,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
@ -1437,6 +1443,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("openAiBaseUrl") as Promise<string | undefined>,
this.getSecret("openAiApiKey") as Promise<string | undefined>,
this.getGlobalState("openAiModelId") as Promise<string | undefined>,
this.getGlobalState("openAiModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("ollamaModelId") as Promise<string | undefined>,
this.getGlobalState("ollamaBaseUrl") as Promise<string | undefined>,
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
@ -1508,6 +1515,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,

View file

@ -38,6 +38,7 @@ export interface ApiHandlerOptions {
openAiBaseUrl?: string
openAiApiKey?: string
openAiModelId?: string
openAiModelInfo?: ModelInfo
ollamaModelId?: string
ollamaBaseUrl?: string
lmStudioModelId?: string

View file

@ -100,14 +100,20 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}, [task.text, windowWidth])
const isCostAvailable = useMemo(() => {
const openAiCompatHasPricing =
apiConfiguration?.apiProvider === "openai" &&
apiConfiguration?.openAiModelInfo?.inputPrice &&
apiConfiguration?.openAiModelInfo?.outputPrice
if (openAiCompatHasPricing) {
return true
}
return (
apiConfiguration?.apiProvider !== "openai" &&
apiConfiguration?.apiProvider !== "vscode-lm" &&
apiConfiguration?.apiProvider !== "ollama" &&
apiConfiguration?.apiProvider !== "lmstudio" &&
apiConfiguration?.apiProvider !== "gemini"
)
}, [apiConfiguration?.apiProvider])
}, [apiConfiguration?.apiProvider, apiConfiguration?.openAiModelInfo])
const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"

View file

@ -41,6 +41,7 @@ import VSCodeButtonLink from "../common/VSCodeButtonLink"
import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker"
import styled from "styled-components"
import * as vscodemodels from "vscode"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "../../utils/vscStyles"
interface ApiOptionsProps {
showModelOptions: boolean
@ -80,6 +81,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
@ -694,6 +696,127 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
)}
<div
style={{
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
display: "flex",
margin: "10px 0",
cursor: "pointer",
alignItems: "center",
}}
onClick={() => setModelConfigurationSelected((val) => !val)}>
<span
className={`codicon ${modelConfigurationSelected ? "codicon-chevron-down" : "codicon-chevron-right"}`}
style={{
marginRight: "4px",
}}></span>
<span
style={{
fontWeight: 700,
textTransform: "uppercase",
}}>
Model Configuration
</span>
</div>
{modelConfigurationSelected && (
<>
<VSCodeCheckbox
checked={apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.supportsImages = isChecked
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
Supports Images
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.contextWindow
? apiConfiguration.openAiModelInfo.contextWindow.toString()
: openAiModelInfoSaneDefaults.contextWindow?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.contextWindow = Number(input.target.value)
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</VSCodeTextField>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.maxTokens
? apiConfiguration.openAiModelInfo.maxTokens.toString()
: openAiModelInfoSaneDefaults.maxTokens?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.maxTokens = input.target.value
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</VSCodeTextField>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.inputPrice
? apiConfiguration.openAiModelInfo.inputPrice.toString()
: openAiModelInfoSaneDefaults.inputPrice?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.inputPrice = input.target.value
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
</VSCodeTextField>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.outputPrice
? apiConfiguration.openAiModelInfo.outputPrice.toString()
: openAiModelInfoSaneDefaults.outputPrice?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.outputPrice = input.target.value
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
</VSCodeTextField>
</div>
</>
)}
<p
style={{
fontSize: "12px",

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import ApiOptions from "../ApiOptions"
import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext"
@ -94,3 +94,59 @@ describe("ApiOptions Component", () => {
expect(modelIdInput).toBeInTheDocument()
})
})
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
useExtensionState: vi.fn(() => ({
apiConfiguration: {
apiProvider: "openai",
requestyApiKey: "",
requestyModelId: "",
},
setApiConfiguration: vi.fn(),
uriScheme: "vscode",
})),
}
})
describe("OpenApiInfoOptions", () => {
const mockPostMessage = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
global.vscode = { postMessage: mockPostMessage }
})
it("renders OpenAI Supports Images input", () => {
render(
<ExtensionStateContextProvider>
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
const apiKeyInput = screen.getByText("Supports Images")
expect(apiKeyInput).toBeInTheDocument()
})
it("renders OpenAI Context Window Size input", () => {
render(
<ExtensionStateContextProvider>
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
const orgIdInput = screen.getByText("Context Window Size")
expect(orgIdInput).toBeInTheDocument()
})
it("renders OpenAI Max Output Tokens input", () => {
render(
<ExtensionStateContextProvider>
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
const modelInput = screen.getByText("Max Output Tokens")
expect(modelInput).toBeInTheDocument()
})
})

View file

@ -45,14 +45,14 @@ describe("useMetaKeyDetection", () => {
// mock the detect functions
const { result } = renderHook(() => useMetaKeyDetection("win32"))
expect(result.current[0]).toBe("windows")
expect(result.current[1]).toBe("Win")
expect(result.current[1]).toBe("Win")
})
it("should detect Mac OS and metaKey from platform", () => {
// mock the detect functions
const { result } = renderHook(() => useMetaKeyDetection("darwin"))
expect(result.current[0]).toBe("mac")
expect(result.current[1]).toBe("⌘ Command")
expect(result.current[1]).toBe("CMD")
})
it("should detect Linux OS and metaKey from platform", () => {

View file

@ -4,12 +4,12 @@ import { detectMetaKeyChar } from "../platformUtils"
describe("detectMetaKeyChar", () => {
it("should return ⌘ Command for darwin platform", () => {
const result = detectMetaKeyChar("darwin")
expect(result).toBe("⌘ Command")
expect(result).toBe("CMD")
})
it("should return ⊞ Win for win32 platform", () => {
const result = detectMetaKeyChar("win32")
expect(result).toBe("Win")
expect(result).toBe("Win")
})
it("should return Alt for linux platform", () => {