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 @@ -30,9 +30,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, {
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
@@ -1,6 +1,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 @@ -74,6 +75,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 @@ -375,6 +375,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 @@ -30,6 +30,7 @@ import {
normalizeConnectionPatchSecretsForIpc,
normalizeConnectionSlugForIpc,
normalizeCreateConnectionInputForIpc,
normalizePreviewConnectionModelsInputForIpc,
} from './connections-ipc-validation.js';
import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js';

Expand All @@ -38,6 +39,7 @@ type HostConnectionsClient = Pick<
| 'createConnection'
| 'deleteCredential'
| 'fetchConnectionModels'
| 'previewConnectionModels'
| 'getConnectionRequestHeaders'
| 'loadConnectionCatalog'
| 'queryCredential'
Expand Down Expand Up @@ -269,6 +271,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({
providerType: input.providerType,
apiKey: input.apiKey ?? null,
...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }),
...(input.requestHeaders === undefined ? {} : { 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
1 change: 1 addition & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,7 @@ 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>;
previewModels(input: import('@maka/core/llm-connections').PreviewConnectionModelsInput, host?: DesktopRuntimeHostRef): Promise<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
3 changes: 3 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2064,6 +2064,9 @@ const makaBridge = {
fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug);
},
previewModels(input: import('@maka/core/llm-connections').PreviewConnectionModelsInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').ModelInfo[]> {
return invokeSelectedRuntimeHost(host, 'connections:previewModels', input);
},
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 @@ -174,6 +174,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 @@ -319,6 +320,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
125 changes: 113 additions & 12 deletions apps/desktop/src/renderer/settings/provider-add-form.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useState, type FormEvent } from 'react';
import {
type ModelInfo,
OPENCODE_FREE_DEFAULT_ENABLED_MODELS,
type ProviderType,
} from '@maka/core/llm-connections';
import { PROVIDER_DEFAULTS, deriveConnectionSlug } from '@maka/core/llm-connections';
import {
providerAuthRequiresSecret,
providerAuthSupportsApiKey,
providerSupportsModelDiscovery,
} from '@maka/core/llm-connections';
import { Banner, HStack, VStack } from '@astryxdesign/core';
import { Banner, HStack, Selector, VStack } from '@astryxdesign/core';
import { Collapsible } from '@astryxdesign/core/Collapsible';
import {
Button,
Expand Down Expand Up @@ -42,9 +44,16 @@ import {

/* No `defaultModel`: the creation gate has no rule that can fail on the model
id, so an error could never be reported against that field. The union is
kept aligned with `AddProviderIssue` plus the two form-local fields the
kept aligned with `AddProviderIssue` plus the three form-local fields the
gate does not own. */
type ProviderFormField = 'slug' | 'apiKey' | 'accountId' | 'baseUrl' | 'advancedRequest' | 'form';
type ProviderFormField =
| 'slug'
| 'apiKey'
| 'accountId'
| 'baseUrl'
| 'modelDiscovery'
| 'advancedRequest'
| 'form';

type ProviderFormError = {
field: ProviderFormField;
Expand All @@ -71,18 +80,22 @@ export function AddProviderForm(props: {
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
const [apiKey, setApiKey] = useState('');
const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel);
const [discoveredModels, setDiscoveredModels] = useState<ModelInfo[] | null>(null);
const [requestHeaders, setRequestHeaders] = useState<RequestHeaderDraft[]>([]);
const [requestBodyText, setRequestBodyText] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
const [error, setError] = useState<ProviderFormError | null>(null);
const [busy, setBusy] = useState(false);
const submitGuard = useActionGuard<'submit'>();
const [fetchingModels, setFetchingModels] = useState(false);
const submitGuard = useActionGuard<'submit' | 'fetch-models'>();
const addProviderMountedRef = useMountedRef();

const isCloudflareWorkersAi = props.providerType === 'cloudflare-workers-ai';
const requiresBaseUrl = !defaults.baseUrl && !isCloudflareWorkersAi;
const showsDefaultModel = recommendedDefaultModel.trim() === '';
const isCustomRelay = defaults.category === 'custom';
const isExperimental = defaults.status === 'phase3-experimental';
const supportsRemoteDiscovery = providerSupportsModelDiscovery(props.providerType);
const supportsApiKey = providerAuthSupportsApiKey(props.providerType);
const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey;
const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType);
Expand Down Expand Up @@ -110,6 +123,58 @@ export function AddProviderForm(props: {
return copy.accountLogin;
}

function invalidateDiscoveredModels() {
setDiscoveredModels(null);
clearFieldError('modelDiscovery');
}

async function fetchModelOptions() {
if (submitGuard.current !== null) return;
setError(null);
const normalizedApiKey = apiKey.trim();
if (requiresApiKey && !normalizedApiKey) {
return setError({ field: 'apiKey', message: copy.keyRequired(display.name) });
}
const normalizedBaseUrl = baseUrl.trim();
if (requiresBaseUrl && !normalizedBaseUrl) {
return setError({ field: 'baseUrl', message: copy.endpointRequired });
}
let normalizedRequestHeaders: Readonly<Record<string, string>>;
try {
normalizedRequestHeaders = newRequestHeaders(requestHeaders);
} catch {
setAdvancedOpen(true);
return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid });
}
submitGuard.begin('fetch-models');
setFetchingModels(true);
try {
const models = await props.bridge.previewModels({
providerType: props.providerType,
...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}),
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
...(Object.keys(normalizedRequestHeaders).length > 0
? { requestHeaders: normalizedRequestHeaders }
: {}),
});
if (!addProviderMountedRef.current) return;
setDiscoveredModels(models);
setDefaultModel((current) =>
models.some((model) => model.id === current) ? current : models[0]!.id,
);
} catch (fetchError) {
if (!addProviderMountedRef.current) return;
setDiscoveredModels(null);
setError({
field: 'modelDiscovery',
message: providerPanelActionErrorMessage(fetchError, locale),
});
} finally {
submitGuard.finish();
if (addProviderMountedRef.current) setFetchingModels(false);
}
}

async function submit() {
if (submitGuard.current !== null) return;
setError(null);
Expand Down Expand Up @@ -191,6 +256,7 @@ export function AddProviderForm(props: {
onHeadersChange={(headers) => {
setRequestHeaders(headers);
clearFieldError('advancedRequest');
invalidateDiscoveredModels();
}}
bodyText={requestBodyText}
onBodyTextChange={(value) => {
Expand Down Expand Up @@ -225,6 +291,7 @@ export function AddProviderForm(props: {
onChange={(next) => {
setApiKey(next);
clearFieldError('apiKey');
invalidateDiscoveredModels();
}}
placeholder={copy.apiKeyPlaceholder}
label={copy.apiKeyLabel}
Expand Down Expand Up @@ -265,6 +332,7 @@ export function AddProviderForm(props: {
onChange={(next) => {
setApiKey(next);
clearFieldError('apiKey');
invalidateDiscoveredModels();
}}
placeholder={copy.apiKeyPlaceholder}
label={copy.apiKeyLabel}
Expand Down Expand Up @@ -323,6 +391,7 @@ export function AddProviderForm(props: {
onChange={(value) => {
setBaseUrl(value);
clearFieldError('baseUrl');
invalidateDiscoveredModels();
}}
placeholder={defaults.baseUrl || 'https://…'}
isDisabled={isExperimental || busy}
Expand All @@ -336,14 +405,46 @@ export function AddProviderForm(props: {
/>
)}
{showsDefaultModel && (
<TextInput
value={defaultModel}
onChange={setDefaultModel}
placeholder={copy.defaultModelPlaceholder}
isDisabled={isExperimental || busy}
label={copy.defaultModel}
description={copy.defaultModelHelp}
/>
discoveredModels ? (
<Selector
label={copy.defaultModel}
value={defaultModel}
options={discoveredModels.map((model) => ({
value: model.id,
label: model.displayName ?? model.id,
description: model.displayName ? model.id : undefined,
}))}
width="100%"
isDisabled={isExperimental || busy || fetchingModels}
onChange={setDefaultModel}
/>
) : (
<TextInput
value={defaultModel}
onChange={setDefaultModel}
placeholder={copy.defaultModelPlaceholder}
isDisabled={isExperimental || busy || fetchingModels}
label={copy.defaultModel}
description={copy.defaultModelHelp}
/>
)
)}
{isCustomRelay && supportsRemoteDiscovery && (
<VStack gap={1.5}>
<Button
variant="secondary"
isDisabled={busy || fetchingModels || isExperimental}
onClick={fetchModelOptions}
label={fetchingModels ? copy.fetchingModels : copy.fetchModels}
/>
{error?.field === 'modelDiscovery' && (
<Banner
status="warning"
title={copy.modelsFetchFailed}
description={`${error.message} ${copy.modelsFetchFallback}`}
/>
)}
</VStack>
)}
{advancedRequestEditor}
</FormLayout>
Expand Down
Loading