From f19053f17c15f31cdf1535d559894249da62e298 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 Let custom compatible providers discover models before saving by extending the existing onboarding verification with transient request headers. Keep manual model entry alongside discovered choices and lock discovery inputs while the request is in flight. --- .../settings-provider-copy.ts | 6 + .../renderer/settings/provider-add-form.tsx | 162 +++++++++++++++--- .../settings/provider-settings.stories.tsx | 83 +++++++-- .../connection-effect-coordinator.test.ts | 56 ++++++ .../connection-effects-protocol.test.ts | 40 +++++ .../src/__tests__/protocol.test.ts | 4 + .../src/protocol/connection-effects.ts | 28 ++- packages/runtime-host/src/protocol/index.ts | 4 +- .../server/connection-effect-coordinator.ts | 6 +- 9 files changed, 348 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index da6a223f72..9909a4ed55 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -231,6 +231,8 @@ 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: '正在获取模型…', discoveredModels: '已获取的模型', chooseDiscoveredModel: '选择一个已获取的模型', + modelsFetchFailed: '未能获取模型', modelsFetchFallback: '你仍可在上方手动填写模型 ID。', stepsAria: '添加连接步骤', stepCredentials: '密钥', stepModels: '选择模型', onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…', onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改', @@ -400,6 +402,8 @@ const zhTwCopy = { saving: '儲存中…', save: '儲存供應商', keyRequired: (name: string) => `請填寫 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服務地址', defaultModel: '預設模型', defaultModelPlaceholder: '留空即可,儲存後自動拉取', defaultModelHelp: '儲存後 Maka 會向該端點拉取模型目錄。只有當端點不提供目錄時,才需要在這裡手填一個模型 ID。', + fetchModels: '取得模型', fetchingModels: '正在取得模型…', discoveredModels: '已取得的模型', chooseDiscoveredModel: '選擇一個已取得的模型', + modelsFetchFailed: '無法取得模型', modelsFetchFallback: '你仍可在上方手動填寫模型 ID。', stepsAria: '新增連線步驟', stepCredentials: '金鑰', stepModels: '選擇模型', onboardingVerifyAndChoose: '驗證並選擇模型', onboardingVerifying: '正在驗證金鑰並取得模型…', onboardingChooseModels: '選擇此連線使用的模型', onboardingChooseModelsHelp: '新增後仍可在連線詳細資料中啟用其他模型。', onboardingEnabledModels: '啟用的模型', onboardingSearchModels: '搜尋模型', onboardingAddConnection: '新增連線', onboardingBack: '返回修改', @@ -570,6 +574,8 @@ 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…', discoveredModels: 'Discovered models', chooseDiscoveredModel: 'Choose a discovered model', + modelsFetchFailed: 'Could not fetch models', modelsFetchFallback: 'You can still enter a model ID manually above.', stepsAria: 'Steps to add the connection', stepCredentials: 'Key', stepModels: 'Choose models', onboardingVerifyAndChoose: 'Verify and choose models', onboardingVerifying: 'Verifying the key and loading models…', onboardingChooseModels: 'Choose models for this connection', onboardingChooseModelsHelp: 'You can enable more models from the connection details later.', onboardingEnabledModels: 'Enabled models', onboardingSearchModels: 'Search models', onboardingAddConnection: 'Add connection', onboardingBack: 'Back to edit', diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 1588995847..4cb8e1c956 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -18,11 +18,12 @@ */ import { useState, type FormEvent } from 'react'; -import type { ProviderType } from '@maka/core/llm-connections'; +import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, + providerSupportsModelDiscovery, } from '@maka/core/llm-connections'; import { Banner, @@ -75,9 +76,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; @@ -130,19 +138,25 @@ export function AddProviderForm(props: { const [formState, setFormState] = useState<{ readonly managedPhase: ManagedOnboardingPhase; readonly error: ProviderFormError | null; + readonly fetchingModels: boolean; + readonly discoveredModels: ModelInfo[] | null; }>(() => ({ managedPhase: { kind: 'input' }, error: null, + fetchingModels: false, + discoveredModels: null, })); - const { managedPhase, error } = formState; + const { managedPhase, error, fetchingModels, discoveredModels } = formState; const [busy, setBusy] = useState(false); - const submitGuard = useActionGuard<'submit'>(); + 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); @@ -174,6 +188,14 @@ export function AddProviderForm(props: { ); } + function invalidateDiscoveredModels() { + setFormState((current) => ({ + ...current, + discoveredModels: null, + error: current.error?.field === 'modelDiscovery' ? null : current.error, + })); + } + // The localized sentence for one field gate. The gate itself is in // provider-add-submission, so the order and the rules are testable without // a locale in the assertion. @@ -214,6 +236,67 @@ export function AddProviderForm(props: { return copy.onboardingUnavailable; } + async function fetchModelOptions() { + const onboarding = props.apiKeyOnboardingBridge; + if (!onboarding || 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'); + setFormState((current) => ({ + ...current, + fetchingModels: true, + discoveredModels: null, + })); + try { + const result = await onboarding.verify({ + target: { kind: 'create', providerType: props.providerType }, + apiKey: normalizedApiKey || null, + baseUrl: normalizedBaseUrl || null, + ...(Object.keys(normalizedRequestHeaders).length > 0 + ? { requestHeaders: normalizedRequestHeaders } + : {}), + }); + if (!addProviderMountedRef.current) return; + if (result.kind !== 'verified') { + setError({ field: 'modelDiscovery', message: onboardingFailureMessage(result) }); + return; + } + const models = stableOnboardingModels(result.models); + if (models.length === 0) { + setError({ field: 'modelDiscovery', message: copy.onboardingNoModels }); + return; + } + setFormState((current) => ({ ...current, discoveredModels: models })); + setDefaultModel((current) => current.trim() || models[0]!.id); + } catch (fetchError) { + if (!addProviderMountedRef.current) return; + setFormState((current) => ({ ...current, discoveredModels: null })); + setError({ + field: 'modelDiscovery', + message: providerPanelActionErrorMessage(fetchError, locale), + }); + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current) { + setFormState((current) => ({ ...current, fetchingModels: false })); + } + } + } + async function verifyManagedApiKey(normalizedApiKey: string) { const onboarding = props.apiKeyOnboardingBridge; if (!onboarding) return; @@ -416,6 +499,7 @@ export function AddProviderForm(props: { onHeadersChange={(headers) => { setRequestHeaders(headers); resetManagedVerification(); + invalidateDiscoveredModels(); clearFieldError('advancedRequest'); }} bodyText={requestBodyText} @@ -424,7 +508,7 @@ export function AddProviderForm(props: { resetManagedVerification(); clearFieldError('advancedRequest'); }} - disabled={busy} + disabled={busy || fetchingModels} copy={{ headers: copy.requestHeaders, headerName: copy.headerName, @@ -633,6 +717,7 @@ export function AddProviderForm(props: { onChange={(next) => { setApiKey(next); resetManagedVerification(); + invalidateDiscoveredModels(); clearFieldError('apiKey'); }} placeholder={copy.apiKeyPlaceholder} @@ -699,7 +784,7 @@ export function AddProviderForm(props: { label={copy.apiKeyLabel} isRequired={requiresApiKey} isOptional={!requiresApiKey} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} status={ error?.field === 'apiKey' ? { type: 'error', message: error.message } @@ -757,10 +842,11 @@ export function AddProviderForm(props: { onChange={(value) => { setBaseUrl(value); resetManagedVerification(); + invalidateDiscoveredModels(); clearFieldError('baseUrl'); }} placeholder={defaults.baseUrl || 'https://…'} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} label={copy.endpointLabel} isRequired={requiresBaseUrl} status={ @@ -771,17 +857,53 @@ export function AddProviderForm(props: { /> )} {showsDefaultModel && ( - { - setDefaultModel(value); - resetManagedVerification(); - }} - placeholder={copy.defaultModelPlaceholder} - isDisabled={isExperimental || busy} - label={copy.defaultModel} - description={copy.defaultModelHelp} - /> + + { + setDefaultModel(value); + resetManagedVerification(); + }} + placeholder={copy.defaultModelPlaceholder} + isDisabled={isExperimental || busy || fetchingModels} + label={copy.defaultModel} + description={copy.defaultModelHelp} + /> + {isCustomRelay && supportsRemoteDiscovery && props.apiKeyOnboardingBridge && ( + <> +