Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => unknown>();
const calls: unknown[] = [];
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/connections-ipc-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<PreviewConnectionModelsInput>;
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;
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,12 @@ export class DesktopRuntimeHostClient {
return this.request("connection.models.fetch", { connectionId });
}

previewConnectionModels(
input: OperationInput<"connection.onboarding.verify">,
): Promise<OperationOutput<"connection.onboarding.verify">> {
return this.request("connection.onboarding.verify", input);
}

testConnection(
connectionId: string,
modelId?: string,
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/main/runtime-host-connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
normalizeConnectionPatchSecretsForIpc,
normalizeConnectionSlugForIpc,
normalizeCreateConnectionInputForIpc,
normalizePreviewConnectionModelsInputForIpc,
} from './connections-ipc-validation.js';
import type {
DesktopConnectionIdentity,
Expand All @@ -70,6 +71,7 @@ type HostConnectionsClient = Pick<
| 'createConnection'
| 'deleteCredential'
| 'fetchConnectionModels'
| 'previewConnectionModels'
| 'getConnectionRequestHeaders'
| 'loadConnectionCatalog'
| 'queryCredential'
Expand Down Expand Up @@ -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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This opens a second Desktop path to one Host operation. connections:previewModels targets connection.onboarding.verify, the same operation connections:onboardingVerify already handles a hundred and seventy lines above, and DesktopRuntimeHostClient.previewConnectionModels is byte-identical to the existing verifyConnectionOnboarding. The new path also hand-rolls normalizePreviewConnectionModelsInputForIpc instead of using CONNECTION_EFFECT_OPERATION_SPECS['connection.onboarding.verify'].decodeInput, which is the protocol's own decoder, so the two channels can drift on what they accept.

The seam is already inside the component that needs it: provider-add-form.tsx:272 calls props.apiKeyOnboardingBridge.verify({ target: { kind: 'create', providerType }, apiKey, baseUrl: null }). Having fetchModelOptions call that with baseUrl and requestHeaders filled in, and render failures through the existing onboardingFailureMessage(result), would let this PR delete the new core type PreviewConnectionModelsInput, the new IPC normalizer, this channel, the duplicate client method, and the fetchModels<T> overload in preload.ts that discriminates on 'connectionId' in input behind a conditional return type. Net effect is a smaller diff that reaches the same behavior with no new concept.

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 }) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,7 @@ export interface MakaBridge {
update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void>;
test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<Pick<ModelDiscoveryResult, 'models' | 'source'>>;
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 ? Pick<ModelDiscoveryResult, 'models' | 'source'> : import('@maka/core/llm-connections').ModelInfo[]>;
hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean>;
getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
setRequestHeaders(
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2698,8 +2698,12 @@ const makaBridge = {
opts,
);
},
fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<Pick<ModelDiscoveryResult, 'models' | 'source'>> {
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection);
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 ? Pick<ModelDiscoveryResult, 'models' | 'source'> : import('@maka/core/llm-connections').ModelInfo[]> {
return invokeSelectedRuntimeHost(
host,
'connectionId' in input ? 'connections:fetchModels' : 'connections:previewModels',
input,
) as Promise<T extends import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity ? Pick<ModelDiscoveryResult, 'models' | 'source'> : import('@maka/core/llm-connections').ModelInfo[]>;
},
hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean> {
return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
IdentifiedLlmConnection,
LlmConnection,
ModelDiscoveryResult,
ModelInfo,
PreviewConnectionModelsInput,
RequestHeaderUpdate,
SavedRequestHeaders,
UpdateConnectionInput,
Expand Down Expand Up @@ -57,6 +59,7 @@ export interface ConnectionsBridge {
fetchModels(connection: DesktopConnectionIdentity): Promise<
Pick<ModelDiscoveryResult, 'models' | 'source'>
>;
previewModels(input: PreviewConnectionModelsInput): Promise<ModelInfo[]>;
hasSecret(connection: DesktopConnectionIdentity): Promise<boolean>;
getRequestHeaders(connection: DesktopConnectionIdentity): Promise<SavedRequestHeaders>;
setRequestHeaders(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '请至少启用一个模型。',
Expand Down Expand Up @@ -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.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading