Преглед изворни кода

feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline (#5315)

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline

* feat: sap provider - show deployed models from the ai core service instance alongside sap provider's supported models in cline
yuvalman пре 4 месеци
родитељ
комит
aea979ff4e

+ 5 - 0
.changeset/blue-chicken-compete.md

@@ -0,0 +1,5 @@
+---
+"claude-dev": minor
+---
+
+sap provider show deployed and not deployed models in ai core service instance

+ 4 - 1
docs/provider-config/sap-aicore.mdx

@@ -36,4 +36,7 @@ Refer to the [Generative AI Hub Supported Models page](https://me.sap.com/notes/
 
 ### Tips and Notes
 
--   **Model Selection:** SAP AI Core offers a wide range of models. You won't be able to use the model, even if selected, if a deployment doesn't exist in the provided resource group.
+-   **Model Selection:** The model dropdown displays models in two separate lists:
+    -   **Deployed Models:** These models are already deployed in your specified resource group and are ready to use immediately.
+    -   **Not Deployed Models:** These models don't have active deployments in your specified resource group. You won't be able to use these models until you create deployments for them in SAP AI Core.
+-   **Creating Deployments:** To use a not deployed model, you'll need to create a deployment in your resource group in sap ai core service instance. See [Create a Deployment for a Generative AI Model](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/create-deployment-for-generative-ai-model-in-sap-ai-core) for instructions.

+ 12 - 0
proto/cline/models.proto

@@ -29,6 +29,8 @@ service ModelsService {
   rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
   // Refreshes and returns Baseten models
   rpc refreshBasetenModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
+  // Fetches available models from SAP AI Core
+  rpc getSapAiCoreModels(SapAiCoreModelsRequest) returns (StringArray);
 }
 
 // List of VS Code LM models
@@ -94,6 +96,16 @@ message OpenAiModelsRequest {
   string api_key = 3;
 }
 
+// Request for fetching SAP AI Core models
+message SapAiCoreModelsRequest {
+  Metadata metadata = 1;
+  string client_id = 2;
+  string client_secret = 3;
+  string base_url = 4;
+  string token_url = 5;
+  string resource_group = 6;
+}
+
 // Request for updating API configuration
 message UpdateApiConfigurationRequest {
   Metadata metadata = 1;

+ 105 - 0
src/core/controller/models/getSapAiCoreModels.ts

@@ -0,0 +1,105 @@
+import axios from "axios"
+import { Controller } from ".."
+import { SapAiCoreModelsRequest } from "@/shared/proto/cline/models"
+import { StringArray } from "@/shared/proto/cline/common"
+
+interface Token {
+	access_token: string
+	expires_in: number
+	scope: string
+	jti: string
+	token_type: string
+	expires_at: number
+}
+
+/**
+ * Authenticates with SAP AI Core and returns an access token
+ * @param clientId SAP AI Core client ID
+ * @param clientSecret SAP AI Core client secret
+ * @param tokenUrl SAP AI Core token URL
+ * @returns Promise<Token> Access token with metadata
+ */
+async function getToken(clientId: string, clientSecret: string, tokenUrl: string): Promise<Token> {
+	const payload = new URLSearchParams({
+		grant_type: "client_credentials",
+		client_id: clientId,
+		client_secret: clientSecret,
+	})
+
+	const url = tokenUrl.replace(/\/+$/, "") + "/oauth/token"
+	const response = await axios.post(url, payload, {
+		headers: { "Content-Type": "application/x-www-form-urlencoded" },
+	})
+	const token = response.data as Token
+	token.expires_at = Date.now() + token.expires_in * 1000
+	return token
+}
+
+/**
+ * Fetches model names from SAP AI Core deployments
+ * @param accessToken Access token for authentication
+ * @param baseUrl SAP AI Core base URL
+ * @param resourceGroup SAP AI Core resource group
+ * @returns Promise<string[]> Array of model names from running deployments
+ */
+async function fetchAiCoreModelNames(accessToken: string, baseUrl: string, resourceGroup: string): Promise<string[]> {
+	if (!accessToken) {
+		return ["ai-core-not-configured"]
+	}
+
+	const headers = {
+		Authorization: `Bearer ${accessToken}`,
+		"AI-Resource-Group": resourceGroup || "default",
+		"Content-Type": "application/json",
+		"AI-Client-Type": "Cline",
+	}
+
+	const url = `${baseUrl}/v2/lm/deployments?$top=10000&$skip=0`
+
+	try {
+		const response = await axios.get(url, { headers })
+		const deployments = response.data.resources
+
+		return deployments
+			.filter((deployment: any) => deployment.targetStatus === "RUNNING")
+			.map((deployment: any) => {
+				const model = deployment.details?.resources?.backend_details?.model
+				if (!model?.name || !model?.version) {
+					return null // Skip this row
+				}
+				return `${model.name}:${model.version}`
+			})
+			.filter((modelName: string | null) => modelName !== null)
+	} catch (error) {
+		console.error("Error fetching deployments:", error)
+		throw new Error("Failed to fetch deployments")
+	}
+}
+
+/**
+ * Fetches available models from SAP AI Core deployments
+ * @param controller The controller instance
+ * @param request The request containing SAP AI Core configuration
+ * @returns StringArray of model names
+ */
+export async function getSapAiCoreModels(controller: Controller, request: SapAiCoreModelsRequest): Promise<StringArray> {
+	try {
+		// Check if required configuration is provided
+		if (!request.clientId || !request.clientSecret || !request.baseUrl) {
+			// Return empty array if configuration is incomplete
+			return StringArray.create({ values: [] })
+		}
+
+		// Direct authentication and model name fetching
+		const token = await getToken(request.clientId, request.clientSecret, request.tokenUrl)
+		const modelNames = await fetchAiCoreModelNames(token.access_token, request.baseUrl, request.resourceGroup)
+
+		// Extract base model names (without version) and sort
+		const baseModelNames = modelNames.map((modelName) => modelName.split(":")[0].toLowerCase()).sort()
+
+		return StringArray.create({ values: baseModelNames })
+	} catch (error) {
+		console.error("Error fetching SAP AI Core models:", error)
+		return StringArray.create({ values: [] })
+	}
+}

+ 125 - 0
webview-ui/src/components/settings/SapAiCoreModelPicker.tsx

@@ -0,0 +1,125 @@
+import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
+import React, { memo, useMemo } from "react"
+import { sapAiCoreModels } from "@shared/api"
+import { DropdownContainer } from "./common/ModelSelector"
+
+export const SAP_AI_CORE_MODEL_PICKER_Z_INDEX = 1_000
+
+export interface SapAiCoreModelPickerProps {
+	sapAiCoreDeployedModels: string[]
+	selectedModelId: string
+	onModelChange: (modelId: string) => void
+	placeholder?: string
+}
+
+interface CategorizedModel {
+	id: string
+	isDeployed: boolean
+	section: "deployed" | "supported"
+}
+
+const SapAiCoreModelPicker: React.FC<SapAiCoreModelPickerProps> = ({
+	sapAiCoreDeployedModels,
+	selectedModelId,
+	onModelChange,
+	placeholder = "Select a model...",
+}) => {
+	const handleModelChange = (event: any) => {
+		const newModelId = event.target.value
+		onModelChange(newModelId)
+	}
+
+	const categorizedModels = useMemo(() => {
+		const allSupportedModels = Object.keys(sapAiCoreModels)
+
+		// Models that are both deployed AND supported in Cline
+		const deployedAndSupported = sapAiCoreDeployedModels.filter((deployedModel: string) =>
+			allSupportedModels.includes(deployedModel),
+		)
+
+		// Models that are supported in Cline but NOT deployed
+		const supportedButNotDeployed = allSupportedModels.filter(
+			(supportedModel: string) => !sapAiCoreDeployedModels.includes(supportedModel),
+		)
+
+		const deployed: CategorizedModel[] = deployedAndSupported.map((id: string) => ({
+			id,
+			isDeployed: true,
+			section: "deployed" as const,
+		}))
+
+		const supported: CategorizedModel[] = supportedButNotDeployed.map((id: string) => ({
+			id,
+			isDeployed: false,
+			section: "supported" as const,
+		}))
+
+		return { deployed, supported }
+	}, [sapAiCoreDeployedModels])
+
+	const renderOptions = () => {
+		const options: React.ReactNode[] = []
+
+		// Add placeholder option
+		options.push(
+			<VSCodeOption key="placeholder" value="">
+				{placeholder}
+			</VSCodeOption>,
+		)
+
+		// Add deployed models section
+		if (categorizedModels.deployed.length > 0) {
+			// Add section separator (disabled option)
+			options.push(
+				<VSCodeOption key="deployed-header" value="" disabled>
+					── Deployed Models ──
+				</VSCodeOption>,
+			)
+
+			categorizedModels.deployed.forEach((model) => {
+				options.push(
+					<VSCodeOption key={model.id} value={model.id}>
+						{model.id}
+					</VSCodeOption>,
+				)
+			})
+		}
+
+		// Add supported but not deployed models section
+		if (categorizedModels.supported.length > 0) {
+			// Add section separator (disabled option)
+			options.push(
+				<VSCodeOption key="supported-header" value="" disabled>
+					── Not Deployed Models ──
+				</VSCodeOption>,
+			)
+
+			categorizedModels.supported.forEach((model) => {
+				options.push(
+					<VSCodeOption key={model.id} value={model.id} style={{ opacity: 0.6 }}>
+						{model.id}
+					</VSCodeOption>,
+				)
+			})
+		}
+
+		return options
+	}
+
+	return (
+		<DropdownContainer className="dropdown-container" zIndex={SAP_AI_CORE_MODEL_PICKER_Z_INDEX}>
+			<label htmlFor="sap-ai-core-model-dropdown">
+				<span className="font-medium">Model</span>
+			</label>
+			<VSCodeDropdown
+				id="sap-ai-core-model-dropdown"
+				value={selectedModelId}
+				onChange={handleModelChange}
+				style={{ width: "100%" }}>
+				{renderOptions()}
+			</VSCodeDropdown>
+		</DropdownContainer>
+	)
+}
+
+export default memo(SapAiCoreModelPicker)

+ 353 - 0
webview-ui/src/components/settings/__tests__/SapAiCoreModelPicker.spec.tsx

@@ -0,0 +1,353 @@
+import { render, screen } from "@testing-library/react"
+import { describe, it, expect, vi } from "vitest"
+import SapAiCoreModelPicker from "../SapAiCoreModelPicker"
+import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
+
+// Mock the shared API models
+vi.mock("@shared/api", async (importOriginal) => {
+	const actual = (await importOriginal()) as Record<string, any>
+	return {
+		...actual,
+		sapAiCoreModels: {
+			"anthropic--claude-3.5-sonnet": {
+				maxTokens: 8192,
+				contextWindow: 200_000,
+				supportsImages: true,
+				supportsPromptCache: false,
+			},
+			"anthropic--claude-3-haiku": {
+				maxTokens: 4096,
+				contextWindow: 200_000,
+				supportsImages: true,
+				supportsPromptCache: false,
+			},
+			"gpt-4o": {
+				maxTokens: 4096,
+				contextWindow: 200_000,
+				supportsImages: true,
+				supportsPromptCache: false,
+			},
+			"gemini-2.5-pro": {
+				maxTokens: 65536,
+				contextWindow: 1_048_576,
+				supportsImages: true,
+				supportsPromptCache: true,
+			},
+		},
+	}
+})
+
+// Mock the ExtensionStateContext
+vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
+	const actual = await importOriginal()
+	return {
+		...(actual || {}),
+		useExtensionState: vi.fn(() => ({
+			apiConfiguration: {
+				apiProvider: "sapaicore",
+				sapAiCoreModelId: "anthropic--claude-3.5-sonnet",
+			},
+			setApiConfiguration: vi.fn(),
+		})),
+	}
+})
+
+describe("SapAiCoreModelPicker Component", () => {
+	vi.clearAllMocks()
+	const mockOnModelChange = vi.fn()
+
+	beforeEach(() => {
+		mockOnModelChange.mockClear()
+	})
+
+	it("renders the model dropdown with correct label", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "gpt-4o"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		const label = screen.getByText("Model")
+		expect(label).toBeInTheDocument()
+
+		const dropdown = screen.getByRole("combobox")
+		expect(dropdown).toBeInTheDocument()
+		expect(dropdown).toHaveAttribute("id", "sap-ai-core-model-dropdown")
+	})
+
+	it("renders with default placeholder", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker sapAiCoreDeployedModels={[]} selectedModelId="" onModelChange={mockOnModelChange} />
+			</ExtensionStateContextProvider>,
+		)
+
+		const placeholderOption = screen.getByText("Select a model...")
+		expect(placeholderOption).toBeInTheDocument()
+	})
+
+	it("renders with custom placeholder", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={[]}
+					selectedModelId=""
+					onModelChange={mockOnModelChange}
+					placeholder="Choose SAP AI Core model..."
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		const placeholderOption = screen.getByText("Choose SAP AI Core model...")
+		expect(placeholderOption).toBeInTheDocument()
+	})
+
+	it("shows deployed models section when deployed models exist", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "gpt-4o"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Check for deployed models section header
+		const deployedHeader = screen.getByText("── Deployed Models ──")
+		expect(deployedHeader).toBeInTheDocument()
+
+		// Check for deployed model options
+		const claudeOption = screen.getByText("anthropic--claude-3.5-sonnet")
+		const gptOption = screen.getByText("gpt-4o")
+		expect(claudeOption).toBeInTheDocument()
+		expect(gptOption).toBeInTheDocument()
+	})
+
+	it("shows not deployed models section when supported but not deployed models exist", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Check for not deployed models section header
+		const notDeployedHeader = screen.getByText("── Not Deployed Models ──")
+		expect(notDeployedHeader).toBeInTheDocument()
+
+		// Check for not deployed model options
+		const haikuOption = screen.getByText("anthropic--claude-3-haiku")
+		const geminiOption = screen.getByText("gemini-2.5-pro")
+		expect(haikuOption).toBeInTheDocument()
+		expect(geminiOption).toBeInTheDocument()
+	})
+
+	it("correctly categorizes models into deployed and not deployed", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "gpt-4o"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Deployed models should appear
+		expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
+		expect(screen.getByText("gpt-4o")).toBeInTheDocument()
+
+		// Not deployed models should appear
+		expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
+		expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
+	})
+
+	it("calls onModelChange when a model is selected", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "gpt-4o"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Test that the component renders correctly and has the expected structure
+		const dropdown = screen.getByRole("combobox")
+		expect(dropdown).toBeInTheDocument()
+		expect(dropdown).toHaveValue("anthropic--claude-3.5-sonnet")
+
+		// Since VSCodeDropdown doesn't work well with testing libraries,
+		// we'll verify the component structure instead of simulating events
+		expect(screen.getByText("gpt-4o")).toBeInTheDocument()
+		expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
+	})
+
+	it("handles selection of not deployed models", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Test that not deployed models are properly displayed
+		const dropdown = screen.getByRole("combobox")
+		expect(dropdown).toBeInTheDocument()
+		expect(dropdown).toHaveValue("anthropic--claude-3.5-sonnet")
+
+		// Verify that not deployed models are shown with proper labeling
+		expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
+		expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
+	})
+
+	it("updates selected value when selectedModelId prop changes", () => {
+		const { rerender } = render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "gpt-4o"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		const dropdown = screen.getByRole("combobox")
+		expect(dropdown).toHaveValue("anthropic--claude-3.5-sonnet")
+
+		// Rerender with different selectedModelId
+		rerender(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "gpt-4o"]}
+					selectedModelId="gpt-4o"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		expect(dropdown).toHaveValue("gpt-4o")
+	})
+
+	it("handles empty deployed models array", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker sapAiCoreDeployedModels={[]} selectedModelId="" onModelChange={mockOnModelChange} />
+			</ExtensionStateContextProvider>,
+		)
+
+		// Should not show deployed models section
+		expect(screen.queryByText("── Deployed Models ──")).not.toBeInTheDocument()
+
+		// Should show not deployed models section with all supported models
+		const notDeployedHeader = screen.getByText("── Not Deployed Models ──")
+		expect(notDeployedHeader).toBeInTheDocument()
+
+		// All models should be marked as not deployed
+		expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
+		expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
+		expect(screen.getByText("gpt-4o")).toBeInTheDocument()
+		expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
+	})
+
+	it("handles case where all supported models are deployed", () => {
+		const allSupportedModels = ["anthropic--claude-3.5-sonnet", "anthropic--claude-3-haiku", "gpt-4o", "gemini-2.5-pro"]
+
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={allSupportedModels}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Should show deployed models section
+		const deployedHeader = screen.getByText("── Deployed Models ──")
+		expect(deployedHeader).toBeInTheDocument()
+
+		// Should not show not deployed models section
+		expect(screen.queryByText("── Not Deployed Models ──")).not.toBeInTheDocument()
+
+		// All models should appear
+		expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
+		expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
+		expect(screen.getByText("gpt-4o")).toBeInTheDocument()
+		expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
+	})
+
+	it("handles models that are deployed but not in supported list", () => {
+		// Include a model that's deployed but not in our mocked sapAiCoreModels
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet", "unsupported-model"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Only supported deployed models should appear in deployed section
+		expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
+		expect(screen.queryByText("unsupported-model")).not.toBeInTheDocument()
+
+		// Other supported models should appear in not deployed section
+		expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
+	})
+
+	it("maintains correct dropdown structure with sections", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet"]}
+					selectedModelId="anthropic--claude-3.5-sonnet"
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Check that section headers are disabled options
+		const deployedHeader = screen.getByText("── Deployed Models ──")
+		const notDeployedHeader = screen.getByText("── Not Deployed Models ──")
+
+		expect(deployedHeader).toBeInTheDocument()
+		expect(notDeployedHeader).toBeInTheDocument()
+
+		// Headers should be disabled (though we can't easily test the disabled attribute in this setup)
+		// The important thing is they exist and provide visual separation
+	})
+
+	it("handles model selection with empty string", () => {
+		render(
+			<ExtensionStateContextProvider>
+				<SapAiCoreModelPicker
+					sapAiCoreDeployedModels={["anthropic--claude-3.5-sonnet"]}
+					selectedModelId=""
+					onModelChange={mockOnModelChange}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// Test that the component handles empty selectedModelId correctly
+		const dropdown = screen.getByRole("combobox")
+		expect(dropdown).toBeInTheDocument()
+		expect(dropdown).toHaveValue("")
+
+		// Verify that the placeholder is shown when no model is selected
+		expect(screen.getByText("Select a model...")).toBeInTheDocument()
+	})
+})

+ 117 - 30
webview-ui/src/components/settings/providers/SapAiCoreProvider.tsx

@@ -1,10 +1,12 @@
-import { sapAiCoreModels } from "@shared/api"
+import { SapAiCoreModelsRequest } from "@shared/proto/index.cline"
 import { Mode } from "@shared/storage/types"
 import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
+import { useCallback, useEffect, useState } from "react"
 import { useExtensionState } from "@/context/ExtensionStateContext"
+import { ModelsServiceClient } from "@/services/grpc-client"
 import { DebouncedTextField } from "../common/DebouncedTextField"
 import { ModelInfoView } from "../common/ModelInfoView"
-import { ModelSelector } from "../common/ModelSelector"
+import SapAiCoreModelPicker from "../SapAiCoreModelPicker"
 import { normalizeApiConfiguration } from "../utils/providerUtils"
 import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
 
@@ -26,18 +28,87 @@ export const SapAiCoreProvider = ({ showModelOptions, isPopup, currentMode }: Sa
 
 	const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode)
 
+	// State for dynamic model fetching
+	const [deployedModelsArray, setDeployedModelsArray] = useState<string[]>([])
+	const [isLoadingModels, setIsLoadingModels] = useState(false)
+	const [modelError, setModelError] = useState<string | null>(null)
+
+	// Check if all required credentials are available
+	const hasRequiredCredentials =
+		apiConfiguration?.sapAiCoreClientId &&
+		apiConfiguration?.sapAiCoreClientSecret &&
+		apiConfiguration?.sapAiCoreBaseUrl &&
+		apiConfiguration?.sapAiCoreTokenUrl &&
+		apiConfiguration?.sapAiResourceGroup
+
+	// Function to fetch SAP AI Core models
+	const fetchSapAiCoreModels = useCallback(async () => {
+		if (!hasRequiredCredentials) {
+			setDeployedModelsArray([])
+			return
+		}
+
+		setIsLoadingModels(true)
+		setModelError(null)
+
+		try {
+			const response = await ModelsServiceClient.getSapAiCoreModels(
+				SapAiCoreModelsRequest.create({
+					clientId: apiConfiguration.sapAiCoreClientId,
+					clientSecret: apiConfiguration.sapAiCoreClientSecret,
+					baseUrl: apiConfiguration.sapAiCoreBaseUrl,
+					tokenUrl: apiConfiguration.sapAiCoreTokenUrl,
+					resourceGroup: apiConfiguration.sapAiResourceGroup,
+				}),
+			)
+
+			if (response && response.values) {
+				setDeployedModelsArray(response.values)
+			} else {
+				setDeployedModelsArray([])
+			}
+		} catch (error) {
+			console.error("Error fetching SAP AI Core models:", error)
+			setModelError("Failed to fetch models. Please check your configuration.")
+			setDeployedModelsArray([])
+		} finally {
+			setIsLoadingModels(false)
+		}
+	}, [
+		apiConfiguration?.sapAiCoreClientId,
+		apiConfiguration?.sapAiCoreClientSecret,
+		apiConfiguration?.sapAiCoreBaseUrl,
+		apiConfiguration?.sapAiCoreTokenUrl,
+		apiConfiguration?.sapAiResourceGroup,
+	])
+
+	// Fetch models when configuration changes
+	useEffect(() => {
+		if (showModelOptions && hasRequiredCredentials) {
+			fetchSapAiCoreModels()
+		}
+	}, [showModelOptions, hasRequiredCredentials, fetchSapAiCoreModels])
+
+	// Handle model selection
+	const handleModelChange = useCallback(
+		(modelId: string) => {
+			handleModeFieldChange({ plan: "planModeApiModelId", act: "actModeApiModelId" }, modelId, currentMode)
+		},
+		[handleModeFieldChange, currentMode],
+	)
+
 	return (
-		<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
+		<div className="flex flex-col gap-1.5">
 			<DebouncedTextField
 				initialValue={apiConfiguration?.sapAiCoreClientId || ""}
 				onChange={(value) => handleFieldChange("sapAiCoreClientId", value)}
 				placeholder="Enter AI Core Client Id..."
 				style={{ width: "100%" }}
 				type="password">
-				<span style={{ fontWeight: 500 }}>AI Core Client Id</span>
+				<span className="font-medium">AI Core Client Id</span>
 			</DebouncedTextField>
 			{apiConfiguration?.sapAiCoreClientId && (
-				<p style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)" }}>
+				<p className="text-xs text-[var(--vscode-descriptionForeground)]">
 					Client Id is set. To change it, please re-enter the value.
 				</p>
 			)}
@@ -48,10 +119,10 @@ export const SapAiCoreProvider = ({ showModelOptions, isPopup, currentMode }: Sa
 				placeholder="Enter AI Core Client Secret..."
 				style={{ width: "100%" }}
 				type="password">
-				<span style={{ fontWeight: 500 }}>AI Core Client Secret</span>
+				<span className="font-medium">AI Core Client Secret</span>
 			</DebouncedTextField>
 			{apiConfiguration?.sapAiCoreClientSecret && (
-				<p style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)" }}>
+				<p className="text-xs text-[var(--vscode-descriptionForeground)]">
 					Client Secret is set. To change it, please re-enter the value.
 				</p>
 			)}
@@ -61,7 +132,7 @@ export const SapAiCoreProvider = ({ showModelOptions, isPopup, currentMode }: Sa
 				onChange={(value) => handleFieldChange("sapAiCoreBaseUrl", value)}
 				placeholder="Enter AI Core Base URL..."
 				style={{ width: "100%" }}>
-				<span style={{ fontWeight: 500 }}>AI Core Base URL</span>
+				<span className="font-medium">AI Core Base URL</span>
 			</DebouncedTextField>
 
 			<DebouncedTextField
@@ -69,7 +140,7 @@ export const SapAiCoreProvider = ({ showModelOptions, isPopup, currentMode }: Sa
 				onChange={(value) => handleFieldChange("sapAiCoreTokenUrl", value)}
 				placeholder="Enter AI Core Auth URL..."
 				style={{ width: "100%" }}>
-				<span style={{ fontWeight: 500 }}>AI Core Auth URL</span>
+				<span className="font-medium">AI Core Auth URL</span>
 			</DebouncedTextField>
 
 			<DebouncedTextField
@@ -77,37 +148,53 @@ export const SapAiCoreProvider = ({ showModelOptions, isPopup, currentMode }: Sa
 				onChange={(value) => handleFieldChange("sapAiResourceGroup", value)}
 				placeholder="Enter AI Core Resource Group..."
 				style={{ width: "100%" }}>
-				<span style={{ fontWeight: 500 }}>AI Core Resource Group</span>
+				<span className="font-medium">AI Core Resource Group</span>
 			</DebouncedTextField>
 
-			<p
-				style={{
-					fontSize: "12px",
-					marginTop: "5px",
-					color: "var(--vscode-descriptionForeground)",
-				}}>
+			<p className="text-xs mt-1.5 text-[var(--vscode-descriptionForeground)]">
 				These credentials are stored locally and only used to make API requests from this extension.
 				<VSCodeLink
-					href="https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/access-sap-ai-core-via-api"
-					style={{ display: "inline" }}>
+					className="inline"
+					href="https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/access-sap-ai-core-via-api">
 					You can find more information about SAP AI Core API access here.
 				</VSCodeLink>
 			</p>
 
 			{showModelOptions && (
 				<>
-					<ModelSelector
-						label="Model"
-						models={sapAiCoreModels}
-						onChange={(e: any) =>
-							handleModeFieldChange(
-								{ plan: "planModeApiModelId", act: "actModeApiModelId" },
-								e.target.value,
-								currentMode,
-							)
-						}
-						selectedModelId={selectedModelId}
-					/>
+					<div className="flex flex-col gap-1.5">
+						{isLoadingModels ? (
+							<div className="text-xs text-[var(--vscode-descriptionForeground)]">Loading models...</div>
+						) : modelError ? (
+							<div className="text-xs text-[var(--vscode-errorForeground)]">
+								{modelError}
+								<button
+									className="ml-2 text-[11px] px-1.5 py-0.5 bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)] border-none rounded-sm cursor-pointer"
+									onClick={fetchSapAiCoreModels}>
+									Retry
+								</button>
+							</div>
+						) : hasRequiredCredentials ? (
+							<>
+								{deployedModelsArray.length === 0 && (
+									<div className="text-xs text-[var(--vscode-errorForeground)] mb-2">
+										Unable to fetch models from SAP AI Core service instance. Please check your SAP AI Core
+										configuration or ensure your deployments are deployed and running in the service instance
+									</div>
+								)}
+								<SapAiCoreModelPicker
+									onModelChange={handleModelChange}
+									placeholder="Select a model..."
+									sapAiCoreDeployedModels={deployedModelsArray}
+									selectedModelId={selectedModelId || ""}
+								/>
+							</>
+						) : (
+							<div className="text-xs text-[var(--vscode-errorForeground)]">
+								Please configure your SAP AI Core credentials to see available models.
+							</div>
+						)}
+					</div>
 
 					<ModelInfoView isPopup={isPopup} modelInfo={selectedModelInfo} selectedModelId={selectedModelId} />
 				</>

+ 7 - 0
webview-ui/src/setupTests.ts

@@ -17,3 +17,10 @@ Object.defineProperty(window, "matchMedia", {
 		dispatchEvent: vi.fn(),
 	})),
 })
+
+// Mock VSCode API for webview tests
+vi.stubGlobal("acquireVsCodeApi", () => ({
+	postMessage: vi.fn(),
+	getState: vi.fn(),
+	setState: vi.fn(),
+}))