Repository: cline/cline
Pull request #3403: [PROTOBUS] Move refreshOpenAiModels to protobus
Author: login: canvrno
Created: 2025-05-09T02:42:18Z | Merged: 2025-05-09T06:33:12Z

### Description

This PR migrates the refreshOpenAiModels message to the gRPC/Protobuf system. It converts the legacy VSCode message passing mechanism to a gRPC method in the ModelsService. The client can now call ModelsServiceClient.refreshOpenAiModels() to obtain an array of Open AI models that are available. This PR also adds a debounced refresh so that the models are retrieved after users are done typing in the API key or Base URL fields.

### Test Procedure

To test this change, launch Cline with an OpenAI key configured, and confirm it is able to obtain a list of OpenAI that are available.

### Type of Change

<!-- Put an 'x' in all boxes that apply -->

-   [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
-   [ ] ✨ New feature (non-breaking change which adds functionality)
-   [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
-   [X] ♻️ Refactor Changes
-   [ ] 💅 Cosmetic Changes
-   [ ] 📚 Documentation update
-   [ ] 🏃 Workflow Changes

### Pre-flight Checklist

<!-- Put an 'x' in all boxes that apply -->

-   [X] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
-   [X] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
-   [X] I have created a changeset using `npm run changeset` (required for user-facing changes)
-   [X] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)

### Screenshots

<!-- For UI changes, add screenshots here -->

### Additional Notes

<!-- Add any additional notes for reviewers -->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Migrates `refreshOpenAiModels` to gRPC/Protobuf, updating the method registration, implementation, and UI handling.
> 
>   - **Behavior**:
>     - Migrates `refreshOpenAiModels` to gRPC/Protobuf in `models.proto` and `refreshOpenAiModels.ts`.
>     - Adds `debouncedRefreshOpenAiModels` in `ApiOptions.tsx` to debounce API calls when typing in OpenAI settings.
>   - **Methods**:
>     - Registers `refreshOpenAiModels` in `methods.ts`.
>     - Implements `refreshOpenAiModels` in `refreshOpenAiModels.ts` to fetch models from OpenAI API.
>   - **UI**:
>     - Updates `ApiOptions.tsx` to use `debouncedRefreshOpenAiModels` for OpenAI settings.
>   - **Misc**:
>     - Removes legacy `refreshOpenAiModels` handling from `index.ts` and `WebviewMessage.ts`.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=cline%2Fcline&utm_source=github&utm_medium=referral)<sup> for a46e90991f0d38ded0ee9a27a7fbab9921d0f15b. You can [customize](https://app.ellipsis.dev/cline/settings/summaries) this summary. It will automatically update as commits are pushed.</sup>


<!-- ELLIPSIS_HIDDEN -->


Diff:
diff --git a/.changeset/sour-kiwis-smash.md b/.changeset/sour-kiwis-smash.md
new file mode 100644
index 00000000000..3541daf2aef
--- /dev/null
+++ b/.changeset/sour-kiwis-smash.md
@@ -0,0 +1,5 @@
+---
+"claude-dev": patch
+---
+
+refreshOpenAiModels protobus migration
diff --git a/proto/models.proto b/proto/models.proto
index 60ebe72cbe5..4304645b081 100644
--- a/proto/models.proto
+++ b/proto/models.proto
@@ -16,6 +16,8 @@ service ModelsService {
   rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
   // Refreshes and returns OpenRouter models
   rpc refreshOpenRouterModels(EmptyRequest) returns (OpenRouterModels);
+  // Refreshes and returns OpenAI models
+  rpc refreshOpenAiModels(OpenAiModelsRequest) returns (StringArray);
 }
 
 // List of VS Code LM models
@@ -49,3 +51,10 @@ message OpenRouterModels {
   map<string, OpenRouterModelInfo> models = 1;
 }
 
+// Request for fetching OpenAI models
+message OpenAiModelsRequest {
+  Metadata metadata = 1;
+  string baseUrl = 2;
+  string apiKey = 3;
+}
+
diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts
index 1ade4cbbf3b..893e92f7d1b 100644
--- a/src/core/controller/index.ts
+++ b/src/core/controller/index.ts
@@ -1,6 +1,5 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import axios from "axios"
-import type { AxiosRequestConfig } from "axios"
 
 import fs from "fs/promises"
 import { setTimeout as setTimeoutPromise } from "node:timers/promises"
@@ -335,11 +334,6 @@ export class Controller {
 			case "refreshRequestyModels":
 				await this.refreshRequestyModels()
 				break
-			case "refreshOpenAiModels":
-				const { apiConfiguration } = await getAllExtensionState(this.context)
-				const openAiModels = await this.getOpenAiModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)
-				this.postMessageToWebview({ type: "openAiModels", openAiModels })
-				break
 			case "refreshClineRules":
 				await refreshClineRulesToggles(this.context, cwd)
 				await refreshExternalRulesToggles(this.context, cwd)
@@ -1117,32 +1111,6 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
 		}
 	}
 
-	// OpenAi
-
-	async getOpenAiModels(baseUrl?: string, apiKey?: string) {
-		try {
-			if (!baseUrl) {
-				return []
-			}
-
-			if (!URL.canParse(baseUrl)) {
-				return []
-			}
-
-			const config: AxiosRequestConfig = {}
-			if (apiKey) {
-				config["headers"] = { Authorization: `Bearer ${apiKey}` }
-			}
-
-			const response = await axios.get(`${baseUrl}/models`, config)
-			const modelsArray = response.data?.data?.map((model: any) => model.id) || []
-			const models = [...new Set<string>(modelsArray)]
-			return models
-		} catch (error) {
-			return []
-		}
-	}
-
 	// OpenRouter
 
 	async handleOpenRouterCallback(code: string) {
diff --git a/src/core/controller/models/methods.ts b/src/core/controller/models/methods.ts
index a35b7bdf232..7c780f1d999 100644
--- a/src/core/controller/models/methods.ts
+++ b/src/core/controller/models/methods.ts
@@ -6,6 +6,7 @@ import { registerMethod } from "./index"
 import { getLmStudioModels } from "./getLmStudioModels"
 import { getOllamaModels } from "./getOllamaModels"
 import { getVsCodeLmModels } from "./getVsCodeLmModels"
+import { refreshOpenAiModels } from "./refreshOpenAiModels"
 import { refreshOpenRouterModels } from "./refreshOpenRouterModels"
 
 // Register all models service methods
@@ -14,5 +15,6 @@ export function registerAllMethods(): void {
 	registerMethod("getLmStudioModels", getLmStudioModels)
 	registerMethod("getOllamaModels", getOllamaModels)
 	registerMethod("getVsCodeLmModels", getVsCodeLmModels)
+	registerMethod("refreshOpenAiModels", refreshOpenAiModels)
 	registerMethod("refreshOpenRouterModels", refreshOpenRouterModels)
 }
diff --git a/src/core/controller/models/refreshOpenAiModels.ts b/src/core/controller/models/refreshOpenAiModels.ts
new file mode 100644
index 00000000000..e4069bd3495
--- /dev/null
+++ b/src/core/controller/models/refreshOpenAiModels.ts
@@ -0,0 +1,40 @@
+import { Controller } from ".."
+import { OpenAiModelsRequest } from "../../../shared/proto/models"
+import { StringArray } from "../../../shared/proto/common"
+import axios from "axios"
+import type { AxiosRequestConfig } from "axios"
+
+/**
+ * Fetches available models from the OpenAI API
+ * @param controller The controller instance
+ * @param request Request containing the base URL and API key
+ * @returns Array of model names
+ */
+export async function refreshOpenAiModels(controller: Controller, request: OpenAiModelsRequest): Promise<StringArray> {
+	try {
+		if (!request.baseUrl) {
+			return StringArray.create({ values: [] })
+		}
+
+		if (!URL.canParse(request.baseUrl)) {
+			return StringArray.create({ values: [] })
+		}
+
+		const config: AxiosRequestConfig = {}
+		if (request.apiKey) {
+			config["headers"] = { Authorization: `Bearer ${request.apiKey}` }
+		}
+
+		const response = await axios.get(`${request.baseUrl}/models`, config)
+		const modelsArray = response.data?.data?.map((model: any) => model.id) || []
+		const models = [...new Set<string>(modelsArray)]
+
+		// Send models to webview
+		controller.postMessageToWebview({ type: "openAiModels", openAiModels: models })
+
+		return StringArray.create({ values: models })
+	} catch (error) {
+		console.error("Error fetching OpenAI models:", error)
+		return StringArray.create({ values: [] })
+	}
+}
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index be65debc7ec..4d0a2ba7687 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -21,7 +21,6 @@ export interface WebviewMessage {
 		| "openMention"
 		| "showChatView"
 		| "refreshRequestyModels"
-		| "refreshOpenAiModels"
 		| "refreshClineRules"
 		| "openMcpSettings"
 		| "restartMcpServer"
diff --git a/src/shared/proto/models.ts b/src/shared/proto/models.ts
index 93ae9bed680..656d765930a 100644
--- a/src/shared/proto/models.ts
+++ b/src/shared/proto/models.ts
@@ -6,7 +6,7 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"
-import { EmptyRequest, StringArray, StringRequest } from "./common"
+import { EmptyRequest, Metadata, StringArray, StringRequest } from "./common"
 
 export const protobufPackage = "cline"
 
@@ -46,6 +46,13 @@ export interface OpenRouterModels_ModelsEntry {
 	value?: OpenRouterModelInfo | undefined
 }
 
+/** Request for fetching OpenAI models */
+export interface OpenAiModelsRequest {
+	metadata?: Metadata | undefined
+	baseUrl: string
+	apiKey: string
+}
+
 function createBaseVsCodeLmModelsArray(): VsCodeLmModelsArray {
 	return { models: [] }
 }
@@ -571,6 +578,99 @@ export const OpenRouterModels_ModelsEntry: MessageFns<OpenRouterModels_ModelsEnt
 	},
 }
 
+function createBaseOpenAiModelsRequest(): OpenAiModelsRequest {
+	return { metadata: undefined, baseUrl: "", apiKey: "" }
+}
+
+export const OpenAiModelsRequest: MessageFns<OpenAiModelsRequest> = {
+	encode(message: OpenAiModelsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
+		if (message.metadata !== undefined) {
+			Metadata.encode(message.metadata, writer.uint32(10).fork()).join()
+		}
+		if (message.baseUrl !== "") {
+			writer.uint32(18).string(message.baseUrl)
+		}
+		if (message.apiKey !== "") {
+			writer.uint32(26).string(message.apiKey)
+		}
+		return writer
+	},
+
+	decode(input: BinaryReader | Uint8Array, length?: number): OpenAiModelsRequest {
+		const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
+		let end = length === undefined ? reader.len : reader.pos + length
+		const message = createBaseOpenAiModelsRequest()
+		while (reader.pos < end) {
+			const tag = reader.uint32()
+			switch (tag >>> 3) {
+				case 1: {
+					if (tag !== 10) {
+						break
+					}
+
+					message.metadata = Metadata.decode(reader, reader.uint32())
+					continue
+				}
+				case 2: {
+					if (tag !== 18) {
+						break
+					}
+
+					message.baseUrl = reader.string()
+					continue
+				}
+				case 3: {
+					if (tag !== 26) {
+						break
+					}
+
+					message.apiKey = reader.string()
+					continue
+				}
+			}
+			if ((tag & 7) === 4 || tag === 0) {
+				break
+			}
+			reader.skip(tag & 7)
+		}
+		return message
+	},
+
+	fromJSON(object: any): OpenAiModelsRequest {
+		return {
+			metadata: isSet(object.metadata) ? Metadata.fromJSON(object.metadata) : undefined,
+			baseUrl: isSet(object.baseUrl) ? globalThis.String(object.baseUrl) : "",
+			apiKey: isSet(object.apiKey) ? globalThis.String(object.apiKey) : "",
+		}
+	},
+
+	toJSON(message: OpenAiModelsRequest): unknown {
+		const obj: any = {}
+		if (message.metadata !== undefined) {
+			obj.metadata = Metadata.toJSON(message.metadata)
+		}
+		if (message.baseUrl !== "") {
+			obj.baseUrl = message.baseUrl
+		}
+		if (message.apiKey !== "") {
+			obj.apiKey = message.apiKey
+		}
+		return obj
+	},
+
+	create<I extends Exact<DeepPartial<OpenAiModelsRequest>, I>>(base?: I): OpenAiModelsRequest {
+		return OpenAiModelsRequest.fromPartial(base ?? ({} as any))
+	},
+	fromPartial<I extends Exact<DeepPartial<OpenAiModelsRequest>, I>>(object: I): OpenAiModelsRequest {
+		const message = createBaseOpenAiModelsRequest()
+		message.metadata =
+			object.metadata !== undefined && object.metadata !== null ? Metadata.fromPartial(object.metadata) : undefined
+		message.baseUrl = object.baseUrl ?? ""
+		message.apiKey = object.apiKey ?? ""
+		return message
+	},
+}
+
 /** Service for model-related operations */
 export type ModelsServiceDefinition = typeof ModelsServiceDefinition
 export const ModelsServiceDefinition = {
@@ -613,6 +713,15 @@ export const ModelsServiceDefinition = {
 			responseStream: false,
 			options: {},
 		},
+		/** Refreshes and returns OpenAI models */
+		refreshOpenAiModels: {
+			name: "refreshOpenAiModels",
+			requestType: OpenAiModelsRequest,
+			requestStream: false,
+			responseType: StringArray,
+			responseStream: false,
+			options: {},
+		},
 	},
 } as const
 
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx
index 7db83a0b734..3033a4f1baa 100644
--- a/webview-ui/src/components/settings/ApiOptions.tsx
+++ b/webview-ui/src/components/settings/ApiOptions.tsx
@@ -8,7 +8,7 @@ import {
 	VSCodeRadioGroup,
 	VSCodeTextField,
 } from "@vscode/webview-ui-toolkit/react"
-import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react"
+import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
 import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
 import { useEvent, useInterval } from "react-use"
 import styled from "styled-components"
@@ -262,6 +262,34 @@ const ApiOptions = ({
 		)
 	}
 
+	// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
+	const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
+
+	useEffect(() => {
+		return () => {
+			if (debounceTimerRef.current) {
+				clearTimeout(debounceTimerRef.current)
+			}
+		}
+	}, [])
+
+	const debouncedRefreshOpenAiModels = useCallback((baseUrl?: string, apiKey?: string) => {
+		if (debounceTimerRef.current) {
+			clearTimeout(debounceTimerRef.current)
+		}
+
+		if (baseUrl && apiKey) {
+			debounceTimerRef.current = setTimeout(() => {
+				ModelsServiceClient.refreshOpenAiModels({
+					baseUrl,
+					apiKey,
+				}).catch((error) => {
+					console.error("Failed to refresh OpenAI models:", error)
+				})
+			}, 500)
+		}
+	}, [])
+
 	return (
 		<div style={{ display: "flex", flexDirection: "column", gap: 5, marginBottom: isPopup ? -10 : 0 }}>
 			<DropdownContainer className="dropdown-container">
@@ -999,7 +1027,12 @@ const ApiOptions = ({
 						value={apiConfiguration?.openAiBaseUrl || ""}
 						style={{ width: "100%", marginBottom: 10 }}
 						type="url"
-						onInput={handleInputChange("openAiBaseUrl")}
+						onInput={(e: any) => {
+							const baseUrl = e.target.value
+							handleInputChange("openAiBaseUrl")({ target: { value: baseUrl } })
+
+							debouncedRefreshOpenAiModels(baseUrl, apiConfiguration?.openAiApiKey)
+						}}
 						placeholder={"Enter base URL..."}>
 						<span style={{ fontWeight: 500 }}>Base URL</span>
 					</VSCodeTextField>
@@ -1007,7 +1040,12 @@ const ApiOptions = ({
 						value={apiConfiguration?.openAiApiKey || ""}
 						style={{ width: "100%", marginBottom: 10 }}
 						type="password"
-						onInput={handleInputChange("openAiApiKey")}
+						onInput={(e: any) => {
+							const apiKey = e.target.value
+							handleInputChange("openAiApiKey")({ target: { value: apiKey } })
+
+							debouncedRefreshOpenAiModels(apiConfiguration?.openAiBaseUrl, apiKey)
+						}}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>API Key</span>
 					</VSCodeTextField>


Reviews:
user: login: ellipsis-dev[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T03:05:29Z
user: login: ellipsis-dev[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T03:37:33Z
user: login: Garoth
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T05:15:17Z
user: login: ocasta181
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T06:07:49Z

Review comments:
diff_hunk: @@ -999,15 +999,41 @@ const ApiOptions = ({
 						value={apiConfiguration?.openAiBaseUrl || ""}
 						style={{ width: "100%", marginBottom: 10 }}
 						type="url"
-						onInput={handleInputChange("openAiBaseUrl")}
+						onInput={(e: any) => {
+							const baseUrl = e.target.value
+							handleInputChange("openAiBaseUrl")({ target: { value: baseUrl } })
+
+							// Refresh models when baseUrl or apiKey changes and both have values
+							if (baseUrl && apiConfiguration?.openAiApiKey) {
+								ModelsServiceClient.refreshOpenAiModels({
path: webview-ui/src/components/settings/ApiOptions.tsx
user: login: ellipsis-dev[bot]
body: Consider debouncing the `refreshOpenAiModels` call on input events to avoid excessive network requests while the user is typing.

created_at: 2025-05-09T03:05:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -262,6 +262,25 @@ const ApiOptions = ({
 		)
 	}
 
+	// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
+	const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
path: webview-ui/src/components/settings/ApiOptions.tsx
user: login: ellipsis-dev[bot]
body: Consider adding a cleanup in a `useEffect` return to clear the timer on unmount (e.g. if `debounceTimerRef.current` exists, clear it) to avoid potential memory leaks.

created_at: 2025-05-09T03:37:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-09T02:42:22Z
body: ###  🦋  Changeset detected

Latest commit: 942d83904f597906cdb2e880d3e5bc724ffe4871

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name       | Type  |
| ---------- | ----- |
| claude-dev | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/cline/cline/new/canvrno/refreshOpenAiModels?filename=.changeset/strange-snakes-doubt.md&value=---%0A%22claude-dev%22%3A%20patch%0A---%0A%0A%5BPROTOBUS%5D%20Move%20refreshOpenAiModels%20to%20protobus%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-09T02:45:55Z
body: <!-- COVERAGE_REPORT -->
## Coverage Report

### Extension Coverage

Base branch: 45%

PR branch: 46%

✅ Coverage increased or remained the same

### Webview Coverage

Base branch: 11%

PR branch: 11%

✅ Coverage increased or remained the same

### Overall Assessment

✅ **Test coverage has been maintained or improved**



<sub>Last updated: 2025-05-09T03:57:03.176335</sub>
author_association: CONTRIBUTOR