From 13a6137d503ce744ce88cfe72ddd71112f519750 Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Mon, 5 Oct 2026 14:04:17 +0800 Subject: [PATCH 1/2] feat(desktop): probe a custom relay with its endpoint and headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom relay has no registry endpoint and no stored headers, so its catalog could only be read after the connection existed — the create-then-discover path that #3442 is about. The managed verify→choose route every built-in provider takes was closed to it: `apiKeyOnboardingRoute` diverted a header-carrying draft to the legacy writer, and the probe had no way to say "and send these headers". Give the probe that way, then route the relay through it: - `connection.onboarding.verify` accepts an optional `requestHeaders` on a `create` target. Only a create target may carry them: an `existing` connection probes with the set it has stored, so a caller-supplied set could discover a catalog the connection itself could not fetch. The field is optional on the wire, so a caller that never sends one keeps talking to a Host that predates it — and a Host that predates it rejects a caller that does send one while decoding the frame, not mid-operation. - `beginConnectionOnboarding` pins the caller's headers for the probe and refuses them on an existing target. They are serialized through `serializeRequestHeaders`, so Maka's own headers stay rejected. - `ConnectionOnboardingSaveInput` stops extending the verify input. Save has no probe of its own to carry headers into, and inheriting a future verify-only field is exactly how one would ride along (#3299 review). - The desktop form routes a custom relay with an endpoint — and no request body overlay — to managed verification, sending its endpoint, protocol and advanced-editor headers. It still saves through create-then-discover: the managed save commits the catalog and the key, but not the endpoint headers the probe just used. A body overlay remains the one probe input the Host cannot carry, so it still diverts to the legacy writer. Refs #3442 --- .../__tests__/provider-add-submission.test.ts | 30 ++-- .../renderer/settings/provider-add-form.tsx | 165 +++++++++++++----- .../settings/provider-add-submission.ts | 39 +++-- .../connection-effect-coordinator.test.ts | 67 +++++++ .../connection-effects-protocol.test.ts | 54 ++++++ .../src/protocol/connection-effects.ts | 104 +++++++++-- .../server/connection-effect-coordinator.ts | 1 + .../storage/src/runtime-policy/coordinator.ts | 27 +++ .../storage/src/runtime-policy/operations.ts | 6 + 9 files changed, 415 insertions(+), 78 deletions(-) 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..545b377dcf8 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -268,31 +268,41 @@ 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, + 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, + hasEndpoint: false, }), { kind: 'legacy', reason: 'request_body' }); + // 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, + 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, + hasEndpoint: true, + }), { kind: 'host' }); + // The overlay is still the one probe input the Host cannot carry. + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'custom', + hasRequestBodyOverlay: true, + hasEndpoint: true, + }), { kind: 'legacy', reason: 'request_body' }); assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'cloudflare-workers-ai', - requestHeaderCount: 0, hasRequestBodyOverlay: false, + hasEndpoint: false, }), { kind: 'legacy', reason: 'cloudflare' }); }); diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 80aafac7224..5501a8a8487 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -73,12 +73,19 @@ import { createProviderWithDiscovery, apiKeyOnboardingRoute, initialOnboardingModelIds, + probeRequestHeaderUpdates, shouldShowManagedOnboardingOutcomeUnknown, stableOnboardingModels, 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 @@ -157,6 +164,10 @@ export function AddProviderForm(props: { 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,16 +231,47 @@ 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 } : {}), + }; + } + + async function verifyManagedApiKey( + normalizedApiKey: string, + customization: FormRequestCustomization, + ) { const onboarding = props.apiKeyOnboardingBridge; if (!onboarding) return; 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 (result.kind !== 'verified') { @@ -270,12 +312,25 @@ export function AddProviderForm(props: { async function saveManagedApiKey( normalizedApiKey: string, phase: Extract, + customization: FormRequestCustomization, ) { const onboarding = props.apiKeyOnboardingBridge; if (!onboarding || phase.selectedIds.length === 0) { setError({ field: 'form', message: copy.onboardingSelectModel }); return; } + // 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 chosen default is the one thing create needs. + if (isCustom) { + await createConnectionFromForm({ + normalizedApiKey, + createdDefaultModel: phase.defaultId, + customization, + }); + return; + } // Catalog order, with the chosen default first: the Host reads the head of // this list as the connection's default model. const selected = new Set(phase.selectedIds); @@ -328,37 +383,17 @@ 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; + customization: FormRequestCustomization; + }) { const issue = validateAddProviderDraft({ providerType: props.providerType, slug, @@ -374,22 +409,23 @@ 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.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 +442,44 @@ export function AddProviderForm(props: { } } + async function submit() { + if (submitGuard.current !== null) return; + setError(null); + const normalizedDefaultModel = defaultModel.trim(); + 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, + 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); + } else if (managedPhase.kind === 'input') { + await verifyManagedApiKey(normalizedApiKey, customization); + } + return; + } + await createConnectionFromForm({ + normalizedApiKey, + createdDefaultModel: normalizedDefaultModel || recommendedDefaultModel, + customization, + }); + } + function submitApiKey(event: FormEvent) { event.preventDefault(); void submit(); @@ -451,16 +525,17 @@ export function AddProviderForm(props: { ); const quickUsesManagedOnboarding = Boolean( - props.apiKeyOnboardingBridge && + usesManagedOnboarding && + props.apiKeyOnboardingBridge && apiKeyOnboardingRoute({ providerType: props.providerType, - requestHeaderCount: requestHeaders.length, hasRequestBodyOverlay: requestBodyText.trim().length > 0, + hasEndpoint: baseUrl.trim().length > 0, }).kind === 'host', ); if ( - usesApiKeyDialog && + usesManagedOnboarding && shouldShowManagedOnboardingOutcomeUnknown(props.hasSaveUncertainty === true, busy) ) { return ( @@ -497,7 +572,7 @@ export function AddProviderForm(props: { ) : 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; diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts index 28a7be0a8ff..f8d8107634e 100644 --- a/apps/desktop/src/renderer/settings/provider-add-submission.ts +++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts @@ -29,18 +29,14 @@ import { import type { CreateConnectionInput, IdentifiedLlmConnection, + RequestHeaderUpdate, SlugValidationIssue, } from '@maka/core/llm-connections'; export type ApiKeyOnboardingRoute = | { readonly kind: 'host' } | { readonly kind: 'legacy'; - readonly reason: - | 'provider_auth' - | 'custom_endpoint' - | 'cloudflare' - | 'request_headers' - | 'request_body'; + readonly reason: 'provider_auth' | 'custom_endpoint' | 'cloudflare' | 'request_body'; }; export function shouldShowManagedOnboardingOutcomeUnknown( @@ -50,11 +46,22 @@ export function shouldShowManagedOnboardingOutcomeUnknown( return hasSaveUncertainty && !busy; } -/** Decide the only writer before either writer performs a side effect. */ +/** + * Decide the only writer before either writer performs a side effect. + * + * A relay with no registry endpoint probes the one the form carries, so + * `hasEndpoint` — not the provider type alone — is what decides whether the + * Host has an endpoint to verify against. Request headers no longer divert a + * draft to the legacy path: the verify operation carries the caller's own + * headers into a create target, which is the whole reason a relay whose + * catalog needs a header can be probed before it is saved. A request body + * overlay still cannot ride the probe, so it still does. + */ export function apiKeyOnboardingRoute(input: { readonly providerType: ProviderType; - readonly requestHeaderCount: number; readonly hasRequestBodyOverlay: boolean; + /** The form carries an endpoint of its own (a custom relay's base URL). */ + readonly hasEndpoint: boolean; }): ApiKeyOnboardingRoute { const definition = PROVIDER_REGISTRY[input.providerType]; if (!providerAuthSupportsApiKey(input.providerType) || definition.authKind !== 'api_key') { @@ -63,12 +70,24 @@ export function apiKeyOnboardingRoute(input: { if (input.providerType === 'cloudflare-workers-ai') { return { kind: 'legacy', reason: 'cloudflare' }; } - if (!definition.baseUrl) return { kind: 'legacy', reason: 'custom_endpoint' }; - if (input.requestHeaderCount > 0) return { kind: 'legacy', reason: 'request_headers' }; + if (!definition.baseUrl && !input.hasEndpoint) { + return { kind: 'legacy', reason: 'custom_endpoint' }; + } if (input.hasRequestBodyOverlay) return { kind: 'legacy', reason: 'request_body' }; return { kind: 'host' }; } +/** + * The advanced request editor hands over name→value pairs; the wire carries + * header updates. They describe the same headers, so a probe and the save that + * follows it must not disagree about which shape they send. + */ +export function probeRequestHeaderUpdates( + headers: Readonly>, +): RequestHeaderUpdate[] { + return Object.entries(headers).map(([name, value]) => ({ name, value })); +} + export function stableOnboardingModels(models: readonly ModelInfo[]): ModelInfo[] { return [...models].sort((left, right) => { const leftLabel = left.displayName?.trim() || left.id; diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index 217bbb698e2..04cf1bea380 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -617,6 +617,73 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers }); }); +test('carries caller request headers into a create probe and refuses them on an existing target', async () => { + await withFixture(async ({ stores }) => { + const sent: Array> = []; + const coordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + now: () => 123, + createTransport: () => ({ + fetch: (async (_url: string, init?: RequestInit) => { + sent.push(Object.fromEntries(new Headers(init?.headers))); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }) as typeof globalThis.fetch, + close: async () => undefined, + }), + runModelDiscovery: async (_connection, _secret, options) => { + const response = await options.fetch('https://relay.example.test/v1/models'); + assert.equal(response.status, 200); + return { ok: true, models: [{ id: 'relay/model' }] }; + }, + }); + + // A relay whose catalog endpoint needs a tenant header: the probe sends the + // caller's own headers, because a create target has no stored set to fall + // back on. Without this the relay cannot be verified before it is added. + assert.deepEqual( + await coordinator.handlers['connection.onboarding.verify']( + { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + apiKey: 'relay-secret', + baseUrl: 'https://relay.example.test/v1', + requestHeaders: [{ name: 'X-Relay-Tenant', value: 'team-a' }], + }, + context, + ), + { ok: true, result: { kind: 'verified', models: [{ id: 'relay/model' }] } }, + ); + assert.deepEqual(sent, [{ 'x-relay-tenant': 'team-a' }]); + + // The probe never stands in for a set the connection would not send: an + // existing connection probes with what it has stored, so a caller-supplied + // set is not something that target can say. + const connection = await createConnection(stores, 0, { + ...connectionDraft('stored-headers', 'custom'), + baseUrl: 'https://relay.example.test/v1', + enabledModelIds: ['relay/model'], + }); + await setConnectionCredential(stores, connection, 'relay-secret'); + assert.deepEqual( + await coordinator.handlers['connection.onboarding.verify']( + { + target: { kind: 'existing', connectionId: connection.connectionId }, + apiKey: '', + baseUrl: null, + requestHeaders: [{ name: 'X-Relay-Tenant', value: 'team-a' }], + }, + context, + ), + { + ok: false, + error: { code: 'invalid_request', message: 'Connection effect request is invalid' }, + }, + ); + assert.deepEqual(sent, [{ 'x-relay-tenant': 'team-a' }]); + }); +}); + test('re-onboarding by connection identity edits a Desktop custom-slug relay in place', async () => { await withFixture(async ({ stores }) => { // Desktop can create a relay under any slug; the wizard resolves that diff --git a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts index bb44a0ba39e..fe11031d132 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -190,6 +190,60 @@ describe('Runtime Host connection effects protocol', () => { }); }); + test('carries caller request headers into a create onboarding probe, and nowhere else', () => { + const headers = [ + { name: 'X-Relay-Tenant', value: 'team-a' }, + { name: 'X-Relay-Key', value: 'probe-only' }, + ]; + const verify = request('connection.onboarding.verify', { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + requestHeaders: headers, + }); + assert.deepEqual(decodeClientFrame(verify), verify); + // Omitting them stays valid, so a caller that has none keeps talking to + // a Host that predates the field. + const headerless = request('connection.onboarding.verify', { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + }); + assert.deepEqual(decodeClientFrame(headerless), headerless); + // An existing connection probes with the headers it has stored, so a + // caller-supplied set is not something this target can say. + assertInvalidRequest('connection.onboarding.verify', { + target: { kind: 'existing', connectionId: EXPECTED.connectionId }, + apiKey: null, + baseUrl: null, + requestHeaders: headers, + }); + // A create target has no stored header set for a value-less update to + // delete, so a bare name is not a header the probe could send. + assertInvalidRequest('connection.onboarding.verify', { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + requestHeaders: [{ name: 'X-Relay-Tenant' }], + }); + // Maka owns the headers that decide credential identity and framing. + assertInvalidRequest('connection.onboarding.verify', { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + requestHeaders: [{ name: 'x-api-key', value: 'probe-only' }], + }); + // Headers are a probe input, not a save one: save spells out its own + // fields and must not inherit a verify-only field by accident. + assertInvalidRequest('connection.onboarding.save', { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + enabledModelIds: ['relay/model'], + requestHeaders: headers, + }); + }); + test('requires a stable connection identity and an explicit nullable test model', () => { const fetch = request('connection.models.fetch', { connectionId: EXPECTED.connectionId }); const connectionTest = request('connection.test.run', { diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index 8e756af9292..25bd16f3707 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -27,10 +27,13 @@ import { decodeProviderType, decodeConnectionTestSummary, decodeConnectionVersionBasis, + normalizeRequestHeaderUpdates, RuntimePolicyDomainDecodeError, + RequestCustomizationValidationError, type ConnectionVersionBasis, type ConnectionOnboardingTarget, type ModelDiscoverySource, + type RequestHeaderUpdate, } from '@maka/core/runtime-policy'; import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import { @@ -103,9 +106,33 @@ export interface ConnectionOnboardingVerifyInput { * "use the registry default or the existing connection's persisted URL". */ readonly baseUrl: string | null; + /** + * Custom request headers the caller wants this discovery to send, for a + * relay whose catalog endpoint needs them. Only a `create` target accepts + * them: an `existing` connection already has stored headers, and the probe + * must not be able to stand in for a header set the connection would not + * send. Omitted means "no caller-supplied headers", so a caller that never + * had them keeps talking to any Host vintage. + * + * The probe only *uses* these; the connection's stored headers are still + * written by `connection.setRequestHeaders`, the same way the create path + * already applies them. + */ + readonly requestHeaders?: readonly RequestHeaderUpdate[]; } -export interface ConnectionOnboardingSaveInput extends ConnectionOnboardingVerifyInput { +/** + * Deliberately not `extends ConnectionOnboardingVerifyInput`: the two inputs + * share `target`/`apiKey`/`baseUrl` today, but save must not inherit whatever + * a probe-only field adds later. `requestHeaders` is the first of those — save + * has no probe of its own to carry them into — and spelling the shared fields + * out is what keeps a future verify-only input from reaching an in-process + * save caller by accident (#3299 review). + */ +export interface ConnectionOnboardingSaveInput { + readonly target: ConnectionOnboardingTarget; + readonly apiKey: string | null; + readonly baseUrl: string | null; /** Empty enables the complete non-empty model set discovered by this Host operation. */ readonly enabledModelIds: readonly string[]; } @@ -258,17 +285,16 @@ export const CONNECTION_EFFECT_OPERATION_SPECS = { } as const; export function decodeConnectionOnboardingSaveInput(value: unknown): ConnectionOnboardingSaveInput { + // Exact, not shaped: `enabledModelIds` is the only field save adds, and + // `requestHeaders` in particular must stay a verify-only input rather than + // ride into save on a caller that happens to have probed with them. const input = requireExactRecord(value, 'connection onboarding save input', [ 'target', 'apiKey', 'baseUrl', 'enabledModelIds', ]); - const verified = decodeConnectionOnboardingVerifyInput({ - target: input.target, - apiKey: input.apiKey, - baseUrl: input.baseUrl, - }); + const target = decodeConnectionOnboardingTarget(input.target); if ( !Array.isArray(input.enabledModelIds) || input.enabledModelIds.length > CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION @@ -281,7 +307,18 @@ export function decodeConnectionOnboardingSaveInput(value: unknown): ConnectionO if (new Set(enabledModelIds).size !== enabledModelIds.length) { throw invalidProtocolFrame('Connection onboarding enabled models must be unique'); } - return { ...verified, enabledModelIds }; + return { + target, + apiKey: + input.apiKey === null + ? null + : requireString(input.apiKey, 'connection onboarding API key', 64 * 1024), + baseUrl: + input.baseUrl === null + ? null + : requireString(input.baseUrl, 'connection onboarding base URL', 2048), + enabledModelIds, + }; } export function decodeConnectionOnboardingSaveResult( @@ -343,13 +380,20 @@ export function decodeConnectionOnboardingSaveResult( export function decodeConnectionOnboardingVerifyInput( value: unknown, ): ConnectionOnboardingVerifyInput { - const input = requireExactRecord(value, 'connection onboarding verification input', [ - 'target', - 'apiKey', - 'baseUrl', - ]); + // `requestHeaders` is optional rather than always-present on the wire: a + // caller that never sends one keeps working against a Host that predates + // the field, and an older Host rejects a caller that does send one up front, + // while it is decoding this frame instead of mid-operation. + const input = requireShapedRecord( + value, + 'connection onboarding verification input', + ['target', 'apiKey', 'baseUrl'], + ['requestHeaders'], + ); + const target = decodeConnectionOnboardingTarget(input.target); + const requestHeaders = decodeOnboardingRequestHeaders(input.requestHeaders, target); return { - target: decodeConnectionOnboardingTarget(input.target), + target, apiKey: input.apiKey === null ? null @@ -358,9 +402,43 @@ export function decodeConnectionOnboardingVerifyInput( input.baseUrl === null ? null : requireString(input.baseUrl, 'connection onboarding base URL', 2048), + ...(requestHeaders === undefined ? {} : { requestHeaders }), }; } +function decodeOnboardingRequestHeaders( + value: unknown, + target: ConnectionOnboardingTarget, +): readonly RequestHeaderUpdate[] | undefined { + if (value === undefined) return undefined; + // Only a create target may carry caller-supplied headers. An existing + // connection probes with the headers it has stored, so accepting them here + // would let a caller discover a catalog the connection itself could not + // fetch. + if (target.kind !== 'create') { + throw invalidProtocolFrame( + 'Connection onboarding request headers require a create target', + ); + } + try { + return normalizeRequestHeaderUpdates(value).map((update) => { + // A create target has no stored header set for a value-less update to + // delete; discovery would silently drop it, so reject instead. + if (update.value === undefined) { + throw new RequestCustomizationValidationError( + `Request header ${update.name} must carry a value`, + ); + } + return { name: update.name, value: update.value }; + }); + } catch (error) { + if (error instanceof RequestCustomizationValidationError) { + throw invalidProtocolFrame(error.message); + } + throw error; + } +} + function decodeConnectionOnboardingTarget(value: unknown): ConnectionOnboardingTarget { const target = requireRecord(value, 'connection onboarding target'); if (target.kind === 'create') { diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 77aadbc5dd6..dde79e8763c 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -236,6 +236,7 @@ export class HostConnectionEffectCoordinator { const begun = await this.#stores.operations.beginConnectionOnboarding({ target: input.target, baseUrl: input.baseUrl, + ...(input.requestHeaders === undefined ? {} : { requestHeaders: input.requestHeaders }), }); if (begun.kind === 'target_missing') { // Identity supplied by the client names a connection that is gone or diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 4249f80f531..a00e5447549 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -1336,7 +1336,34 @@ export class RuntimePolicyCoordinator { const headersLocator = connectionRequestHeadersLocator(target.candidate.connectionId); requestHeadersCredential = credentialStatus(vault, headersLocator); if (existing) { + if (input.requestHeaders !== undefined) { + // An existing connection probes with the headers it has stored. A + // caller-supplied set would discover a catalog the connection itself + // could not fetch, so the two are mutually exclusive. + throw codecError( + 'invalid_connection_input', + 'Request headers belong to a create onboarding target', + ); + } requestHeadersSecret = findCredential(vault, headersLocator)?.secret ?? null; + } else if (input.requestHeaders !== undefined) { + // A create target has no stored header set, so the caller's own + // headers are the only material discovery can send. They are pinned + // for this probe; the connection's stored headers are still written + // through the dedicated header operation once it exists. + const updates = decodeRequestHeaderUpdates(input.requestHeaders); + if (updates.some((update) => update.value === undefined)) { + throw codecError( + 'invalid_connection_input', + 'Request headers for a create onboarding target must carry values', + ); + } + // Object.fromEntries rather than an indexed assignment: the latter + // routes `__proto__` through the prototype setter and drops the + // header instead of defining it. + requestHeadersSecret = serializeRequestHeaders( + Object.fromEntries(updates.map(({ name, value }) => [name, value as string])), + ); } // The proxy discovery will run through is pinned HERE, like // beginModelFetch pins it — re-resolving it later would let an A→B→A diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 15ea920e585..71485d684c1 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -285,6 +285,12 @@ export interface ConnectionOnboardingTicket { export interface BeginConnectionOnboardingInput { readonly target: ConnectionOnboardingTarget; readonly baseUrl: string | null; + /** + * Caller-supplied headers a create target's discovery probe should send. + * An existing connection probes with the headers it has stored, so this is + * only accepted — and only meaningful — when onboarding creates one. + */ + readonly requestHeaders?: readonly RequestHeaderUpdate[]; } /** From 0d9439d4bd81674030bd0e256e98a91df176f33c Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Tue, 6 Oct 2026 00:42:50 +0800 Subject: [PATCH 2/2] fix(desktop): carry a relay's selection and headers through its own writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed save commits a key and a catalog and nothing else, so a draft that carried advanced request headers was stored without them — and a Host that needed the header would fail the discovery it re-runs at save, having passed verify. Those drafts now keep the writer that persists them. A custom relay's probe is no longer a condition of creating it: a catalog that fails or comes back empty falls back to create-then-discover, which reports the failure instead of refusing, and the typed default model seeds the picker rather than being dropped. The picker's selection rides the create, so the connection enables what the user ticked instead of the default alone, and every error the picker step can produce is now shown. RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 207: the verify wire takes a field an older Host rejects at decode, ahead of admission. --- .../__tests__/provider-add-submission.test.ts | 29 +++++ .../runtime-host-connections-ipc-main.test.ts | 110 ++++++++++++++++ .../src/main/connections-ipc-validation.ts | 44 ++++++- .../main/runtime-host-connections-ipc-main.ts | 5 + .../renderer/settings/provider-add-form.tsx | 118 +++++++++++++----- .../settings/provider-add-submission.ts | 44 ++++++- packages/runtime-host/src/protocol/index.ts | 7 +- 7 files changed, 319 insertions(+), 38 deletions(-) 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 545b377dcf8..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, @@ -272,18 +273,30 @@ test('routes an API-key draft to Host onboarding unless a probe input cannot rid assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'openai', hasRequestBodyOverlay: false, + hasRequestHeaders: false, hasEndpoint: false, }), { kind: 'host' }); assert.deepEqual(apiKeyOnboardingRoute({ providerType: 'openai', 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', 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 @@ -291,21 +304,37 @@ test('routes an API-key draft to Host onboarding unless a probe input cannot rid 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', 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 5501a8a8487..aa19e504c0d 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -76,6 +76,7 @@ import { probeRequestHeaderUpdates, shouldShowManagedOnboardingOutcomeUnknown, stableOnboardingModels, + usesLegacyConnectionWriter, validateAddProviderDraft, type AddProviderIssue, } from './provider-add-submission'; @@ -160,6 +161,11 @@ 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; @@ -259,12 +265,18 @@ export function AddProviderForm(props: { }; } + /** + * 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 { @@ -273,8 +285,9 @@ export function AddProviderForm(props: { apiKey: normalizedApiKey || 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' @@ -282,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); @@ -319,25 +335,28 @@ export function AddProviderForm(props: { setError({ field: 'form', message: copy.onboardingSelectModel }); return; } + // Catalog order, with the chosen default first: the Host reads the head of + // 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 chosen default is the one thing create needs. + // 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; } - // Catalog order, with the chosen default first: the Host reads the head of - // this list as the connection's default model. - 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); submitGuard.begin('submit'); setBusy(true); try { @@ -392,6 +411,8 @@ export function AddProviderForm(props: { 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({ @@ -419,6 +440,9 @@ export function AddProviderForm(props: { baseUrl: resolvedBaseUrl, ...(isCustom ? { defaultApiProtocol } : {}), 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 } @@ -445,7 +469,6 @@ export function AddProviderForm(props: { async function submit() { if (submitGuard.current !== null) return; setError(null); - const normalizedDefaultModel = defaultModel.trim(); let customization: FormRequestCustomization; try { customization = { @@ -460,6 +483,7 @@ export function AddProviderForm(props: { 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) { @@ -468,14 +492,20 @@ export function AddProviderForm(props: { } if (managedPhase.kind === 'models') { await saveManagedApiKey(normalizedApiKey, managedPhase, customization); - } else if (managedPhase.kind === 'input') { - await verifyManagedApiKey(normalizedApiKey, customization); + return; + } + if ( + managedPhase.kind === 'input' && + (await verifyManagedApiKey(normalizedApiKey, customization)) + ) { + return; } - 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: normalizedDefaultModel || recommendedDefaultModel, + createdDefaultModel: preferredDefaultModel, customization, }); } @@ -524,12 +554,17 @@ export function AddProviderForm(props: { ); - const quickUsesManagedOnboarding = Boolean( + // 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, hasRequestBodyOverlay: requestBodyText.trim().length > 0, + hasRequestHeaders: requestHeaders.length > 0, hasEndpoint: baseUrl.trim().length > 0, }).kind === 'host', ); @@ -561,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 ? ( {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 && }