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 8f62b36c3d..505761c998 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 @@ -81,9 +81,49 @@ test('registers pure Connection reads for replacement-Host retry', () => { assert.ok(effects.has('connections:create')); assert.ok(effects.has('connections:onboardingVerify')); assert.ok(effects.has('connections:onboardingSave')); + 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('forwards managed onboarding and emits only after a canonical save', async () => { const handlers = new Map unknown>(); const calls: unknown[] = []; diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 3c0b7a3e71..6d49d2af4b 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'; @@ -95,6 +96,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' || providerDefaultsOf(input.providerType) === undefined) { + 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 54e5932172..bc89d17746 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -509,6 +509,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 deeb731da8..0b9a6eff36 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -58,6 +58,7 @@ import { normalizeConnectionPatchSecretsForIpc, normalizeConnectionSlugForIpc, normalizeCreateConnectionInputForIpc, + normalizePreviewConnectionModelsInputForIpc, } from './connections-ipc-validation.js'; import type { DesktopConnectionIdentity, @@ -70,6 +71,7 @@ type HostConnectionsClient = Pick< | 'createConnection' | 'deleteCredential' | 'fetchConnectionModels' + | 'previewConnectionModels' | 'getConnectionRequestHeaders' | 'loadConnectionCatalog' | 'queryCredential' @@ -343,6 +345,20 @@ export function registerRuntimeHostConnectionsIpc( const latest = requireConnectionIdentity(await snapshot(), connectionIdentity(current)); return { models: [...latest.models], source: result.source }; }); + 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, identity: 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 8c4c5431d9..b443be5ebf 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1401,7 +1401,7 @@ export interface MakaBridge { update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; - fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise>; + fetchModels(input: T, host?: DesktopRuntimeHostRef): Promise : import('@maka/core/llm-connections').ModelInfo[]>; hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 10949441a2..17e0c56394 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2698,8 +2698,12 @@ const makaBridge = { opts, ); }, - fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise> { - return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection); + fetchModels(input: T, host?: DesktopRuntimeHostRef): Promise : import('@maka/core/llm-connections').ModelInfo[]> { + return invokeSelectedRuntimeHost( + host, + 'connectionId' in input ? 'connections:fetchModels' : 'connections:previewModels', + input, + ) as Promise : import('@maka/core/llm-connections').ModelInfo[]>; }, hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection); diff --git a/apps/desktop/src/renderer/features/connection-settings/ports.ts b/apps/desktop/src/renderer/features/connection-settings/ports.ts index d6980a953b..8cb7f8f813 100644 --- a/apps/desktop/src/renderer/features/connection-settings/ports.ts +++ b/apps/desktop/src/renderer/features/connection-settings/ports.ts @@ -23,6 +23,8 @@ import type { IdentifiedLlmConnection, LlmConnection, ModelDiscoveryResult, + ModelInfo, + PreviewConnectionModelsInput, RequestHeaderUpdate, SavedRequestHeaders, UpdateConnectionInput, @@ -57,6 +59,7 @@ export interface ConnectionsBridge { fetchModels(connection: DesktopConnectionIdentity): Promise< Pick >; + previewModels(input: PreviewConnectionModelsInput): Promise; hasSecret(connection: DesktopConnectionIdentity): Promise; getRequestHeaders(connection: DesktopConnectionIdentity): Promise; setRequestHeaders( 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 d46309144e..8312a17b61 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 @@ -203,6 +203,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。', onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…', onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改', onboardingAuthFailed: '密钥验证失败,请检查后重试。', onboardingTimeout: '验证超时,请检查网络或代理后重试。', onboardingNetwork: '无法连接模型服务,请检查网络、代理或服务地址。', onboardingUnavailable: '模型连接服务暂时不可用,请稍后重试。', onboardingInvalidResponse: '模型服务返回了无法识别的结果,请稍后重试。', onboardingCatalogFull: '连接数量已达上限,请先删除不再使用的连接。', onboardingModelsChanged: '可用模型已发生变化,请重新验证后选择。', onboardingNoModels: '没有发现可用模型,当前未创建连接。', onboardingSelectModel: '请至少启用一个模型。', @@ -363,6 +364,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.', 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', onboardingAuthFailed: 'The key could not be verified. Check it and try again.', onboardingTimeout: 'Verification timed out. Check the network or proxy and try again.', onboardingNetwork: 'Could not reach the model provider. Check the network, proxy, or service URL.', onboardingUnavailable: 'The model connection service is unavailable. Try again later.', onboardingInvalidResponse: 'The model provider returned an unrecognized response. Try again later.', onboardingCatalogFull: 'The connection limit has been reached. Remove an unused connection first.', onboardingModelsChanged: 'The available models changed. Verify again and make a new selection.', onboardingNoModels: 'No usable models were found. No connection was created.', onboardingSelectModel: 'Enable at least one model.', diff --git a/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts b/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts index 781261546b..1f317751fc 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts @@ -44,6 +44,7 @@ export function createDesktopConnectionSettingsServices( delete: (connection) => bridge().connections.delete(connection, host), test: (connection, options) => bridge().connections.test(connection, options, host), fetchModels: (connection) => bridge().connections.fetchModels(connection, host), + previewModels: (input) => bridge().connections.fetchModels(input, host), hasSecret: (connection) => bridge().connections.hasSecret(connection, host), getRequestHeaders: (connection) => bridge().connections.getRequestHeaders(connection, host), setRequestHeaders: (connection, headers) => diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 78b0ecc6de..5f208411f4 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -18,13 +18,14 @@ */ 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, HStack, MultiSelector, Text, VStack } from '@astryxdesign/core'; +import { Banner, HStack, MultiSelector, Selector, Text, VStack } from '@astryxdesign/core'; import { Collapsible } from '@astryxdesign/core/Collapsible'; import { Button, @@ -64,9 +65,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; @@ -111,19 +119,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); @@ -155,6 +169,11 @@ export function AddProviderForm(props: { ); } + function invalidateDiscoveredModels() { + setFormState((current) => ({ ...current, discoveredModels: null })); + clearFieldError('modelDiscovery'); + } + // 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. @@ -195,6 +214,55 @@ export function AddProviderForm(props: { return copy.onboardingUnavailable; } + 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'); + setFormState((current) => ({ ...current, fetchingModels: 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; + setFormState((current) => ({ ...current, discoveredModels: models })); + setDefaultModel((current) => + models.some((model) => model.id === current) ? current : 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; @@ -391,6 +459,7 @@ export function AddProviderForm(props: { setRequestHeaders(headers); resetManagedVerification(); clearFieldError('advancedRequest'); + invalidateDiscoveredModels(); }} bodyText={requestBodyText} onBodyTextChange={(value) => { @@ -398,7 +467,7 @@ export function AddProviderForm(props: { resetManagedVerification(); clearFieldError('advancedRequest'); }} - disabled={busy} + disabled={busy || fetchingModels} copy={{ headers: copy.requestHeaders, headerName: copy.headerName, @@ -509,6 +578,7 @@ export function AddProviderForm(props: { setApiKey(next); resetManagedVerification(); clearFieldError('apiKey'); + invalidateDiscoveredModels(); }} placeholder={copy.apiKeyPlaceholder} label={copy.apiKeyLabel} @@ -568,12 +638,13 @@ export function AddProviderForm(props: { setApiKey(next); resetManagedVerification(); 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 } @@ -632,9 +703,10 @@ export function AddProviderForm(props: { setBaseUrl(value); resetManagedVerification(); clearFieldError('baseUrl'); + invalidateDiscoveredModels(); }} placeholder={defaults.baseUrl || 'https://…'} - isDisabled={isExperimental || busy} + isDisabled={isExperimental || busy || fetchingModels} label={copy.endpointLabel} isRequired={requiresBaseUrl} status={ @@ -645,17 +717,49 @@ export function AddProviderForm(props: { /> )} {showsDefaultModel && ( - { - setDefaultModel(value); - resetManagedVerification(); - }} - placeholder={copy.defaultModelPlaceholder} - isDisabled={isExperimental || busy} - label={copy.defaultModel} - description={copy.defaultModelHelp} - /> + discoveredModels ? ( + ({ + value: model.id, + label: model.displayName ?? model.id, + description: model.displayName ? model.id : undefined, + }))} + width="100%" + isDisabled={isExperimental || busy || fetchingModels} + onChange={setDefaultModel} + /> + ) : ( + { + setDefaultModel(value); + resetManagedVerification(); + }} + placeholder={copy.defaultModelPlaceholder} + isDisabled={isExperimental || busy || fetchingModels} + label={copy.defaultModel} + description={copy.defaultModelHelp} + /> + ) + )} + {isCustomRelay && supportsRemoteDiscovery && ( + +