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 @@ -49,9 +49,49 @@ test('registers pure Connection reads for replacement-Host retry', () => {
'connections:hasSecret',
]);
assert.ok(effects.has('connections:create'));
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('retries connection delete after a stale revision instead of failing permanently', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
let revision = 1;
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 @@ -93,6 +94,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' || !(input.providerType in PROVIDER_DEFAULTS)) {
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 @@ -467,6 +467,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 @@ -50,6 +50,7 @@ import {
normalizeConnectionPatchSecretsForIpc,
normalizeConnectionSlugForIpc,
normalizeCreateConnectionInputForIpc,
normalizePreviewConnectionModelsInputForIpc,
} from './connections-ipc-validation.js';
import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js';

Expand All @@ -58,6 +59,7 @@ type HostConnectionsClient = Pick<
| 'createConnection'
| 'deleteCredential'
| 'fetchConnectionModels'
| 'previewConnectionModels'
| 'getConnectionRequestHeaders'
| 'loadConnectionCatalog'
| 'queryCredential'
Expand Down Expand Up @@ -289,6 +291,20 @@ export function registerRuntimeHostConnectionsIpc(
fetchedAt: result.fetchedAt,
};
});
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, slug: 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 @@ -1302,7 +1302,7 @@ export interface MakaBridge {
update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
delete(slug: string, host?: DesktopRuntimeHostRef): Promise<void>;
test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult>;
fetchModels<T extends string | import('@maka/core/llm-connections').PreviewConnectionModelsInput>(input: T, host?: DesktopRuntimeHostRef): Promise<T extends string ? ModelDiscoveryResult : import('@maka/core/llm-connections').ModelInfo[]>;
hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean>;
getRequestHeaders(slug: string, 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 @@ -2543,8 +2543,12 @@ const makaBridge = {
test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult> {
return invokeSelectedRuntimeHost(host, 'connections:test', slug, opts);
},
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug);
fetchModels<T extends string | import('@maka/core/llm-connections').PreviewConnectionModelsInput>(input: T, host?: DesktopRuntimeHostRef): Promise<T extends string ? ModelDiscoveryResult : import('@maka/core/llm-connections').ModelInfo[]> {
return invokeSelectedRuntimeHost(
host,
typeof input === 'string' ? 'connections:fetchModels' : 'connections:previewModels',
input,
) as Promise<T extends string ? ModelDiscoveryResult : import('@maka/core/llm-connections').ModelInfo[]>;
},
hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean> {
return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,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。',
...zhCapabilitiesCopy,
},
oauthFlow: {
Expand Down Expand Up @@ -354,6 +355,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.',
...enCapabilitiesCopy,
},
oauthFlow: {
Expand Down
Loading