diff --git a/src/js/api.js b/src/js/api.js
index 445dd83..1ea6c2d 100644
--- a/src/js/api.js
+++ b/src/js/api.js
@@ -341,6 +341,13 @@ const API = {
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
+ // ── Team Providers ──────────────────────
+ teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
+ teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
+ teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
+ teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
+ teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
+
// ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); },
createPreset(preset) { return this._post('/api/v1/presets', preset); },
diff --git a/src/js/app.js b/src/js/app.js
index 0e2347a..cc9e35a 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -11,6 +11,16 @@ const App = {
abortController: null,
serverSettings: {},
+ // Find model by composite ID, with fallback to bare model_id match
+ findModel(id) {
+ if (!id) return null;
+ // Exact match on composite ID (configId:modelId)
+ let m = App.models.find(m => m.id === id);
+ if (m) return m;
+ // Fallback: bare model_id (backward compat with stored settings)
+ return App.models.find(m => m.baseModelId === id) || null;
+ },
+
settings: {
model: '',
stream: true,
@@ -211,8 +221,12 @@ async function fetchModels() {
App.models = (data.models || []).map(m => {
const isPreset = !!m.is_preset;
- const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id);
const baseModelId = m.model_id || m.id;
+ // Presets use preset_id; base models use configId:modelId to avoid
+ // collisions when the same model exists across team/personal/global providers
+ const id = isPreset
+ ? (m.preset_id || m.id)
+ : (m.config_id ? `${m.config_id}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
@@ -225,6 +239,8 @@ async function fetchModels() {
presetScope: m.preset_scope || null,
presetAvatar: m.preset_avatar || null,
presetTeamName: m.preset_team_name || null,
+ source: m.source || (isPreset ? 'preset' : 'global'),
+ teamName: m.preset_team_name || null,
hidden: !isPreset && App.hiddenModels.has(baseModelId),
};
});
@@ -337,7 +353,7 @@ async function sendMessage() {
input.style.height = 'auto';
const selectedId = UI.getModelValue();
- const modelInfo = App.models.find(m => m.id === selectedId);
+ const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
@@ -430,7 +446,7 @@ async function regenerateMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const selectedId = UI.getModelValue();
- const modelInfo = App.models.find(m => m.id === selectedId);
+ const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
@@ -501,7 +517,7 @@ async function submitEdit(messageId, newContent) {
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
- const modelInfo = App.models.find(m => m.id === selectedId);
+ const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
@@ -876,7 +892,7 @@ function initListeners() {
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
});
document.getElementById('settingsModel').addEventListener('change', function() {
- const model = App.models.find(m => m.id === this.value);
+ const model = App.findModel(this.value);
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
@@ -1030,6 +1046,14 @@ function initListeners() {
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
+ document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
+ try {
+ await API.adminUpdateTeam(UI._teamEditId, {
+ settings: JSON.stringify({ allow_team_providers: this.checked })
+ });
+ UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
+ } catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
+ });
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddMemberForm').style.display = 'none';
});
@@ -1100,6 +1124,86 @@ function initListeners() {
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
+ // Team — providers
+ const defaultEndpoints = {
+ openai: 'https://api.openai.com/v1',
+ anthropic: 'https://api.anthropic.com',
+ venice: 'https://api.venice.ai/api/v1',
+ openrouter: 'https://openrouter.ai/api/v1',
+ };
+ document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
+ const form = document.getElementById('settingsTeamAddProvider');
+ form.style.display = form.style.display === 'none' ? '' : 'none';
+ // Populate provider type dropdown
+ const sel = document.getElementById('teamProviderType');
+ if (sel && sel.options.length === 0) {
+ ['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => {
+ const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' };
+ const opt = document.createElement('option');
+ opt.value = p;
+ opt.textContent = labels[p] || p;
+ sel.appendChild(opt);
+ });
+ }
+ });
+ document.getElementById('teamProviderType')?.addEventListener('change', function() {
+ const ep = document.getElementById('teamProviderEndpoint');
+ if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) {
+ ep.value = defaultEndpoints[this.value] || '';
+ }
+ });
+ document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => {
+ document.getElementById('settingsTeamAddProvider').style.display = 'none';
+ });
+ document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => {
+ const teamId = UI._managingTeamId;
+ const name = document.getElementById('teamProviderName').value.trim();
+ const provider = document.getElementById('teamProviderType').value;
+ const endpoint = document.getElementById('teamProviderEndpoint').value.trim();
+ const apiKey = document.getElementById('teamProviderKey').value;
+ const isPrivate = document.getElementById('teamProviderPrivate').checked;
+ if (!name) return UI.toast('Name required', 'error');
+ if (!endpoint) return UI.toast('Endpoint required', 'error');
+ try {
+ await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate });
+ document.getElementById('settingsTeamAddProvider').style.display = 'none';
+ document.getElementById('teamProviderName').value = '';
+ document.getElementById('teamProviderEndpoint').value = '';
+ document.getElementById('teamProviderKey').value = '';
+ document.getElementById('teamProviderPrivate').checked = false;
+ UI.toast('Provider added');
+ await UI.loadTeamManageProviders(teamId);
+ await fetchModels();
+ } catch (e) { UI.toast(e.message, 'error'); }
+ });
+
+ // Team — edit provider
+ document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => {
+ document.getElementById('settingsTeamEditProvider').style.display = 'none';
+ });
+ document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => {
+ const teamId = UI._managingTeamId;
+ const provId = UI._editingTeamProviderId;
+ if (!provId) return;
+ const updates = {};
+ const name = document.getElementById('teamProviderEditName').value.trim();
+ const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim();
+ const apiKey = document.getElementById('teamProviderEditKey').value;
+ const defaultModel = document.getElementById('teamProviderEditDefault').value.trim();
+ const isPrivate = document.getElementById('teamProviderEditPrivate').checked;
+ if (name) updates.name = name;
+ if (endpoint) updates.endpoint = endpoint;
+ if (apiKey) updates.api_key = apiKey;
+ if (defaultModel) updates.model_default = defaultModel;
+ updates.is_private = isPrivate;
+ try {
+ await API.teamUpdateProvider(teamId, provId, updates);
+ document.getElementById('settingsTeamEditProvider').style.display = 'none';
+ UI.toast('Provider updated');
+ await UI.loadTeamManageProviders(teamId);
+ } catch (e) { UI.toast(e.message, 'error'); }
+ });
+
// User — personal presets
var _userPresetForm = null;
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
@@ -1876,6 +1980,37 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
+async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) {
+ try {
+ await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive });
+ UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated');
+ await UI.loadTeamManageProviders(teamId);
+ await fetchModels();
+ } catch (e) { UI.toast(e.message, 'error'); }
+}
+
+async function settingsDeleteTeamProvider(teamId, providerId, name) {
+ if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return;
+ try {
+ await API.teamDeleteProvider(teamId, providerId);
+ UI.toast('Provider deleted');
+ await UI.loadTeamManageProviders(teamId);
+ await fetchModels();
+ } catch (e) { UI.toast(e.message, 'error'); }
+}
+
+function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) {
+ UI._editingTeamProviderId = provId;
+ document.getElementById('settingsTeamAddProvider').style.display = 'none';
+ const form = document.getElementById('settingsTeamEditProvider');
+ form.style.display = '';
+ document.getElementById('teamProviderEditName').value = name;
+ document.getElementById('teamProviderEditEndpoint').value = endpoint;
+ document.getElementById('teamProviderEditKey').value = '';
+ document.getElementById('teamProviderEditDefault').value = defaultModel;
+ document.getElementById('teamProviderEditPrivate').checked = isPrivate;
+}
+
// ── Notes Panel ─────────────────────────────
var _editingNoteId = null;
diff --git a/src/js/ui.js b/src/js/ui.js
index c424410..dd83afd 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -389,7 +389,7 @@ const UI = {
document.getElementById('typingIndicator')?.remove();
const label = modelName || 'Assistant';
- const currentModel = App.models.find(m => m.id === App.settings.model);
+ const currentModel = App.findModel(App.settings.model);
const streamAvatar = currentModel?.presetAvatar || null;
const div = document.createElement('div');
div.className = 'message assistant';
@@ -557,10 +557,14 @@ const UI = {
});
addGroup('🔧 My Presets', personalPresets);
- addGroup('Models', models);
- // Restore selection
- const match = App.models.find(m => m.id === current);
+ // No team model groups — team providers are for preset building only.
+ // Team members access team models through curated presets.
+ addGroup('Models', models.filter(m => m.source !== 'personal'));
+ addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
+
+ // Restore selection (supports both composite and bare model IDs)
+ const match = App.findModel(current);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
} else if (App.models.length > 0) {
@@ -588,7 +592,8 @@ const UI = {
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.presetAvatar ? `

` : '';
- div.innerHTML = `${avatar}
${esc(m.name || m.id)}${m.provider ? `
${esc(m.provider)}` : ''}`;
+ const teamBadge = m.source === 'team' && m.teamName ? `
👥 ${esc(m.teamName)}` : '';
+ div.innerHTML = `${avatar}
${esc(m.name || m.id)}${teamBadge}${m.provider ? `
${esc(m.provider)}` : ''}`;
div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown();
@@ -619,7 +624,7 @@ const UI = {
getSelectedModelCaps() {
const modelId = UI.getModelValue();
if (!modelId) return {};
- const model = App.models.find(m => m.id === modelId);
+ const model = App.findModel(modelId);
// Model in list has resolved caps; fallback to client-side lookup
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
return model.capabilities;
@@ -708,7 +713,7 @@ const UI = {
document.getElementById('sendBtn').disabled = on;
if (on) {
const container = document.getElementById('chatMessages');
- const currentModel = App.models.find(m => m.id === App.settings.model);
+ const currentModel = App.findModel(App.settings.model);
const typingAvatar = currentModel?.presetAvatar || null;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
@@ -921,7 +926,13 @@ const UI = {
document.getElementById('settingsTeamManageName').textContent = teamName;
document.getElementById('settingsTeamAddMember').style.display = 'none';
document.getElementById('settingsTeamAddPreset').style.display = 'none';
- await Promise.all([UI.loadTeamManageMembers(teamId), UI.loadTeamManagePresets(teamId)]);
+ document.getElementById('settingsTeamAddProvider').style.display = 'none';
+ document.getElementById('settingsTeamEditProvider').style.display = 'none';
+ await Promise.all([
+ UI.loadTeamManageMembers(teamId),
+ UI.loadTeamManageProviders(teamId),
+ UI.loadTeamManagePresets(teamId),
+ ]);
},
async loadTeamManageMembers(teamId) {
@@ -948,6 +959,42 @@ const UI = {
} catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
},
+ async loadTeamManageProviders(teamId) {
+ const el = document.getElementById('settingsTeamProviders');
+ if (!el) return;
+ el.innerHTML = '
Loading...
';
+ const addBtn = document.getElementById('settingsTeamAddProviderBtn');
+ try {
+ const resp = await API.teamListProviders(teamId);
+ const provs = resp.providers || [];
+ const allowed = resp.allow_team_providers !== false;
+
+ // Show/hide add button and entire section based on policy
+ if (addBtn) addBtn.style.display = allowed ? '' : 'none';
+
+ if (!allowed && provs.length === 0) {
+ el.innerHTML = '
Team provider management is disabled by your administrator
';
+ return;
+ }
+ el.innerHTML = provs.map(p => {
+ const statusCls = p.is_active ? 'badge-admin' : 'badge-user';
+ const statusLabel = p.is_active ? 'active' : 'inactive';
+ const privateBadge = p.is_private ? '
🔒 private' : '';
+ return `
+
+
${esc(p.name)}
+
${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} ${statusLabel}${privateBadge}
+
+
+
+
+
+
+
`;
+ }).join('') || '
No team providers — add one to give your team access to additional models
';
+ } catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
+ },
+
async loadTeamManagePresets(teamId) {
const el = document.getElementById('settingsTeamPresets');
el.innerHTML = '
Loading...
';
@@ -975,13 +1022,27 @@ const UI = {
const resp = await API.teamListAvailableModels(teamId);
const models = resp.models || [];
sel.innerHTML = '
';
- models.forEach(m => {
- const opt = document.createElement('option');
- opt.value = m.model_id || m.id;
- const vis = m.visibility === 'team' ? ' [team-only]' : '';
- opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
- sel.appendChild(opt);
- });
+
+ // Group by source for clarity
+ const globalModels = models.filter(m => m.source !== 'team');
+ const teamModels = models.filter(m => m.source === 'team');
+
+ const addGroup = (label, items) => {
+ if (!items.length) return;
+ const group = document.createElement('optgroup');
+ group.label = label;
+ items.forEach(m => {
+ const opt = document.createElement('option');
+ opt.value = m.model_id || m.id;
+ const vis = m.visibility === 'team' ? ' [team-only]' : '';
+ opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
+ group.appendChild(opt);
+ });
+ sel.appendChild(group);
+ };
+
+ addGroup('Global Models', globalModels);
+ addGroup('Team Provider Models', teamModels);
} catch (e) {
// Fallback to App.models if team endpoint fails
sel.innerHTML = '
';
@@ -1350,12 +1411,14 @@ const UI = {
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
- // Load team settings for policy checkbox
+ // Load team settings for policy checkboxes
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
- } catch (e) { /* proceed with unchecked default */ }
+ // allow_team_providers defaults to true if not explicitly set
+ document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
+ } catch (e) { /* proceed with defaults */ }
await this.loadTeamMembers(teamId);
},
@@ -1493,7 +1556,8 @@ const UI = {
if (caps.tool_calling) badges.push('
🔧');
if (caps.vision) badges.push('
👁');
if (caps.thinking) badges.push('
💭');
- const src = m.source === 'personal' ? '
personal' : '';
+ const src = m.source === 'personal' ? '
personal'
+ : m.source === 'team' ? `
👥 ${esc(m.team_name || 'team')}` : '';
const hidden = App.hiddenModels.has(mid);
const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';