blob: 3673c277f294a833646b6a952749fc85b3e1a776 [file]
/*
* 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 { deriveConnectionSlug } from '@maka/core/llm-connections';
import { isRetiredProvider } from '@maka/core/provider-registry';
import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
import {
readRuntimeHostConnectionCatalog,
type RuntimeHostConnection,
} from '@maka/runtime-host/client';
import { listApiKeyOnboardableProviders } from './onboarding-catalog.js';
import type {
MakaOnboardingSurface,
ModelChoice,
OnboardingProviderEntry,
} from './pi-tui-contracts.js';
/** Adapt the TUI onboarding workflow to Host-owned verification and persistence. */
export function createRuntimeHostOnboardingSurface(
connection: RuntimeHostConnection,
): MakaOnboardingSurface {
return {
listProviders: async () => projectProviders(await readRuntimeHostConnectionCatalog(connection)),
verify: async (input) => {
try {
const result = await connection.request('connection.onboarding.verify', {
providerType: input.providerType,
connectionId: input.connectionId ?? null,
apiKey: trimmedOrNull(input.apiKey),
baseUrl: trimmedOrNull(input.baseUrl),
});
if (result.kind === 'verified') return { kind: 'ok', models: [...result.models] };
return {
kind: 'error',
text: onboardingFailureText(result),
...(result.kind === 'rejected' && result.reason === 'connection_not_found'
? { stale: true }
: {}),
};
} catch (error) {
return { kind: 'error', text: errorText(error) };
}
},
save: async (input) => {
try {
const result = await connection.request('connection.onboarding.save', {
providerType: input.providerType,
connectionId: input.connectionId ?? null,
apiKey: trimmedOrNull(input.apiKey),
baseUrl: trimmedOrNull(input.baseUrl),
enabledModelIds: [...input.enabledModelIds],
});
if (result.kind !== 'saved') {
return { kind: 'error', text: onboardingFailureText(result) };
}
return {
kind: 'ok',
modelChoices: projectRuntimeHostModelChoices(
await readRuntimeHostConnectionCatalog(connection),
),
};
} catch (error) {
return { kind: 'error', text: errorText(error) };
}
},
};
}
export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapshot): ModelChoice[] {
const choices: ModelChoice[] = [];
for (const connection of catalog.connections) {
// A retained retired connection stays enabled so its credential remains
// visible and deletable, but every send through it is refused — offering
// its models here would only let the user pick something that fails on
// selection.
if (!connection.enabled || isRetiredProvider(connection.providerType)) continue;
const modelsById = new Map(connection.models.map((model) => [model.id, model]));
const ids = new Set(connection.enabledModelIds);
if (catalog.defaultTarget?.connectionId === connection.connectionId) {
ids.add(catalog.defaultTarget.modelId);
}
for (const model of ids) {
choices.push({
connectionSlug: connection.slug,
connectionName: connection.name,
providerType: connection.providerType,
model,
displayName: modelsById.get(model)?.displayName,
isDefaultConnection: catalog.defaultTarget?.connectionId === connection.connectionId,
contextWindow: modelsById.get(model)?.contextWindow,
});
}
}
return choices;
}
export function projectProviders(catalog: ConnectionCatalogSnapshot): OnboardingProviderEntry[] {
const bySlug = new Map(catalog.connections.map((connection) => [connection.slug, connection]));
return listApiKeyOnboardableProviders().map((provider) => {
// Prefer the canonical-slug connection; failing that, a provider's sole
// connection is unambiguously "the" one to edit — a Desktop-created relay
// under a custom slug must read as configured here, or saving would
// duplicate it at the canonical slug. With several non-canonical
// connections there is no honest single answer, so the wizard offers a
// fresh canonical-slug setup.
const canonical = bySlug.get(deriveConnectionSlug(provider.providerType));
const ofType = catalog.connections.filter(
(connection) => connection.providerType === provider.providerType,
);
const existing =
canonical?.providerType === provider.providerType
? canonical
: ofType.length === 1
? ofType[0]
: undefined;
return {
...provider,
hasConnection: existing !== undefined,
...(existing ? { connectionId: existing.connectionId } : {}),
enabledModelIds: existing ? [...existing.enabledModelIds] : [],
};
});
}
function trimmedOrNull(value: string | undefined): string | null {
const secret = value?.trim() ?? '';
return secret.length === 0 ? null : secret;
}
function onboardingFailureText(input: {
readonly kind: 'rejected' | 'failed';
readonly reason?: string;
readonly errorClass?: string;
}): string {
if (input.kind === 'failed') return `Connection verification failed: ${input.errorClass}`;
switch (input.reason) {
case 'credential_not_configured':
return 'API key is required';
case 'base_url_not_configured':
return 'A base URL is required for this provider';
case 'connection_not_found':
return 'The existing connection is gone — reopen /setup and try again';
case 'superseded':
return 'The connection changed while onboarding — reopen /setup and try again';
case 'provider_unsupported':
return 'This provider does not support API-key onboarding';
case 'slug_conflict':
return 'The provider connection name is already used by another provider';
case 'model_unavailable':
return 'The selected model is no longer available';
default:
return 'Connection onboarding was rejected';
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}