import { TooltipContent, TooltipTrigger } from "@radix-ui/react-tooltip" import { azureOpenAiDefaultApiVersion, type OpenAiCompatibleModelInfo, openAiModelInfoSafeDefaults } from "@shared/api" import { OpenAiModelsRequest } from "@shared/proto/cline/models" import { fromProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion" import type { Mode } from "@shared/storage/types" import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" import { useCallback, useEffect, useRef, useState } from "react" import { Tooltip } from "@/components/ui/tooltip" import { useExtensionState } from "@/context/ExtensionStateContext" import { useDynamicProviderSelection } from "@/hooks/useDynamicProviderSelection" import { fromProtobufProviderModelOverrides, type ProviderModelOverrides, useProviderConfig } from "@/hooks/useProviderConfig" import { ModelsServiceClient } from "@/services/grpc-client" import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles" import { ApiKeyField } from "../common/ApiKeyField" import { BaseUrlField } from "../common/BaseUrlField" import { DebouncedTextField } from "../common/DebouncedTextField" import { ModelInfoView } from "../common/ModelInfoView" import { DropdownContainer } from "../common/ModelSelector" import ReasoningEffortSelector from "../ReasoningEffortSelector" import { reasoningEffortPatch } from "../utils/reasoningEffort" import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers" import { useProviderApiKeyField } from "../utils/useProviderApiKeyField" /** * Props for the OpenAICompatibleProvider component */ interface OpenAICompatibleProviderProps { providerId: string showModelOptions: boolean isPopup?: boolean currentMode: Mode } /** * The OpenAI Compatible provider configuration component */ export const OpenAICompatibleProvider = ({ providerId, showModelOptions, isPopup, currentMode, }: OpenAICompatibleProviderProps) => { const { apiConfiguration, remoteConfigSettings } = useExtensionState() const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers() const { config, write, commitSelection, reasoningEffort } = useProviderConfig(providerId) const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false) const [isCustomOpenAiModelEntryVisible, setIsCustomOpenAiModelEntryVisible] = useState(false) const [availableOpenAiModels, setAvailableOpenAiModels] = useState([]) const [isRefreshingOpenAiModels, setIsRefreshingOpenAiModels] = useState(false) const [openAiModelsError, setOpenAiModelsError] = useState(undefined) const [modelFieldErrors, setModelFieldErrors] = useState>>({}) // Only the built-in "openai" provider stores its API key in the legacy // ApiConfiguration field; custom providers keep it in their per-provider // config (available only as a masked length), so there is no plaintext key // to seed the model-refresh request with. const legacyOpenAiApiKey = providerId === "openai" ? apiConfiguration?.openAiApiKey || "" : "" const latestOpenAiBaseUrlRef = useRef(config?.baseUrl || "") const latestOpenAiApiKeyRef = useRef(legacyOpenAiApiKey) const openAiModelsRequestRef = useRef(0) useEffect(() => { latestOpenAiBaseUrlRef.current = config?.baseUrl || "" }, [config?.baseUrl]) useEffect(() => { latestOpenAiApiKeyRef.current = legacyOpenAiApiKey }, [legacyOpenAiApiKey]) const handleProviderConfigWriteError = useCallback((fieldName: string, error: unknown) => { console.error(`Failed to update OpenAI Compatible ${fieldName}:`, error) }, []) // Built-in "openai" persists model selection to its legacy ApiConfiguration // fields; custom/unknown providers persist via their per-provider committed // selection. Prefer the committed selection and fall back to the legacy // fields so the built-in provider keeps working unchanged. const isOpenAiProvider = providerId === "openai" || providerId === "openai-compatible" const { selectedModelId: legacySelectedModelId, selectedModelInfo: legacySelectedModelInfo } = useDynamicProviderSelection( providerId, apiConfiguration, currentMode, ) const committedSelection = currentMode === "plan" ? config?.planSelection : config?.actSelection const selectedModelId = committedSelection?.modelId ?? legacySelectedModelId const selectedModelInfo = committedSelection?.modelInfo ? fromProtobufModelInfo(committedSelection.modelInfo) : legacySelectedModelInfo // The Model Configuration section reads/writes the resolved model info. // OpenAiCompatibleModelInfo only adds optional fields over ModelInfo, so a // resolved ModelInfo satisfies it structurally. const openAiModelInfo: OpenAiCompatibleModelInfo = selectedModelInfo ?? openAiModelInfoSafeDefaults const selectedModelOverrides = fromProtobufProviderModelOverrides(committedSelection?.overrides) ?? {} // Plan and Act have independent selections, so each mode gets its own // pending accumulator: a pending commit in one mode must never become // the base (or the model id) for an edit in the other mode, and a round // trip to the other mode must not disturb this mode's pending state. // Slots start empty; the reseed effect below fills a mode's slot before // it can be edited. const selectedModelOverridesRef = useRef>({ plan: { modelId: undefined, overrides: {} }, act: { modelId: undefined, overrides: {} }, }) // Counts commits whose commit+read-back round-trip has not finished yet, // per mode. const pendingCommitsRef = useRef>({ plan: 0, act: 0 }) useEffect(() => { // Do not reseed the pending-override accumulator from server state // while this mode's commits are in flight: an earlier commit's // read-back can land after a later local edit, and reseeding from // that stale snapshot would silently drop the already-committed // newer field. if (pendingCommitsRef.current[currentMode] > 0) { return } selectedModelOverridesRef.current[currentMode] = { modelId: selectedModelId, overrides: selectedModelOverrides } }, [committedSelection?.overrides, selectedModelId, currentMode]) const commitOpenAiSelection = useCallback( (modelId: string, overrides?: ProviderModelOverrides) => { if (!modelId.trim()) { return } const mode = currentMode pendingCommitsRef.current[mode] += 1 void commitSelection(mode, { providerId, modelId, ...(overrides !== undefined ? { overrides } : {}), }) .catch((error) => handleProviderConfigWriteError("model selection", error)) .finally(() => { pendingCommitsRef.current[mode] -= 1 }) }, [commitSelection, currentMode, handleProviderConfigWriteError, providerId], ) const updateModelOverride = useCallback( (key: K, value: ProviderModelOverrides[K] | undefined) => { // Prefer this mode's pending model id: while a model-id commit is // still round-tripping, `selectedModelId` reads back the old id // and an edit would be committed against the model just switched // away from. const pending = selectedModelOverridesRef.current[currentMode] const modelId = (pending.modelId ?? selectedModelId)?.trim() if (!modelId) { return } const currentOverrides = pending.modelId === modelId ? pending.overrides : {} const nextOverrides = { ...currentOverrides } if (value === undefined) { delete nextOverrides[key] } else { Object.assign(nextOverrides, { [key]: value }) } selectedModelOverridesRef.current[currentMode] = { modelId, overrides: nextOverrides } commitOpenAiSelection(modelId, nextOverrides) }, [commitOpenAiSelection, currentMode, selectedModelId], ) const updateNumericModelOverride = useCallback( (key: NumericModelOverrideKey, label: string, value: string) => { const parsed = parseOptionalFiniteNumber(value) if (!parsed.valid) { setModelFieldErrors((current) => ({ ...current, [key]: `${label} must be a valid number.` })) return } setModelFieldErrors((current) => { const next = { ...current } delete next[key] return next }) // Debounced fields fire with their initial value on mount and on // model/mode switches; committing that echo would persist resolved // catalog values as user overrides. Only commit actual edits. // Compare against the pending override when one is in flight so a // quick revert during a commit round-trip is not mistaken for an // echo of the (stale) displayed value. const pending = selectedModelOverridesRef.current[currentMode] const pendingOverrides = pending.modelId === selectedModelId?.trim() ? pending.overrides : undefined const effectiveValue = pendingOverrides && Object.hasOwn(pendingOverrides, key) ? displayedModelNumber(pendingOverrides[key] as number | undefined) : displayedModelNumber(openAiModelInfo?.[key]) if (parsed.value === effectiveValue) { return } updateModelOverride(key, parsed.value) }, [updateModelOverride, currentMode, openAiModelInfo, selectedModelId], ) // Debounced function to refresh OpenAI models (prevents excessive API calls while typing) const debounceTimerRef = useRef(null) useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } openAiModelsRequestRef.current += 1 } }, []) const refreshOpenAiModels = useCallback( async (baseUrl?: string, apiKey?: string) => { const trimmedBaseUrl = baseUrl?.trim() const requestId = openAiModelsRequestRef.current + 1 openAiModelsRequestRef.current = requestId if (!trimmedBaseUrl) { setAvailableOpenAiModels([]) setOpenAiModelsError(undefined) setIsRefreshingOpenAiModels(false) return } setIsRefreshingOpenAiModels(true) setOpenAiModelsError(undefined) try { // providerId lets the host back the request with this provider's // stored API key and headers — the webview only sees a masked key // for custom providers, so it can't send the credential itself. const response = await ModelsServiceClient.refreshOpenAiModels( OpenAiModelsRequest.create({ providerId, baseUrl: trimmedBaseUrl, apiKey, }), ) if (openAiModelsRequestRef.current === requestId) { setAvailableOpenAiModels(response.values) } } catch (error) { console.error("Failed to refresh OpenAI models:", error) if (openAiModelsRequestRef.current === requestId) { setAvailableOpenAiModels([]) setOpenAiModelsError(error instanceof Error ? error.message : String(error)) } } finally { if (openAiModelsRequestRef.current === requestId) { setIsRefreshingOpenAiModels(false) } } }, [providerId], ) const debouncedRefreshOpenAiModels = useCallback( (baseUrl?: string, apiKey?: string) => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } debounceTimerRef.current = setTimeout(() => { void refreshOpenAiModels(baseUrl, apiKey) }, 500) }, [refreshOpenAiModels], ) useEffect(() => { void refreshOpenAiModels(config?.baseUrl, latestOpenAiApiKeyRef.current) }, [config?.baseUrl, refreshOpenAiModels]) const handleOpenAiModelSelection = useCallback( (modelId: string) => { if (isOpenAiProvider) { handleModeFieldChange({ plan: "planModeOpenAiModelId", act: "actModeOpenAiModelId" }, modelId, currentMode) } // Model metadata here is user-authored (prices, context window, // capabilities), not catalog data, so it must survive a model-id // edit like it did when the legacy extension kept it in a single // id-independent blob. Recommit the displayed overrides under the // new id; otherwise an unknown id resolves to safe defaults whose // zero prices misbill paid requests as $0. const overrides = selectedModelOverridesRef.current[currentMode].overrides const hasOverrides = Object.keys(overrides).length > 0 selectedModelOverridesRef.current[currentMode] = { modelId, overrides } commitOpenAiSelection(modelId, hasOverrides ? overrides : undefined) }, [commitOpenAiSelection, currentMode, handleModeFieldChange, isOpenAiProvider], ) const { savedApiKeyMask, handleApiKeyChange } = useProviderApiKeyField({ apiKeyLength: config?.apiKeyLength, onApiKeyChange: (apiKey) => { latestOpenAiApiKeyRef.current = apiKey debouncedRefreshOpenAiModels(latestOpenAiBaseUrlRef.current, apiKey) }, providerName: "OpenAI Compatible", write, }) return (
Base URL {remoteConfigSettings?.openAiBaseUrl !== undefined && ( )}
{ latestOpenAiBaseUrlRef.current = value void write({ baseUrl: value }).catch((error) => handleProviderConfigWriteError("base URL", error)) debouncedRefreshOpenAiModels(value, latestOpenAiApiKeyRef.current) }} placeholder={"Enter base URL..."} style={{ width: "100%", marginBottom: 10 }} type="text" />
{openAiModelsError &&
{openAiModelsError}
} {availableOpenAiModels.length > 0 ? (
{ const modelId = (event.target as HTMLSelectElement).value if (modelId === "__custom__") { setIsCustomOpenAiModelEntryVisible(true) return } setIsCustomOpenAiModelEntryVisible(false) handleOpenAiModelSelection(modelId) }} value={selectedModelId && availableOpenAiModels.includes(selectedModelId) ? selectedModelId : ""}> {selectedModelId && !availableOpenAiModels.includes(selectedModelId) && ( {selectedModelId} (not in current list) )} {availableOpenAiModels.map((modelId) => ( {modelId} ))} Use custom model ID… {(isCustomOpenAiModelEntryVisible || (selectedModelId && !availableOpenAiModels.includes(selectedModelId))) && ( handleOpenAiModelSelection(value)} placeholder={"Enter Model ID..."} style={{ width: "100%" }}> Custom Model ID )}
) : ( handleOpenAiModelSelection(value)} placeholder={"Enter Model ID..."} style={{ width: "100%", marginBottom: 10 }} /> )} {/* OpenAI Compatible Custom Headers */} {(() => { const headers = config?.headers ?? {} const headerEntries = Object.entries(headers) return (
Custom Headers {remoteConfigSettings?.openAiHeaders !== undefined && ( )}
{ const currentHeaders = { ...headers } const headerCount = Object.keys(currentHeaders).length const newKey = `header${headerCount + 1}` currentHeaders[newKey] = "" void write({ headers: currentHeaders }).catch((error) => handleProviderConfigWriteError("headers", error), ) }}> Add Header
{headerEntries.map(([key, value], index) => (
{ const currentHeaders = config?.headers ?? {} if (newValue && newValue !== key) { const { [key]: _, ...rest } = currentHeaders void write({ headers: { ...rest, [newValue]: value, }, }).catch((error) => handleProviderConfigWriteError("headers", error)) } }} placeholder="Header name" style={{ width: "40%" }} /> { void write({ headers: { ...(config?.headers ?? {}), [key]: newValue, }, }).catch((error) => handleProviderConfigWriteError("headers", error)) }} placeholder="Header value" style={{ width: "40%" }} /> { const { [key]: _, ...rest } = config?.headers ?? {} void write({ headers: rest }).catch((error) => handleProviderConfigWriteError("headers", error), ) }}> Remove
))}
) })()} {remoteConfigSettings?.azureApiVersion !== undefined ? ( handleFieldChange("azureApiVersion", value)} placeholder={`Default: ${azureOpenAiDefaultApiVersion}`} showLockIcon={true} /> This setting is managed by your organization's remote configuration ) : ( handleFieldChange("azureApiVersion", value)} placeholder={`Default: ${azureOpenAiDefaultApiVersion}`} /> )} { const isChecked = e.target.checked === true return handleFieldChange("azureIdentity", isChecked) }}> Use Azure Identity Authentication
setModelConfigurationSelected((val) => !val)} style={{ color: getAsVar(VSC_DESCRIPTION_FOREGROUND), display: "flex", margin: "10px 0", cursor: "pointer", alignItems: "center", }}> Model Configuration
{modelConfigurationSelected && ( <> {/* Render the in-flight pending override, not the stale resolved value: fast-foundation checkboxes re-emit programmatic checked re-syncs as change events, so re-applying the stale value during the commit round-trip would commit it right back (#13694). */} updateModelOverride("supportsVision", e.target.checked === true)}> Supports Images
updateNumericModelOverride("contextWindow", "Context Window Size", value)}> Context Window Size {modelFieldErrors.contextWindow &&
{modelFieldErrors.contextWindow}
}
updateNumericModelOverride("maxTokens", "Max Output Tokens", value)} placeholder="not set"> Max Output Tokens {modelFieldErrors.maxTokens &&
{modelFieldErrors.maxTokens}
}
updateNumericModelOverride("inputPrice", "Input Price", value)}> Input Price / 1M tokens {modelFieldErrors.inputPrice &&
{modelFieldErrors.inputPrice}
}
updateNumericModelOverride("outputPrice", "Output Price", value)}> Output Price / 1M tokens {modelFieldErrors.outputPrice &&
{modelFieldErrors.outputPrice}
}
updateNumericModelOverride("temperature", "Temperature", value)} placeholder="not set"> Temperature {modelFieldErrors.temperature &&
{modelFieldErrors.temperature}
}
)}

(Note: Cline uses complex prompts, so behavior can vary across models. Less capable models may not work as expected.)

{showModelOptions && ( <> { void write(reasoningEffortPatch(effort)).catch((err) => console.error("Failed to update OpenAI Compatible reasoning effort:", err), ) }} value={reasoningEffort} /> )}
) } type NumericModelOverrideKey = "contextWindow" | "maxTokens" | "inputPrice" | "outputPrice" | "temperature" type PendingModelSelection = { modelId: string | undefined; overrides: ProviderModelOverrides } type ParsedOptionalNumber = { valid: true; value: number | undefined } | { valid: false } // -1 is the legacy UI sentinel for "not set"; it renders (and compares) as unset. function displayedModelNumber(value: number | undefined): number | undefined { return typeof value === "number" && Number.isFinite(value) && value !== -1 ? value : undefined } function formatOptionalModelNumber(value: number | undefined): string { return displayedModelNumber(value)?.toString() ?? "" } function parseOptionalFiniteNumber(value: string): ParsedOptionalNumber { const trimmed = value.trim() if (!trimmed) { return { valid: true, value: undefined } } const parsed = Number(trimmed) return Number.isFinite(parsed) ? { valid: true, value: parsed } : { valid: false } }