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
87 changes: 87 additions & 0 deletions apps/desktop/src/main/__tests__/provider-add-catalog-probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { ConnectionCatalogProbeOutcome } from '../../shared/connection-catalog-probe.js';
import {
catalogProbeChoices,
catalogProbeRequest,
runCatalogProbe,
type CatalogProbeDraft,
} from '../../renderer/settings/provider-add-catalog-probe.js';

function draft(over: Partial<CatalogProbeDraft> = {}): CatalogProbeDraft {
return {
providerType: 'openai-compatible',
baseUrl: 'https://relay.example.test/v1',
apiKey: 'sk-relay',
...over,
};
}

test('refuses to probe before the form has an endpoint', () => {
assert.equal(catalogProbeRequest(draft({ baseUrl: ' ' })), null);
assert.equal(catalogProbeRequest(draft({ baseUrl: '' })), null);
});

test('trims the endpoint and passes a trimmed key, or none when the key is blank', () => {
assert.deepEqual(
catalogProbeRequest(draft({ baseUrl: ' https://relay.example.test/v1 ', apiKey: ' sk-relay ' })),
{
providerType: 'openai-compatible',
baseUrl: 'https://relay.example.test/v1',
apiKey: 'sk-relay',
},
);
assert.deepEqual(catalogProbeRequest(draft({ apiKey: ' ' })), {
providerType: 'openai-compatible',
baseUrl: 'https://relay.example.test/v1',
apiKey: null,
});
});

test('sends the request as the form drafted it and returns the host verdict', async () => {
const outcome: ConnectionCatalogProbeOutcome = {
kind: 'ready',
models: [{ id: 'relay-model' }],
};
const probed = await runCatalogProbe(async (request) => {
assert.deepEqual(request, catalogProbeRequest(draft()));
return outcome;
}, catalogProbeRequest(draft())!);
assert.equal(probed, outcome);
});

test('turns a rejected IPC frame into a failed verdict instead of throwing', async () => {
const probed = await runCatalogProbe(async () => {
throw new Error('An endpoint is required to probe the model catalog');
}, catalogProbeRequest(draft())!);
assert.deepEqual(probed, { kind: 'failed', errorClass: 'unknown' });
});

test('offers the model id as both the chooser value and its label', () => {
assert.deepEqual(
catalogProbeChoices([
{ id: 'relay-model', contextWindow: 128_000 },
{ id: 'vision-model', displayName: 'Relay Vision' },
]),
[{ value: 'relay-model', label: 'relay-model' }, { value: 'vision-model', label: 'vision-model' }],
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS } from '@maka/core/llm-connections';
import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
import type { ConnectionOnboardingVerifyInput } from '@maka/runtime-host/protocol';
import {
projectConnectionCatalogProbe,
projectHostConnections,
projectHostConnectionTest,
registerRuntimeHostConnectionsIpc,
} from '../runtime-host-connections-ipc-main.js';
import { normalizeConnectionCatalogProbeInput } from '../connections-ipc-validation.js';

test('registers pure Connection reads for replacement-Host retry', () => {
const reads = new Set<string>();
Expand Down Expand Up @@ -351,6 +354,108 @@ test('preserves the Host-tested model and diagnostics for the existing Desktop U
);
});

test('probes a relay catalog through the host before a connection exists', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
registerRuntimeHostConnectionsIpc({
ipcMain: {
handle: (channel, handler) => {
handlers.set(channel, handler as (...args: unknown[]) => unknown);
},
},
client: {
probeConnectionModels: async (input: ConnectionOnboardingVerifyInput) => {
assert.deepEqual(input, {
providerType: 'openai-compatible',
// No connection exists to edit yet: the probe targets the canonical
// slug as null, exactly like the post-create discovery fetch.
connectionId: null,
apiKey: 'relay-key',
baseUrl: 'https://relay.example.test/v1',
});
return {
kind: 'verified',
models: [{ id: 'relay-model', contextWindow: 128_000 }],
};
},
} as never,
emitConnectionListChanged() {},
});

// Endpoint and key are canonicalized at the boundary before reaching the
// host; the handler never sees the surrounding whitespace the user typed.
const outcome = await handlers.get('connections:probeModels')?.(
{},
{
providerType: 'openai-compatible',
baseUrl: ' https://relay.example.test/v1 ',
apiKey: ' relay-key ',
},
);
assert.deepEqual(outcome, {
kind: 'ready',
models: [{ id: 'relay-model', contextWindow: 128_000 }],
});
});

test('projects the host onboarding verdict onto the probe outcome contract', () => {
assert.deepEqual(
projectConnectionCatalogProbe({
kind: 'verified',
models: [{ id: 'relay-model' }],
}),
{ kind: 'ready', models: [{ id: 'relay-model' }] },
);
assert.deepEqual(
projectConnectionCatalogProbe({ kind: 'rejected', reason: 'credential_not_configured' }),
{ kind: 'rejected', reason: 'credential_not_configured' },
);
assert.deepEqual(
projectConnectionCatalogProbe({ kind: 'rejected', reason: 'base_url_not_configured' }),
{ kind: 'rejected', reason: 'base_url_not_configured' },
);
// A fresh probe never names an existing connection, so a connection-identity
// rejection collapses to a generic failure rather than a reason the form
// cannot render.
assert.deepEqual(
projectConnectionCatalogProbe({ kind: 'rejected', reason: 'connection_not_found' }),
{ kind: 'failed', errorClass: 'connection_not_found' },
);
assert.deepEqual(
projectConnectionCatalogProbe({ kind: 'failed', errorClass: 'auth' }),
{ kind: 'failed', errorClass: 'auth' },
);
});

test('refuses a catalog probe without a probeable endpoint', () => {
assert.throws(
() =>
normalizeConnectionCatalogProbeInput({
providerType: 'openai-compatible',
baseUrl: ' ',
apiKey: null,
}),
/endpoint is required/u,
);
assert.throws(
() =>
normalizeConnectionCatalogProbeInput({
providerType: 'openai-compatible',
baseUrl: 'not a url',
apiKey: 'relay-key',
}),
/valid URL/u,
);
assert.throws(
() =>
normalizeConnectionCatalogProbeInput({
providerType: 'no-such-provider',
baseUrl: 'https://relay.example.test/v1',
apiKey: null,
}),
/Invalid provider type/u,
);
});

function catalog(): ConnectionCatalogSnapshot {
return {
revision: 7,
Expand Down
37 changes: 37 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 ProviderType,
type UpdateConnectionInput,
} from '@maka/core/llm-connections';
import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy';
Expand Down Expand Up @@ -130,3 +131,39 @@ export function normalizeConnectionBaseUrlValueForIpc(
if (!result.ok) throw new Error(result.error);
return result.value;
}

/** The probe's contract over the renderer boundary: a provider type, the
* endpoint the catalog should be read from, and an optional API key. */
export interface ConnectionCatalogProbeInput {
readonly providerType: ProviderType;
readonly baseUrl: string;
readonly apiKey: string | null;
}

/**
* Validate and canonicalize the catalog-probe IPC payload. The endpoint is
* required — this probe exists to read a catalog from an endpoint the app
* has not met yet, so there is nothing to probe without one. Reuses the
* connection base URL normalization so a malformed endpoint rejects with the
* same copy the connection form already shows.
*/
export function normalizeConnectionCatalogProbeInput(value: unknown): ConnectionCatalogProbeInput {
if (typeof value !== 'object' || value === null) {
throw new Error('Invalid connection catalog probe');
}
const raw = value as Record<string, unknown>;
const providerType = raw.providerType;
if (typeof providerType !== 'string' || !PROVIDER_DEFAULTS[providerType as ProviderType]) {
throw new Error('Invalid provider type');
}
const typedProviderType = providerType as ProviderType;
const apiKey = typeof raw.apiKey === 'string' && raw.apiKey.trim().length > 0
? raw.apiKey.trim()
: null;
const baseUrl = normalizeConnectionBaseUrlValueForIpc(
typedProviderType,
String(raw.baseUrl ?? ''),
);
if (baseUrl.length === 0) throw new Error('An endpoint is required to probe the model catalog');
return { providerType: typedProviderType, baseUrl, apiKey };
}
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 @@ -402,6 +402,12 @@ export class DesktopRuntimeHostClient {
return this.request("connection.models.fetch", { connectionId });
}

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

testConnection(
connectionId: string,
modelId?: string,
Expand Down
41 changes: 40 additions & 1 deletion apps/desktop/src/main/runtime-host-connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import type {
CredentialLocator,
} from '@maka/core/runtime-policy';
import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy';
import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol';
import type {
ConnectionOnboardingVerifyResult,
ConnectionTestRunResult,
} from '@maka/runtime-host/protocol';
import { normalizeConnectionCatalogProbeInput } from './connections-ipc-validation.js';
import type { ConnectionCatalogProbeOutcome } from '../shared/connection-catalog-probe.js';
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
import {
handleReconnectableRead,
Expand All @@ -59,6 +64,7 @@ type HostConnectionsClient = Pick<
| 'fetchConnectionModels'
| 'getConnectionRequestHeaders'
| 'loadConnectionCatalog'
| 'probeConnectionModels'
| 'queryCredential'
| 'removeConnection'
| 'replaceConnectionRequestHeaders'
Expand Down Expand Up @@ -288,6 +294,20 @@ export function registerRuntimeHostConnectionsIpc(
fetchedAt: result.fetchedAt,
};
});
// Probe the model catalog an endpoint serves BEFORE a connection exists, so
// the custom-relay form can offer a real choice instead of a guess. Runs the
// same discovery the post-create fetch uses against a transient connection;
// `connectionId` is null because there is nothing to edit yet.
deps.ipcMain.handle('connections:probeModels', async (_event, raw: unknown) => {
const input = normalizeConnectionCatalogProbeInput(raw);
const result = await deps.client.probeConnectionModels({
providerType: input.providerType,
connectionId: null,
apiKey: input.apiKey,
baseUrl: input.baseUrl,
});
return projectConnectionCatalogProbe(result);
});
deps.ipcMain.handle(
'connections:test',
async (_event, slug: unknown, options?: { model?: unknown }) => {
Expand Down Expand Up @@ -325,6 +345,25 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn
};
}

/** Project the host's onboarding-verify result onto the renderer's shared
* probe contract. The ready branch carries the discovered catalog; the
* rejected and failed branches report why there is none. */
export function projectConnectionCatalogProbe(
result: ConnectionOnboardingVerifyResult,
): ConnectionCatalogProbeOutcome {
if (result.kind === 'verified') return { kind: 'ready', models: [...result.models] };
if (result.kind === 'rejected') {
// The probe targets no existing connection (a fresh relay), so a
// connection-identity rejection cannot describe it — surface it as a
// generic failure instead of a rejected reason the form cannot render.
if (result.reason === 'connection_not_found') {
return { kind: 'failed', errorClass: 'connection_not_found' };
}
return { kind: 'rejected', reason: result.reason };
}
return { kind: 'failed', errorClass: result.errorClass };
}

export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmConnection[] {
return catalog.connections.map((connection) => {
const defaultModel =
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export type AppIconImportResult =

export type { DesktopSessionSummary } from '../shared/desktop-session-projection.js';
import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js';
import type {
ConnectionCatalogProbeOutcome,
ConnectionCatalogProbeRequest,
} from '../shared/connection-catalog-probe.js';
import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js';
import type { DesktopDiagnosticInput } from './diagnostics-contract.js';
import type { Result } from '@maka/core/result';
Expand Down Expand Up @@ -943,6 +947,10 @@ export interface MakaBridge {
delete(slug: string, host?: DesktopRuntimeHostRef): Promise<void>;
test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult>;
probeModels(
request: ConnectionCatalogProbeRequest,
host?: DesktopRuntimeHostRef,
): Promise<ConnectionCatalogProbeOutcome>;
hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean>;
getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
setRequestHeaders(
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ import {
type DesktopTargetScope,
} from '../shared/runtime-host-identity.js';
import type { GoalArmOutcome, GoalArmRequest } from '../shared/goal-arm.js';
import type {
ConnectionCatalogProbeOutcome,
ConnectionCatalogProbeRequest,
} from '../shared/connection-catalog-probe.js';
import {
invokeProjectedSessionRuntimeHost as invokeProjectedSessionRuntimeHostBridge,
projectProtocolSessionIds,
Expand Down Expand Up @@ -2165,6 +2169,12 @@ const makaBridge = {
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug);
},
probeModels(
request: ConnectionCatalogProbeRequest,
host?: DesktopRuntimeHostRef,
): Promise<ConnectionCatalogProbeOutcome> {
return invokeSelectedRuntimeHost(host, 'connections:probeModels', request);
},
hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean> {
return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug);
},
Expand Down
Loading