diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts index 3e856fcd9ee..70521fabbd4 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -26,6 +26,7 @@ import { initialOnboardingModelIds, shouldShowManagedOnboardingOutcomeUnknown, stableOnboardingModels, + usesLegacyConnectionWriter, validateAddProviderDraft, type AddProviderDraft, type AddProviderField, @@ -268,34 +269,72 @@ test('a duplicate slug outranks a missing key, so one fix is asked for at a time ); }); -test('routes only fixed-endpoint API-key drafts without request customization to Host onboarding', () => { +test('routes an API-key draft to Host onboarding unless a probe input cannot ride along', () => { assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'openai', - requestHeaderCount: 0, hasRequestBodyOverlay: false, + hasRequestHeaders: false, + hasEndpoint: false, }), { kind: 'host' }); assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'openai', - requestHeaderCount: 1, - hasRequestBodyOverlay: false, - }), { kind: 'legacy', reason: 'request_headers' }); - assert.deepEqual(apiKeyOnboardingRoute({ - providerType: 'openai', - requestHeaderCount: 0, hasRequestBodyOverlay: true, + hasRequestHeaders: false, + hasEndpoint: false, }), { kind: 'legacy', reason: 'request_body' }); + // The verify wire carries headers into a create target, but the managed save + // that would follow commits the key and the catalog and nothing else. A + // built-in with headers would therefore be stored without them. + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'openai', + hasRequestBodyOverlay: false, + hasRequestHeaders: true, + hasEndpoint: false, + }), { kind: 'legacy', reason: 'request_headers' }); + // A relay with no registry endpoint has nothing to verify against until the + // form supplies one. assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'custom', - requestHeaderCount: 0, hasRequestBodyOverlay: false, + hasRequestHeaders: false, + hasEndpoint: false, }), { kind: 'legacy', reason: 'custom_endpoint' }); + // …and once it does, the probe carries the form's endpoint and headers, so + // the relay joins the managed route rather than the legacy create-then-fetch. + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'custom', + hasRequestBodyOverlay: false, + hasRequestHeaders: false, + hasEndpoint: true, + }), { kind: 'host' }); + // Headers do not divert a relay, because the writer its save already uses is + // the one that persists them. + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'custom', + hasRequestBodyOverlay: false, + hasRequestHeaders: true, + hasEndpoint: true, + }), { kind: 'host' }); + // The overlay is still the one probe input the Host cannot carry. + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'custom', + hasRequestBodyOverlay: true, + hasRequestHeaders: false, + hasEndpoint: true, + }), { kind: 'legacy', reason: 'request_body' }); assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'cloudflare-workers-ai', - requestHeaderCount: 0, hasRequestBodyOverlay: false, + hasRequestHeaders: false, + hasEndpoint: false, }), { kind: 'legacy', reason: 'cloudflare' }); }); +test('the relay is the one provider whose save is still the create-then-discover writer', () => { + assert.equal(usesLegacyConnectionWriter('custom'), true); + assert.equal(usesLegacyConnectionWriter('openai'), false); +}); + test('uses a stable discovered-model order and prefers the registered recommendation', () => { const models = [ { id: 'z-model', displayName: 'Zulu' }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 256d7a37b13..5f7dd1adb76 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -531,6 +531,116 @@ test('creates a connection with only the explicitly selected model', async () => assert.deepEqual(createdModels, ['minimax-m3']); }); +test('creates a connection with the picker selection, default first', async () => { + const handlers = new Map unknown>(); + let createdModels: readonly string[] = []; + const emptyCatalog: ConnectionCatalogSnapshot = { + revision: 0, + defaultTarget: null, + connections: [], + }; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + loadConnectionCatalog: async () => + createdModels.length === 0 + ? emptyCatalog + : { + revision: 1, + defaultTarget: null, + connections: [ + { + connectionId: 'connection-relay', + revision: 1, + slug: 'my-relay', + name: 'My Relay', + providerType: 'custom', + enabled: true, + enabledModelIds: createdModels, + catalogEntries: [], + models: [], + }, + ], + }, + createConnection: async ( + _revision: number, + draft: { readonly enabledModelIds: readonly string[] }, + ) => { + createdModels = draft.enabledModelIds; + return { + kind: 'committed', + connection: { connectionId: 'connection-relay', revision: 1 }, + }; + }, + } as never, + emitConnectionListChanged() {}, + }); + + await handlers.get('connections:create')?.({}, { + slug: 'my-relay', + name: 'My Relay', + providerType: 'custom', + defaultModel: 'relay/second', + // The picker chose this one first and the default second; the catalog + // entries only exist once the probe has answered. + enabledModelIds: ['relay/first', 'relay/second'], + }); + + // The create is the only write that can carry the selection, so the default + // being listed second must not silently win the head of the list. + assert.deepEqual(createdModels, ['relay/second', 'relay/first']); +}); + +test('refuses a connection whose enabled model ids are not model ids', () => { + assert.throws( + () => + normalizeCreateConnectionInputForIpc({ + slug: 'my-relay', + name: 'My Relay', + providerType: 'custom', + defaultModel: 'relay/first', + enabledModelIds: ['relay/first', 'relay/first'], + }), + /duplicate model ids/, + ); + assert.throws( + () => + normalizeCreateConnectionInputForIpc({ + slug: 'my-relay', + name: 'My Relay', + providerType: 'custom', + defaultModel: 'relay/first', + enabledModelIds: [''], + }), + /empty model id/, + ); + assert.throws( + () => + normalizeCreateConnectionInputForIpc({ + slug: 'my-relay', + name: 'My Relay', + providerType: 'custom', + defaultModel: 'relay/first', + enabledModelIds: ['relay/first', 7], + }), + /must be model ids/, + ); + assert.deepEqual( + normalizeCreateConnectionInputForIpc({ + slug: 'my-relay', + name: 'My Relay', + providerType: 'custom', + defaultModel: 'relay/first', + enabledModelIds: ['relay/second', 'relay/first'], + }).enabledModelIds, + ['relay/second', 'relay/first'], + ); +}); + test('projects the Host default target without inventing a second Connection authority', () => { const connections = projectHostConnections(catalog()); diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 7d7824b9e7a..a35ce8bc094 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -22,7 +22,12 @@ import { type CreateConnectionInput, type UpdateConnectionInput, } from '@maka/core/llm-connections'; -import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; +import { + CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, + CONNECTION_MODEL_ID_MAX_LENGTH, + normalizeOptionalRequestBodyOverlay, + normalizeRequestHeaders, +} from '@maka/core/runtime-policy'; import { PROVIDER_REGISTRY, providerDefaultsOf } from '@maka/core/llm-connections'; import { normalizeModelOverrides } from '@maka/core/model-thinking'; @@ -57,6 +62,38 @@ export function normalizeConnectionApiKeyForIpc(value: unknown, label: string): return value; } +/** + * The picker's selection now rides along with the create, so it needs the gate + * the catalog codec puts on the same list: a bounded array of unique, bounded, + * non-empty ids. Without one, `...input` below would forward whatever the + * renderer sent straight into the Host's catalog. + */ +export function normalizeConnectionEnabledModelIdsForIpc( + value: unknown, + label: string, +): string[] { + if (!Array.isArray(value) || value.length > CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS) { + throw new Error( + `${label} must be an array of at most ${CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS} model ids`, + ); + } + const modelIds = value.map((modelId): string => { + if (typeof modelId !== 'string') throw new Error(`${label} must be model ids`); + if (modelId.length === 0) throw new Error(`${label} must not contain an empty model id`); + if (modelId.length > CONNECTION_MODEL_ID_MAX_LENGTH) { + throw new Error(`${label} must be ${CONNECTION_MODEL_ID_MAX_LENGTH} characters or fewer`); + } + if (IPC_CONTROL_CHARACTER_PATTERN.test(modelId)) { + throw new Error(`${label} contains invalid characters`); + } + return modelId; + }); + if (new Set(modelIds).size !== modelIds.length) { + throw new Error(`${label} must not contain duplicate model ids`); + } + return modelIds; +} + export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConnectionInput { if (typeof value !== 'object' || value === null) throw new Error('Invalid Connection input'); const input = value as Partial; @@ -84,6 +121,10 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn input.requestBodyOverlay === undefined ? undefined : normalizeOptionalRequestBodyOverlay(input.requestBodyOverlay); + const enabledModelIds = + input.enabledModelIds === undefined + ? undefined + : normalizeConnectionEnabledModelIdsForIpc(input.enabledModelIds, 'enabledModelIds'); const normalized = { ...input, slug, @@ -91,6 +132,7 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn ...(modelOverrides === undefined ? {} : { modelOverrides }), ...(requestHeaders === undefined ? {} : { requestHeaders }), ...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }), + ...(enabledModelIds === undefined ? {} : { enabledModelIds }), } as CreateConnectionInput; return normalizeConnectionBaseUrlForIpc(normalized); } diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index eb58a357098..f5499a65e61 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -213,6 +213,11 @@ export function registerRuntimeHostConnectionsIpc( enabled: true, enabledModelIds: connectionEnabledModelIds({ defaultModel: input.defaultModel, + // A picker that chose models before the connection existed has + // nowhere else to put them: the create is the only write. + ...(input.enabledModelIds === undefined + ? {} + : { enabledModelIds: input.enabledModelIds }), }), ...(modelOverrides === undefined ? {} : { modelOverrides }), ...(input.requestBodyOverlay === undefined diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 80aafac7224..aa19e504c0d 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -73,12 +73,20 @@ import { createProviderWithDiscovery, apiKeyOnboardingRoute, initialOnboardingModelIds, + probeRequestHeaderUpdates, shouldShowManagedOnboardingOutcomeUnknown, stableOnboardingModels, + usesLegacyConnectionWriter, validateAddProviderDraft, type AddProviderIssue, } from './provider-add-submission'; +/** The advanced editor's material, normalized once and shared by both writers. */ +interface FormRequestCustomization { + readonly headers: Readonly>; + readonly bodyOverlay: ReturnType; +} + /* No `defaultModel`: the creation gate has no rule that can fail on the model id, so an error could never be reported against that field. The union is kept aligned with `AddProviderIssue` plus the two form-local fields the @@ -153,10 +161,19 @@ export function AddProviderForm(props: { const isCloudflareWorkersAi = props.providerType === 'cloudflare-workers-ai'; const requiresBaseUrl = !defaults.baseUrl && !isCloudflareWorkersAi; const showsDefaultModel = recommendedDefaultModel.trim() === ''; + // The form asks for a model id exactly when the registry recommends none, + // and the probe's catalog is the only place that answer can be honored. + // Falling back to the recommendation keeps providers that ship one seeded + // as they were. + const preferredDefaultModel = defaultModel.trim() || recommendedDefaultModel; const isExperimental = defaults.status === 'phase3-experimental'; const supportsApiKey = providerAuthSupportsApiKey(props.providerType); const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey; const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType); + // The managed verify→choose route used to belong to the key-only dialog + // alone. A custom relay walks the same two steps through the ordinary form, + // which is the one that owns the endpoint the probe needs. + const usesManagedOnboarding = usesApiKeyDialog || isCustom; function setManagedPhase(next: ManagedOnboardingPhase) { setFormState((current) => ({ ...current, managedPhase: next })); } @@ -220,19 +237,57 @@ export function AddProviderForm(props: { return copy.onboardingUnavailable; } - async function verifyManagedApiKey(normalizedApiKey: string) { + /** + * What the probe must carry beyond the key: a custom relay's endpoint and + * whatever headers the advanced editor holds. Both describe the connection + * the form is about to create, so probing without them would answer for a + * relay nobody asked to add. + */ + function probeMaterial(customization: FormRequestCustomization) { + const headerUpdates = probeRequestHeaderUpdates(customization.headers); + return { + baseUrl: isCustom ? baseUrl.trim() || null : null, + ...(headerUpdates.length === 0 ? {} : { requestHeaders: headerUpdates }), + }; + } + + /** + * A custom target has to name the protocol it speaks — the Host resolves the + * catalog endpoint from it, and the wire closes the field to every other + * provider. Only the probe asks for one: a custom save still goes through + * create-then-discover, which carries the protocol on its own input. + */ + function probeTarget() { + return { + kind: 'create' as const, + providerType: props.providerType, + ...(isCustom ? { defaultApiProtocol } : {}), + }; + } + + /** + * Returns false when the caller should create the connection instead: a + * probe that cannot answer is not a refusal for a relay whose save is the + * create-then-discover writer, which reports a failed discovery rather than + * refusing to create. Every other provider keeps the managed route's answer. + */ + async function verifyManagedApiKey( + normalizedApiKey: string, + customization: FormRequestCustomization, + ): Promise { const onboarding = props.apiKeyOnboardingBridge; - if (!onboarding) return; + if (!onboarding) return false; submitGuard.begin('submit'); setBusy(true); try { const result = await onboarding.verify({ - target: { kind: 'create', providerType: props.providerType }, + target: probeTarget(), apiKey: normalizedApiKey || null, - baseUrl: null, + ...probeMaterial(customization), }); - if (!addProviderMountedRef.current) return; + if (!addProviderMountedRef.current) return true; if (result.kind !== 'verified') { + if (usesLegacyConnectionWriter(props.providerType)) return false; setError({ field: result.kind === 'failed' && result.errorClass === 'auth' @@ -240,27 +295,30 @@ export function AddProviderForm(props: { : 'form', message: onboardingFailureMessage(result), }); - return; + return true; } const models = stableOnboardingModels(result.models); - const selectedIds = initialOnboardingModelIds(models, recommendedDefaultModel); + const selectedIds = initialOnboardingModelIds(models, preferredDefaultModel); if (selectedIds.length === 0) { + if (usesLegacyConnectionWriter(props.providerType)) return false; setError({ field: 'form', message: copy.onboardingNoModels }); - return; + return true; } setManagedPhase({ kind: 'models', models, selectedIds, - defaultId: selectedIds.includes(recommendedDefaultModel) - ? recommendedDefaultModel + defaultId: selectedIds.includes(preferredDefaultModel) + ? preferredDefaultModel : selectedIds[0]!, filter: '', }); + return true; } catch (err) { if (addProviderMountedRef.current) { setError({ field: 'form', message: providerPanelActionErrorMessage(err, locale) }); } + return true; } finally { submitGuard.finish(); if (addProviderMountedRef.current) setBusy(false); @@ -270,6 +328,7 @@ export function AddProviderForm(props: { async function saveManagedApiKey( normalizedApiKey: string, phase: Extract, + customization: FormRequestCustomization, ) { const onboarding = props.apiKeyOnboardingBridge; if (!onboarding || phase.selectedIds.length === 0) { @@ -277,12 +336,27 @@ export function AddProviderForm(props: { return; } // Catalog order, with the chosen default first: the Host reads the head of - // this list as the connection's default model. + // this list as the connection's default model, and the create writer + // derives its enabled set from the same ordering. const selected = new Set(phase.selectedIds); const stableIds = phase.models .map((model) => model.id) .filter((modelId) => selected.has(modelId) && modelId !== phase.defaultId); if (selected.has(phase.defaultId)) stableIds.unshift(phase.defaultId); + // A custom relay still saves through create-then-discover. The managed + // save commits the catalog and the key, but not the endpoint headers this + // form just probed with — a connection saved without them would fetch + // nothing. The picker's selection rides along, so the connection enables + // what the user ticked rather than the default alone. + if (isCustom) { + await createConnectionFromForm({ + normalizedApiKey, + createdDefaultModel: phase.defaultId, + enabledModelIds: stableIds, + customization, + }); + return; + } submitGuard.begin('submit'); setBusy(true); try { @@ -328,37 +402,19 @@ export function AddProviderForm(props: { } } - async function submit() { - if (submitGuard.current !== null) return; - setError(null); - const normalizedApiKey = apiKey.trim(); - const normalizedCloudflareAccountId = cloudflareAccountId.trim(); - const normalizedDefaultModel = defaultModel.trim(); - let normalizedRequestHeaders: Readonly>; - let requestBodyOverlay: ReturnType; - try { - normalizedRequestHeaders = newRequestHeaders(requestHeaders); - requestBodyOverlay = parseRequestBodyOverlay(requestBodyText); - } catch { - setAdvancedOpen(true); - return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); - } - const onboardingRoute = apiKeyOnboardingRoute({ - providerType: props.providerType, - requestHeaderCount: Object.keys(normalizedRequestHeaders).length, - hasRequestBodyOverlay: requestBodyOverlay !== undefined, - }); - if (onboardingRoute.kind === 'host' && props.apiKeyOnboardingBridge) { - if (requiresApiKey && !normalizedApiKey) { - return setError({ field: 'apiKey', message: copy.keyRequired(display.name) }); - } - if (managedPhase.kind === 'models') { - await saveManagedApiKey(normalizedApiKey, managedPhase); - } else if (managedPhase.kind === 'input') { - await verifyManagedApiKey(normalizedApiKey); - } - return; - } + /** + * Create the connection through the legacy writer, then let its catalog + * answer for itself. Both the plain form and a verified custom relay's + * picker land here — this is the writer that persists the endpoint headers + * a managed save would leave behind. + */ + async function createConnectionFromForm(input: { + normalizedApiKey: string; + createdDefaultModel: string; + /** The picker's selection, default first; omitted when the form showed none. */ + enabledModelIds?: readonly string[]; + customization: FormRequestCustomization; + }) { const issue = validateAddProviderDraft({ providerType: props.providerType, slug, @@ -374,22 +430,26 @@ export function AddProviderForm(props: { const resolvedBaseUrl = isCloudflareWorkersAi ? defaults.baseUrlTemplate?.replace( '${CLOUDFLARE_ACCOUNT_ID}', - encodeURIComponent(normalizedCloudflareAccountId), + encodeURIComponent(cloudflareAccountId.trim()), ) : baseUrl || undefined; - const createdDefaultModel = normalizedDefaultModel || recommendedDefaultModel; const created = await createProviderWithDiscovery(props.bridge, { slug, name: name || display.name, providerType: props.providerType, baseUrl: resolvedBaseUrl, ...(isCustom ? { defaultApiProtocol } : {}), - defaultModel: createdDefaultModel, - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - ...(Object.keys(normalizedRequestHeaders).length > 0 - ? { requestHeaders: normalizedRequestHeaders } + defaultModel: input.createdDefaultModel, + ...(input.enabledModelIds === undefined + ? {} + : { enabledModelIds: [...input.enabledModelIds] }), + ...(input.normalizedApiKey ? { apiKey: input.normalizedApiKey } : {}), + ...(Object.keys(input.customization.headers).length > 0 + ? { requestHeaders: input.customization.headers } : {}), - ...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }), + ...(input.customization.bodyOverlay === undefined + ? {} + : { requestBodyOverlay: input.customization.bodyOverlay }), }); if (!addProviderMountedRef.current) return; await props.onCreated(created.connection.slug, created.modelDiscoveryError); @@ -406,6 +466,50 @@ export function AddProviderForm(props: { } } + async function submit() { + if (submitGuard.current !== null) return; + setError(null); + let customization: FormRequestCustomization; + try { + customization = { + headers: newRequestHeaders(requestHeaders), + bodyOverlay: parseRequestBodyOverlay(requestBodyText), + }; + } catch { + setAdvancedOpen(true); + return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); + } + const normalizedApiKey = apiKey.trim(); + const onboardingRoute = apiKeyOnboardingRoute({ + providerType: props.providerType, + hasRequestBodyOverlay: customization.bodyOverlay !== undefined, + hasRequestHeaders: Object.keys(customization.headers).length > 0, + hasEndpoint: baseUrl.trim().length > 0, + }); + if (onboardingRoute.kind === 'host' && props.apiKeyOnboardingBridge) { + if (requiresApiKey && !normalizedApiKey) { + return setError({ field: 'apiKey', message: copy.keyRequired(display.name) }); + } + if (managedPhase.kind === 'models') { + await saveManagedApiKey(normalizedApiKey, managedPhase, customization); + return; + } + if ( + managedPhase.kind === 'input' && + (await verifyManagedApiKey(normalizedApiKey, customization)) + ) { + return; + } + } + // Either the draft never belonged to the managed route, or the probe could + // not answer a relay that the create writer is willing to save anyway. + await createConnectionFromForm({ + normalizedApiKey, + createdDefaultModel: preferredDefaultModel, + customization, + }); + } + function submitApiKey(event: FormEvent) { event.preventDefault(); void submit(); @@ -450,17 +554,23 @@ export function AddProviderForm(props: { ); - const quickUsesManagedOnboarding = Boolean( - props.apiKeyOnboardingBridge && + // Whether the first press proves the key and opens the catalog rather than + // saving. Both step-one layouts — the key-only dialog and the ordinary form + // a custom relay walks — press the same button for the same thing, so they + // read the same answer and say the same words. + const startsManagedOnboarding = Boolean( + usesManagedOnboarding && + props.apiKeyOnboardingBridge && apiKeyOnboardingRoute({ providerType: props.providerType, - requestHeaderCount: requestHeaders.length, hasRequestBodyOverlay: requestBodyText.trim().length > 0, + hasRequestHeaders: requestHeaders.length > 0, + hasEndpoint: baseUrl.trim().length > 0, }).kind === 'host', ); if ( - usesApiKeyDialog && + usesManagedOnboarding && shouldShowManagedOnboardingOutcomeUnknown(props.hasSaveUncertainty === true, busy) ) { return ( @@ -486,7 +596,7 @@ export function AddProviderForm(props: { // and the page says so up front rather than springing a second form on a // user who thought they were done. A single-step route shows no stepper: // one step is not progress. - const managedStepper = quickUsesManagedOnboarding ? ( + const managedStepper = startsManagedOnboarding ? ( ) : null; - if (usesApiKeyDialog && managedPhase.kind === 'models') { + if (usesManagedOnboarding && managedPhase.kind === 'models') { const normalizedFilter = managedPhase.filter.trim().toLocaleLowerCase(); const setFilter = (filter: string) => setManagedPhase({ ...managedPhase, filter }); const showsFilter = managedPhase.models.length > MODEL_FILTER_THRESHOLD; @@ -608,7 +718,10 @@ export function AddProviderForm(props: {
{busy ? {copy.saving} : null}
- {error?.field === 'form' && } + {/* Any error, not just `form`: this step has no slug/key/endpoint input + for a create failure to attach to, so reporting only `form` would + drop the reason the connection was refused. */} + {error && }