Skip to content

Commit 89642f0

Browse files
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.
1 parent f37f5c3 commit 89642f0

20 files changed

Lines changed: 361 additions & 19 deletions

apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,50 @@ test('registers pure Connection reads for replacement-Host retry', () => {
4949
'connections:hasSecret',
5050
]);
5151
assert.ok(effects.has('connections:create'));
52+
assert.ok(effects.has('connections:previewModels'));
5253
assert.ok(effects.has('connections:test'));
5354
});
5455

56+
test('previews unsaved custom relay models without mutating the Connection catalog', async () => {
57+
const handlers = new Map<string, (...args: unknown[]) => unknown>();
58+
let previewInput: unknown;
59+
let listChanges = 0;
60+
registerRuntimeHostConnectionsIpc({
61+
ipcMain: {
62+
handle: (channel, handler) => {
63+
handlers.set(channel, handler as (...args: unknown[]) => unknown);
64+
},
65+
},
66+
client: {
67+
previewConnectionModels: async (input: unknown) => {
68+
previewInput = input;
69+
return { kind: 'verified', models: [{ id: 'relay-model' }] };
70+
},
71+
} as never,
72+
emitConnectionListChanged() {
73+
listChanges += 1;
74+
},
75+
});
76+
77+
assert.deepEqual(
78+
await handlers.get('connections:previewModels')?.({}, {
79+
providerType: 'openai-compatible',
80+
baseUrl: ' https://relay.example/v1 ',
81+
apiKey: 'preview-secret',
82+
requestHeaders: { 'X-Tenant': 'tenant-a' },
83+
}),
84+
[{ id: 'relay-model' }],
85+
);
86+
assert.deepEqual(previewInput, {
87+
providerType: 'openai-compatible',
88+
connectionId: null,
89+
baseUrl: 'https://relay.example/v1',
90+
apiKey: 'preview-secret',
91+
requestHeaders: { 'X-Tenant': 'tenant-a' },
92+
});
93+
assert.equal(listChanges, 0);
94+
});
95+
5596
test('retries connection delete after a stale revision instead of failing permanently', async () => {
5697
const handlers = new Map<string, (...args: unknown[]) => unknown>();
5798
let revision = 1;

apps/desktop/src/main/connections-ipc-validation.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import {
2121
normalizeConnectionBaseUrl,
2222
type CreateConnectionInput,
23+
type PreviewConnectionModelsInput,
2324
type UpdateConnectionInput,
2425
} from '@maka/core/llm-connections';
2526
import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy';
@@ -93,6 +94,38 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn
9394
return normalizeConnectionBaseUrlForIpc(normalized);
9495
}
9596

97+
export function normalizePreviewConnectionModelsInputForIpc(
98+
value: unknown,
99+
): PreviewConnectionModelsInput {
100+
if (typeof value !== 'object' || value === null) {
101+
throw new Error('Invalid Connection model preview input');
102+
}
103+
const input = value as Partial<PreviewConnectionModelsInput>;
104+
if (typeof input.providerType !== 'string' || !(input.providerType in PROVIDER_DEFAULTS)) {
105+
throw new Error('Invalid Connection model preview provider');
106+
}
107+
const apiKey = input.apiKey === undefined
108+
? undefined
109+
: normalizeConnectionApiKeyForIpc(input.apiKey, 'apiKey');
110+
const requestHeaders = input.requestHeaders === undefined
111+
? undefined
112+
: normalizeRequestHeaders(input.requestHeaders);
113+
let baseUrl: string | undefined;
114+
if (input.baseUrl !== undefined) {
115+
const normalized = normalizeConnectionBaseUrl(input.baseUrl);
116+
if (!normalized.ok || normalized.value.length === 0) {
117+
throw new Error(normalized.ok ? 'baseUrl is required' : normalized.error);
118+
}
119+
baseUrl = normalized.value;
120+
}
121+
return {
122+
providerType: input.providerType,
123+
...(baseUrl === undefined ? {} : { baseUrl }),
124+
...(apiKey === undefined ? {} : { apiKey }),
125+
...(requestHeaders === undefined ? {} : { requestHeaders }),
126+
};
127+
}
128+
96129
export function normalizeConnectionPatchSecretsForIpc(value: unknown): UpdateConnectionInput {
97130
if (typeof value !== 'object' || value === null) throw new Error('Invalid Connection update');
98131
const patch = value as UpdateConnectionInput;

apps/desktop/src/main/runtime-host-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,12 @@ export class DesktopRuntimeHostClient {
403403
return this.request("connection.models.fetch", { connectionId });
404404
}
405405

406+
previewConnectionModels(
407+
input: OperationInput<"connection.onboarding.verify">,
408+
): Promise<OperationOutput<"connection.onboarding.verify">> {
409+
return this.request("connection.onboarding.verify", input);
410+
}
411+
406412
testConnection(
407413
connectionId: string,
408414
modelId?: string,

apps/desktop/src/main/runtime-host-connections-ipc-main.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
normalizeConnectionPatchSecretsForIpc,
5050
normalizeConnectionSlugForIpc,
5151
normalizeCreateConnectionInputForIpc,
52+
normalizePreviewConnectionModelsInputForIpc,
5253
} from './connections-ipc-validation.js';
5354
import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js';
5455

@@ -57,6 +58,7 @@ type HostConnectionsClient = Pick<
5758
| 'createConnection'
5859
| 'deleteCredential'
5960
| 'fetchConnectionModels'
61+
| 'previewConnectionModels'
6062
| 'getConnectionRequestHeaders'
6163
| 'loadConnectionCatalog'
6264
| 'queryCredential'
@@ -288,6 +290,21 @@ export function registerRuntimeHostConnectionsIpc(
288290
fetchedAt: result.fetchedAt,
289291
};
290292
});
293+
deps.ipcMain.handle('connections:previewModels', async (_event, raw: unknown) => {
294+
const input = normalizePreviewConnectionModelsInputForIpc(raw);
295+
const result = await deps.client.previewConnectionModels({
296+
providerType: input.providerType,
297+
connectionId: null,
298+
apiKey: input.apiKey ?? null,
299+
baseUrl: input.baseUrl ?? null,
300+
requestHeaders: input.requestHeaders ?? {},
301+
});
302+
if (result.kind !== 'verified') {
303+
const reason = result.kind === 'failed' ? result.errorClass : result.reason;
304+
throw new Error(`Unable to preview Connection models: ${reason}`);
305+
}
306+
return [...result.models];
307+
});
291308
deps.ipcMain.handle(
292309
'connections:test',
293310
async (_event, slug: unknown, options?: { model?: unknown }) => {

apps/desktop/src/preload/bridge-contract.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ export interface MakaBridge {
960960
delete(slug: string, host?: DesktopRuntimeHostRef): Promise<void>;
961961
test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
962962
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult>;
963+
previewModels(input: import('@maka/core/llm-connections').PreviewConnectionModelsInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').ModelInfo[]>;
963964
hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean>;
964965
getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
965966
setRequestHeaders(

apps/desktop/src/preload/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,6 +2202,9 @@ const makaBridge = {
22022202
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
22032203
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug);
22042204
},
2205+
previewModels(input: import('@maka/core/llm-connections').PreviewConnectionModelsInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').ModelInfo[]> {
2206+
return invokeSelectedRuntimeHost(host, 'connections:previewModels', input);
2207+
},
22052208
hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean> {
22062209
return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug);
22072210
},

apps/desktop/src/renderer/locales/settings-provider-copy.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const zhCopy = {
195195
saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`,
196196
apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址',
197197
defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。',
198+
fetchModels: '获取模型', fetchingModels: '正在获取模型…', modelsFetchFailed: '未能获取模型', modelsFetchFallback: '你仍可在下方手动填写模型 ID。',
198199
...zhCapabilitiesCopy,
199200
},
200201
oauthFlow: {
@@ -342,6 +343,7 @@ const enCopy: ProviderSettingsCopy = {
342343
saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`,
343344
apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL',
344345
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.',
346+
fetchModels: 'Fetch models', fetchingModels: 'Fetching models…', modelsFetchFailed: 'Could not fetch models', modelsFetchFallback: 'You can still enter a model ID manually below.',
345347
...enCapabilitiesCopy,
346348
},
347349
oauthFlow: {

apps/desktop/src/renderer/settings/provider-add-form.tsx

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919

2020
import { useState, type FormEvent } from 'react';
2121
import {
22+
type ModelInfo,
2223
OPENCODE_FREE_DEFAULT_ENABLED_MODELS,
2324
type ProviderType,
2425
} from '@maka/core/llm-connections';
2526
import { PROVIDER_DEFAULTS, deriveConnectionSlug } from '@maka/core/llm-connections';
2627
import {
2728
providerAuthRequiresSecret,
2829
providerAuthSupportsApiKey,
30+
providerSupportsModelDiscovery,
2931
} from '@maka/core/llm-connections';
30-
import { Banner, HStack, VStack } from '@astryxdesign/core';
32+
import { Banner, HStack, Selector, VStack } from '@astryxdesign/core';
3133
import { Collapsible } from '@astryxdesign/core/Collapsible';
3234
import {
3335
Button,
@@ -61,9 +63,16 @@ import {
6163

6264
/* No `defaultModel`: the creation gate has no rule that can fail on the model
6365
id, so an error could never be reported against that field. The union is
64-
kept aligned with `AddProviderIssue` plus the two form-local fields the
66+
kept aligned with `AddProviderIssue` plus the three form-local fields the
6567
gate does not own. */
66-
type ProviderFormField = 'slug' | 'apiKey' | 'accountId' | 'baseUrl' | 'advancedRequest' | 'form';
68+
type ProviderFormField =
69+
| 'slug'
70+
| 'apiKey'
71+
| 'accountId'
72+
| 'baseUrl'
73+
| 'modelDiscovery'
74+
| 'advancedRequest'
75+
| 'form';
6776

6877
type ProviderFormError = {
6978
field: ProviderFormField;
@@ -90,18 +99,22 @@ export function AddProviderForm(props: {
9099
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
91100
const [apiKey, setApiKey] = useState('');
92101
const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel);
102+
const [discoveredModels, setDiscoveredModels] = useState<ModelInfo[] | null>(null);
93103
const [requestHeaders, setRequestHeaders] = useState<RequestHeaderDraft[]>([]);
94104
const [requestBodyText, setRequestBodyText] = useState('');
95105
const [advancedOpen, setAdvancedOpen] = useState(false);
96106
const [error, setError] = useState<ProviderFormError | null>(null);
97107
const [busy, setBusy] = useState(false);
98-
const submitGuard = useActionGuard<'submit'>();
108+
const [fetchingModels, setFetchingModels] = useState(false);
109+
const submitGuard = useActionGuard<'submit' | 'fetch-models'>();
99110
const addProviderMountedRef = useMountedRef();
100111

101112
const isCloudflareWorkersAi = props.providerType === 'cloudflare-workers-ai';
102113
const requiresBaseUrl = !defaults.baseUrl && !isCloudflareWorkersAi;
103114
const showsDefaultModel = recommendedDefaultModel.trim() === '';
115+
const isCustomRelay = defaults.category === 'custom';
104116
const isExperimental = defaults.status === 'phase3-experimental';
117+
const supportsRemoteDiscovery = providerSupportsModelDiscovery(props.providerType);
105118
const supportsApiKey = providerAuthSupportsApiKey(props.providerType);
106119
const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey;
107120
const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType);
@@ -129,6 +142,58 @@ export function AddProviderForm(props: {
129142
return copy.accountLogin;
130143
}
131144

145+
function invalidateDiscoveredModels() {
146+
setDiscoveredModels(null);
147+
clearFieldError('modelDiscovery');
148+
}
149+
150+
async function fetchModelOptions() {
151+
if (submitGuard.current !== null) return;
152+
setError(null);
153+
const normalizedApiKey = apiKey.trim();
154+
if (requiresApiKey && !normalizedApiKey) {
155+
return setError({ field: 'apiKey', message: copy.keyRequired(display.name) });
156+
}
157+
const normalizedBaseUrl = baseUrl.trim();
158+
if (requiresBaseUrl && !normalizedBaseUrl) {
159+
return setError({ field: 'baseUrl', message: copy.endpointRequired });
160+
}
161+
let normalizedRequestHeaders: Readonly<Record<string, string>>;
162+
try {
163+
normalizedRequestHeaders = newRequestHeaders(requestHeaders);
164+
} catch {
165+
setAdvancedOpen(true);
166+
return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid });
167+
}
168+
submitGuard.begin('fetch-models');
169+
setFetchingModels(true);
170+
try {
171+
const models = await props.bridge.previewModels({
172+
providerType: props.providerType,
173+
...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}),
174+
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
175+
...(Object.keys(normalizedRequestHeaders).length > 0
176+
? { requestHeaders: normalizedRequestHeaders }
177+
: {}),
178+
});
179+
if (!addProviderMountedRef.current) return;
180+
setDiscoveredModels(models);
181+
setDefaultModel((current) =>
182+
models.some((model) => model.id === current) ? current : models[0]!.id,
183+
);
184+
} catch (fetchError) {
185+
if (!addProviderMountedRef.current) return;
186+
setDiscoveredModels(null);
187+
setError({
188+
field: 'modelDiscovery',
189+
message: providerPanelActionErrorMessage(fetchError, locale),
190+
});
191+
} finally {
192+
submitGuard.finish();
193+
if (addProviderMountedRef.current) setFetchingModels(false);
194+
}
195+
}
196+
132197
async function submit() {
133198
if (submitGuard.current !== null) return;
134199
setError(null);
@@ -210,6 +275,7 @@ export function AddProviderForm(props: {
210275
onHeadersChange={(headers) => {
211276
setRequestHeaders(headers);
212277
clearFieldError('advancedRequest');
278+
invalidateDiscoveredModels();
213279
}}
214280
bodyText={requestBodyText}
215281
onBodyTextChange={(value) => {
@@ -244,6 +310,7 @@ export function AddProviderForm(props: {
244310
onChange={(next) => {
245311
setApiKey(next);
246312
clearFieldError('apiKey');
313+
invalidateDiscoveredModels();
247314
}}
248315
placeholder={copy.apiKeyPlaceholder}
249316
label={copy.apiKeyLabel}
@@ -284,6 +351,7 @@ export function AddProviderForm(props: {
284351
onChange={(next) => {
285352
setApiKey(next);
286353
clearFieldError('apiKey');
354+
invalidateDiscoveredModels();
287355
}}
288356
placeholder={copy.apiKeyPlaceholder}
289357
label={copy.apiKeyLabel}
@@ -342,6 +410,7 @@ export function AddProviderForm(props: {
342410
onChange={(value) => {
343411
setBaseUrl(value);
344412
clearFieldError('baseUrl');
413+
invalidateDiscoveredModels();
345414
}}
346415
placeholder={defaults.baseUrl || 'https://…'}
347416
isDisabled={isExperimental || busy}
@@ -355,14 +424,46 @@ export function AddProviderForm(props: {
355424
/>
356425
)}
357426
{showsDefaultModel && (
358-
<TextInput
359-
value={defaultModel}
360-
onChange={setDefaultModel}
361-
placeholder={copy.defaultModelPlaceholder}
362-
isDisabled={isExperimental || busy}
363-
label={copy.defaultModel}
364-
description={copy.defaultModelHelp}
365-
/>
427+
discoveredModels ? (
428+
<Selector
429+
label={copy.defaultModel}
430+
value={defaultModel}
431+
options={discoveredModels.map((model) => ({
432+
value: model.id,
433+
label: model.displayName ?? model.id,
434+
description: model.displayName ? model.id : undefined,
435+
}))}
436+
width="100%"
437+
isDisabled={isExperimental || busy || fetchingModels}
438+
onChange={setDefaultModel}
439+
/>
440+
) : (
441+
<TextInput
442+
value={defaultModel}
443+
onChange={setDefaultModel}
444+
placeholder={copy.defaultModelPlaceholder}
445+
isDisabled={isExperimental || busy || fetchingModels}
446+
label={copy.defaultModel}
447+
description={copy.defaultModelHelp}
448+
/>
449+
)
450+
)}
451+
{isCustomRelay && supportsRemoteDiscovery && (
452+
<VStack gap={1.5}>
453+
<Button
454+
variant="secondary"
455+
isDisabled={busy || fetchingModels || isExperimental}
456+
onClick={fetchModelOptions}
457+
label={fetchingModels ? copy.fetchingModels : copy.fetchModels}
458+
/>
459+
{error?.field === 'modelDiscovery' && (
460+
<Banner
461+
status="warning"
462+
title={copy.modelsFetchFailed}
463+
description={`${error.message} ${copy.modelsFetchFallback}`}
464+
/>
465+
)}
466+
</VStack>
366467
)}
367468
{advancedRequestEditor}
368469
</FormLayout>

0 commit comments

Comments
 (0)