Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface LanguageModelButtonProps {
}

/** Human-readable label for a provider's maturity status, or undefined for stable providers. */
function getStatusLabel(status: LanguageModelButtonProps['status']): string | undefined {
export function getStatusLabel(status: LanguageModelButtonProps['status']): string | undefined {
switch (status) {
case 'preview':
return localize('positron.languageModelButton.status.preview', "Preview");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import { useEffect, useState } from 'react';

import { localize } from '../../../../../nls.js';
import { IDisposable } from '../../../../../base/common/lifecycle.js';
import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js';
import { IPositronAssistantConfigurationService, IPositronLanguageModelSource } from '../../common/interfaces/positronAssistantService.js';
import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js';
import { groupProviders, ProviderSectionId } from '../../common/providerGrouping.js';
import { syncAuthSessions } from '../languageModelSessionSync.js';
import { ProviderListItem } from './providerListItem.js';

interface ProviderListProps {
sources: IPositronLanguageModelSource[];
}

/**
* One-line provider descriptions shown for not-yet-connected providers, keyed by
* provider id. Positron provider metadata does not carry a description yet, so
* this static map mirrors the copy from the provider-configuration design
* prototype. Missing ids simply render no description.
*/
const PROVIDER_DESCRIPTIONS: Record<string, string> = {
'amazon-bedrock': localize('positron.configureLLMProvidersModal.desc.bedrock', "Access Claude and other models via AWS"),
'anthropic-api': localize('positron.configureLLMProvidersModal.desc.anthropic', "Access Claude models directly via Anthropic API"),
'copilot-auth': localize('positron.configureLLMProvidersModal.desc.copilot', "AI models via GitHub Copilot subscription"),
'deepseek-api': localize('positron.configureLLMProvidersModal.desc.deepseek', "Access DeepSeek reasoning models"),
'google': localize('positron.configureLLMProvidersModal.desc.google', "Access Gemini models via Google AI Studio"),
'google-cloud': localize('positron.configureLLMProvidersModal.desc.googleCloud', "Gemini via Google Cloud with enterprise features"),
'ms-foundry': localize('positron.configureLLMProvidersModal.desc.msFoundry', "Access Azure OpenAI and AI Studio models"),
'openai-api': localize('positron.configureLLMProvidersModal.desc.openai', "GPT-4o, o1, and OpenAI-compatible endpoints"),
'posit-ai': localize('positron.configureLLMProvidersModal.desc.positAI', "Managed model service for Positron Desktop"),
'snowflake-cortex': localize('positron.configureLLMProvidersModal.desc.snowflake', "Access LLMs via Snowflake data platform"),
};

/** Localized heading per section id. */
function sectionTitle(id: ProviderSectionId): string {
switch (id) {
case 'connected':
return localize('positron.configureLLMProvidersModal.section.connected', "Connected Providers");
case 'needs-attention':
return localize('positron.configureLLMProvidersModal.section.needsAttention', "Needs Attention");
case 'model-providers':
return localize('positron.configureLLMProvidersModal.section.modelProviders', "Model Providers");
}
}

/** The grouped, sectioned provider list shown in the Configure LLM Providers modal. */
export const ProviderList = (props: ProviderListProps) => {
const services = usePositronReactServicesContext();

// Local copy of sources so live auth/config changes re-render the list.
const [sources, setSources] = useState<IPositronLanguageModelSource[]>(props.sources);

// Re-sync if the caller hands us a new sources array.
useEffect(() => setSources(props.sources), [props.sources]);

// Provider config changes (sign in / out) while the modal is open.
useEffect(() => {
const configService = services.get(IPositronAssistantConfigurationService);
const disposables: IDisposable[] = [];
disposables.push(configService.onChangeProviderConfig(newSource => {
setSources(prev => prev.map(s => s.provider.id === newSource.provider.id ? newSource : s));
}));
return () => disposables.forEach(d => d.dispose());
}, [services]);

// Auth session changes for API-key providers.
useEffect(() => {
const authService = services.get(IAuthenticationService);
const disposable = syncAuthSessions(
authService,
props.sources.map(s => s.provider.id),
(providerId, signedIn) => {
setSources(prev => {
const index = prev.findIndex(s => s.provider.id === providerId);
if (index === -1) {
return prev;
}
const next = [...prev];
next[index] = { ...prev[index], signedIn };
return next;
});
}
);
return () => disposable.dispose();
}, [services, props.sources]);

const sections = groupProviders(sources);

return (
<div className='provider-list'>
{sections.map(section => (
<div key={section.id} className='provider-list-section'>
<label className='provider-list-section-heading'>{sectionTitle(section.id)}</label>
{section.items.map(item => (
<ProviderListItem
key={item.provider.id}
description={PROVIDER_DESCRIPTIONS[item.provider.id]}
section={section.id}
source={item}
/>
))}
</div>
))}

<div className='provider-list-section'>
<label className='provider-list-section-heading'>
{localize('positron.configureLLMProvidersModal.section.custom', "Custom Provider")}
<span className='provider-list-item-badge experimental'>
{localize('positron.configureLLMProvidersModal.badge.experimental', "Experimental")}
</span>
</label>
<p className='provider-list-custom-desc'>
{localize('positron.configureLLMProvidersModal.customDescription', "Works with any OpenAI-compatible API endpoint that uses the /v1/chat/completions endpoint for chat.")}
</p>
{/* Placeholder until the custom-provider flow lands (see #14818). */}
<button className='provider-list-add-custom' type='button'>
<span aria-hidden='true' className='codicon codicon-add' />
{localize('positron.configureLLMProvidersModal.addCustom', "Add custom provider")}
</button>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import { localize } from '../../../../../nls.js';
import { positronClassNames } from '../../../../../base/common/positronUtilities.js';
import { IPositronLanguageModelSource, LanguageModelAutoconfigureType } from '../../common/interfaces/positronAssistantService.js';
import { ProviderSectionId } from '../../common/providerGrouping.js';
import { AuthMethod } from '../types.js';
import { LanguageModelIcon, getStatusLabel } from './languageModelButton.js';

interface ProviderListItemProps {
source: IPositronLanguageModelSource;
/** Which section the row is rendered in; drives badges and the action label. */
section: ProviderSectionId;
/** One-line description shown for not-yet-connected providers. */
description?: string;
/** Invoked by the row's action button. The connect/manage flows (see #14818/#14819) hang off this. */
onAction?: () => void;
}

/** How a connected provider authenticated, shown as a badge. */
function authBadgeLabel(source: IPositronLanguageModelSource): string | undefined {
const autoconfigure = source.defaults.autoconfigure;
if (autoconfigure?.type === LanguageModelAutoconfigureType.EnvVariable && autoconfigure.signedIn) {
return localize('positron.configureLLMProvidersModal.badge.environment', "Environment");
}
if (source.supportedOptions.includes(AuthMethod.OAUTH)) {
return localize('positron.configureLLMProvidersModal.badge.oauth', "OAuth");
}
return undefined;
}

/** The per-section action button label. */
function actionLabel(section: ProviderSectionId): string {
switch (section) {
case 'connected':
return localize('positron.configureLLMProvidersModal.action.edit', "Edit");
case 'needs-attention':
return localize('positron.configureLLMProvidersModal.action.fix', "Fix Connection");
case 'model-providers':
return localize('positron.configureLLMProvidersModal.action.connect', "Connect");
}
}

/**
* A single provider row: a rounded-square provider icon, name, status/maturity
* badges, and a per-section action button (the only interactive element - the
* row itself is not clickable).
*/
export const ProviderListItem = (props: ProviderListItemProps) => {
const { source, section, description, onAction } = props;
const maturityLabel = getStatusLabel(source.provider.status);
const authLabel = section === 'connected' ? authBadgeLabel(source) : undefined;

return (
<div className='provider-list-item'>
<div className='provider-list-item-icon'>
<LanguageModelIcon logoUrl={source.provider.logoUrl} provider={source.provider.id} />
</div>
<div className='provider-list-item-text'>
<div className='provider-list-item-name'>
<span className='provider-list-item-display-name'>{source.provider.displayName}</span>
{maturityLabel && <span className={positronClassNames('provider-list-item-badge', source.provider.status)}>{maturityLabel}</span>}
{authLabel && <span className='provider-list-item-badge environment'>{authLabel}</span>}
{section === 'needs-attention' &&
<span className='provider-list-item-badge error'>
{localize('positron.configureLLMProvidersModal.badge.error', "Error")}
</span>
}
</div>
{section === 'needs-attention' && source.statusMessage &&
<div className='provider-list-item-error'>{source.statusMessage}</div>
}
{section === 'model-providers' && description &&
<div className='provider-list-item-desc'>{description}</div>
}
</div>
<div className='provider-list-item-actions'>
<button className='provider-list-item-action' type='button' onClick={onAction}>
{section === 'model-providers' && <span aria-hidden='true' className='codicon codicon-add' />}
{actionLabel(section)}
</button>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

.provider-list {
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}

.provider-list-section {
display: flex;
flex-direction: column;
gap: 4px;
}

.provider-list-section-heading {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
margin-bottom: 4px;
}

/* Provider row */
.provider-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 4px;
text-align: left;
}

/* Rounded-square provider avatar (36x36, 6px radius per the design). */
.provider-list-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2));
background-color: var(--vscode-editorWidget-background);
flex-shrink: 0;
}

.provider-list-item-icon .language-model.icon {
width: 22px;
height: 22px;
flex-shrink: 0;
}

.provider-list-item-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}

.provider-list-item-name {
display: flex;
align-items: center;
gap: 8px;
}

.provider-list-item-display-name {
font-weight: 600;
}

.provider-list-item-desc,
.provider-list-custom-desc {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}

.provider-list-item-error {
font-size: 12px;
color: var(--vscode-errorForeground);
}

/* Badges */
.provider-list-item-badge {
font-size: 11px;
line-height: 1.4;
padding: 0 6px;
border-radius: 8px;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
white-space: nowrap;
}

.provider-list-item-badge.error {
background-color: transparent;
color: var(--vscode-errorForeground);
border: 1px solid var(--vscode-errorForeground);
}

/* Action buttons */
.provider-list-item-actions {
flex-shrink: 0;
}

.provider-list-item-action,
.provider-list-add-custom {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--vscode-button-border, var(--vscode-contrastBorder, transparent));
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
cursor: pointer;
font-size: 12px;
}

.provider-list-item-action .codicon,
.provider-list-add-custom .codicon {
font-size: 14px;
}

.provider-list-item-action:hover,
.provider-list-add-custom:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}

.provider-list-add-custom {
align-self: flex-start;
margin-top: 4px;
}
Loading
Loading