From d9a595ea539395caee746e00a519d098351e6465 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 | 1 + apps/desktop/src/preload/preload.ts | 3 + .../locales/settings-provider-copy.ts | 2 + .../renderer/settings/provider-add-form.tsx | 125 ++++++++++++++++-- .../settings/provider-panel-shared.ts | 3 + .../settings/runtime-host-settings-bridge.ts | 1 + .../settings/provider-settings.stories.tsx | 5 + .../settings/settings-pages.stories.tsx | 5 + packages/core/src/llm-connections.ts | 8 ++ .../connection-effect-coordinator.test.ts | 56 ++++++++ .../connection-effects-protocol.test.ts | 33 +++++ .../src/__tests__/protocol.test.ts | 8 +- .../src/protocol/connection-effects.ts | 38 +++++- packages/runtime-host/src/protocol/index.ts | 5 +- .../server/connection-effect-coordinator.ts | 17 ++- 19 files changed, 382 insertions(+), 23 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 a994f2362c..a3c739112d 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 @@ -30,9 +30,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, { + 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 3fe667738e..78a8d32b0b 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -1,6 +1,7 @@ import { normalizeConnectionBaseUrl, type CreateConnectionInput, + type PreviewConnectionModelsInput, type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; @@ -74,6 +75,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 c5248220a2..53a8b403f3 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -375,6 +375,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 97f57bd631..788854f2b4 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -30,6 +30,7 @@ import { normalizeConnectionPatchSecretsForIpc, normalizeConnectionSlugForIpc, normalizeCreateConnectionInputForIpc, + normalizePreviewConnectionModelsInputForIpc, } from './connections-ipc-validation.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; @@ -38,6 +39,7 @@ type HostConnectionsClient = Pick< | 'createConnection' | 'deleteCredential' | 'fetchConnectionModels' + | 'previewConnectionModels' | 'getConnectionRequestHeaders' | 'loadConnectionCatalog' | 'queryCredential' @@ -269,6 +271,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({ + providerType: input.providerType, + apiKey: input.apiKey ?? null, + ...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }), + ...(input.requestHeaders === undefined ? {} : { 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 8eed5626c0..6d7add61a3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -798,6 +798,7 @@ export interface MakaBridge { delete(slug: string, host?: DesktopRuntimeHostRef): Promise; test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise; + previewModels(input: import('@maka/core/llm-connections').PreviewConnectionModelsInput, 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 231dacbf2b..b75e6ebd04 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2064,6 +2064,9 @@ const makaBridge = { fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug); }, + previewModels(input: import('@maka/core/llm-connections').PreviewConnectionModelsInput, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:previewModels', input); + }, 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 3247af53ef..18020e2cce 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -174,6 +174,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: { @@ -319,6 +320,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 db712c6305..30686f7d2e 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent } from 'react'; import { + type ModelInfo, OPENCODE_FREE_DEFAULT_ENABLED_MODELS, type ProviderType, } from '@maka/core/llm-connections'; @@ -7,8 +8,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, @@ -42,9 +44,16 @@ 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; @@ -71,18 +80,22 @@ export function AddProviderForm(props: { const [cloudflareAccountId, setCloudflareAccountId] = useState(''); const [apiKey, setApiKey] = useState(''); const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel); + const [discoveredModels, setDiscoveredModels] = useState(null); const [requestHeaders, setRequestHeaders] = useState([]); const [requestBodyText, setRequestBodyText] = useState(''); const [advancedOpen, setAdvancedOpen] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const submitGuard = useActionGuard<'submit'>(); + const [fetchingModels, setFetchingModels] = useState(false); + const submitGuard = useActionGuard<'submit' | 'fetch-models'>(); const addProviderMountedRef = useMountedRef(); 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); @@ -110,6 +123,58 @@ export function AddProviderForm(props: { return copy.accountLogin; } + function invalidateDiscoveredModels() { + setDiscoveredModels(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'); + setFetchingModels(true); + 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; + setDiscoveredModels(models); + setDefaultModel((current) => + models.some((model) => model.id === current) ? current : models[0]!.id, + ); + } catch (fetchError) { + if (!addProviderMountedRef.current) return; + setDiscoveredModels(null); + setError({ + field: 'modelDiscovery', + message: providerPanelActionErrorMessage(fetchError, locale), + }); + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current) setFetchingModels(false); + } + } + async function submit() { if (submitGuard.current !== null) return; setError(null); @@ -191,6 +256,7 @@ export function AddProviderForm(props: { onHeadersChange={(headers) => { setRequestHeaders(headers); clearFieldError('advancedRequest'); + invalidateDiscoveredModels(); }} bodyText={requestBodyText} onBodyTextChange={(value) => { @@ -225,6 +291,7 @@ export function AddProviderForm(props: { onChange={(next) => { setApiKey(next); clearFieldError('apiKey'); + invalidateDiscoveredModels(); }} placeholder={copy.apiKeyPlaceholder} label={copy.apiKeyLabel} @@ -265,6 +332,7 @@ export function AddProviderForm(props: { onChange={(next) => { setApiKey(next); clearFieldError('apiKey'); + invalidateDiscoveredModels(); }} placeholder={copy.apiKeyPlaceholder} label={copy.apiKeyLabel} @@ -323,6 +391,7 @@ export function AddProviderForm(props: { onChange={(value) => { setBaseUrl(value); clearFieldError('baseUrl'); + invalidateDiscoveredModels(); }} placeholder={defaults.baseUrl || 'https://…'} isDisabled={isExperimental || busy} @@ -336,14 +405,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 && ( + +