diff --git a/application/single_app/config.py b/application/single_app/config.py index 14e7f949f..43ca4b9fe 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.314" +VERSION = "0.261.315" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index e5eb57623..b7aa484e6 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -28,6 +28,7 @@ from functions_model_capabilities import ModelTokenBudgetError from functions_model_endpoint_app_identity import check_application_identity_request, check_application_identity_save from functions_model_endpoint_runtime import build_model_endpoint_sync_chat_client +from functions_model_endpoint_providers import get_model_endpoint_provider_ui_options from functions_model_endpoint_types import ( DEFAULT_ANTHROPIC_VERSION, MODEL_ENDPOINT_PROVIDER_CUSTOM, @@ -1169,7 +1170,8 @@ def get_user_model_endpoints(): user_settings = get_user_settings(user_id) endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) return jsonify({ - "endpoints": sanitize_model_endpoints_for_frontend(endpoints) + "endpoints": sanitize_model_endpoints_for_frontend(endpoints), + "custom_api_types": get_model_endpoint_provider_ui_options(), }) diff --git a/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx b/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx index ee50c9614..7d09486c2 100644 --- a/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx +++ b/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx @@ -42,6 +42,7 @@ import { PROVIDER_OPTIONS, authTypeLabel, buildConnectionPayload, + buildPersonalConnectionPayload, createAdminModelConnectionsAdapter, defaultOpenAiApiVersion, defaultEmbeddingApi, @@ -59,10 +60,12 @@ import { modelSupportsCapability, projectNameFromEndpoint, providerLabel, + personalTestBlockedReason, setModelCapabilityEnabled, setEmbeddingOperation, toEditableConnection, validateConnection, + validateAdvancedConnection, visibleFields, ENDPOINT_REBASE_FIELDS, type ConnectionModel, @@ -80,6 +83,7 @@ import { CatalogProfilePicker } from './ModelCatalogManager'; import { CustomAuthenticationFields, CustomConnectionFields } from './CustomConnectionFields'; import { CustomNetworkPolicyEditor } from './CustomNetworkPolicyEditor'; import { GlassButton } from '../ui/primitives'; +import { ConnectionCapacityFields, ConnectionModelMetadata } from '../workspace/ConnectionMetadataFields'; import { useModelConnectionsStore, modelConnectionsChanged } from '../../stores/modelConnectionsStore'; import { toast } from '../../stores/toastStore'; @@ -388,6 +392,7 @@ function ConnectionEditor({ const [discovering, setDiscovering] = useState(false); const [testing, setTesting] = useState(false); const [testingModelId, setTestingModelId] = useState(null); + const [iconBusy, setIconBusy] = useState(false); const [formError, setFormError] = useState(null); // A stale-revision conflict keeps the draft and offers a reload rather than losing the edit. const [needsReload, setNeedsReload] = useState(false); @@ -416,8 +421,11 @@ function ConnectionEditor({ const canSave = isNew ? adapter.canCreate : adapter.allows('edit', initial); const shown = useMemo(() => visibleFields(draft), [draft]); - const savedBinding = !isNew && JSON.stringify(buildConnectionPayload(draft)) === - JSON.stringify(buildConnectionPayload(toEditableConnection(current))); + const testBlockedReason = adapter.requiresSavedConfigurationTests ? personalTestBlockedReason(draft, current) : null; + const savedBinding = !isNew && (adapter.requiresSavedConfigurationTests ? !testBlockedReason + : JSON.stringify(buildConnectionPayload(draft)) === JSON.stringify(buildConnectionPayload(toEditableConnection(current)))); + const editorPayload = (value: ModelConnection) => adapter.advancedEditing + ? buildPersonalConnectionPayload(value) : buildConnectionPayload(value); const setField = useCallback((path: string, value: unknown) => { setErrors((current) => { @@ -430,7 +438,7 @@ function ConnectionEditor({ }); setDraft((current) => { const next = { ...current }; - if (path === 'name' || path === 'provider' || path === 'enabled' || path === 'api_type') { + if (!path.includes('.')) { (next as Record)[path] = value; // Switching provider changes which API version default applies, and the // previous provider's default would otherwise be silently carried over. @@ -481,6 +489,7 @@ function ConnectionEditor({ }; const runDiscovery = async () => { + if (testBlockedReason) { setFormError(testBlockedReason); return; } const validation = validateConnection(draft, { requireDiscovery: true }); // Discovery needs the connection and credentials, but not the model list, so a // missing name should not stop it. @@ -494,7 +503,7 @@ function ConnectionEditor({ setDiscovering(true); setFormError(null); try { - const response = await adapter.discover(buildConnectionPayload(draft)); + const response = await adapter.discover(editorPayload(draft)); const discovered = Array.isArray(response.models) ? response.models : []; const { models, added } = mergeDiscoveredModels(draft.models ?? [], discovered); setModels(models); @@ -539,6 +548,11 @@ function ConnectionEditor({ }; const runModelTest = async (model: ConnectionModel, capability: ImplementedCapability) => { + if (testBlockedReason) { setFormError(testBlockedReason); return; } + if (adapter.requiresSavedConfigurationTests && model.enabled === false) { + setFormError('Enable the model and save the endpoint before testing it.'); + return; + } if (embeddingOnly && capability !== 'embeddings') return; if (capability !== 'chat' && !savedBinding) { setFormError(`Save the connection first. ${capability === 'embeddings' ? 'Embedding' : 'Image'} tests use only the saved model and credentials.`); @@ -574,7 +588,7 @@ function ConnectionEditor({ toast.success(`${deploymentName} generated an image.`); } } else { - await adapter.testConnectionModel(buildConnectionPayload(draft), model); + await adapter.testConnectionModel(editorPayload(draft), model); toast.success(`${deploymentName} answered a chat request. Image inference was not tested.`); } } catch (error) { @@ -585,7 +599,7 @@ function ConnectionEditor({ }; const save = async () => { - const validation = validateConnection(draft); + const validation = { ...validateConnection(draft), ...(adapter.advancedEditing ? validateAdvancedConnection(draft) : {}) }; if (Object.keys(validation).length) { setErrors(validation); setFormError('Some details still need attention.'); @@ -596,7 +610,7 @@ function ConnectionEditor({ setFormError(null); setNeedsReload(false); try { - const payload = buildConnectionPayload(draft); + const payload = editorPayload(draft); const response = initial.id ? await adapter.update(current, payload) : await adapter.create(payload); @@ -643,7 +657,7 @@ function ConnectionEditor({ } }; - const busy = saving || discovering || testing || reloading || testingModelId !== null; + const busy = saving || discovering || testing || reloading || testingModelId !== null || iconBusy; const models = draft.models ?? []; const projectHint = foundry ? projectNameFromEndpoint(draft.connection?.endpoint) : ''; @@ -700,6 +714,7 @@ function ConnectionEditor({ ) : null}
+ {adapter.advancedEditing ? : null} Identity @@ -1149,7 +1164,7 @@ function ConnectionEditor({ variant="subtle" size="sm" onClick={() => void runDiscovery()} - disabled={busy || !shown.discovery} + disabled={busy || !shown.discovery || Boolean(testBlockedReason)} > {discovering ? ( @@ -1194,10 +1209,14 @@ function ConnectionEditor({ Add manually -

+ {adapter.scope.kind === 'personal' ?

+ {testBlockedReason || 'Chat tests may incur inference costs. Saving configuration is not an inference test. Image and embedding tests are available only for administrator-managed connections.'} +

:

Connection checks do not test image or embedding inference. Operation tests use saved bindings and may incur inference costs. {!savedBinding ? ' Save the connection first before testing images or embeddings, or choosing a default.' : ''} -

+

} + {adapter.advancedEditing ? setField(key, value)} /> : null} {errors.models ?

{errors.models}

: null} {models.length === 0 ? ( @@ -1327,9 +1346,17 @@ function ConnectionEditor({ .map(([name, message]) => [name.slice(`model_${index}_`.length), message]))} onChange={(nextModel) => setModels(models.map((item, at) => at === index ? nextModel : item))} /> + {adapter.advancedEditing ? 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 }) => ( +
+ + 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]) => ( +
+ + +

{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 ( +
+
+ +