From 8623bb86c4ae5cc6487a5ab1ba0aaf70031c1d53 Mon Sep 17 00:00:00 2001 From: localhost-copilot <318096335+localhost-copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:53:46 -0700 Subject: [PATCH] feat(desktop): fetch custom relay models before connect Add transient model discovery for unsaved custom relay configurations, expose it through the Desktop bridge, and let users select a discovered model while preserving manual entry as fallback. --- .../runtime-host-connections-ipc-main.test.ts | 40 +++++ .../src/main/connections-ipc-validation.ts | 33 ++++ apps/desktop/src/main/runtime-host-client.ts | 6 + .../main/runtime-host-connections-ipc-main.ts | 16 ++ apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 8 +- .../locales/settings-provider-copy.ts | 2 + .../renderer/settings/provider-add-form.tsx | 153 +++++++++++++++--- .../settings/provider-panel-shared.ts | 3 + .../settings/runtime-host-settings-bridge.ts | 9 +- .../settings/provider-settings.stories.tsx | 55 ++++++- .../settings/settings-pages.stories.tsx | 5 + docs/astryx-surface-file-inventory.md | 2 +- packages/core/src/llm-connections.ts | 8 + .../connection-effect-coordinator.test.ts | 56 +++++++ .../connection-effects-protocol.test.ts | 33 ++++ .../src/__tests__/protocol.test.ts | 4 + .../src/protocol/connection-effects.ts | 30 +++- packages/runtime-host/src/protocol/index.ts | 4 +- .../server/connection-effect-coordinator.ts | 6 +- 20 files changed, 438 insertions(+), 37 deletions(-) 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 a452275c24..577eed1c91 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 @@ -49,9 +49,49 @@ test('registers pure Connection reads for replacement-Host retry', () => { 'connections:hasSecret', ]); assert.ok(effects.has('connections:create')); + assert.ok(effects.has('connections:previewModels')); assert.ok(effects.has('connections:test')); }); +test('previews unsaved custom relay models without mutating the Connection catalog', async () => { + const handlers = new Map unknown>(); + let previewInput: unknown; + let listChanges = 0; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + previewConnectionModels: async (input: unknown) => { + previewInput = input; + return { kind: 'verified', models: [{ id: 'relay-model' }] }; + }, + } as never, + emitConnectionListChanged() { + listChanges += 1; + }, + }); + + assert.deepEqual( + await handlers.get('connections:previewModels')?.({}, { + providerType: 'openai-compatible', + baseUrl: ' https://relay.example/v1 ', + apiKey: 'preview-secret', + requestHeaders: { 'X-Tenant': 'tenant-a' }, + }), + [{ id: 'relay-model' }], + ); + assert.deepEqual(previewInput, { + target: { kind: 'create', providerType: 'openai-compatible' }, + baseUrl: 'https://relay.example/v1', + apiKey: 'preview-secret', + requestHeaders: { 'X-Tenant': 'tenant-a' }, + }); + assert.equal(listChanges, 0); +}); + test('retries connection delete after a stale revision instead of failing permanently', async () => { const handlers = new Map unknown>(); let revision = 1; diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 603ee5a65a..7488f815d6 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -20,6 +20,7 @@ import { normalizeConnectionBaseUrl, type CreateConnectionInput, + type PreviewConnectionModelsInput, type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; @@ -93,6 +94,38 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn return normalizeConnectionBaseUrlForIpc(normalized); } +export function normalizePreviewConnectionModelsInputForIpc( + value: unknown, +): PreviewConnectionModelsInput { + if (typeof value !== 'object' || value === null) { + throw new Error('Invalid Connection model preview input'); + } + const input = value as Partial; + if (typeof input.providerType !== 'string' || !(input.providerType in PROVIDER_DEFAULTS)) { + throw new Error('Invalid Connection model preview provider'); + } + const apiKey = input.apiKey === undefined + ? undefined + : normalizeConnectionApiKeyForIpc(input.apiKey, 'apiKey'); + const requestHeaders = input.requestHeaders === undefined + ? undefined + : normalizeRequestHeaders(input.requestHeaders); + let baseUrl: string | undefined; + if (input.baseUrl !== undefined) { + const normalized = normalizeConnectionBaseUrl(input.baseUrl); + if (!normalized.ok || normalized.value.length === 0) { + throw new Error(normalized.ok ? 'baseUrl is required' : normalized.error); + } + baseUrl = normalized.value; + } + return { + providerType: input.providerType, + ...(baseUrl === undefined ? {} : { baseUrl }), + ...(apiKey === undefined ? {} : { apiKey }), + ...(requestHeaders === undefined ? {} : { requestHeaders }), + }; +} + export function normalizeConnectionPatchSecretsForIpc(value: unknown): UpdateConnectionInput { if (typeof value !== 'object' || value === null) throw new Error('Invalid Connection update'); const patch = value as UpdateConnectionInput; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index e772116784..e4ef09eaaa 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -467,6 +467,12 @@ export class DesktopRuntimeHostClient { return this.request("connection.models.fetch", { connectionId }); } + previewConnectionModels( + input: OperationInput<"connection.onboarding.verify">, + ): Promise> { + return this.request("connection.onboarding.verify", input); + } + testConnection( connectionId: string, modelId?: string, 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 78951d41c4..724b3b491a 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -50,6 +50,7 @@ import { normalizeConnectionPatchSecretsForIpc, normalizeConnectionSlugForIpc, normalizeCreateConnectionInputForIpc, + normalizePreviewConnectionModelsInputForIpc, } from './connections-ipc-validation.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; @@ -58,6 +59,7 @@ type HostConnectionsClient = Pick< | 'createConnection' | 'deleteCredential' | 'fetchConnectionModels' + | 'previewConnectionModels' | 'getConnectionRequestHeaders' | 'loadConnectionCatalog' | 'queryCredential' @@ -289,6 +291,20 @@ export function registerRuntimeHostConnectionsIpc( fetchedAt: result.fetchedAt, }; }); + deps.ipcMain.handle('connections:previewModels', async (_event, raw: unknown) => { + const input = normalizePreviewConnectionModelsInputForIpc(raw); + const result = await deps.client.previewConnectionModels({ + target: { kind: 'create', providerType: input.providerType }, + apiKey: input.apiKey ?? null, + baseUrl: input.baseUrl ?? null, + requestHeaders: input.requestHeaders ?? {}, + }); + if (result.kind !== 'verified') { + const reason = result.kind === 'failed' ? result.errorClass : result.reason; + throw new Error(`Unable to preview Connection models: ${reason}`); + } + return [...result.models]; + }); deps.ipcMain.handle( 'connections:test', async (_event, slug: unknown, options?: { model?: unknown }) => { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 0eb9473594..c7371be300 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1302,7 +1302,7 @@ export interface MakaBridge { update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; delete(slug: string, host?: DesktopRuntimeHostRef): Promise; test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; - fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise; + fetchModels(input: T, host?: DesktopRuntimeHostRef): Promise; hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise; getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5552ff34fd..f2fbc7cfa3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2543,8 +2543,12 @@ const makaBridge = { test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:test', slug, opts); }, - fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug); + fetchModels(input: T, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost( + host, + typeof input === 'string' ? 'connections:fetchModels' : 'connections:previewModels', + input, + ) as Promise; }, hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug); diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 6a2633bd39..c9197efe22 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -201,6 +201,7 @@ const zhCopy = { saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址', defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。', + fetchModels: '获取模型', fetchingModels: '正在获取模型…', modelsFetchFailed: '未能获取模型', modelsFetchFallback: '你仍可在下方手动填写模型 ID。', ...zhCapabilitiesCopy, }, oauthFlow: { @@ -354,6 +355,7 @@ const enCopy: ProviderSettingsCopy = { saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`, apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL', defaultModel: 'Default model', defaultModelPlaceholder: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.', + fetchModels: 'Fetch models', fetchingModels: 'Fetching models…', modelsFetchFailed: 'Could not fetch models', modelsFetchFallback: 'You can still enter a model ID manually below.', ...enCapabilitiesCopy, }, oauthFlow: { diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index a3f9c592d6..439e891bf4 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -19,6 +19,7 @@ import { useState, type FormEvent } from 'react'; import { + type ModelInfo, OPENCODE_FREE_DEFAULT_ENABLED_MODELS, type ProviderType, } from '@maka/core/llm-connections'; @@ -26,8 +27,9 @@ import { PROVIDER_DEFAULTS, deriveConnectionSlug } from '@maka/core/llm-connecti import { providerAuthRequiresSecret, providerAuthSupportsApiKey, + providerSupportsModelDiscovery, } from '@maka/core/llm-connections'; -import { Banner, HStack, VStack } from '@astryxdesign/core'; +import { Banner, HStack, Selector, VStack } from '@astryxdesign/core'; import { Collapsible } from '@astryxdesign/core/Collapsible'; import { Button, @@ -61,15 +63,27 @@ import { /* 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 + kept aligned with `AddProviderIssue` plus the three form-local fields the gate does not own. */ -type ProviderFormField = 'slug' | 'apiKey' | 'accountId' | 'baseUrl' | 'advancedRequest' | 'form'; +type ProviderFormField = + | 'slug' + | 'apiKey' + | 'accountId' + | 'baseUrl' + | 'modelDiscovery' + | 'advancedRequest' + | 'form'; type ProviderFormError = { field: ProviderFormField; message: string; }; +type ProviderFormActionState = { + active: 'submit' | 'fetch-models' | null; + discoveredModels: ModelInfo[] | null; +}; + export function AddProviderForm(props: { bridge: ConnectionsBridge; providerType: ProviderType; @@ -94,14 +108,23 @@ export function AddProviderForm(props: { const [requestBodyText, setRequestBodyText] = useState(''); const [advancedOpen, setAdvancedOpen] = useState(false); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const submitGuard = useActionGuard<'submit'>(); + const [actionState, setActionState] = useState({ + active: null, + discoveredModels: null, + }); + const submitGuard = useActionGuard<'submit' | 'fetch-models'>(); const addProviderMountedRef = useMountedRef(); + const busy = actionState.active === 'submit'; + const fetchingModels = actionState.active === 'fetch-models'; + const discoveredModels = actionState.discoveredModels; + const isCloudflareWorkersAi = props.providerType === 'cloudflare-workers-ai'; const requiresBaseUrl = !defaults.baseUrl && !isCloudflareWorkersAi; const showsDefaultModel = recommendedDefaultModel.trim() === ''; + const isCustomRelay = defaults.category === 'custom'; const isExperimental = defaults.status === 'phase3-experimental'; + const supportsRemoteDiscovery = providerSupportsModelDiscovery(props.providerType); const supportsApiKey = providerAuthSupportsApiKey(props.providerType); const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey; const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType); @@ -129,6 +152,60 @@ export function AddProviderForm(props: { return copy.accountLogin; } + function invalidateDiscoveredModels() { + setActionState((current) => ({ ...current, discoveredModels: null })); + clearFieldError('modelDiscovery'); + } + + async function fetchModelOptions() { + if (submitGuard.current !== null) return; + setError(null); + const normalizedApiKey = apiKey.trim(); + if (requiresApiKey && !normalizedApiKey) { + return setError({ field: 'apiKey', message: copy.keyRequired(display.name) }); + } + const normalizedBaseUrl = baseUrl.trim(); + if (requiresBaseUrl && !normalizedBaseUrl) { + return setError({ field: 'baseUrl', message: copy.endpointRequired }); + } + let normalizedRequestHeaders: Readonly>; + try { + normalizedRequestHeaders = newRequestHeaders(requestHeaders); + } catch { + setAdvancedOpen(true); + return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); + } + submitGuard.begin('fetch-models'); + setActionState((current) => ({ ...current, active: 'fetch-models' })); + try { + const models = await props.bridge.previewModels({ + providerType: props.providerType, + ...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}), + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + ...(Object.keys(normalizedRequestHeaders).length > 0 + ? { requestHeaders: normalizedRequestHeaders } + : {}), + }); + if (!addProviderMountedRef.current) return; + setActionState((current) => ({ ...current, discoveredModels: models })); + setDefaultModel((current) => + models.some((model) => model.id === current) ? current : models[0]!.id, + ); + } catch (fetchError) { + if (!addProviderMountedRef.current) return; + setActionState((current) => ({ ...current, discoveredModels: null })); + setError({ + field: 'modelDiscovery', + message: providerPanelActionErrorMessage(fetchError, locale), + }); + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current) { + setActionState((current) => ({ ...current, active: null })); + } + } + } + async function submit() { if (submitGuard.current !== null) return; setError(null); @@ -154,7 +231,7 @@ export function AddProviderForm(props: { return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); } submitGuard.begin('submit'); - setBusy(true); + setActionState((current) => ({ ...current, active: 'submit' })); try { const resolvedBaseUrl = isCloudflareWorkersAi ? defaults.baseUrlTemplate?.replace( @@ -189,7 +266,9 @@ export function AddProviderForm(props: { } } finally { submitGuard.finish(); - if (addProviderMountedRef.current) setBusy(false); + if (addProviderMountedRef.current) { + setActionState((current) => ({ ...current, active: null })); + } } } @@ -210,13 +289,14 @@ export function AddProviderForm(props: { onHeadersChange={(headers) => { setRequestHeaders(headers); clearFieldError('advancedRequest'); + invalidateDiscoveredModels(); }} bodyText={requestBodyText} onBodyTextChange={(value) => { setRequestBodyText(value); clearFieldError('advancedRequest'); }} - disabled={busy} + disabled={busy || fetchingModels} copy={{ headers: copy.requestHeaders, headerName: copy.headerName, @@ -244,6 +324,7 @@ export function AddProviderForm(props: { onChange={(next) => { setApiKey(next); clearFieldError('apiKey'); + invalidateDiscoveredModels(); }} placeholder={copy.apiKeyPlaceholder} label={copy.apiKeyLabel} @@ -284,12 +365,13 @@ export function AddProviderForm(props: { onChange={(next) => { setApiKey(next); clearFieldError('apiKey'); + invalidateDiscoveredModels(); }} placeholder={copy.apiKeyPlaceholder} label={copy.apiKeyLabel} isRequired={requiresApiKey} isOptional={!requiresApiKey} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} status={ error?.field === 'apiKey' ? { type: 'error', message: error.message } @@ -342,9 +424,10 @@ export function AddProviderForm(props: { onChange={(value) => { setBaseUrl(value); clearFieldError('baseUrl'); + invalidateDiscoveredModels(); }} placeholder={defaults.baseUrl || 'https://…'} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} label={copy.endpointLabel} isRequired={requiresBaseUrl} status={ @@ -355,14 +438,46 @@ export function AddProviderForm(props: { /> )} {showsDefaultModel && ( - + discoveredModels ? ( + ({ + value: model.id, + label: model.displayName ?? model.id, + description: model.displayName ? model.id : undefined, + }))} + width="100%" + isDisabled={isExperimental || busy || fetchingModels} + onChange={setDefaultModel} + /> + ) : ( + + ) + )} + {isCustomRelay && supportsRemoteDiscovery && ( + +