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
59 changes: 49 additions & 10 deletions apps/desktop/src/main/__tests__/provider-add-submission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
initialOnboardingModelIds,
shouldShowManagedOnboardingOutcomeUnknown,
stableOnboardingModels,
usesLegacyConnectionWriter,
validateAddProviderDraft,
type AddProviderDraft,
type AddProviderField,
Expand Down Expand Up @@ -268,34 +269,72 @@ test('a duplicate slug outranks a missing key, so one fix is asked for at a time
);
});

test('routes only fixed-endpoint API-key drafts without request customization to Host onboarding', () => {
test('routes an API-key draft to Host onboarding unless a probe input cannot ride along', () => {
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'openai',
requestHeaderCount: 0,
hasRequestBodyOverlay: false,
hasRequestHeaders: false,
hasEndpoint: false,
}), { kind: 'host' });
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'openai',
requestHeaderCount: 1,
hasRequestBodyOverlay: false,
}), { kind: 'legacy', reason: 'request_headers' });
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'openai',
requestHeaderCount: 0,
hasRequestBodyOverlay: true,
hasRequestHeaders: false,
hasEndpoint: false,
}), { kind: 'legacy', reason: 'request_body' });
// The verify wire carries headers into a create target, but the managed save
// that would follow commits the key and the catalog and nothing else. A
// built-in with headers would therefore be stored without them.
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'openai',
hasRequestBodyOverlay: false,
hasRequestHeaders: true,
hasEndpoint: false,
}), { kind: 'legacy', reason: 'request_headers' });
// A relay with no registry endpoint has nothing to verify against until the
// form supplies one.
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'custom',
requestHeaderCount: 0,
hasRequestBodyOverlay: false,
hasRequestHeaders: false,
hasEndpoint: false,
}), { kind: 'legacy', reason: 'custom_endpoint' });
// …and once it does, the probe carries the form's endpoint and headers, so
// the relay joins the managed route rather than the legacy create-then-fetch.
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'custom',
hasRequestBodyOverlay: false,
hasRequestHeaders: false,
hasEndpoint: true,
}), { kind: 'host' });
// Headers do not divert a relay, because the writer its save already uses is
// the one that persists them.
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'custom',
hasRequestBodyOverlay: false,
hasRequestHeaders: true,
hasEndpoint: true,
}), { kind: 'host' });
// The overlay is still the one probe input the Host cannot carry.
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'custom',
hasRequestBodyOverlay: true,
hasRequestHeaders: false,
hasEndpoint: true,
}), { kind: 'legacy', reason: 'request_body' });
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'cloudflare-workers-ai',
requestHeaderCount: 0,
hasRequestBodyOverlay: false,
hasRequestHeaders: false,
hasEndpoint: false,
}), { kind: 'legacy', reason: 'cloudflare' });
});

test('the relay is the one provider whose save is still the create-then-discover writer', () => {
assert.equal(usesLegacyConnectionWriter('custom'), true);
assert.equal(usesLegacyConnectionWriter('openai'), false);
});

test('uses a stable discovered-model order and prefers the registered recommendation', () => {
const models = [
{ id: 'z-model', displayName: 'Zulu' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,116 @@ test('creates a connection with only the explicitly selected model', async () =>
assert.deepEqual(createdModels, ['minimax-m3']);
});

test('creates a connection with the picker selection, default first', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
let createdModels: readonly string[] = [];
const emptyCatalog: ConnectionCatalogSnapshot = {
revision: 0,
defaultTarget: null,
connections: [],
};
registerRuntimeHostConnectionsIpc({
ipcMain: {
handle: (channel, handler) => {
handlers.set(channel, handler as (...args: unknown[]) => unknown);
},
},
client: {
loadConnectionCatalog: async () =>
createdModels.length === 0
? emptyCatalog
: {
revision: 1,
defaultTarget: null,
connections: [
{
connectionId: 'connection-relay',
revision: 1,
slug: 'my-relay',
name: 'My Relay',
providerType: 'custom',
enabled: true,
enabledModelIds: createdModels,
catalogEntries: [],
models: [],
},
],
},
createConnection: async (
_revision: number,
draft: { readonly enabledModelIds: readonly string[] },
) => {
createdModels = draft.enabledModelIds;
return {
kind: 'committed',
connection: { connectionId: 'connection-relay', revision: 1 },
};
},
} as never,
emitConnectionListChanged() {},
});

await handlers.get('connections:create')?.({}, {
slug: 'my-relay',
name: 'My Relay',
providerType: 'custom',
defaultModel: 'relay/second',
// The picker chose this one first and the default second; the catalog
// entries only exist once the probe has answered.
enabledModelIds: ['relay/first', 'relay/second'],
});

// The create is the only write that can carry the selection, so the default
// being listed second must not silently win the head of the list.
assert.deepEqual(createdModels, ['relay/second', 'relay/first']);
});

test('refuses a connection whose enabled model ids are not model ids', () => {
assert.throws(
() =>
normalizeCreateConnectionInputForIpc({
slug: 'my-relay',
name: 'My Relay',
providerType: 'custom',
defaultModel: 'relay/first',
enabledModelIds: ['relay/first', 'relay/first'],
}),
/duplicate model ids/,
);
assert.throws(
() =>
normalizeCreateConnectionInputForIpc({
slug: 'my-relay',
name: 'My Relay',
providerType: 'custom',
defaultModel: 'relay/first',
enabledModelIds: [''],
}),
/empty model id/,
);
assert.throws(
() =>
normalizeCreateConnectionInputForIpc({
slug: 'my-relay',
name: 'My Relay',
providerType: 'custom',
defaultModel: 'relay/first',
enabledModelIds: ['relay/first', 7],
}),
/must be model ids/,
);
assert.deepEqual(
normalizeCreateConnectionInputForIpc({
slug: 'my-relay',
name: 'My Relay',
providerType: 'custom',
defaultModel: 'relay/first',
enabledModelIds: ['relay/second', 'relay/first'],
}).enabledModelIds,
['relay/second', 'relay/first'],
);
});

test('projects the Host default target without inventing a second Connection authority', () => {
const connections = projectHostConnections(catalog());

Expand Down
44 changes: 43 additions & 1 deletion apps/desktop/src/main/connections-ipc-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ import {
type CreateConnectionInput,
type UpdateConnectionInput,
} from '@maka/core/llm-connections';
import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy';
import {
CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS,
CONNECTION_MODEL_ID_MAX_LENGTH,
normalizeOptionalRequestBodyOverlay,
normalizeRequestHeaders,
} from '@maka/core/runtime-policy';
import { PROVIDER_REGISTRY, providerDefaultsOf } from '@maka/core/llm-connections';
import { normalizeModelOverrides } from '@maka/core/model-thinking';

Expand Down Expand Up @@ -57,6 +62,38 @@ export function normalizeConnectionApiKeyForIpc(value: unknown, label: string):
return value;
}

/**
* The picker's selection now rides along with the create, so it needs the gate
* the catalog codec puts on the same list: a bounded array of unique, bounded,
* non-empty ids. Without one, `...input` below would forward whatever the
* renderer sent straight into the Host's catalog.
*/
export function normalizeConnectionEnabledModelIdsForIpc(
value: unknown,
label: string,
): string[] {
if (!Array.isArray(value) || value.length > CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS) {
throw new Error(
`${label} must be an array of at most ${CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS} model ids`,
);
}
const modelIds = value.map((modelId): string => {
if (typeof modelId !== 'string') throw new Error(`${label} must be model ids`);
if (modelId.length === 0) throw new Error(`${label} must not contain an empty model id`);
if (modelId.length > CONNECTION_MODEL_ID_MAX_LENGTH) {
throw new Error(`${label} must be ${CONNECTION_MODEL_ID_MAX_LENGTH} characters or fewer`);
}
if (IPC_CONTROL_CHARACTER_PATTERN.test(modelId)) {
throw new Error(`${label} contains invalid characters`);
}
return modelId;
});
if (new Set(modelIds).size !== modelIds.length) {
throw new Error(`${label} must not contain duplicate model ids`);
}
return modelIds;
}

export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConnectionInput {
if (typeof value !== 'object' || value === null) throw new Error('Invalid Connection input');
const input = value as Partial<CreateConnectionInput>;
Expand Down Expand Up @@ -84,13 +121,18 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn
input.requestBodyOverlay === undefined
? undefined
: normalizeOptionalRequestBodyOverlay(input.requestBodyOverlay);
const enabledModelIds =
input.enabledModelIds === undefined
? undefined
: normalizeConnectionEnabledModelIdsForIpc(input.enabledModelIds, 'enabledModelIds');
const normalized = {
...input,
slug,
...(apiKey === undefined ? {} : { apiKey }),
...(modelOverrides === undefined ? {} : { modelOverrides }),
...(requestHeaders === undefined ? {} : { requestHeaders }),
...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }),
...(enabledModelIds === undefined ? {} : { enabledModelIds }),
} as CreateConnectionInput;
return normalizeConnectionBaseUrlForIpc(normalized);
}
Expand Down
5 changes: 5 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 @@ -213,6 +213,11 @@ export function registerRuntimeHostConnectionsIpc(
enabled: true,
enabledModelIds: connectionEnabledModelIds({
defaultModel: input.defaultModel,
// A picker that chose models before the connection existed has
// nowhere else to put them: the create is the only write.
...(input.enabledModelIds === undefined
? {}
: { enabledModelIds: input.enabledModelIds }),
}),
...(modelOverrides === undefined ? {} : { modelOverrides }),
...(input.requestBodyOverlay === undefined
Expand Down
Loading
Loading