Skip to content

Commit 8683d26

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 8bc4846 commit 8683d26

20 files changed

Lines changed: 439 additions & 38 deletions

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,49 @@ 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+
target: { kind: 'create', providerType: 'openai-compatible' },
88+
baseUrl: 'https://relay.example/v1',
89+
apiKey: 'preview-secret',
90+
requestHeaders: { 'X-Tenant': 'tenant-a' },
91+
});
92+
assert.equal(listChanges, 0);
93+
});
94+
5595
test('retries connection delete after a stale revision instead of failing permanently', async () => {
5696
const handlers = new Map<string, (...args: unknown[]) => unknown>();
5797
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
@@ -486,6 +486,12 @@ export class DesktopRuntimeHostClient {
486486
return this.request("connection.models.fetch", { connectionId });
487487
}
488488

489+
previewConnectionModels(
490+
input: OperationInput<"connection.onboarding.verify">,
491+
): Promise<OperationOutput<"connection.onboarding.verify">> {
492+
return this.request("connection.onboarding.verify", input);
493+
}
494+
489495
testConnection(
490496
connectionId: string,
491497
modelId?: string,

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
normalizeConnectionPatchSecretsForIpc,
5151
normalizeConnectionSlugForIpc,
5252
normalizeCreateConnectionInputForIpc,
53+
normalizePreviewConnectionModelsInputForIpc,
5354
} from './connections-ipc-validation.js';
5455
import type {
5556
DesktopConnectionIdentity,
@@ -61,6 +62,7 @@ type HostConnectionsClient = Pick<
6162
| 'createConnection'
6263
| 'deleteCredential'
6364
| 'fetchConnectionModels'
65+
| 'previewConnectionModels'
6466
| 'getConnectionRequestHeaders'
6567
| 'loadConnectionCatalog'
6668
| 'queryCredential'
@@ -305,6 +307,20 @@ export function registerRuntimeHostConnectionsIpc(
305307
fetchedAt: result.fetchedAt,
306308
};
307309
});
310+
deps.ipcMain.handle('connections:previewModels', async (_event, raw: unknown) => {
311+
const input = normalizePreviewConnectionModelsInputForIpc(raw);
312+
const result = await deps.client.previewConnectionModels({
313+
target: { kind: 'create', providerType: input.providerType },
314+
apiKey: input.apiKey ?? null,
315+
baseUrl: input.baseUrl ?? null,
316+
requestHeaders: input.requestHeaders ?? {},
317+
});
318+
if (result.kind !== 'verified') {
319+
const reason = result.kind === 'failed' ? result.errorClass : result.reason;
320+
throw new Error(`Unable to preview Connection models: ${reason}`);
321+
}
322+
return [...result.models];
323+
});
308324
deps.ipcMain.handle(
309325
'connections:test',
310326
async (_event, identity: unknown, options?: { model?: unknown }) => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1363,7 +1363,7 @@ export interface MakaBridge {
13631363
update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
13641364
delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void>;
13651365
test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
1366-
fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult>;
1366+
fetchModels<T extends import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | import('@maka/core/llm-connections').PreviewConnectionModelsInput>(input: T, host?: DesktopRuntimeHostRef): Promise<T extends import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity ? ModelDiscoveryResult : import('@maka/core/llm-connections').ModelInfo[]>;
13671367
hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean>;
13681368
getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
13691369
setRequestHeaders(

apps/desktop/src/preload/preload.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2640,8 +2640,12 @@ const makaBridge = {
26402640
opts,
26412641
);
26422642
},
2643-
fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
2644-
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection);
2643+
fetchModels<T extends import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | import('@maka/core/llm-connections').PreviewConnectionModelsInput>(input: T, host?: DesktopRuntimeHostRef): Promise<T extends import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity ? ModelDiscoveryResult : import('@maka/core/llm-connections').ModelInfo[]> {
2644+
return invokeSelectedRuntimeHost(
2645+
host,
2646+
'connectionId' in input ? 'connections:fetchModels' : 'connections:previewModels',
2647+
input,
2648+
) as Promise<T extends import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity ? ModelDiscoveryResult : import('@maka/core/llm-connections').ModelInfo[]>;
26452649
},
26462650
hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean> {
26472651
return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection);

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

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

0 commit comments

Comments
 (0)