name.startsWith(`model_${index}_`))
+ .map(([name, message]) => [name.slice(`model_${index}_`.length), message]))}
+ onChange={(nextModel) => setModels(models.map((item, at) => at === index ? nextModel : item))}
+ onIconBusyChange={setIconBusy}
+ /> : null}
{canTest && !embeddingOnly && modelPublishesCapability(model, 'chat') ? (
- void runModelTest(model, 'chat')}>
+ void runModelTest(model, 'chat')}>
Test chat
) : null}
@@ -1632,7 +1659,11 @@ export function ModelConnectionsManager({
{help ? {help}
: null}
- Configure credentials once, then choose independent chat, image and embedding defaults. Images and embeddings remain available when chat uses its classic endpoint.
+
+ {adapter.scope.kind === 'personal'
+ ? 'Save each connection individually and enable the models your agents and workflows should use. Personal connections do not change global model defaults.'
+ : 'Configure credentials once, then choose independent chat, image and embedding defaults. Images and embeddings remain available when chat uses its classic endpoint.'}
+
{adapter.canEditNetworkPolicy && !loading ? : null}
{adapter.showMigrationNotices ? [migration, embeddingMigration].map((notice, index) => notice?.message ? (
diff --git a/application/v2_ui/src/components/workspace/ConnectionMetadataFields.tsx b/application/v2_ui/src/components/workspace/ConnectionMetadataFields.tsx
new file mode 100644
index 000000000..460d50bce
--- /dev/null
+++ b/application/v2_ui/src/components/workspace/ConnectionMetadataFields.tsx
@@ -0,0 +1,168 @@
+// ConnectionMetadataFields.tsx
+
+import { useEffect, useId, useRef, useState } from 'react';
+import {
+ CAPACITY_FIELDS, CAPACITY_IDENTITY_FIELDS, TOKEN_ACCOUNTING_OPTIONS, TOKEN_PROVIDER_OPTIONS,
+ type ConnectionCapacity,
+} from '../../lib/connectionMetadata';
+import type { ConnectionModel } from '../../lib/modelConnections';
+import { isResourceIconImage, loadResourceIconNames, resizeResourceIcon } from '../../lib/resourceIcons';
+import { GlassButton } from '../ui/primitives';
+
+const inputClass = 'w-full min-w-0 rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus:border-accent focus:outline-none disabled:opacity-60';
+const defaultIcons = ['bi-stars', 'bi-robot', 'bi-lightbulb', 'bi-code-square', 'bi-search', 'bi-database', 'bi-globe'];
+
+export function ConnectionCapacityFields({ record, model = false, disabled, errors, onChange }: {
+ record: ConnectionCapacity;
+ model?: boolean;
+ disabled: boolean;
+ errors: Record;
+ onChange: (key: keyof ConnectionCapacity, value: string | null) => void;
+}) {
+ const id = useId();
+ const fields = [...(model ? CAPACITY_IDENTITY_FIELDS : []), ...CAPACITY_FIELDS];
+ const hasError = [...fields.map(({ key }) => key), 'tokenLimitProvider', 'outputTokenAccounting']
+ .some((key) => errors[key]);
+ return (
+
+
+ Advanced {model ? 'model' : 'endpoint'} capacity
+
+
+ {model ? 'Each blank value inherits independently from the endpoint, then the exact catalog model.'
+ : 'Defaults for models on this endpoint. Blank values inherit from the exact catalog model; model overrides take precedence.'}
+ {' '}Use verified specifications, not a guessed capacity. Independent input and output maxima do not have to fit simultaneously in the context window.
+
+
+ {fields.map(({ key, label, help }) => (
+
+
{label}
+
field.key === key) ? 'numeric' : 'text'}
+ autoComplete="off" placeholder="Inherit" disabled={disabled}
+ value={record[key] ?? ''} aria-invalid={Boolean(errors[key])}
+ aria-describedby={`${id}-${key}-help ${id}-${key}-error`}
+ onChange={(event) => onChange(key, event.target.value || null)}
+ />
+
{help}
+
{errors[key]}
+
+ ))}
+ {([
+ ['tokenLimitProvider', 'Token limit provider', TOKEN_PROVIDER_OPTIONS],
+ ['outputTokenAccounting', 'Output token accounting', TOKEN_ACCOUNTING_OPTIONS],
+ ] as const).map(([key, label, options]) => (
+
+
{label}
+
onChange(key, event.target.value || null)}>
+ {record[key] && !options.some((option) => option === record[key]) ? Invalid saved value - choose an option : null}
+ {options.map((option) => (
+
+ {option === '' ? 'Inherit' : option === 'total_generation' ? 'Total generation (including reasoning)'
+ : option === 'visible_only' ? 'Visible output only' : option.replaceAll('_', ' ')}
+
+ ))}
+
+
{errors[key]}
+
+ ))}
+
+
+ );
+}
+
+export function ConnectionModelMetadata({ model, disabled, errors, onChange, onIconBusyChange }: {
+ model: ConnectionModel;
+ disabled: boolean;
+ errors: Record;
+ onChange: (model: ConnectionModel) => void;
+ onIconBusyChange: (busy: boolean) => void;
+}) {
+ const id = useId();
+ const [icons, setIcons] = useState(defaultIcons);
+ const [search, setSearch] = useState('');
+ const [iconError, setIconError] = useState(null);
+ const [loadingIcons, setLoadingIcons] = useState(false);
+ const uploadSequence = useRef(0);
+ useEffect(() => () => { uploadSequence.current += 1; }, []);
+ const icon = model.icon;
+ const selected = icon?.kind === 'bootstrap' ? icon.value : '';
+ const visible = icons.filter((name) => name.includes(search.toLowerCase()));
+
+ const loadIcons = async () => {
+ setIconError(null);
+ setLoadingIcons(true);
+ try { setIcons(await loadResourceIconNames()); } catch (cause) {
+ setIconError(cause instanceof Error ? cause.message : 'Could not load the local icon catalogue.');
+ } finally { setLoadingIcons(false); }
+ };
+ const upload = async (file: File) => {
+ const sequence = ++uploadSequence.current;
+ setIconError(null);
+ onIconBusyChange(true);
+ try {
+ const value = await resizeResourceIcon(file);
+ if (sequence === uploadSequence.current) onChange({ ...model, icon: { kind: 'image', value, mime_type: 'image/png' } });
+ } catch (cause) {
+ if (sequence === uploadSequence.current) setIconError(cause instanceof Error ? cause.message : 'Could not load the icon image.');
+ } finally {
+ if (sequence === uploadSequence.current) onIconBusyChange(false);
+ }
+ };
+
+ return (
+
+
+ Model description
+
+
+
Response length
+
onChange({ ...model, responseLength: event.target.value || null })} />
+
Optional per-request generation allowance for standard chat, not model capacity.
+
{errors.responseLength}
+
+
onChange({ ...model, [key]: value })} />
+
+ Model icon
+
+ {icon?.kind === 'image' && isResourceIconImage(icon.value) ?
: null}
+ {selected && /^bi-[a-z0-9][a-z0-9-]{0,80}$/.test(selected) ?
: null}
+
onChange({ ...model, icon: {} })}>Use default icon
+
+ Search Bootstrap icons
+ setSearch(event.target.value)} />
+ Bootstrap icon
+ onChange({ ...model, icon: event.target.value ? { kind: 'bootstrap', value: event.target.value } : {} })}>
+ Default icon
+ {selected && !visible.includes(selected) ? {selected} : null}
+ {visible.map((name) => {name} )}
+
+ void loadIcons()}>
+ {loadingIcons ? 'Loading icons...' : 'Load all local icons'}
+
+ Upload model icon
+ {
+ const file = event.target.files?.[0];
+ if (file) void upload(file);
+ event.target.value = '';
+ }} />
+ PNG or JPEG, resized to at most 128 by 128 pixels and a 350000-character PNG payload.
+ {iconError || errors.icon ? {iconError || errors.icon}
: null}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/workspaceAgents/AgentIdentityFields.tsx b/application/v2_ui/src/components/workspaceAgents/AgentIdentityFields.tsx
index c5634cdb5..61909eb1b 100644
--- a/application/v2_ui/src/components/workspaceAgents/AgentIdentityFields.tsx
+++ b/application/v2_ui/src/components/workspaceAgents/AgentIdentityFields.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { Bot } from 'lucide-react';
-import { api } from '../../lib/apiClient';
+import { loadResourceIconNames, resizeResourceIcon } from '../../lib/resourceIcons';
import type { AgentConfiguration, AgentEditorOptions, WorkspaceAgentType } from '../../lib/workspaceAuthoring';
import {
AGENT_INPUT_CLASS, AGENT_TYPE_LABELS, changeAgentType, clearAgentDraftFields, isAgentIconImage, renameAgentDraft,
@@ -25,28 +25,6 @@ export function AgentIcon({ icon }: { icon: AgentConfiguration['icon'] }) {
return ;
}
-async function resizeAgentIcon(file: File): Promise {
- if (!['image/png', 'image/jpeg'].includes(file.type)) throw new Error('Choose a PNG or JPEG image.');
- const url = URL.createObjectURL(file);
- try {
- const image = new Image();
- image.src = url;
- await image.decode();
- const scale = Math.min(1, 128 / Math.max(image.width, image.height));
- const canvas = document.createElement('canvas');
- canvas.width = Math.max(1, Math.round(image.width * scale));
- canvas.height = Math.max(1, Math.round(image.height * scale));
- const context = canvas.getContext('2d');
- if (!context) throw new Error('This browser cannot resize the icon.');
- context.drawImage(image, 0, 0, canvas.width, canvas.height);
- const data = canvas.toDataURL('image/png');
- if (!isAgentIconImage(data)) throw new Error('The resized icon exceeds the 350000-character limit.');
- return data;
- } finally {
- URL.revokeObjectURL(url);
- }
-}
-
export function AgentIdentityFields({
draft, setDraft, options, isNew, onIconBusyChange,
}: {
@@ -63,10 +41,7 @@ export function AgentIdentityFields({
const loadIcons = async () => {
setIconError(null);
try {
- const css = await api.get('/static/css/bootstrap-icons.css');
- const available = [...new Set([...css.matchAll(/\.bi-([a-z0-9][a-z0-9-]*)::before/g)].map((match) => `bi-${match[1]}`))].sort();
- if (!available.length) throw new Error('The local icon catalogue could not be read.');
- setIcons(available);
+ setIcons(await loadResourceIconNames());
} catch (error) {
setIconError(error instanceof Error ? error.message : 'Could not load the local icon catalogue.');
}
@@ -78,7 +53,7 @@ export function AgentIdentityFields({
onIconBusyChange?.(true);
setIconError(null);
try {
- const value = await resizeAgentIcon(file);
+ const value = await resizeResourceIcon(file);
if (sequence === uploadSequence.current) setDraft((current) => ({ ...current, icon: { kind: 'image', value, mime_type: 'image/png' } }));
} catch (error) {
if (sequence === uploadSequence.current) setIconError(error instanceof Error ? error.message : 'Could not load the image.');
diff --git a/application/v2_ui/src/lib/connectionMetadata.ts b/application/v2_ui/src/lib/connectionMetadata.ts
new file mode 100644
index 000000000..9d5ff2ed6
--- /dev/null
+++ b/application/v2_ui/src/lib/connectionMetadata.ts
@@ -0,0 +1,89 @@
+// connectionMetadata.ts
+
+import { isResourceIconImage } from './resourceIcons';
+import type { ResourceIcon } from './resourceIcons';
+
+export interface ConnectionCapacity {
+ contextWindow?: number | string | null;
+ inputTokenLimit?: number | string | null;
+ outputTokenLimit?: number | string | null;
+ catalogModelId?: string | null;
+ modelVersion?: string | null;
+ tokenLimitProvider?: string | null;
+ outputTokenAccounting?: string | null;
+}
+
+export const CAPACITY_FIELDS = [
+ { key: 'contextWindow', label: 'Context window (tokens)', help: 'Verified shared total for input and generation together.' },
+ { key: 'inputTokenLimit', label: 'Input token limit (tokens)', help: 'An independent input ceiling, not the shared context window.' },
+ { key: 'outputTokenLimit', label: 'Output token limit (tokens)', help: 'The hard provider output ceiling, not the requested response length.' },
+] as const;
+
+export const CAPACITY_IDENTITY_FIELDS = [
+ { key: 'catalogModelId', label: 'Catalog model ID', help: 'The published model ID, not a display name or deployment alias.' },
+ { key: 'modelVersion', label: 'Model version', help: 'The exact deployed snapshot, not the endpoint API version.' },
+] as const;
+
+export const TOKEN_PROVIDER_OPTIONS = ['', 'azure', 'openai', 'anthropic', 'google', 'vertex', 'xai', 'publisher', 'custom'] as const;
+export const TOKEN_ACCOUNTING_OPTIONS = ['', 'total_generation', 'visible_only', 'unknown'] as const;
+
+export function tokenCapacity(value: unknown, label: string): number | null {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return null;
+ const normalized = typeof value === 'string' && /^[0-9]+$/.test(value.trim()) ? Number(value.trim()) : value;
+ if (typeof normalized !== 'number' || !Number.isSafeInteger(normalized) || normalized <= 0) {
+ throw new Error(`${label} must be a positive whole number no greater than 9007199254740991, or blank to inherit.`);
+ }
+ return normalized;
+}
+
+export function capacityErrors(record: ConnectionCapacity, model = false): Record {
+ const errors: Record = {};
+ for (const { key, label } of CAPACITY_FIELDS) {
+ try { tokenCapacity(record[key], label); } catch (cause) {
+ if (!(cause instanceof Error)) throw cause;
+ errors[key] = cause.message;
+ }
+ }
+ const fields = [...(model ? CAPACITY_IDENTITY_FIELDS : []),
+ { key: 'tokenLimitProvider', label: 'Token limit provider' },
+ { key: 'outputTokenAccounting', label: 'Output token accounting' }] as const;
+ for (const { key, label } of fields) {
+ const value = record[key];
+ if (value !== null && value !== undefined && (typeof value !== 'string'
+ || value.trim().length > 256 || /[\u0000-\u001f]/.test(value.trim()))) {
+ errors[key] = `${label} must be text of at most 256 characters without control characters.`;
+ }
+ }
+ const provider = typeof record.tokenLimitProvider === 'string' ? record.tokenLimitProvider.trim() : record.tokenLimitProvider ?? '';
+ const accounting = typeof record.outputTokenAccounting === 'string' ? record.outputTokenAccounting.trim() : record.outputTokenAccounting ?? '';
+ if (!TOKEN_PROVIDER_OPTIONS.some((value) => value === provider)) {
+ errors.tokenLimitProvider = 'Choose a supported token-limit provider.';
+ }
+ if (!TOKEN_ACCOUNTING_OPTIONS.some((value) => value === accounting)) {
+ errors.outputTokenAccounting = 'Choose a supported output-token accounting mode.';
+ }
+ return errors;
+}
+
+export function capacityPayload(record: ConnectionCapacity, model = false): Record {
+ const errors = capacityErrors(record, model);
+ if (Object.keys(errors).length) throw new Error(Object.values(errors)[0]);
+ const payload: Record = {};
+ for (const { key, label } of CAPACITY_FIELDS) {
+ if (key in record) payload[key] = tokenCapacity(record[key], label);
+ }
+ const fields = [...(model ? CAPACITY_IDENTITY_FIELDS : []),
+ { key: 'tokenLimitProvider' }, { key: 'outputTokenAccounting' }] as const;
+ for (const { key } of fields) {
+ if (key in record) payload[key] = record[key]?.trim() || null;
+ }
+ return payload;
+}
+
+export function validModelIcon(icon: unknown): icon is ResourceIcon | Record {
+ if (typeof icon !== 'object' || icon === null || Array.isArray(icon)) return false;
+ if (!Object.keys(icon).length) return true;
+ if (!('kind' in icon) || !('value' in icon) || typeof icon.value !== 'string') return false;
+ return icon.kind === 'image' ? isResourceIconImage(icon.value)
+ : icon.kind === 'bootstrap' && /^bi-[a-z0-9][a-z0-9-]{0,80}$/.test(icon.value);
+}
diff --git a/application/v2_ui/src/lib/modelConnections.ts b/application/v2_ui/src/lib/modelConnections.ts
index 8e989c591..c00095c9b 100644
--- a/application/v2_ui/src/lib/modelConnections.ts
+++ b/application/v2_ui/src/lib/modelConnections.ts
@@ -24,6 +24,8 @@ import {
import { isRecord } from './workspaceAuthoring';
import { requireWorkspaceId } from './workspaceContext';
import type { RebaseField } from './rebaseDraft';
+import { capacityErrors, capacityPayload, tokenCapacity, validModelIcon, type ConnectionCapacity } from './connectionMetadata';
+import type { ResourceIcon } from './resourceIcons';
/* -------------------------------------------------------------------------- */
/* Types */
@@ -97,7 +99,7 @@ export interface ConnectionMigrationNotice {
imported_connections?: number;
}
-export interface ConnectionModel {
+export interface ConnectionModel extends ConnectionCapacity {
catalogProfileId?: string;
id?: string;
deploymentName?: string;
@@ -106,7 +108,8 @@ export interface ConnectionModel {
description?: string;
enabled?: boolean;
isDiscovered?: boolean;
- responseLength?: number | string;
+ responseLength?: number | string | null;
+ icon?: ResourceIcon | Record;
supportsChat?: boolean;
supportsImageGeneration?: boolean;
supportsEmbeddings?: boolean;
@@ -151,7 +154,7 @@ export interface ConnectionIdentityHeader {
value_type?: string;
}
-export interface ModelConnection {
+export interface ModelConnection extends ConnectionCapacity {
id: string;
name?: string;
provider?: string;
@@ -795,6 +798,49 @@ export function buildConnectionPayload(connection: ModelConnection): Record {
+ const errors = capacityErrors(connection);
+ (connection.models ?? []).forEach((model, index) => {
+ const modelErrors = capacityErrors(model, true);
+ try { tokenCapacity(model.responseLength, 'Response length'); } catch (cause) {
+ if (!(cause instanceof Error)) throw cause;
+ modelErrors.responseLength = cause.message;
+ }
+ if (model.icon !== undefined && !validModelIcon(model.icon)) {
+ modelErrors.icon = 'Choose a local Bootstrap icon or a bounded PNG/JPEG image.';
+ }
+ for (const [key, value] of Object.entries(modelErrors)) errors[`model_${index}_${key}`] = value;
+ });
+ return errors;
+}
+
+export function buildPersonalConnectionPayload(connection: ModelConnection): Record {
+ const errors = validateAdvancedConnection(connection);
+ if (Object.keys(errors).length) throw new Error(Object.values(errors)[0]);
+ const payload = buildConnectionPayload(connection);
+ return {
+ ...payload,
+ ...capacityPayload(connection),
+ models: (connection.models ?? []).map((model) => ({
+ ...model,
+ ...capacityPayload(model, true),
+ ...(connection.provider !== 'custom' ? { deploymentName: String(model.deploymentName ?? '').trim() } : {}),
+ ...('responseLength' in model ? { responseLength: tokenCapacity(model.responseLength, 'Response length') } : {}),
+ displayName: String(model.displayName || connectionRequestModel(connection, model)).trim(),
+ enabled: model.enabled !== false,
+ })),
+ };
+}
+
+export function personalTestBlockedReason(draft: ModelConnection, saved: ModelConnection): string | null {
+ if (!saved.id) return null;
+ if (Object.keys(validateAdvancedConnection(draft)).length || Object.keys(validateAdvancedConnection(saved)).length
+ || JSON.stringify(buildPersonalConnectionPayload(draft)) !== JSON.stringify(buildPersonalConnectionPayload(toEditableConnection(saved)))) {
+ return 'Save changes before discovering or testing models. These operations use the saved endpoint and enabled models.';
+ }
+ return null;
+}
+
/**
* Merge discovered deployments into the model list already on the connection.
*
@@ -1146,6 +1192,7 @@ export const saveDefaultModel = (selection: DefaultModelSelection) =>
export type ModelConnectionsScope =
| { kind: 'admin' }
+ | { kind: 'personal' }
| { kind: 'group'; id: string; name: string };
export const ENDPOINT_OPERATIONS = ['create', 'edit', 'delete', 'enable', 'test'] as const;
@@ -1193,6 +1240,8 @@ export class EndpointInUseError extends Error {
export interface ModelConnectionsAdapter {
scope: ModelConnectionsScope;
+ advancedEditing?: boolean;
+ requiresSavedConfigurationTests?: boolean;
/** Whether the "Test connection" affordance renders (/api/models/test-connection, admin only). */
canTestConnection: boolean;
/** Whether the image and embedding capability tests render (admin-settings route, admin only). */
@@ -1278,6 +1327,9 @@ export function endpointOperationAllowed(
if (scope.kind === 'admin') {
return true;
}
+ if (scope.kind === 'personal') {
+ return supported.has(operation);
+ }
if (!supported.has(operation)) {
return false;
}
@@ -1287,6 +1339,121 @@ export function endpointOperationAllowed(
return Array.isArray(connection?.endpoint_actions) && connection.endpoint_actions.includes(operation);
}
+function hasOptionalStrings(value: Record, keys: string[]): boolean {
+ return keys.every((key) => value[key] === undefined || value[key] === null || typeof value[key] === 'string');
+}
+
+function personalModel(value: unknown): value is ConnectionModel {
+ return isRecord(value)
+ && hasOptionalStrings(value, ['id', 'deploymentName', 'modelName', 'displayName', 'description', 'catalogProfileId'])
+ && ['enabled', 'supportsChat', 'supportsImageGeneration', 'supportsEmbeddings', 'supportsVision']
+ .every((key) => value[key] === undefined || typeof value[key] === 'boolean')
+ && ['embedding_config', 'embedding_policy', 'capability_status']
+ .every((key) => value[key] === undefined || isRecord(value[key]))
+ && (value.enabled_capabilities === undefined
+ || (Array.isArray(value.enabled_capabilities) && value.enabled_capabilities.every((entry) => typeof entry === 'string')));
+}
+
+function personalEndpoint(value: unknown): ModelConnection {
+ if (!isRecord(value) || typeof value.id !== 'string' || !value.id.trim()
+ || !hasOptionalStrings(value, ['name', 'provider', 'api_type'])
+ || ['connection', 'auth', 'management', 'identity_header'].some((key) =>
+ value[key] !== undefined && !isRecord(value[key]))
+ || ['enabled', 'has_api_key', 'has_client_secret', 'has_bearer_token'].some((key) =>
+ value[key] !== undefined && typeof value[key] !== 'boolean')
+ || (value.models !== undefined && (!Array.isArray(value.models) || !value.models.every(personalModel)))) {
+ throw new Error('A personal model endpoint response was malformed. Refresh and try again.');
+ }
+ requireWorkspaceId(value.id);
+ const textFields = {
+ connection: ['endpoint', 'openai_api_version', 'api_version', 'anthropic_version', 'url_mode',
+ 'client_cert_path', 'client_key_path', 'project_api_version', 'project_name'],
+ auth: ['type', 'managed_identity_type', 'managed_identity_client_id', 'tenant_id', 'client_id',
+ 'api_key', 'client_secret', 'bearer_token', 'token_url', 'scope', 'api_key_header',
+ 'api_key_prefix', 'management_cloud', 'custom_authority', 'foundry_scope'],
+ management: ['subscription_id', 'resource_group'],
+ identity_header: ['mode', 'header_name', 'value_type'],
+ };
+ for (const [key, fields] of Object.entries(textFields)) {
+ const block = value[key];
+ if (isRecord(block) && !hasOptionalStrings(block, fields)) {
+ throw new Error('A personal model endpoint response was malformed. Refresh and try again.');
+ }
+ }
+ return value as ModelConnection;
+}
+
+function isCustomApiDescriptor(value: unknown): value is CustomApiTypeDescriptor {
+ return isRecord(value) && typeof value.value === 'string'
+ && ['openai', 'azure_openai', 'anthropic', 'gemini'].includes(value.value)
+ && ['label', 'protocol', 'urlPolicy', 'versionField', 'defaultVersion',
+ 'defaultApiKeyHeader', 'defaultApiKeyPrefix', 'description'].every((key) => typeof value[key] === 'string')
+ && typeof value.usesModelName === 'boolean' && typeof value.requiresApiVersion === 'boolean'
+ && Array.isArray(value.authTypes) && value.authTypes.every((entry) => typeof entry === 'string');
+}
+
+export function createPersonalModelConnectionsAdapter(): ModelConnectionsAdapter {
+ const base = '/api/user/model-endpoints';
+ const endpointUrl = (id: string) => `${base}/${encodeURIComponent(requireWorkspaceId(id))}`;
+ const readEndpoint = (response: unknown) => {
+ if (!isRecord(response)) throw new Error('The personal model endpoint response was malformed.');
+ return { endpoint: personalEndpoint(response.endpoint) };
+ };
+ return {
+ scope: { kind: 'personal' },
+ advancedEditing: true,
+ requiresSavedConfigurationTests: true,
+ canTestConnection: false,
+ canTestCapabilities: false,
+ canEditNetworkPolicy: false,
+ showMigrationNotices: false,
+ canCreate: true,
+ supported: new Set(ENDPOINT_OPERATIONS),
+ allows: () => true,
+ list: async (signal) => {
+ const response = await api.get(base, signal);
+ if (!isRecord(response) || !Array.isArray(response.endpoints)
+ || !Array.isArray(response.custom_api_types) || !response.custom_api_types.every(isCustomApiDescriptor)) {
+ throw new Error('The personal model endpoint list was malformed. Refresh and try again.');
+ }
+ const endpoints = response.endpoints.map(personalEndpoint);
+ if (new Set(endpoints.map((endpoint) => endpoint.id)).size !== endpoints.length) {
+ throw new Error('The personal model endpoint list contained duplicate IDs. Refresh and try again.');
+ }
+ return { endpoints, custom_api_types: response.custom_api_types };
+ },
+ create: async (payload) => readEndpoint(await api.post(base, payload)),
+ update: async (connection, payload) => readEndpoint(await api.patch(endpointUrl(connection.id), payload)),
+ remove: async (connection) => {
+ const response = await api.delete(endpointUrl(connection.id));
+ if (!isRecord(response) || response.success !== true) {
+ throw new Error('The endpoint deletion could not be confirmed. Refresh before trying again.');
+ }
+ return { success: true };
+ },
+ discover: async (payload) => {
+ const response = await api.post('/api/user/models/fetch', payload);
+ if (!isRecord(response) || !Array.isArray(response.models) || !response.models.every((model) =>
+ personalModel(model) && ['deploymentName', 'deployment'].some((key) =>
+ typeof model[key] === 'string' && model[key].trim()))) {
+ throw new Error('The model discovery response was malformed. Try again.');
+ }
+ return { models: response.models };
+ },
+ testConnection: () => { throw new Error('Connection testing is not available for personal endpoints.'); },
+ testConnectionModel: async (payload, model) => {
+ const response = await api.post('/api/user/models/test-model', {
+ ...payload, model: typeof model === 'string' ? { deploymentName: model } : model,
+ });
+ if (!isRecord(response) || response.success !== true) {
+ throw new Error('The model did not confirm a successful chat response.');
+ }
+ return { success: true };
+ },
+ onChanged: () => {},
+ };
+}
+
function groupEndpointsUrl(groupId: string, endpointId?: string): string {
const base = `/api/groups/${encodeURIComponent(requireWorkspaceId(groupId))}/model-endpoints`;
return endpointId ? `${base}/${encodeURIComponent(requireWorkspaceId(endpointId))}` : base;
diff --git a/application/v2_ui/src/lib/resourceIcons.ts b/application/v2_ui/src/lib/resourceIcons.ts
new file mode 100644
index 000000000..460d457ac
--- /dev/null
+++ b/application/v2_ui/src/lib/resourceIcons.ts
@@ -0,0 +1,44 @@
+// resourceIcons.ts
+
+import { api } from './apiClient';
+
+export interface ResourceIcon {
+ kind: 'bootstrap' | 'image';
+ value: string;
+ mime_type?: string;
+}
+
+export function isResourceIconImage(value: unknown): value is string {
+ return typeof value === 'string' && value.length <= 350000
+ && /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/.test(value);
+}
+
+export async function loadResourceIconNames(): Promise {
+ const css = await api.get('/static/css/bootstrap-icons.css');
+ const names = [...new Set([...css.matchAll(/\.bi-([a-z0-9][a-z0-9-]*)::before/g)]
+ .map((match) => `bi-${match[1]}`))].sort();
+ if (!names.length) throw new Error('The local icon catalogue could not be read.');
+ return names;
+}
+
+export async function resizeResourceIcon(file: File): Promise {
+ if (!['image/png', 'image/jpeg'].includes(file.type)) throw new Error('Choose a PNG or JPEG image.');
+ const url = URL.createObjectURL(file);
+ try {
+ const image = new Image();
+ image.src = url;
+ await image.decode();
+ const scale = Math.min(1, 128 / Math.max(image.width, image.height));
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.max(1, Math.round(image.width * scale));
+ canvas.height = Math.max(1, Math.round(image.height * scale));
+ const context = canvas.getContext('2d');
+ if (!context) throw new Error('This browser cannot resize the icon.');
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
+ const data = canvas.toDataURL('image/png');
+ if (!isResourceIconImage(data)) throw new Error('The resized icon exceeds the 350000-character limit.');
+ return data;
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/application/v2_ui/src/lib/workspaceAgentAuthoring.ts b/application/v2_ui/src/lib/workspaceAgentAuthoring.ts
index a1abdacaf..6b3a8f06d 100644
--- a/application/v2_ui/src/lib/workspaceAgentAuthoring.ts
+++ b/application/v2_ui/src/lib/workspaceAgentAuthoring.ts
@@ -2,6 +2,7 @@
// Agent-only draft rules, shared by the routed editor and executable functional tests.
import type { WorkspaceModelEndpoint } from './types';
+import { isResourceIconImage } from './resourceIcons';
import type { ModelCatalogEntry } from './models';
import { getModelSupportedLevels, type ReasoningEffort } from './reasoning';
import {
@@ -430,8 +431,7 @@ export function setAgentSecret(draft: AgentConfiguration, path: string, value: s
}
export function isAgentIconImage(value: unknown): value is string {
- return typeof value === 'string' && value.length <= 350000 &&
- /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/.test(value);
+ return isResourceIconImage(value);
}
/** An explicit save may fill Foundry's canonical placeholder, never replace a prompt. */
diff --git a/application/v2_ui/src/pages/workspace/EndpointsSection.tsx b/application/v2_ui/src/pages/workspace/EndpointsSection.tsx
index 3f3b96d9d..f2ac735c2 100644
--- a/application/v2_ui/src/pages/workspace/EndpointsSection.tsx
+++ b/application/v2_ui/src/pages/workspace/EndpointsSection.tsx
@@ -1,172 +1,19 @@
// EndpointsSection.tsx
-// Personal model endpoints: list, enable or disable, and delete.
-//
-// The connection editor -- provider, API versions, authentication and the model list --
-// stays in the classic interface. What is here is the part that is safe to change without
-// a form: whether an endpoint is in play, and whether it should exist at all.
-import { useMemo, useState } from 'react';
-import { Power, Server, Trash2 } from 'lucide-react';
-import {
- ConfirmAction,
- Pill,
- ResourceRow,
- RowAction,
- SectionIntro,
- SectionList,
- SectionSearch,
-} from '../../components/workspace/primitives';
-import {
- errorMessage,
- useSectionResource,
-} from '../../components/workspace/useSectionResource';
-import {
- deleteModelEndpoint,
- fetchModelEndpoints,
- updateModelEndpoint,
-} from '../../lib/workspaceApi';
-import type { WorkspaceModelEndpoint } from '../../lib/types';
+import { ModelConnectionsManager } from '../../components/admin/ModelConnectionsManager';
+import { SectionIntro } from '../../components/workspace/primitives';
+import { createPersonalModelConnectionsAdapter } from '../../lib/modelConnections';
-const PROVIDER_LABELS: Record = {
- aoai: 'Azure OpenAI',
- aifoundry: 'AI Foundry',
- new_foundry: 'Foundry',
-};
-
-export function providerLabel(provider: unknown): string {
- const raw = String(provider ?? '').trim();
- return PROVIDER_LABELS[raw] ?? (raw || 'Endpoint');
-}
+const PERSONAL_ENDPOINT_ADAPTER = createPersonalModelConnectionsAdapter();
export function EndpointsSection() {
- const { items, loading, error, setItems, setError } =
- useSectionResource(
- fetchModelEndpoints,
- 'Failed to load model endpoints.',
- );
-
- const [query, setQuery] = useState('');
- const [busyId, setBusyId] = useState(null);
-
- const visible = useMemo(() => {
- const needle = query.trim().toLowerCase();
- if (!needle) {
- return items;
- }
- return items.filter((endpoint) =>
- `${endpoint.name ?? ''} ${endpoint.provider ?? ''}`.toLowerCase().includes(needle),
- );
- }, [items, query]);
-
- const onToggle = async (endpoint: WorkspaceModelEndpoint) => {
- const previous = items;
- const next = !endpoint.enabled;
- setBusyId(endpoint.id);
- setItems(
- items.map((item) =>
- item.id === endpoint.id ? { ...item, enabled: next } : item,
- ),
- );
- try {
- // A partial update, so the stripped secrets in the copy this page holds are
- // never sent back and cannot overwrite what is stored.
- await updateModelEndpoint(endpoint.id, { enabled: next });
- } catch (toggleError) {
- setItems(previous);
- setError(errorMessage(toggleError, 'Could not update the endpoint.'));
- } finally {
- setBusyId(null);
- }
- };
-
- const onDelete = async (endpoint: WorkspaceModelEndpoint) => {
- const previous = items;
- setBusyId(endpoint.id);
- setItems(items.filter((item) => item.id !== endpoint.id));
- try {
- await deleteModelEndpoint(endpoint.id);
- } catch (deleteError) {
- setItems(previous);
- setError(errorMessage(deleteError, 'Could not delete the endpoint.'));
- } finally {
- setBusyId(null);
- }
- };
-
return (
-
-
- Adding an endpoint and editing its connection details are still done in the{' '}
-
- classic workspace
-
- .
-
-
-
-
-
}
- emptyTitle={
- items.length === 0 ? 'No endpoints yet' : 'No endpoints match your search'
- }
- emptyDescription={
- items.length === 0
- ? 'Add an endpoint to use a model your administrator has not published.'
- : undefined
- }
- getKey={(endpoint, index) => String(endpoint.id ?? index)}
- renderItem={(endpoint) => {
- const modelCount = Array.isArray(endpoint.models)
- ? endpoint.models.length
- : 0;
- return (
-
}
- title={String(endpoint.name ?? 'Untitled endpoint')}
- subtitle={`${providerLabel(endpoint.provider)}${
- modelCount
- ? ` ยท ${modelCount} model${modelCount === 1 ? '' : 's'}`
- : ''
- }`}
- meta={
-
- {endpoint.enabled ? 'Enabled' : 'Disabled'}
-
- }
- actions={
- <>
-
}
- label={
- endpoint.enabled
- ? `Disable ${endpoint.name ?? 'endpoint'}`
- : `Enable ${endpoint.name ?? 'endpoint'}`
- }
- busy={busyId === endpoint.id}
- onClick={() => void onToggle(endpoint)}
- />
-
}
- label={`Delete ${endpoint.name ?? 'endpoint'}`}
- confirmLabel="Delete"
- busy={busyId === endpoint.id}
- onConfirm={() => void onDelete(endpoint)}
- />
- >
- }
- />
- );
- }}
+ description="Model connections you own, for your agents and workflows. Credentials stay server-side; leave a stored secret blank to keep it."
/>
+
);
}
diff --git a/docs/explanation/features/V2_PERSONAL_ENDPOINTS.md b/docs/explanation/features/V2_PERSONAL_ENDPOINTS.md
new file mode 100644
index 000000000..6e9981d81
--- /dev/null
+++ b/docs/explanation/features/V2_PERSONAL_ENDPOINTS.md
@@ -0,0 +1,118 @@
+# V2 Personal Endpoint Editor
+
+## Overview
+
+Implemented in version: **0.261.315**, tracked in
+`application/single_app/config.py`. Refs
+[issue #1722, row 6](https://github.com/microsoft/simplechat/issues/1722).
+
+The personal **Endpoints** section now creates and edits connections natively
+instead of sending users to the classic workspace. It reuses
+`ModelConnectionsManager` through a personal adapter, preserving the established
+admin/group forms without exposing their privileged API operations.
+
+Dependencies are the existing personal-endpoint feature/governance gates,
+supported resource credentials, and the V2 build. No new settings, routes,
+frontend dependencies, external browser assets, or deployment version are added.
+
+## Architecture and API contract
+
+`pages/workspace/EndpointsSection.tsx` supplies a stable
+`createPersonalModelConnectionsAdapter` from `lib/modelConnections.ts`.
+
+| Operation | Personal route and shape |
+| --- | --- |
+| List | `GET /api/user/model-endpoints`: sanitized `endpoints` and public `custom_api_types` registry descriptors. |
+| Create | `POST /api/user/model-endpoints`: one connection payload. |
+| Read/edit | `GET/PATCH /api/user/model-endpoints/`: one endpoint; edits never replace the collection. |
+| Toggle | Per-item `PATCH` with only `{enabled}`. |
+| Delete | Per-item `DELETE`, without a body. |
+| Discovery | `POST /api/user/models/fetch`. |
+| Chat test | `POST /api/user/models/test-model`. |
+
+Malformed envelopes/records fail visibly rather than falling back to empty
+lists or apparent success. IDs are validated and URL-encoded. Personal scope
+has neither the group's revisions nor `endpoint_actions`; it does not invent
+a conditional-write protocol. Existing server ownership and governance checks
+remain authoritative.
+
+`route_backend_models.py` adds only safe provider registry descriptors to the
+list response. Stored secrets and Key Vault references remain sanitized.
+The adapter hides tenant connection tests, capability inference tests, global
+defaults/migrations, and network-policy editing. The public model catalogue
+picker still reads `GET /api/models/catalog`.
+
+## Saved configuration and credential boundaries
+
+Existing personal discovery/tests deliberately resolve saved connection/auth
+details. Tests also require the stored enabled model. The editor disables
+discovery/chat tests whenever its serialized draft differs from that saved
+binding, and guards handlers with the same save-first explanation. Discovered
+changes also require saving. Unsaved new connections retain existing transient
+request support without weakening application-identity restrictions.
+
+`has_api_key`, `has_client_secret`, and `has_bearer_token` flags explain
+stored credentials. Blank fields are omitted from saves; explicit replacements
+go through the existing normalization/Key Vault helpers. No draft is written
+to browser storage. Save, discovery, and inference success remain distinct.
+
+## Advanced metadata and icons
+
+`lib/connectionMetadata.ts` and
+`components/workspace/ConnectionMetadataFields.tsx` add personal-only endpoint
+defaults and model overrides for `contextWindow`, `inputTokenLimit`,
+`outputTokenLimit`, `tokenLimitProvider`, and `outputTokenAccounting`.
+Model overrides also include `catalogModelId` and `modelVersion`.
+Descriptions, optional `responseLength`, and icons are editable.
+
+Positive safe integers are required for capacities/response length. Cleared
+overrides serialize as explicit `null`; capacity inheritance is independent
+per field. The backend removes a cleared response length. Independent maxima
+are not rejected merely because their sum exceeds context capacity.
+
+`lib/resourceIcons.ts` extracts the agent editor's existing local icon loading
+and raster resizing. PNG/JPEG images are bounded to 128 pixels per dimension
+and 350000 characters. Bootstrap classes are validated; remote images and
+SVG are not accepted. Upload failures are announced, saving waits for resizing,
+and unmounted drafts cannot be overwritten by late image results.
+
+## Usage
+
+See [Connect personal models in V2](../../guides/personal-model-endpoints.md)
+for provider/auth configuration, manual and discovered models, save-first tests,
+capacity inheritance, icon selection, and troubleshooting.
+
+## Testing and limitations
+
+`functional_tests/test_v2_personal_endpoints_api.py` runs the registered personal
+routes offline, including sanitization, credentials, null clears, ownership,
+governance, deletion, and saved/transient test semantics.
+`functional_tests/test_v2_personal_endpoints_logic.mjs` executes the production
+adapter, serializers, numeric validation, icon checks, and saved-binding gate.
+
+`ui_tests/test_v2_personal_endpoints.py` drives the built SPA through a closed
+workspace fixture backed by the same real Flask route harness. It covers
+provider/auth creation, advanced edits/reopening, discovery, chat tests, icons,
+failures, denied sections, keyboard focus, both themes, and mobile/desktop
+overflow. Shared group/admin/agent suites cover the reused seams.
+
+### Validation results
+
+The production build/typecheck, 63 personal/group/admin/shared-icon browser
+cases, 81 executable TypeScript checks, and targeted real-route regressions
+passed. Route policy coverage, documentation coverage/site quality, and the
+changed production files' XSS sink checks also passed.
+
+The broader existing workspace-authoring suite retained 11 action-related
+failures; all 11 reproduced against an isolated unchanged-HEAD production
+bundle. The existing full-app capacity test retained two failures because its
+offline admin-page render attempted a release-update network request; both
+also reproduced on unchanged HEAD. Those pre-existing failures are not claimed
+as fixed or as passing coverage for this feature.
+
+The tests do not qualify live Azure inference, root/separately hosted routing,
+or Teams. Companion [issue #1723](https://github.com/microsoft/simplechat/issues/1723)
+owns legacy/root routing and New UI only policy. Personal saves still lack
+optimistic revisions; this change does not claim protection against concurrent
+edits to the same endpoint. Image and embedding inference tests remain
+administrator-only.
diff --git a/docs/guides/index.md b/docs/guides/index.md
index 959b295b4..0b2f681f1 100644
--- a/docs/guides/index.md
+++ b/docs/guides/index.md
@@ -58,6 +58,7 @@ Use this section when you want momentum first: each page is a practical path thr
| --- | --- | --- |
| [Create and manage tags]({{ '/guides/create-and-manage-tags/' | relative_url }}) | Create colored workspace tags and apply them to documents. | Workspace users |
| [Create a file sync]({{ '/guides/create-a-file-sync/' | relative_url }}) | Connect an external file source so SimpleChat can import workspace documents. | Workspace users |
+| [Connect personal models in V2]({{ '/guides/personal-model-endpoints/' | relative_url }}) | Configure your own model resources and verify saved chat models without opening classic pages. | Workspace users |
| [Manage group workspaces]({{ '/guides/manage-group-workspaces/' | relative_url }}) | Use group workspaces for shared documents, prompts, agents, actions, and workflows. | Workspace users |
| [Use public workspaces]({{ '/guides/use-public-workspaces/' | relative_url }}) | Browse and manage shared public workspace content when your tenant enables it. | Workspace users |
| [Review approval requests]({{ '/guides/review-approval-requests/' | relative_url }}) | Find, approve, or deny requests that need reviewer action. | Workspace users |
diff --git a/docs/guides/personal-model-endpoints.md b/docs/guides/personal-model-endpoints.md
new file mode 100644
index 000000000..1e159b862
--- /dev/null
+++ b/docs/guides/personal-model-endpoints.md
@@ -0,0 +1,115 @@
+---
+layout: page
+title: "Connect personal models in V2"
+description: "Configure your own model connections, verify saved chat models, and maintain their capacity metadata without leaving V2."
+section: "Guides"
+audience: user
+version: "0.261.315"
+---
+
+## What this does
+
+**My Workspace > Endpoints** keeps the resource address, authentication, and
+models for a personal connection together. Use it when your agents or workflows
+need a permitted model resource that is not supplied by an administrator.
+Creation and editing stay in V2; no classic workspace page is required.
+
+Implemented in version: **0.261.315**, recorded in
+`application/single_app/config.py`. This closes the personal endpoint editor
+gap in row 6 of [issue #1722](https://github.com/microsoft/simplechat/issues/1722).
+
+The section depends on administrator-enabled personal endpoints and governance
+access. It does not grant resource permissions or change global chat, image,
+or embedding defaults.
+
+## Configure a personal connection
+
+Choose **Add connection**, or **Edit** beside an existing connection. Give it a
+recognizable purpose, select the provider, and enter the resource's actual
+endpoint and API versions. Foundry project URLs and inference resource URLs
+serve different purposes; use the provider-specific field guidance.
+
+Select an authentication method that has access to that resource. Managed
+identity still uses the application's approved identity; a personal endpoint
+cannot grant access to an arbitrary host or token audience. Azure service
+principals need tenant/client details and a secret. Custom APIs offer API keys,
+bearer tokens, or OAuth2 client credentials, subject to the administrator's
+network policy.
+
+A stored credential is never shown. Leave its field blank to keep it, or enter
+a replacement deliberately. The draft remains only in the current editor;
+closing it without saving discards those changes.
+
+## Add and verify models
+
+For Azure/Foundry, discovery reads existing deployments when the selected
+credentials have the necessary management/project access. An inference-only
+Azure API key does not enumerate resource deployments; use **Add manually**
+and enter the deployment name instead.
+
+For Custom connections, choose the real API contract: OpenAI, Azure OpenAI,
+Anthropic, or Gemini. Enter the model name or deployment identifier expected
+by that contract. Keep gateway base paths intact. Selecting a contract does
+not make an incompatible service support it.
+
+Discovery merges new models without replacing your existing descriptions or
+overrides. Newly discovered and manually added models start disabled. Enable
+the intended models and publish only supported operations.
+
+**Save changes before discovering or testing an edited existing endpoint.**
+These personal operations resolve the saved resource and enabled model list,
+not unsaved connection overrides. Discovery that changes the list also needs
+a save before chat tests. A new connection can discover/test its transient
+configuration before creation when the server permits it.
+
+**Test chat** sends an inference request and may incur provider costs. A
+successful save is not a connection test; a successful chat test does not
+prove image or embedding readiness. Personal connections have no tenant
+connection test or image/embedding inference-test buttons.
+
+## Record verified capacity and model identity
+
+Expand **Advanced endpoint capacity** for shared defaults, or **Advanced model
+capacity** for a model-specific override. Each blank field inherits independently:
+model override, then endpoint default, then exact catalog metadata where available.
+Never guess limits to make a model seem usable.
+
+| Field | Meaning |
+| --- | --- |
+| Context window | Verified shared capacity for input and generation together. |
+| Input/output token limits | Independent provider ceilings; these maxima do not have to fit simultaneously in the context window. |
+| Catalog model ID and model version | The published model and exact snapshot, not a deployment alias or endpoint API version. |
+| Token limit provider and output accounting | Provenance and whether reasoning generation counts toward output capacity. |
+| Response length | Optional per-request generation allowance for standard chat, not model capacity. |
+
+Numeric values must be positive whole numbers no greater than
+`9007199254740991`. Fractions, exponent notation, zero, and negative values are
+rejected. Clear an override and save to restore inheritance.
+
+## Customize model presentation
+
+Give each model a useful description. Under **Model icon**, search Bootstrap
+icons; **Load all local icons** reads the application's local catalogue.
+Alternatively upload PNG/JPEG. The browser resizes it to at most 128 by 128
+pixels and rejects oversized stored payloads. SVG and remote image URLs are
+not accepted. **Use default icon** clears the custom icon.
+
+## Maintain and troubleshoot
+
+Disable a connection temporarily without losing its configuration. Delete
+only when it is no longer needed; confirmation removes that connection and
+its stored credentials. Other personal connections remain unchanged.
+
+Failed reads offer **Retry connections**, rather than pretending the list is
+empty. Failed saves, discovery, and tests keep the editor available and explain
+the error. Resolve resource permissions or network policy instead of changing
+to an unapproved endpoint to bypass a failure.
+
+Personal saves do not have group-style optimistic revisions. Coordinate with
+another editor using the same account before editing the same connection.
+
+## Related
+
+- [Build agents and actions in My Workspace]({{ '/guides/workspace-agents-and-actions/' | relative_url }})
+- [Model endpoint identity setup]({{ '/guides/model-endpoint-identity-setup/' | relative_url }})
+- [Configure global AI Connections]({{ '/guides/configure-ai-connections/' | relative_url }})
diff --git a/docs/guides/workspace-agents-and-actions.md b/docs/guides/workspace-agents-and-actions.md
index 7b60b28d1..29933d25c 100644
--- a/docs/guides/workspace-agents-and-actions.md
+++ b/docs/guides/workspace-agents-and-actions.md
@@ -71,6 +71,9 @@ which behavior remains managed in Foundry.
Give the agent a recognizable display name and a description of its responsibility.
Select a permitted model connection. Custom connection details and additional
configuration are available without making them necessary for an ordinary agent.
+To maintain a resource you own, use the native
+[personal Endpoints editor]({{ '/guides/personal-model-endpoints/' | relative_url }})
+for its provider, authentication, model list, and verified capacity metadata.
For a policy-bound assistant, use assigned knowledge to choose the source
workspaces and then narrow that pool with tags or specific documents. Review
diff --git a/functional_tests/test_v2_personal_endpoints_api.py b/functional_tests/test_v2_personal_endpoints_api.py
new file mode 100644
index 000000000..c426d2edb
--- /dev/null
+++ b/functional_tests/test_v2_personal_endpoints_api.py
@@ -0,0 +1,238 @@
+# test_v2_personal_endpoints_api.py
+"""
+Functional tests for native V2 personal endpoint editing.
+Version: 0.261.315
+Implemented in: 0.261.315
+
+Exercise real registered personal routes, normalization, and Key Vault helpers
+through the existing offline harness. Network I/O is blocked and injected state
+is restored after every test.
+"""
+
+import copy
+import json
+
+import pytest
+
+from test_support.group_endpoint_harness import (
+ aoai_endpoint,
+ foundry_endpoint,
+ group_endpoint_environment,
+)
+
+
+BASE = "/api/user/model-endpoints"
+
+
+@pytest.fixture(scope="module")
+def environment():
+ with group_endpoint_environment() as env:
+ yield env
+
+
+@pytest.fixture
+def personal(environment):
+ environment.reset()
+ endpoint = aoai_endpoint(
+ "personal-one", contextWindow=128000, inputTokenLimit=64000,
+ outputTokenLimit=16000, tokenLimitProvider="azure",
+ outputTokenAccounting="total_generation",
+ )
+ endpoint["models"][0].update({
+ "contextWindow": 100000, "inputTokenLimit": 60000,
+ "outputTokenLimit": 10000, "catalogModelId": "gpt-4o",
+ "modelVersion": "2024-08-06", "tokenLimitProvider": "azure",
+ "outputTokenAccounting": "total_generation", "responseLength": 512,
+ "description": "Keep this model", "icon": {"kind": "bootstrap", "value": "bi-stars"},
+ "metadata": {"retain": False},
+ })
+ environment.seed_personal_endpoints("owner", [endpoint])
+ environment.seed_personal_endpoints("other-user", [aoai_endpoint("private-other")])
+ environment.vault.writes.clear()
+ yield environment
+ environment.reset()
+
+
+def read_endpoint(env):
+ response = env.call("GET", f"{BASE}/personal-one")
+ assert response.status_code == 200
+ return response.get_json()["endpoint"]
+
+
+def test_personal_list_exposes_registry_not_settings_or_credentials(personal):
+ response = personal.call("GET", BASE)
+ assert response.status_code == 200
+ payload = response.get_json()
+ assert set(payload) == {"endpoints", "custom_api_types"}
+ assert {entry["value"] for entry in payload["custom_api_types"]} == {
+ "openai", "azure_openai", "anthropic", "gemini",
+ }
+ assert [entry["id"] for entry in payload["endpoints"]] == ["personal-one"]
+ assert payload["endpoints"][0]["has_api_key"] is True
+ assert "api_key" not in payload["endpoints"][0]["auth"]
+ assert "sk-plain" not in json.dumps(payload)
+ assert "--model-endpoint--" not in json.dumps(payload)
+
+
+def test_native_create_edit_reopen_and_secret_rotation(personal):
+ payload = aoai_endpoint("", api_key="new-test-only-key")
+ response = personal.call("POST", BASE, payload)
+ assert response.status_code == 201
+ created = response.get_json()["endpoint"]
+ assert created["id"] and created["has_api_key"]
+ assert "api_key" not in created["auth"]
+ created["name"] = "Renamed native endpoint"
+ updated = personal.call("PATCH", f"{BASE}/{created['id']}", created)
+ assert updated.status_code == 200
+ assert updated.get_json()["endpoint"]["name"] == created["name"]
+ stored = personal.personal_endpoint("owner", created["id"])
+ assert personal.vault.secrets[stored["auth"]["api_key"]] == "new-test-only-key"
+ rotated = personal.call("PATCH", f"{BASE}/{created['id']}", {"auth": {"api_key": "replacement-test-only-key"}})
+ assert rotated.status_code == 200
+ stored = personal.personal_endpoint("owner", created["id"])
+ assert personal.vault.secrets[stored["auth"]["api_key"]] == "replacement-test-only-key"
+
+
+def test_blank_secret_edit_and_toggle_preserve_all_metadata(personal):
+ payload = read_endpoint(personal)
+ original = copy.deepcopy(personal.personal_endpoint("owner", "personal-one"))
+ payload["name"] = "Personal renamed"
+ payload["auth"]["api_key"] = ""
+ response = personal.call("PATCH", f"{BASE}/personal-one", payload)
+ assert response.status_code == 200
+ toggled = personal.call("PATCH", f"{BASE}/personal-one", {"enabled": False})
+ assert toggled.status_code == 200
+ stored = personal.personal_endpoint("owner", "personal-one")
+ assert stored["auth"]["api_key"] == original["auth"]["api_key"]
+ assert stored["models"] == original["models"]
+ assert stored["contextWindow"] == original["contextWindow"]
+ assert personal.vault.writes == []
+
+
+def test_explicit_nulls_restore_capacity_and_response_inheritance(personal):
+ payload = read_endpoint(personal)
+ for key in ("contextWindow", "inputTokenLimit", "outputTokenLimit", "tokenLimitProvider", "outputTokenAccounting"):
+ payload[key] = None
+ payload["models"][0][key] = None
+ payload["models"][0].update({"catalogModelId": None, "modelVersion": None, "responseLength": None})
+ response = personal.call("PATCH", f"{BASE}/personal-one", payload)
+ assert response.status_code == 200
+ saved = response.get_json()["endpoint"]
+ assert saved["contextWindow"] is None
+ assert saved["models"][0]["contextWindow"] is None
+ assert saved["models"][0]["catalogModelId"] is None
+ assert "responseLength" not in saved["models"][0]
+ assert saved["models"][0]["description"] == "Keep this model"
+ assert saved["models"][0]["icon"] == {"kind": "bootstrap", "value": "bi-stars"}
+
+
+@pytest.mark.parametrize("value", [True, 0, -1, 1.5, "1e3", "9007199254740992"])
+@pytest.mark.parametrize("target", ["endpoint", "model"])
+def test_invalid_capacity_fails_before_persistence(personal, value, target):
+ before = copy.deepcopy(personal.user_settings)
+ payload = {"contextWindow": value}
+ if target == "model":
+ payload = read_endpoint(personal)
+ payload["models"][0]["contextWindow"] = value
+ response = personal.call("PATCH", f"{BASE}/personal-one", payload)
+ assert response.status_code == 400
+ assert response.get_json()["error_code"] == "model_context_invalid"
+ assert personal.user_settings == before
+ assert personal.vault.writes == []
+
+
+@pytest.mark.parametrize("method", ["GET", "PATCH", "DELETE"])
+def test_other_users_endpoint_is_not_accessible(personal, method):
+ response = personal.call(method, f"{BASE}/private-other", {} if method == "PATCH" else None)
+ assert response.status_code == 404
+ assert personal.personal_endpoint("other-user", "private-other") is not None
+
+
+def test_governance_denial_blocks_reads_and_writes(personal):
+ personal.denied_features.add("governance_user_endpoints")
+ for method, path, body in [
+ ("GET", BASE, None), ("POST", BASE, aoai_endpoint("blocked")),
+ ("PATCH", f"{BASE}/personal-one", {"name": "blocked"}),
+ ("DELETE", f"{BASE}/personal-one", None),
+ ]:
+ response = personal.call(method, path, body)
+ assert response.status_code == 403
+ assert personal.personal_endpoint("owner", "personal-one")["name"] != "blocked"
+ assert personal.vault.writes == []
+
+
+def test_feature_disabled_blocks_personal_management(personal):
+ personal.settings["allow_user_custom_endpoints"] = False
+ for method, path, body in [
+ ("GET", BASE, None), ("POST", BASE, aoai_endpoint("blocked")),
+ ("PATCH", f"{BASE}/personal-one", {"enabled": False}),
+ ("DELETE", f"{BASE}/personal-one", None),
+ ]:
+ response = personal.call(method, path, body)
+ assert response.status_code == 400
+ assert response.get_json() == {"error": "Allow User Custom Endpoints is disabled."}
+ assert personal.personal_endpoint("owner", "personal-one")["enabled"] is True
+ assert personal.vault.writes == []
+
+
+def test_independent_maxima_and_trimmed_model_identity_are_preserved(personal):
+ payload = read_endpoint(personal)
+ payload.update({"contextWindow": 1000, "inputTokenLimit": 900, "outputTokenLimit": 900})
+ payload["models"][0].update({
+ "contextWindow": 1000, "inputTokenLimit": 900, "outputTokenLimit": 900,
+ "catalogModelId": " \t" + "x" * 256 + "\n ", "modelVersion": " snapshot ",
+ })
+ response = personal.call("PATCH", f"{BASE}/personal-one", payload)
+ assert response.status_code == 200, response.get_json()
+ saved = response.get_json()["endpoint"]
+ assert saved["inputTokenLimit"] + saved["outputTokenLimit"] > saved["contextWindow"]
+ assert saved["models"][0]["catalogModelId"] == "x" * 256
+ assert saved["models"][0]["modelVersion"] == "snapshot"
+
+
+def test_delete_removes_only_one_endpoint_and_its_credential(personal):
+ original_secret = personal.personal_endpoint("owner", "personal-one")["auth"]["api_key"]
+ response = personal.call("DELETE", f"{BASE}/personal-one")
+ assert response.status_code == 200 and response.get_json() == {"success": True}
+ assert personal.personal_endpoint("owner", "personal-one") is None
+ assert personal.personal_endpoint("other-user", "private-other") is not None
+ assert original_secret in personal.vault.deletes
+
+
+def test_existing_chat_test_uses_saved_binding_not_draft_overrides(personal):
+ payload = read_endpoint(personal)
+ payload["connection"]["endpoint"] = "https://different.openai.azure.com"
+ payload["auth"]["api_key"] = "draft-test-only-key"
+ payload["model"] = payload["models"][0]
+ response = personal.call("POST", "/api/user/models/test-model", payload)
+ assert response.status_code == 200
+ assert personal.chat_clients[-1]["endpoint"] == "https://group-a.openai.azure.com"
+ assert personal.chat_clients[-1]["auth"]["api_key"] == "sk-plain"
+
+
+def test_stored_disabled_model_is_not_testable(personal):
+ payload = read_endpoint(personal)
+ payload["models"][0]["enabled"] = False
+ updated = personal.call("PATCH", f"{BASE}/personal-one", payload)
+ assert updated.status_code == 200
+ response = personal.call("POST", "/api/user/models/test-model", {
+ "endpoint_id": "personal-one", "model": {"id": "chat"},
+ })
+ assert response.status_code == 404
+ assert personal.chat_clients == []
+
+
+def test_new_draft_chat_and_foundry_discovery(personal):
+ draft = aoai_endpoint("", api_key="draft-only-key")
+ draft["model"] = draft["models"][0]
+ tested = personal.call("POST", "/api/user/models/test-model", draft)
+ assert tested.status_code == 200
+ assert personal.chat_clients[-1]["auth"]["api_key"] == "draft-only-key"
+ foundry = foundry_endpoint("")
+ discovered = personal.call("POST", "/api/user/models/fetch", foundry)
+ assert discovered.status_code == 200
+ assert discovered.get_json()["models"][0]["deploymentName"] == "gpt-4o"
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_v2_personal_endpoints_logic.mjs b/functional_tests/test_v2_personal_endpoints_logic.mjs
new file mode 100644
index 000000000..a292b90d4
--- /dev/null
+++ b/functional_tests/test_v2_personal_endpoints_logic.mjs
@@ -0,0 +1,194 @@
+// test_v2_personal_endpoints_logic.mjs
+// Version: 0.261.315
+// Implemented in: 0.261.315
+// Execute the production personal adapter, payload builders and metadata validation.
+
+import assert from 'node:assert/strict';
+import './test_support/tsResolve.mjs';
+
+const {
+ createPersonalModelConnectionsAdapter, createAdminModelConnectionsAdapter,
+ createGroupModelConnectionsAdapter, buildPersonalConnectionPayload, toEditableConnection,
+ personalTestBlockedReason, validateAdvancedConnection, emptyConnection,
+} = await import('../application/v2_ui/src/lib/modelConnections.ts');
+const { tokenCapacity, capacityPayload, capacityErrors, validModelIcon } =
+ await import('../application/v2_ui/src/lib/connectionMetadata.ts');
+
+const originalFetch = globalThis.fetch;
+const calls = [];
+let answer = {};
+let status = 200;
+globalThis.fetch = async (url, options) => {
+ calls.push({ url, method: options.method, body: options.body ? JSON.parse(options.body) : null });
+ return new Response(JSON.stringify(answer), { status, headers: { 'content-type': 'application/json' } });
+};
+let checks = 0;
+async function check(name, fn) {
+ await fn();
+ checks += 1;
+ console.log(`ok ${name}`);
+}
+function endpoint(provider = 'aoai') {
+ return {
+ id: 'personal id:encoded', name: 'Personal', provider, enabled: true,
+ ...(provider === 'custom' ? { api_type: 'openai' } : {}),
+ connection: { endpoint: 'https://personal.openai.azure.com', openai_api_version: '2024-10-21' },
+ auth: { type: 'api_key' }, has_api_key: true,
+ contextWindow: 128000, tokenLimitProvider: 'azure', outputTokenAccounting: 'total_generation',
+ models: [{
+ id: 'm', deploymentName: 'gpt-4o', modelName: 'gpt-4o', enabled: true,
+ responseLength: 512, contextWindow: 64000, catalogModelId: 'gpt-4o',
+ modelVersion: '2024-08-06', icon: { kind: 'bootstrap', value: 'bi-stars' },
+ description: 'Description', metadata: { keep: false },
+ }],
+ };
+}
+const descriptor = {
+ value: 'openai', label: 'OpenAI', protocol: 'openai', urlPolicy: 'auto',
+ versionField: '', defaultVersion: '', defaultApiKeyHeader: 'Authorization',
+ defaultApiKeyPrefix: 'Bearer', description: 'OpenAI API', usesModelName: true,
+ requiresApiVersion: false, authTypes: ['api_key'],
+};
+try {
+ const adapter = createPersonalModelConnectionsAdapter();
+ await check('personal adapter exposes only personal editing/test affordances', () => {
+ assert.equal(adapter.scope.kind, 'personal');
+ assert.ok(adapter.canCreate && adapter.advancedEditing && adapter.requiresSavedConfigurationTests);
+ for (const key of ['canTestConnection', 'canTestCapabilities', 'canEditNetworkPolicy', 'showMigrationNotices']) {
+ assert.equal(adapter[key], false);
+ }
+ const admin = createAdminModelConnectionsAdapter(() => {});
+ const group = createGroupModelConnectionsAdapter({ kind: 'group', id: 'g', name: 'Group' }, {});
+ assert.equal(admin.advancedEditing, undefined);
+ assert.equal(group.requiresSavedConfigurationTests, undefined);
+ });
+ await check('list/create/edit/toggle/delete use the exact personal contract', async () => {
+ answer = { endpoints: [endpoint()], custom_api_types: [descriptor] };
+ const list = await adapter.list();
+ assert.equal(list.endpoints.length, 1);
+ answer = { endpoint: endpoint() };
+ await adapter.create({ name: 'New' });
+ await adapter.update(endpoint(), { name: 'Edit' });
+ await adapter.update(endpoint(), { enabled: false });
+ answer = { success: true };
+ await adapter.remove(endpoint());
+ assert.deepEqual(calls.map(({ url, method }) => [method, url]), [
+ ['GET', '/api/user/model-endpoints'], ['POST', '/api/user/model-endpoints'],
+ ['PATCH', '/api/user/model-endpoints/personal%20id%3Aencoded'],
+ ['PATCH', '/api/user/model-endpoints/personal%20id%3Aencoded'],
+ ['DELETE', '/api/user/model-endpoints/personal%20id%3Aencoded'],
+ ]);
+ assert.deepEqual(calls[3].body, { enabled: false });
+ assert.equal(calls[4].body, null);
+ assert.ok(!calls.some((call) => call.body?.endpoints || call.body?.expected_revision));
+ });
+ await check('malformed envelopes, records and success-shaped defaults fail loudly', async () => {
+ for (const malformed of [{}, { endpoints: [] }, { endpoints: [{}], custom_api_types: [] },
+ { endpoints: [endpoint(), endpoint()], custom_api_types: [] },
+ { endpoints: [], custom_api_types: [{}] },
+ { endpoints: [{ ...endpoint(), name: {} }], custom_api_types: [] },
+ { endpoints: [{ ...endpoint(), models: [null] }], custom_api_types: [] }]) {
+ answer = malformed;
+ await assert.rejects(adapter.list(), /malformed|duplicate/);
+ }
+ answer = { endpoint: {} };
+ await assert.rejects(adapter.create({ name: 'New' }), /malformed/);
+ answer = {};
+ await assert.rejects(adapter.remove(endpoint()), /confirmed/);
+ await assert.rejects(adapter.discover({}), /malformed/);
+ await assert.rejects(adapter.testConnectionModel({}, 'gpt'), /successful/);
+ for (const models of [[{}], [{ deploymentName: {} }], [{ deploymentName: ' ' }]]) {
+ answer = { models };
+ await assert.rejects(adapter.discover({}), /malformed/);
+ }
+ for (const id of ['../outside', 'broken?query', ' trailing ', '', '.', '..']) {
+ await assert.rejects(adapter.update({ ...endpoint(), id }, {}), /identifier/);
+ answer = { endpoints: [{ ...endpoint(), id }], custom_api_types: [] };
+ await assert.rejects(adapter.list(), /malformed|identifier/);
+ }
+ });
+ await check('discovery/chat tests go only to personal routes and propagate denial', async () => {
+ answer = { models: [{ deploymentName: 'gpt' }] };
+ await adapter.discover({ provider: 'aoai' });
+ assert.equal(calls.at(-1).url, '/api/user/models/fetch');
+ answer = { success: true };
+ await adapter.testConnectionModel({ id: 'p' }, 'gpt');
+ assert.deepEqual(calls.at(-1), {
+ url: '/api/user/models/test-model', method: 'POST',
+ body: { id: 'p', model: { deploymentName: 'gpt' } },
+ });
+ status = 403; answer = { error: 'Endpoint access denied.' };
+ await assert.rejects(adapter.list(), /Endpoint access denied/);
+ status = 200;
+ });
+ await check('capacity validation retains exact integers and rejects coercion', () => {
+ for (const valid of [1, 9007199254740991, ' 0128 ']) assert.ok(tokenCapacity(valid, 'Capacity') > 0);
+ for (const blank of [null, undefined, '', ' ']) assert.equal(tokenCapacity(blank, 'Capacity'), null);
+ for (const invalid of [true, false, 0, -1, 1.5, NaN, Infinity, '1e3', '1.0', '9007199254740992', {}, []]) {
+ assert.throws(() => tokenCapacity(invalid, 'Capacity'), /positive whole/);
+ }
+ assert.ok(capacityErrors({ tokenLimitProvider: 12 }).tokenLimitProvider);
+ assert.ok(capacityErrors({ outputTokenAccounting: 'guessed' }).outputTokenAccounting);
+ assert.ok(capacityErrors({ catalogModelId: 'x'.repeat(257) }, true).catalogModelId);
+ assert.deepEqual(capacityPayload({ catalogModelId: ` \t${'x'.repeat(256)}\n ` }, true), {
+ catalogModelId: 'x'.repeat(256),
+ });
+ assert.ok(capacityErrors({ modelVersion: 'snapshot\u0000hidden' }, true).modelVersion);
+ assert.deepEqual(capacityPayload({ contextWindow: '', outputTokenAccounting: null }), {
+ contextWindow: null, outputTokenAccounting: null,
+ });
+ });
+ await check('Azure and Custom payloads preserve advanced metadata and omit blank secrets', () => {
+ for (const provider of ['aoai', 'custom']) {
+ const saved = endpoint(provider);
+ const draft = toEditableConnection(saved);
+ const payload = buildPersonalConnectionPayload(draft);
+ assert.equal(payload.contextWindow, 128000);
+ assert.equal(payload.models[0].contextWindow, 64000);
+ assert.equal(payload.models[0].responseLength, 512);
+ assert.deepEqual(payload.models[0].metadata, { keep: false });
+ assert.equal(payload.auth.api_key, undefined);
+ assert.equal(payload.has_api_key, undefined);
+ draft.auth.api_key = 'replacement';
+ draft.contextWindow = null;
+ draft.models[0].responseLength = '';
+ draft.models[0].catalogModelId = null;
+ const edited = buildPersonalConnectionPayload(draft);
+ assert.equal(edited.contextWindow, null);
+ assert.equal(edited.models[0].responseLength, null);
+ assert.equal(edited.models[0].catalogModelId, null);
+ assert.equal(edited.auth.api_key, 'replacement');
+ }
+ });
+ await check('changed or invalid existing drafts require saving before discovery/test', () => {
+ const saved = endpoint();
+ assert.equal(personalTestBlockedReason(toEditableConnection(saved), saved), null);
+ for (const change of [
+ (draft) => { draft.connection.endpoint = 'https://changed.test'; },
+ (draft) => { draft.auth.api_key = 'rotated'; },
+ (draft) => { draft.models[0].enabled = false; },
+ (draft) => { draft.models.push({ deploymentName: 'new' }); },
+ (draft) => { draft.models[0].responseLength = '0'; },
+ (draft) => { draft.contextWindow = '12e2'; },
+ ]) {
+ const draft = toEditableConnection(saved);
+ change(draft);
+ assert.match(personalTestBlockedReason(draft, saved), /Save changes/);
+ }
+ assert.equal(personalTestBlockedReason(emptyConnection(), emptyConnection()), null);
+ });
+ await check('icon validation allows only local classes and bounded raster payloads', () => {
+ for (const value of [{}, { kind: 'bootstrap', value: 'bi-stars' }, { kind: 'image', value: 'data:image/png;base64,aGVsbG8=' }]) {
+ assert.ok(validModelIcon(value));
+ }
+ for (const value of [null, [], { kind: 'bootstrap', value: 'bi-stars onclick=x' },
+ { kind: 'image', value: 'https://remote.test/icon.png' },
+ { kind: 'image', value: 'data:image/svg+xml;base64,aGVsbG8=' }]) assert.equal(validModelIcon(value), false);
+ const draft = endpoint();
+ draft.models[0].responseLength = '1.5';
+ assert.ok(validateAdvancedConnection(draft).model_0_responseLength);
+ });
+} finally {
+ globalThis.fetch = originalFetch;
+}
+console.log(`${checks} personal endpoint runtime checks passed.`);
diff --git a/ui_tests/fixtures/personal_endpoints.py b/ui_tests/fixtures/personal_endpoints.py
new file mode 100644
index 000000000..85517cd52
--- /dev/null
+++ b/ui_tests/fixtures/personal_endpoints.py
@@ -0,0 +1,95 @@
+# personal_endpoints.py
+"""
+Production-SPA personal endpoint fixture backed by real offline Flask routes.
+Version: 0.261.315
+Implemented in: 0.261.315
+
+Reuse the workspace shell and Azure Playwright connection support. Personal
+CRUD, discovery, normalization, governance and secret storage run unchanged
+through the functional endpoint harness, with external services/network blocked.
+"""
+
+import json
+from urllib.parse import urlsplit
+
+import pytest
+
+from ui_tests.fixtures.workspace_authoring import (
+ OWNER_ID, SPA_INDEX, WorkspaceAuthoringFixture, connect_options, # noqa: F401
+)
+from test_support.group_endpoint_harness import (
+ aoai_endpoint, foundry_endpoint, group_endpoint_environment,
+)
+
+
+BASE = "/api/user/model-endpoints"
+ENDPOINT_ID = "personal-chat"
+ENDPOINT_NAME = "Personal research connection"
+FOUNDRY_ID = "personal-foundry"
+
+
+class PersonalEndpointsFixture(WorkspaceAuthoringFixture):
+ def __init__(self, page, environment):
+ super().__init__(page)
+ self.environment = environment
+ endpoint = aoai_endpoint(
+ ENDPOINT_ID, name=ENDPOINT_NAME, contextWindow=128000,
+ outputTokenAccounting="total_generation",
+ )
+ endpoint["models"][0].update({
+ "deploymentName": "existing-chat", "modelName": "gpt-4o",
+ "description": "Research model", "responseLength": 512,
+ "contextWindow": 64000, "catalogModelId": "gpt-4o",
+ "modelVersion": "2024-08-06", "icon": {"kind": "bootstrap", "value": "bi-stars"},
+ "metadata": {"keep": False},
+ })
+ environment.seed_personal_endpoints(OWNER_ID, [endpoint, foundry_endpoint(FOUNDRY_ID)])
+ environment.as_user(OWNER_ID)
+ environment.vault.writes.clear()
+ self.private_values.update({"sk-plain", "sp-plain"})
+ self.malformed_list = False
+
+ def _route(self, route):
+ if route.request.method == "GET" and route.request.url == "http://simplechat.test/v2/workspace/endpoints":
+ route.fulfill(path=str(SPA_INDEX), content_type="text/html")
+ return
+ super()._route(route)
+
+ def _dispatch(self, route, entry):
+ if entry.path == BASE and entry.method == "GET" and self.malformed_list:
+ self._json(route, {"endpoints": [{}], "custom_api_types": []})
+ return
+ if entry.path == BASE or entry.path.startswith(f"{BASE}/") or entry.path in {
+ "/api/user/models/fetch", "/api/user/models/test-model",
+ }:
+ response = self.environment.call(entry.method, entry.path, entry.body)
+ self._json(route, response.get_json(), response.status_code)
+ return
+ if entry.path == "/api/models/catalog" and entry.method == "GET":
+ self._json(route, {"profiles": [], "tasks": [], "generation": 1})
+ return
+ super()._dispatch(route, entry)
+
+ def stored(self, endpoint_id=ENDPOINT_ID):
+ return self.environment.personal_endpoint(OWNER_ID, endpoint_id)
+
+ def assert_clean(self):
+ super().assert_clean()
+ forbidden = ("/api/v2/admin/", "/api/groups/", "/legacy", "/my_workspace")
+ assert not [
+ entry for entry in self.requests
+ if entry.path.startswith(forbidden) or entry.path in {"/api/models/fetch", "/api/models/test-model"}
+ ], "Personal endpoints reached an admin, group or classic route."
+ for _, payload in self.responses:
+ serialized = json.dumps(payload)
+ assert "sk-plain" not in serialized and "sp-plain" not in serialized
+ assert "--model-endpoint--" not in serialized, "Key Vault references reached the browser."
+ assert urlsplit(self.page.url).path.startswith("/v2/"), "The editor left the native SPA."
+
+
+@pytest.fixture
+def personal_endpoints_ui(page):
+ with group_endpoint_environment() as environment:
+ fixture = PersonalEndpointsFixture(page, environment)
+ yield fixture
+ fixture.assert_clean()
diff --git a/ui_tests/test_v2_personal_endpoints.py b/ui_tests/test_v2_personal_endpoints.py
new file mode 100644
index 000000000..6d79b7041
--- /dev/null
+++ b/ui_tests/test_v2_personal_endpoints.py
@@ -0,0 +1,352 @@
+# test_v2_personal_endpoints.py
+"""
+Native personal endpoint production-SPA workflows and real-route persistence.
+Version: 0.261.315
+Implemented in: 0.261.315
+
+Exercise the shared connection editor with personal APIs, not an admin session.
+The closed fixture uses Azure Playwright when configured, or the established
+local browser fallback. No live Azure inference or deployment is claimed.
+"""
+
+import base64
+import re
+
+import pytest
+from playwright.sync_api import expect
+
+from ui_tests.fixtures.personal_endpoints import (
+ BASE, ENDPOINT_ID, ENDPOINT_NAME, FOUNDRY_ID,
+ connect_options, personal_endpoints_ui, # noqa: F401
+)
+from test_support.group_endpoint_harness import foundry_endpoint
+
+
+pytestmark = pytest.mark.ui
+LAYOUTS = [
+ pytest.param("light", 1440, 900, id="desktop-light"),
+ pytest.param("dark", 1440, 900, id="desktop-dark"),
+ pytest.param("light", 390, 844, id="mobile-light"),
+ pytest.param("dark", 390, 844, id="mobile-dark"),
+]
+
+
+def open_endpoints(ui, **options):
+ ui.open("/workspace/endpoints", **options)
+ expect(ui.page.get_by_role("heading", name="Endpoints", exact=True)).to_be_visible()
+
+
+def row(ui, name=ENDPOINT_NAME):
+ return ui.page.get_by_role("listitem").filter(has_text=name)
+
+
+def edit(ui, name=ENDPOINT_NAME):
+ row(ui, name).get_by_role("button", name=f"Edit {name}", exact=True).click()
+ dialog = ui.page.get_by_role("dialog")
+ expect(dialog).to_be_visible()
+ return dialog
+
+
+def save(ui, dialog, name=ENDPOINT_NAME):
+ dialog.get_by_role("button", name="Save changes", exact=True).click()
+ expect(dialog).to_have_count(0)
+ expect(row(ui, name)).to_be_visible()
+
+
+@pytest.mark.parametrize("theme,width,height", LAYOUTS)
+def test_personal_editor_layout_and_keyboard(personal_endpoints_ui, theme, width, height):
+ ui = personal_endpoints_ui
+ open_endpoints(ui, theme=theme, width=width, height=height)
+ expect(ui.page.get_by_role("link", name=re.compile("classic", re.I))).to_have_count(0)
+ expect(ui.page.get_by_text("Images and embeddings remain available when chat uses its classic endpoint.", exact=False)).to_have_count(0)
+ button = row(ui).get_by_role("button", name=f"Edit {ENDPOINT_NAME}", exact=True)
+ button.focus()
+ button.press("Enter")
+ dialog = ui.page.get_by_role("dialog")
+ expect(dialog).to_be_visible()
+ expect(dialog.get_by_role("button", name="Test connection", exact=True)).to_have_count(0)
+ dialog.locator("summary").filter(has_text="Advanced endpoint capacity").click()
+ dialog.locator("summary").filter(has_text="Advanced model capacity").click()
+ dialog.locator("summary").filter(has_text="Model icon").click()
+ ui.assert_no_overflow()
+ assert dialog.evaluate("element => element.scrollWidth <= element.clientWidth + 1")
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ expect(button).to_be_focused()
+
+
+@pytest.mark.parametrize("provider,auth_type,api_type", [
+ ("aoai", "managed_identity", None),
+ ("aoai", "service_principal", None),
+ ("aoai", "api_key", None),
+ ("aifoundry", "service_principal", None),
+ ("new_foundry", "api_key", None),
+ ("openai_compatible", "api_key", None),
+ ("custom", "api_key", "openai"),
+ ("custom", "bearer", "anthropic"),
+ ("custom", "oauth2_client_credentials", "gemini"),
+ ("custom", "api_key", "azure_openai"),
+])
+def test_create_and_reopen_provider_authentication(personal_endpoints_ui, provider, auth_type, api_type):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ ui.page.get_by_role("button", name="Add connection", exact=True).click()
+ dialog = ui.page.get_by_role("dialog")
+ dialog.get_by_label("Name", exact=True).fill("Native endpoint")
+ dialog.get_by_label("Provider", exact=True).select_option(provider)
+ if api_type:
+ dialog.get_by_label("Custom API type", exact=True).select_option(api_type)
+ url = "https://personal.openai.azure.com"
+ if provider in {"aifoundry", "new_foundry"}:
+ url = "https://personal.services.ai.azure.com/api/projects/research"
+ elif provider in {"custom", "openai_compatible"}:
+ url = "https://gateway.example.com/v1"
+ dialog.locator("#connection-endpoint").fill(url)
+ dialog.get_by_label("Method", exact=True).select_option(auth_type)
+ if auth_type == "api_key":
+ dialog.get_by_label("API key", exact=True).fill("new-only-test-key")
+ elif auth_type == "service_principal":
+ dialog.get_by_label("Tenant id", exact=True).fill("tenant-test")
+ dialog.get_by_label("Client id", exact=True).fill("client-test")
+ dialog.get_by_label("Client secret", exact=True).fill("new-only-test-secret")
+ elif auth_type == "bearer":
+ dialog.locator("#custom-auth-bearer_token").fill("new-only-test-bearer")
+ elif auth_type == "oauth2_client_credentials":
+ dialog.get_by_label("OAuth2 token URL", exact=True).fill("https://gateway.example.com/oauth/token")
+ dialog.get_by_label("OAuth2 client ID", exact=True).fill("client-test")
+ dialog.get_by_label("OAuth2 client secret", exact=True).fill("new-only-test-secret")
+ if api_type == "azure_openai":
+ dialog.get_by_label("API version", exact=True).fill("2024-10-21")
+ dialog.get_by_role("button", name="Add manually", exact=True).click()
+ uses_model_name = provider == "custom" and api_type != "azure_openai"
+ dialog.get_by_label("Model name" if uses_model_name else "Deployment name", exact=True).fill("native-model")
+ dialog.get_by_role("button", name="Create connection", exact=True).click()
+ expect(dialog).to_have_count(0)
+ created = next(entry for entry in ui.writes if entry.path == BASE and entry.method == "POST")
+ assert created.body["provider"] == provider and created.body["auth"]["type"] == auth_type
+ assert "endpoints" not in created.body and "expected_revision" not in created.body
+ dialog = edit(ui, "Native endpoint")
+ expect(dialog.get_by_label("Provider", exact=True)).to_have_value(provider)
+ expect(dialog.get_by_label("Method", exact=True)).to_have_value(auth_type)
+ for field in dialog.locator('input[type="password"]').all():
+ expect(field).to_have_value("")
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ ui.assert_no_secret_storage("new-only-test-key", "new-only-test-secret", "new-only-test-bearer")
+
+
+def test_metadata_round_trip_and_explicit_clears(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ dialog = edit(ui)
+ expect(dialog.get_by_label("API key", exact=True)).to_have_value("")
+ dialog.locator("summary").filter(has_text="Advanced endpoint capacity").click()
+ dialog.get_by_label("Context window (tokens)", exact=True).first.fill("256000")
+ dialog.locator("summary").filter(has_text="Advanced model capacity").click()
+ dialog.get_by_label("Context window (tokens)", exact=True).last.fill("")
+ dialog.get_by_label("Catalog model ID", exact=True).fill("")
+ dialog.get_by_label("Model description", exact=True).fill(" Plain description")
+ dialog.get_by_label("Response length", exact=True).fill("1024")
+ save(ui, dialog)
+ stored = ui.stored()
+ assert stored["contextWindow"] == 256000
+ assert stored["models"][0]["contextWindow"] is None and stored["models"][0]["catalogModelId"] is None
+ assert stored["models"][0]["responseLength"] == 1024 and stored["models"][0]["metadata"] == {"keep": False}
+ assert stored["models"][0]["icon"] == {"kind": "bootstrap", "value": "bi-stars"}
+ assert ui.environment.vault.writes == []
+ dialog = edit(ui)
+ expect(dialog.get_by_label("Model description", exact=True)).to_have_value(" Plain description")
+ expect(dialog.locator('img[src="x"]')).to_have_count(0)
+ dialog.get_by_label("Response length", exact=True).fill("")
+ dialog.get_by_label("API key", exact=True).fill("replacement-only-test-key")
+ save(ui, dialog)
+ assert "responseLength" not in ui.stored()["models"][0]
+ secret_name = ui.stored()["auth"]["api_key"]
+ assert ui.environment.vault.secrets[secret_name] == "replacement-only-test-key"
+ ui.assert_no_secret_storage("replacement-only-test-key")
+
+
+def test_invalid_capacity_is_visible_and_never_written(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ dialog = edit(ui)
+ dialog.locator("summary").filter(has_text="Advanced endpoint capacity").click()
+ field = dialog.get_by_label("Context window (tokens)", exact=True).first
+ field.fill("1e3")
+ dialog.get_by_role("button", name="Save changes", exact=True).click()
+ expect(field).to_have_value("1e3")
+ expect(field).to_have_attribute("aria-invalid", "true")
+ expect(dialog.get_by_role("alert").filter(has_text="positive whole number")).to_be_visible()
+ assert not ui.writes
+ field.fill("9007199254740991")
+ save(ui, dialog)
+ assert ui.stored()["contextWindow"] == 9007199254740991
+
+
+def test_saved_binding_gates_edits_discovery_and_disabled_models(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ dialog = edit(ui)
+ test = dialog.get_by_role("button", name="Test chat", exact=True)
+ expect(test).to_be_enabled()
+ with ui.page.expect_response(lambda response: response.url.endswith("/api/user/models/test-model")) as tested:
+ test.click()
+ assert tested.value.status == 200, tested.value.json()
+ expect(ui.page.get_by_text("existing-chat answered a chat request. Image inference was not tested.", exact=True)).to_be_visible()
+ assert len(ui.environment.chat_clients) == 1
+ dialog.get_by_label("Endpoint URL", exact=True).fill("https://changed.openai.azure.com")
+ expect(test).to_be_disabled()
+ expect(dialog.get_by_role("status").filter(has_text="Save changes before")).to_be_visible()
+ save(ui, dialog)
+ dialog = edit(ui)
+ dialog.get_by_label("Enable existing-chat", exact=True).uncheck()
+ expect(dialog.get_by_role("button", name="Test chat", exact=True)).to_have_count(0)
+ save(ui, dialog)
+ dialog = edit(ui)
+ expect(dialog.get_by_role("button", name="Test chat", exact=True)).to_have_count(0)
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ foundry_name = ui.stored(FOUNDRY_ID)["name"]
+ dialog = edit(ui, foundry_name)
+ discover = dialog.get_by_role("button", name="Discover models", exact=True)
+ expect(discover).to_be_enabled()
+ dialog.get_by_label("Client secret", exact=True).fill("rotation-only-test-secret")
+ expect(discover).to_be_disabled()
+ dialog.get_by_label("Client secret", exact=True).fill("")
+ # A saved model list change, even a display name, must not test the old binding.
+ dialog.get_by_label("Display name", exact=True).fill("Updated model")
+ expect(discover).to_be_disabled()
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+
+
+def test_discovery_merges_without_erasing_advanced_models(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ endpoint = ui.stored()
+ foundry = foundry_endpoint(ENDPOINT_ID)
+ endpoint.update({key: foundry[key] for key in ("provider", "connection", "auth", "management")})
+ ui.environment.seed_personal_endpoints("workspace-editor-user", [endpoint])
+ open_endpoints(ui)
+ dialog = edit(ui)
+ with ui.page.expect_response(lambda response: response.url.endswith("/api/user/models/fetch")) as discovered:
+ dialog.get_by_role("button", name="Discover models", exact=True).click()
+ assert discovered.value.status == 200, discovered.value.json()
+ expect(dialog.get_by_label("Deployment name", exact=True)).to_have_count(2)
+ expect(dialog.get_by_role("button", name="Test chat", exact=True)).to_be_disabled()
+ save(ui, dialog)
+ assert ui.stored()["models"][0]["responseLength"] == 512
+ assert ui.stored()["models"][1]["deploymentName"] == "gpt-4o"
+ assert ui.stored()["models"][1]["enabled"] is False
+
+
+def test_model_icons_local_catalog_upload_errors_and_reset(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ dialog = edit(ui)
+ dialog.locator("summary").filter(has_text="Model icon").click()
+ dialog.get_by_role("button", name="Load all local icons", exact=True).click()
+ expect(dialog.get_by_role("button", name="Load all local icons", exact=True)).to_be_enabled()
+ dialog.get_by_label("Search Bootstrap icons", exact=True).fill("robot")
+ dialog.get_by_label("Bootstrap icon", exact=True).select_option("bi-robot")
+ upload = dialog.get_by_label("Upload model icon", exact=True)
+ upload.set_input_files({"name": "invalid.svg", "mimeType": "image/svg+xml", "buffer": b" "})
+ expect(dialog.get_by_role("alert").filter(has_text="Choose a PNG or JPEG")).to_be_visible()
+ upload.set_input_files({"name": "corrupt.png", "mimeType": "image/png", "buffer": b"corrupt"})
+ expect(dialog.get_by_role("alert")).to_be_visible()
+ png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j7ioAAAAASUVORK5CYII=")
+ upload.set_input_files({"name": "icon.png", "mimeType": "image/png", "buffer": png})
+ expect(dialog.get_by_role("img", name="Model icon preview", exact=True)).to_be_visible()
+ assert dialog.get_by_role("img", name="Model icon preview", exact=True).evaluate(
+ "image => image.naturalWidth <= 128 && image.naturalHeight <= 128")
+ save(ui, dialog)
+ assert ui.stored()["models"][0]["icon"]["value"].startswith("data:image/png;base64,")
+ dialog = edit(ui)
+ dialog.locator("summary").filter(has_text="Model icon").click()
+ dialog.get_by_role("button", name="Use default icon", exact=True).click()
+ save(ui, dialog)
+ assert not ui.stored()["models"][0]["icon"]
+
+
+def test_toggle_delete_and_cancel_preserve_other_endpoints(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ dialog = edit(ui)
+ dialog.get_by_label("Name", exact=True).fill("Cancelled draft")
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ assert not ui.writes
+ row(ui).get_by_role("button", name=f"Disable {ENDPOINT_NAME}", exact=True).click()
+ expect(row(ui).get_by_role("button", name=f"Enable {ENDPOINT_NAME}", exact=True)).to_be_visible()
+ assert ui.writes[-1].body == {"enabled": False}
+ assert ui.stored()["models"][0]["responseLength"] == 512
+ row(ui).get_by_role("button", name=f"Delete {ENDPOINT_NAME}", exact=True).click()
+ dialog = ui.page.get_by_role("dialog")
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ assert ui.stored() is not None
+ row(ui).get_by_role("button", name=f"Delete {ENDPOINT_NAME}", exact=True).click()
+ ui.page.get_by_role("dialog").get_by_role("button", name="Delete", exact=True).click()
+ expect(row(ui)).to_have_count(0)
+ assert ui.stored() is None and ui.stored(FOUNDRY_ID) is not None
+ assert ui.writes[-1].method == "DELETE" and ui.writes[-1].body is None
+
+
+@pytest.mark.parametrize("malformed", [False, True])
+def test_failed_list_is_not_an_empty_state(personal_endpoints_ui, malformed):
+ ui = personal_endpoints_ui
+ if malformed:
+ ui.malformed_list = True
+ else:
+ ui.reject_next("GET", BASE, error="Endpoints temporarily unavailable.")
+ ui.open("/workspace/endpoints")
+ expect(ui.page.get_by_role("alert")).to_be_visible()
+ expect(ui.page.get_by_text(re.compile("^No connections yet"))).to_have_count(0)
+ ui.malformed_list = False
+ ui.page.get_by_role("button", name="Retry connections", exact=True).click()
+ expect(row(ui)).to_be_visible()
+
+
+def test_failed_save_and_chat_keep_the_draft(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ dialog = edit(ui)
+ ui.reject_next("POST", "/api/user/models/test-model", error="Provider did not respond.")
+ dialog.get_by_role("button", name="Test chat", exact=True).click()
+ expect(dialog.get_by_role("alert").filter(has_text="Provider did not respond.")).to_be_visible()
+ dialog.get_by_label("Name", exact=True).fill("Retry personal endpoint")
+ ui.reject_next("PATCH", f"{BASE}/{ENDPOINT_ID}", error="Endpoint could not be saved.")
+ dialog.get_by_role("button", name="Save changes", exact=True).click()
+ expect(dialog.get_by_role("alert").filter(has_text="Endpoint could not be saved.")).to_be_visible()
+ expect(dialog.get_by_label("Name", exact=True)).to_have_value("Retry personal endpoint")
+ save(ui, dialog, "Retry personal endpoint")
+
+
+def test_denied_section_never_loads_endpoints(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ ui.disabled_sections["endpoints"] = "Personal endpoints are unavailable under this policy."
+ ui.open("/workspace/endpoints")
+ expect(ui.page.get_by_text("Personal endpoints are unavailable under this policy.", exact=True)).to_be_visible()
+ expect(ui.page.get_by_role("button", name="Add connection", exact=True)).to_have_count(0)
+ assert not [entry for entry in ui.requests if entry.path == BASE]
+
+
+def test_new_draft_discovery_failure_retry_and_test(personal_endpoints_ui):
+ ui = personal_endpoints_ui
+ open_endpoints(ui)
+ ui.page.get_by_role("button", name="Add connection", exact=True).click()
+ dialog = ui.page.get_by_role("dialog")
+ dialog.get_by_label("Name", exact=True).fill("Unsaved Foundry")
+ dialog.get_by_label("Provider", exact=True).select_option("aifoundry")
+ dialog.get_by_label("Project endpoint", exact=True).fill("https://personal.services.ai.azure.com/api/projects/research")
+ dialog.get_by_label("Method", exact=True).select_option("service_principal")
+ dialog.get_by_label("Tenant id", exact=True).fill("tenant-test")
+ dialog.get_by_label("Client id", exact=True).fill("client-test")
+ dialog.get_by_label("Client secret", exact=True).fill("transient-only-test-secret")
+ ui.reject_next("POST", "/api/user/models/fetch", error="Discovery is temporarily unavailable.")
+ dialog.get_by_role("button", name="Discover models", exact=True).click()
+ expect(dialog.get_by_role("alert").filter(has_text="Discovery is temporarily unavailable.")).to_be_visible()
+ dialog.get_by_role("button", name="Discover models", exact=True).click()
+ expect(dialog.get_by_label("Deployment name", exact=True)).to_have_value("gpt-4o")
+ dialog.get_by_label("Enable gpt-4o", exact=True).check()
+ with ui.page.expect_response(lambda response: response.url.endswith("/api/user/models/test-model")) as tested:
+ dialog.get_by_role("button", name="Test chat", exact=True).click()
+ assert tested.value.status == 200, tested.value.json()
+ for entry in ui.writes:
+ assert "id" not in entry.body and "endpoint_id" not in entry.body
+ assert len(ui.environment.user_settings["workspace-editor-user"]["settings"]["personal_model_endpoints"]) == 2
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ ui.assert_no_secret_storage("transient-only-test-secret")
diff --git a/ui_tests/test_v2_resource_icons.py b/ui_tests/test_v2_resource_icons.py
new file mode 100644
index 000000000..866663dfe
--- /dev/null
+++ b/ui_tests/test_v2_resource_icons.py
@@ -0,0 +1,51 @@
+# test_v2_resource_icons.py
+"""
+Regression coverage for shared agent/model local icon utilities.
+Version: 0.261.315
+Implemented in: 0.261.315
+
+The existing production agent editor must retain its local catalogue, bounded
+raster upload, error reporting and default reset after utility extraction.
+"""
+
+import base64
+
+import pytest
+from playwright.sync_api import expect
+
+from ui_tests.fixtures.workspace_authoring import (
+ AGENT_ID, connect_options, workspace_ui, # noqa: F401
+)
+
+
+pytestmark = pytest.mark.ui
+
+
+def test_agent_icon_catalogue_upload_and_default_are_preserved(workspace_ui):
+ ui, page = workspace_ui, workspace_ui.page
+ ui.open(f"/workspace/agents/{AGENT_ID}")
+ upload = page.get_by_label("Upload icon", exact=True)
+ icon_fields = page.get_by_role("group", name="Agent icon", exact=True)
+ expect(upload).to_be_visible()
+ page.get_by_role("button", name="Load all local icons", exact=True).click()
+ page.get_by_label("Search Bootstrap icons", exact=True).fill("database")
+ page.get_by_label("Bootstrap icon", exact=True).select_option("bi-database")
+ expect(icon_fields.locator("i.bi-database")).to_be_visible()
+ upload.set_input_files({"name": "not-an-icon.svg", "mimeType": "image/svg+xml", "buffer": b" "})
+ expect(page.get_by_role("alert").filter(has_text="Choose a PNG or JPEG image.")).to_be_visible()
+ data = page.evaluate("""() => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 512;
+ canvas.height = 256;
+ canvas.getContext('2d').fillRect(0, 0, 512, 256);
+ return canvas.toDataURL('image/png').split(',')[1];
+ }""")
+ upload.set_input_files({"name": "large-icon.png", "mimeType": "image/png", "buffer": base64.b64decode(data)})
+ image = icon_fields.locator('img[src^="data:image/png;base64,"]')
+ expect(image).to_be_visible()
+ dimensions = image.evaluate("image => [image.naturalWidth, image.naturalHeight, image.src.length]")
+ assert dimensions[:2] == [128, 64] and dimensions[2] <= 350000
+ page.get_by_role("button", name="Use default icon", exact=True).click()
+ expect(image).to_have_count(0)
+ expect(page.get_by_label("Bootstrap icon", exact=True)).to_have_value("bi-robot")
+ assert not ui.editor_writes