diff --git a/config/ai_connectors.go b/config/ai_connectors.go index 910e16f..4cc00e5 100644 --- a/config/ai_connectors.go +++ b/config/ai_connectors.go @@ -123,6 +123,16 @@ func RenderManagedAIConnectorsSection(connectors []uicfg.ConnectorRemote, sectio builder.WriteString(strconv.Quote(connector.GCPLocation)) builder.WriteString("\n") } + if connector.AWSAccessKeyID != "" { + builder.WriteString("aws_access_key_id = ") + builder.WriteString(strconv.Quote(connector.AWSAccessKeyID)) + builder.WriteString("\n") + } + if connector.AWSRegion != "" { + builder.WriteString("aws_region = ") + builder.WriteString(strconv.Quote(connector.AWSRegion)) + builder.WriteString("\n") + } if connector.SelectedModel != "" { builder.WriteString("selected_model = ") builder.WriteString(strconv.Quote(connector.SelectedModel)) diff --git a/internal/appui/ui_connectors.go b/internal/appui/ui_connectors.go index 85b70c6..7c884d8 100644 --- a/internal/appui/ui_connectors.go +++ b/internal/appui/ui_connectors.go @@ -62,6 +62,7 @@ func RunUI(c *cli.Context) error { mux.HandleFunc("/api/ui/connectors/settings", srv.handleHelperSettings) mux.HandleFunc("/api/ui/connectors/validate-key", srv.handleValidateKey) mux.HandleFunc("/api/ui/connectors/ollama/models", srv.handleOllamaModels) + mux.HandleFunc("/api/ui/connectors/bedrock/models", srv.handleBedrockModels) mux.HandleFunc("/api/ui/connectors/providers/", srv.handleProviderModels) mux.HandleFunc("/api/ui/usage-chip", srv.handleUsageChip) mux.HandleFunc("/api/ui/connectors/", srv.handleConnectorByID) diff --git a/internal/appui/ui_connectors_handlers.go b/internal/appui/ui_connectors_handlers.go index 7454cf1..32f2aa7 100644 --- a/internal/appui/ui_connectors_handlers.go +++ b/internal/appui/ui_connectors_handlers.go @@ -408,6 +408,26 @@ func (s *connectorManagerServer) handleOllamaModels(w http.ResponseWriter, r *ht writeRawJSON(w, status, respBody) } +func (s *connectorManagerServer) handleBedrockModels(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "failed to read request body") + return + } + + status, respBody, err := s.proxyJSONRequest(http.MethodPost, "/api/v1/aiconnectors/bedrock/models", body) + if err != nil { + writeJSONError(w, http.StatusBadGateway, err.Error()) + return + } + writeRawJSON(w, status, respBody) +} + func (s *connectorManagerServer) handleProviderModels(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -431,4 +451,3 @@ func (s *connectorManagerServer) handleProviderModels(w http.ResponseWriter, r * } writeRawJSON(w, status, respBody) } - diff --git a/internal/staticserve/static/ui-connectors.html b/internal/staticserve/static/ui-connectors.html index 1dc3c32..5b97d69 100644 --- a/internal/staticserve/static/ui-connectors.html +++ b/internal/staticserve/static/ui-connectors.html @@ -423,7 +423,7 @@ } button.tertiary-danger:hover { background: var(--status-error-bg); } button:disabled { opacity: 0.6; cursor: not-allowed; } - .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } .connector-name-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -441,7 +441,7 @@ white-space: nowrap; opacity: 0.92; } - .status { margin-top: 8px; font-size: 13px; } + .status { margin-top: 8px; margin-bottom: 10px; font-size: 13px; } .status.ok { color: var(--status-success-text); } .status.err { color: var(--status-error-text); } .connectors-content { diff --git a/internal/staticserve/static/ui-connectors.js b/internal/staticserve/static/ui-connectors.js index d57073b..700d297 100644 --- a/internal/staticserve/static/ui-connectors.js +++ b/internal/staticserve/static/ui-connectors.js @@ -33,6 +33,7 @@ function App() { const [modelsFetched, setModelsFetched] = useState(false); const [form, setForm] = useState(defaultForm()); const [ollamaModels, setOllamaModels] = useState([]); + const [bedrockModels, setBedrockModels] = useState([]); const [dynamicModels, setDynamicModels] = useState([]); const [apiDefaultModel, setApiDefaultModel] = useState(''); const [session, setSession] = useState(null); @@ -44,7 +45,7 @@ function App() { }, [form.provider_name]); useEffect(() => { - if (!form.provider_name || form.provider_name === 'ollama') { + if (!form.provider_name || form.provider_name === 'ollama' || form.provider_name === 'bedrock') { setDynamicModels([]); setApiDefaultModel(''); return; @@ -93,11 +94,14 @@ function App() { if (form.provider_name === 'ollama' && ollamaModels.length > 0) { return ollamaModels; } + if (form.provider_name === 'bedrock' && bedrockModels.length > 0) { + return bedrockModels.map((model) => model.model_id); + } if (dynamicModels.length > 0) { return dynamicModels; } return selectedProvider.models || []; - }, [selectedProvider, form.provider_name, ollamaModels, dynamicModels]); + }, [selectedProvider, form.provider_name, ollamaModels, bedrockModels, dynamicModels]); const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); @@ -431,6 +435,7 @@ function App() { const connector = connectors.find((entry) => String(entry.id) === String(route.connectorID)); if (connector) { setOllamaModels([]); + setBedrockModels([]); setModelsFetched(false); setFetchingModels(false); setForm({ @@ -442,6 +447,8 @@ function App() { selected_model: connector.selected_model || '', gcp_project_id: connector.gcp_project_id || '', gcp_location: connector.gcp_location || '', + aws_access_key_id: connector.aws_access_key_id || '', + aws_region: connector.aws_region || '', role: connector.role || 'leader', }); setStatus(`Editing connector #${connector.id}`); @@ -452,6 +459,7 @@ function App() { function resetFormState(role) { setForm({ ...defaultForm(), role: role || 'leader' }); setOllamaModels([]); + setBedrockModels([]); setApiDefaultModel(''); setModelsFetched(false); setFetchingModels(false); @@ -471,12 +479,21 @@ function App() { : '', gcp_project_id: '', gcp_location: '', + aws_access_key_id: '', + aws_region: '', })); if (provider.id !== 'ollama') { setOllamaModels([]); + } + if (provider.id !== 'bedrock') { + setBedrockModels([]); + } + if (provider.id !== 'ollama' && provider.id !== 'bedrock') { setApiDefaultModel(''); setModelsFetched(false); } + setStatus(''); + setError(''); } function setFormField(field, value) { @@ -501,8 +518,14 @@ function App() { selected_model: connector.selected_model || '', gcp_project_id: connector.gcp_project_id || '', gcp_location: connector.gcp_location || '', + aws_access_key_id: connector.aws_access_key_id || '', + aws_region: connector.aws_region || '', role: connector.role || 'leader', }); + setOllamaModels([]); + setBedrockModels([]); + setModelsFetched(false); + setFetchingModels(false); setStatus(`Editing connector #${connector.id}`); navigate(`/connectors/edit/${connector.id}`); } @@ -545,6 +568,51 @@ function App() { } } + async function fetchBedrockModels() { + setFetchingModels(true); + setError(''); + setStatus(''); + const awsRegion = (form.aws_region || '').trim(); + if (!awsRegion) { + setError('Region is required for Bedrock model discovery'); + setFetchingModels(false); + return; + } + try { + const response = await api('/api/ui/connectors/bedrock/models', { + method: 'POST', + body: JSON.stringify({ + access_key_id: (form.aws_access_key_id || '').trim(), + secret_access_key: form.api_key || '', + region: awsRegion, + }), + }); + const models = response.models || []; + setBedrockModels(models); + setModelsFetched(true); + const modelIds = models.map((model) => model.model_id); + setForm((prev) => { + if (prev.selected_model && modelIds.includes(prev.selected_model)) { + return prev; + } + return { ...prev, selected_model: '' }; + }); + if (models.length === 0) { + setError('No foundation models found for this region. Request model access in the AWS Bedrock console first.'); + } else { + setStatus(`Fetched ${models.length} Bedrock model(s)`); + } + } catch (err) { + setModelsFetched(false); + if (await handleAuthError(err)) { + return; + } + setError(err.message || String(err)); + } finally { + setFetchingModels(false); + } + } + async function saveConnector() { const provider = providers.find((entry) => entry.id === form.provider_name) || providers[0]; const connectorName = (form.connector_name || '').trim(); @@ -582,6 +650,27 @@ function App() { return; } + if (!selectedModel) { + setError('Please select a model'); + setStatus(''); + return; + } + } else if (provider.id === 'bedrock') { + if (!(form.aws_access_key_id || '').trim()) { + setError('AWS Access Key ID is required'); + setStatus(''); + return; + } + if (!apiKey) { + setError('AWS Secret Access Key is required'); + setStatus(''); + return; + } + if (!(form.aws_region || '').trim()) { + setError('Region is required'); + setStatus(''); + return; + } if (!selectedModel) { setError('Please select a model'); setStatus(''); @@ -609,6 +698,8 @@ function App() { model: selectedModel || apiDefaultModel || undefined, gcp_project_id: (form.gcp_project_id || '').trim() || undefined, gcp_location: (form.gcp_location || '').trim() || undefined, + aws_access_key_id: (form.aws_access_key_id || '').trim() || undefined, + aws_region: (form.aws_region || '').trim() || undefined, }), }); @@ -627,6 +718,8 @@ function App() { selected_model: selectedModel || apiDefaultModel || undefined, gcp_project_id: (form.gcp_project_id || '').trim() || undefined, gcp_location: (form.gcp_location || '').trim() || undefined, + aws_access_key_id: (form.aws_access_key_id || '').trim() || undefined, + aws_region: (form.aws_region || '').trim() || undefined, display_order: 0, role: form.role || 'leader', }; @@ -759,6 +852,12 @@ function App() { return !apiKey || !gcpProjectID || !gcpLocation; } + if (provider.id === 'bedrock') { + const awsAccessKeyID = (form.aws_access_key_id || '').trim(); + const awsRegion = (form.aws_region || '').trim(); + return !apiKey || !awsAccessKeyID || !awsRegion || !selectedModel; + } + if (!apiKey) { return true; } @@ -838,6 +937,7 @@ function App() { onProviderChange=${setProvider} onFieldChange=${setFormField} onFetchOllamaModels=${fetchOllamaModels} + onFetchBedrockModels=${fetchBedrockModels} onSave=${saveConnector} onGenerateName=${generateConnectorName} onCancel=${() => { diff --git a/internal/staticserve/static/ui-connectors/pages/ConnectorFormPage.js b/internal/staticserve/static/ui-connectors/pages/ConnectorFormPage.js index d2ef6f3..8263fec 100644 --- a/internal/staticserve/static/ui-connectors/pages/ConnectorFormPage.js +++ b/internal/staticserve/static/ui-connectors/pages/ConnectorFormPage.js @@ -23,6 +23,7 @@ export function ConnectorFormPage({ onProviderChange, onFieldChange, onFetchOllamaModels, + onFetchBedrockModels, onSave, onGenerateName, onCancel, @@ -38,10 +39,13 @@ export function ConnectorFormPage({ const isOllama = form.provider_name === 'ollama'; const isGeminiEnterprise = form.provider_name === 'gemini-enterprise'; + const isBedrock = form.provider_name === 'bedrock'; const showBaseURL = Boolean(selectedProvider.requiresBaseURL); const connectorName = (form.connector_name || '').trim(); const apiKey = (form.api_key || '').trim(); const baseURL = (form.base_url || '').trim(); + const awsAccessKeyID = (form.aws_access_key_id || '').trim(); + const awsRegion = (form.aws_region || '').trim(); let hasValidBaseURL = true; if (showBaseURL) { @@ -86,12 +90,93 @@ export function ConnectorFormPage({ const hasClientValidationError = !connectorName || (isGeminiEnterprise && (!apiKey || !form.gcp_project_id || !form.gcp_location)) || - (!isOllama && !isGeminiEnterprise && !apiKey) || + (isBedrock && (!apiKey || !awsAccessKeyID || !awsRegion || !form.selected_model)) || + (!isOllama && !isGeminiEnterprise && !isBedrock && !apiKey) || (showBaseURL && (!baseURL || !hasValidBaseURL)); const fetchModelsDisabled = fetchingModels || !baseURL || !hasValidBaseURL; + const bedrockFetchDisabled = fetchingModels || !awsRegion; const effectiveSaveDisabled = saving || saveDisabled || hasClientValidationError; + // Custom searchable model dropdown, shared between the generic provider path and Bedrock + // (Bedrock's model list comes from a live "Fetch Models" call instead of the static + // per-provider catalog, but once fetched it renders through this same picker). + const renderModelDropdown = () => html` +